gongxiangp2/scripts/audit-legacy-material-previews.mjs
WuYongJie a890660853 fix(materials): audit historical previews and gate production releases
Preserve known legacy failures by reviewed identity, probe material delivery before and after releases, and retain originals and downloads.
2026-10-23 19:47:58 +08:00

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
})
}