Preserve known legacy failures by reviewed identity, probe material delivery before and after releases, and retain originals and downloads.
238 lines
16 KiB
JavaScript
238 lines
16 KiB
JavaScript
// Opt-in, read-only audit of one exact registered legacy dependency inventory.
|
|
// No database/object writes, source substitution, retries or original-file GETs.
|
|
import { createHash } from 'node:crypto'
|
|
import * as fs from 'node:fs/promises'
|
|
import { spawn } from 'node:child_process'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { checkMaterialPreviewHealth, directFetch, LIMITS, registeredSource } from './check-material-preview-health.mjs'
|
|
import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs'
|
|
|
|
export const AUDIT_LIMITS = Object.freeze({ expectedCount: 472, batchSize: 50, concurrency: 2 })
|
|
const sha = value => createHash('sha256').update(value).digest('hex')
|
|
const assert = (value, code) => { if (!value) throw new Error(code) }
|
|
const previewRoles = new Set(['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image'])
|
|
const imageExtension = /\.(png|jpe?g|webp)$/i
|
|
|
|
export function metadataCandidates(row) {
|
|
const candidates = [], excludedAssets = []
|
|
const add = (source, origin, identity, size) => {
|
|
let url
|
|
try { url = new URL(source) } catch { return }
|
|
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || !imageExtension.test(url.pathname)) return
|
|
let publicCheckable = false
|
|
try { registeredSource(source); publicCheckable = true } catch {}
|
|
candidates.push({ source, origin, identity, ...(size === undefined ? {} : { declaredBytes: String(size) }),
|
|
sameAsRegistered: source === row.previewUrl, legacyHost: LEGACY_DOMAINS.includes(url.hostname), publicCheckable,
|
|
metadataOnly: true, bytesRead: 0, originalIdentityVerified: false })
|
|
}
|
|
for (const [index, source] of (row.displayImages || []).entries()) add(source, 'displayImages', String(index))
|
|
for (const asset of row.assets || []) {
|
|
if (!previewRoles.has(asset.assetType)) {
|
|
excludedAssets.push({ assetId: asset.id, assetType: asset.assetType, reason: 'not_a_preview_role', bytesRead: 0 })
|
|
continue
|
|
}
|
|
const before = candidates.length
|
|
add(asset.fileUrl, 'MaterialAsset', asset.id, asset.fileSize)
|
|
if (before === candidates.length) excludedAssets.push({ assetId: asset.id, assetType: asset.assetType, reason: 'not_a_supported_image_url', bytesRead: 0 })
|
|
}
|
|
for (const sku of row.skus || []) if (sku.previewUrl) add(sku.previewUrl, 'MaterialSku.previewUrl', sku.id)
|
|
return { materialId: row.id, materialType: row.materialType, candidates, excludedAssets }
|
|
}
|
|
|
|
export function validateAuditSnapshot(snapshot, { host, expectedCount = AUDIT_LIMITS.expectedCount } = {}) {
|
|
assert(LEGACY_DOMAINS.includes(host), 'registered_legacy_host_required')
|
|
assert(snapshot?.readOnly === true && snapshot.count === expectedCount && Array.isArray(snapshot.materials)
|
|
&& snapshot.materials.length === expectedCount, 'exact_inventory_count_required')
|
|
const seen = new Set()
|
|
for (const row of snapshot.materials) {
|
|
assert(typeof row.id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(row.id) && !seen.has(row.id), 'unique_material_identity_required')
|
|
seen.add(row.id)
|
|
assert(typeof row.previewUrl === 'string', 'source_required')
|
|
const url = new URL(row.previewUrl)
|
|
assert(url.hostname === host && ['https:', 'http:'].includes(url.protocol), 'inventory_host_changed')
|
|
}
|
|
return snapshot.materials
|
|
}
|
|
|
|
// Hash every fully consumed response, including the explicit FRP HTML 404 body.
|
|
// The checker still owns the byte/time budgets and cancels rejected streams.
|
|
export async function auditPreviewBatch(manifest, { fetchImpl = directFetch, ...options } = {}) {
|
|
const proofs = new Map()
|
|
const capture = async (url, fetchOptions) => {
|
|
const response = await fetchImpl(url, fetchOptions)
|
|
if (!response.body) return response
|
|
const reader = response.body.getReader(), digest = createHash('sha256')
|
|
let count = 0
|
|
const stream = new ReadableStream({
|
|
async pull(controller) {
|
|
try {
|
|
const item = await reader.read()
|
|
if (item.done) {
|
|
proofs.set(url, { responseBodySha256: digest.digest('hex'), responseBytes: count, responseBodyComplete: true })
|
|
controller.close()
|
|
} else {
|
|
count += item.value.byteLength
|
|
digest.update(item.value)
|
|
controller.enqueue(item.value)
|
|
}
|
|
} catch (error) { controller.error(error) }
|
|
},
|
|
cancel(reason) { return reader.cancel(reason) },
|
|
})
|
|
const wrapped = new Response(stream, { status: response.status, statusText: response.statusText, headers: response.headers })
|
|
Object.defineProperties(wrapped, { url: { value: response.url }, redirected: { value: response.redirected } })
|
|
return wrapped
|
|
}
|
|
const report = await checkMaterialPreviewHealth(manifest, { ...options, fetchImpl: capture })
|
|
return { ...report, results: report.results.map(result => ({ ...result,
|
|
...(proofs.get(manifest.materials.find(entry => entry.materialId === result.materialId).registeredSource)
|
|
|| { responseBodySha256: null, responseBodyComplete: false }) })) }
|
|
}
|
|
|
|
export async function auditUniquePreviews(materials, { onBatch = async () => {}, ...options } = {}) {
|
|
const byUrl = new Map(), rejected = []
|
|
for (const row of materials) {
|
|
try {
|
|
const source = registeredSource(row.previewUrl).href
|
|
if (!byUrl.has(source)) byUrl.set(source, { materialId: row.id, registeredSource: source, materialIds: [] })
|
|
byUrl.get(source).materialIds.push(row.id)
|
|
} catch (error) {
|
|
rejected.push({ materialId: row.id, ok: false, classification: 'source_rejected', code: error.code || 'invalid_url', attempts: 0 })
|
|
}
|
|
}
|
|
const entries = [...byUrl.values()], batches = []
|
|
for (let index = 0; index < entries.length; index += AUDIT_LIMITS.batchSize) {
|
|
batches.push({ version: 1, reviewed: true, materials: entries.slice(index, index + AUDIT_LIMITS.batchSize)
|
|
.map(({ materialId, registeredSource }) => ({ materialId, registeredSource })) })
|
|
}
|
|
const reports = new Array(batches.length)
|
|
let next = 0
|
|
await Promise.all(Array.from({ length: Math.min(AUDIT_LIMITS.concurrency, batches.length) }, async () => {
|
|
while (next < batches.length) {
|
|
const index = next++
|
|
reports[index] = await auditPreviewBatch(batches[index], options)
|
|
await onBatch({ index, manifest: batches[index], report: reports[index] })
|
|
}
|
|
}))
|
|
const byRepresentative = new Map(reports.flatMap(report => report.results).map(result => [result.materialId, result]))
|
|
const byId = new Map(rejected.map(result => [result.materialId, result]))
|
|
for (const entry of entries) for (const materialId of entry.materialIds) {
|
|
byId.set(materialId, { ...byRepresentative.get(entry.materialId), materialId, checkedRepresentativeId: entry.materialId,
|
|
sharedRegisteredSource: entry.materialIds.length > 1 })
|
|
}
|
|
return { readOnly: true, limits: AUDIT_LIMITS, materialCount: materials.length, uniqueRegisteredSources: entries.length,
|
|
actualGetAttempts: entries.length, rejectedBeforeGet: rejected.length, retries: 0, allRowsAccounted: byId.size === materials.length,
|
|
deduplication: entries, batches, reports, results: materials.map(row => ({ ...byId.get(row.id), materialType: row.materialType })) }
|
|
}
|
|
|
|
async function sshStdin(command, input, onLine) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('ssh.exe', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o',
|
|
'UserKnownHostsFile=C:/Users/54972/.ssh/gongxiangp2-prod.known_hosts', 'gongxiangp2-prod-legacy-freenas', command],
|
|
{ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
let output = '', errors = '', pending = '', lineFailure
|
|
child.stdout.on('data', bytes => {
|
|
output += bytes
|
|
if (Buffer.byteLength(output) > 32 * 1024 * 1024) { child.kill(); lineFailure = new Error('ssh_output_budget_exceeded'); return }
|
|
if (onLine) {
|
|
pending += bytes
|
|
let end
|
|
while ((end = pending.indexOf('\n')) !== -1) {
|
|
const line = pending.slice(0, end); pending = pending.slice(end + 1)
|
|
if (line.trim()) { try { onLine(JSON.parse(line)) } catch (error) { lineFailure = error; child.kill() } }
|
|
}
|
|
}
|
|
})
|
|
child.stderr.on('data', bytes => { if (errors.length < 4000) errors += bytes })
|
|
child.on('error', reject)
|
|
child.on('close', code => lineFailure ? reject(lineFailure) : code === 0 ? resolve(output) : reject(new Error('read_only_remote_audit_failed')))
|
|
child.stdin.on('error', () => {})
|
|
child.stdin.end(input)
|
|
})
|
|
}
|
|
|
|
export async function main(args = process.argv.slice(2)) {
|
|
if (!args.includes('--run')) return { planOnly: true, readOnly: true, expectedCount: AUDIT_LIMITS.expectedCount,
|
|
metadata: ['Material.id', 'materialType', 'previewUrl', 'displayImages', 'MaterialAsset preview metadata', 'active MaterialSku.previewUrl'],
|
|
health: { ...AUDIT_LIMITS, ...LIMITS }, modelCalls: 0, productionWrites: 0 }
|
|
assert(args.length === 3 && args[0] === '--run' && args[1] === '--output-dir' && path.isAbsolute(args[2]), 'run_requires_new_absolute_output_directory')
|
|
const directory = path.resolve(args[2])
|
|
await fs.mkdir(directory, { recursive: false, mode: 0o700 })
|
|
const write = (file, value) => fs.writeFile(path.join(directory, file), JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 })
|
|
const receipt = JSON.parse(await fs.readFile('output/native-brief-panel-20261023/preview-recovery-reviewed-manifest.json', 'utf8'))
|
|
const host = new URL(receipt.materials[0].oldPreviewUrl).hostname
|
|
assert(LEGACY_DOMAINS.includes(host), 'reviewed_legacy_host_required')
|
|
const identityWire = `import json,subprocess\nc=json.loads(subprocess.check_output(['docker','inspect','youyidian-3d-material-blue']))[0]\nf=subprocess.check_output(['docker','exec',c['Id'],'cat','/app/DEPLOYMENT_COMMIT'],text=True).strip()\nprint(json.dumps({'containerId':c['Id'],'containerName':c['Name'].lstrip('/'),'imageName':c['Config']['Image'],'imageId':c['Image'],'running':c['State']['Running'],'deploymentCommit':f}))\n`
|
|
const identity = JSON.parse(await sshStdin('python3 -u -', identityWire))
|
|
assert(identity.running && /^[a-f0-9]{64}$/.test(identity.containerId) && /^[a-f0-9]{40}$/.test(identity.deploymentCommit), 'running_production_identity_required')
|
|
await write('production-identity.json', { readOnly: true, checkedAt: new Date().toISOString(), ...identity })
|
|
const command = `docker exec -i -w /app ${identity.containerId} node --input-type=module`
|
|
const metadataWire = `import {createRequire} from 'node:module';const require=createRequire(import.meta.url),fs=require('node:fs'),assert=require('node:assert/strict');
|
|
assert.equal(fs.readFileSync('/app/DEPLOYMENT_COMMIT','utf8').trim(),${JSON.stringify(identity.deploymentCommit)});
|
|
const {PrismaClient}=require('@prisma/client');const p=new PrismaClient({log:[]});
|
|
try{const result=await p.$transaction(async tx=>{
|
|
await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY');
|
|
const where={deletedAt:null,OR:[{previewUrl:{startsWith:${JSON.stringify('https://' + host + '/')},mode:'insensitive'}},{previewUrl:{startsWith:${JSON.stringify('http://' + host + '/')},mode:'insensitive'}}]};
|
|
const count=await tx.material.count({where});assert.equal(count,${AUDIT_LIMITS.expectedCount});const materials=[];let cursor;
|
|
while(true){const rows=await tx.material.findMany({where,orderBy:{id:'asc'},take:100,...(cursor?{cursor:{id:cursor},skip:1}:{}),select:{id:true,materialType:true,previewUrl:true,displayImages:true,
|
|
assets:{orderBy:[{sortOrder:'asc'},{id:'asc'}],select:{id:true,assetType:true,fileUrl:true,fileSize:true,sortOrder:true}},
|
|
skus:{where:{deletedAt:null},orderBy:{id:'asc'},select:{id:true,previewUrl:true}}}});if(!rows.length)break;materials.push(...rows);assert.ok(materials.length<=count);cursor=rows.at(-1).id;}
|
|
assert.equal(materials.length,count);return{readOnly:true,capturedAt:new Date().toISOString(),count,host:${JSON.stringify(host)},isolationLevel:'RepeatableRead',historyImportFilter:false,materials};
|
|
},{isolationLevel:'RepeatableRead',timeout:60000,maxWait:5000});console.log(JSON.stringify(result,(_,v)=>typeof v==='bigint'?v.toString():v));}finally{await p.$disconnect();}`
|
|
const snapshot = JSON.parse(await sshStdin(command, metadataWire))
|
|
const materials = validateAuditSnapshot(snapshot, { host })
|
|
await write('material-metadata.json', snapshot)
|
|
const candidateMapping = materials.map(metadataCandidates)
|
|
await write('preview-candidate-mapping.json', { readOnly: true, metadataOnly: true, candidateGets: 0, materials: candidateMapping })
|
|
const checker = await fs.readFile('scripts/check-material-preview-health.mjs', 'utf8')
|
|
const own = await fs.readFile(fileURLToPath(import.meta.url), 'utf8')
|
|
const checkSource = checker.replace("import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs'", `const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`)
|
|
const helperSource = own.slice(own.indexOf('export async function auditPreviewBatch'), own.indexOf('async function sshStdin'))
|
|
const wire = checkSource + `\nconst AUDIT_LIMITS=${JSON.stringify(AUDIT_LIMITS)};\n` + helperSource + `
|
|
const fsRuntime=createRequire(import.meta.url)('node:fs');
|
|
if(fsRuntime.readFileSync('/app/DEPLOYMENT_COMMIT','utf8').trim()!==${JSON.stringify(identity.deploymentCommit)})throw Error('production_changed');
|
|
console.log(JSON.stringify({event:'started',expectedCount:${materials.length},checkerSha256:${JSON.stringify(sha(checker))}}));
|
|
const audit=await auditUniquePreviews(${JSON.stringify(materials.map(({ id, materialType, previewUrl }) => ({ id, materialType, previewUrl })))},
|
|
{onBatch:async value=>console.log(JSON.stringify({event:'batch',...value}))});
|
|
if(fsRuntime.readFileSync('/app/DEPLOYMENT_COMMIT','utf8').trim()!==${JSON.stringify(identity.deploymentCommit)})throw Error('production_changed');
|
|
console.log(JSON.stringify({event:'complete',audit}));`
|
|
await write('dispatch-audit.json', { readOnly: true, startedAt: new Date().toISOString(), host, materialCount: materials.length,
|
|
checkerSha256: sha(checker), helperSha256: sha(own), wireSha256: sha(wire), maxConcurrentGets: 2, retries: 0, productionWrites: 0, candidateGets: 0 })
|
|
const batchWrites = []
|
|
let completed
|
|
try {
|
|
await sshStdin(command, wire, event => {
|
|
if (event.event === 'batch') batchWrites.push(write(`batch-${String(event.index + 1).padStart(3, '0')}.json`, event))
|
|
else if (event.event === 'complete') completed = event.audit
|
|
})
|
|
await Promise.all(batchWrites)
|
|
assert(completed?.allRowsAccounted && completed.results.length === materials.length, 'complete_response_mapping_required')
|
|
const summary = { readOnly: true, completedAt: new Date().toISOString(), ...identity, materialCount: materials.length,
|
|
uniqueRegisteredSources: completed.uniqueRegisteredSources, actualGetAttempts: completed.actualGetAttempts,
|
|
results: completed.results, healthyCount: completed.results.filter(item => item.ok).length,
|
|
failedCount: completed.results.filter(item => !item.ok).length, statusCounts: {}, classificationCounts: {},
|
|
candidateMetadataCount: candidateMapping.reduce((n, row) => n + row.candidates.length, 0), candidateGets: 0,
|
|
allRowsAccounted: true, fullImageAuditExecuted: true, retries: 0, databaseWrites: 0, storageWrites: 0, modelCalls: 0 }
|
|
for (const item of completed.results) {
|
|
summary.statusCounts[item.status ?? 'not_dispatched'] = (summary.statusCounts[item.status ?? 'not_dispatched'] || 0) + 1
|
|
summary.classificationCounts[item.classification] = (summary.classificationCounts[item.classification] || 0) + 1
|
|
}
|
|
await write('unique-url-audit.json', completed)
|
|
await write('audit-summary.json', summary)
|
|
return { ...summary, results: undefined, outputDirectory: directory }
|
|
} catch (error) {
|
|
await Promise.allSettled(batchWrites)
|
|
await write('audit-incomplete.json', { readOnly: true, complete: false, code: 'read_only_audit_incomplete', completedBatches: batchWrites.length,
|
|
noAutomaticRetry: true, metadataCount: materials.length })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main().then(result => console.log(JSON.stringify(result, null, 2)), () => {
|
|
console.log(JSON.stringify({ readOnly: true, complete: false, code: 'read_only_audit_failed', retries: 0 }))
|
|
process.exitCode = 1
|
|
})
|
|
}
|