gongxiangp2/scripts/recover-material-previews.mjs

98 lines
6.7 KiB
JavaScript

// Opt-in, reviewed recovery of authentic cached previews. No model calls, deletes,
// download-source changes or URL-prefix substitution. The caller durably saves
// the returned plan before apply, and provides exact storage/DB adapters.
import { createHash } from 'node:crypto'
export const OPERATION_ID = 'material-preview-cache-20261023'
export const digest = value => createHash('sha256').update(value).digest('hex')
export const json = value => JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v)
const check = (condition, message) => { if (!condition) throw new Error(message) }
const idPattern = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/
export function validateRecovery(manifest) {
check(manifest?.version === 1 && manifest.reviewed === true && manifest.operationId === OPERATION_ID, 'reviewed recovery required')
check(manifest.previewQuality === 'temporary-160px-derived' && manifest.materials?.length === 11, 'exact eleven temporary previews required')
check(idPattern.test(manifest.actorId), 'actor required')
const ids = new Set()
for (const row of manifest.materials) {
check(idPattern.test(row.materialId) && !ids.has(row.materialId), 'unique material identity required')
ids.add(row.materialId)
const old = new URL(row.oldPreviewUrl)
check(old.protocol === 'https:' && !old.username && !old.password && !old.search && !old.hash, 'exact original URL required')
check(/^[a-f0-9]{64}$/.test(row.sourceSha256) && /^[a-f0-9]{64}$/.test(row.cacheSha256), 'source and cache hashes required')
const bytes = Buffer.from(row.bodyBase64, 'base64')
check(bytes.length === row.bytes && bytes.length >= 100 && bytes.length <= 16384 && digest(bytes) === row.cacheSha256, 'cache bytes mismatch')
check(bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP', 'WebP required')
check(row.width === 160 && row.height === 160 && row.provenance === '20261019-sharp-rotate-inside160-webp80', 'verified derivation required')
check(row.destinationKey === `由壹点AI/public-display/${OPERATION_ID}/${row.materialId}-${row.cacheSha256}.webp`, 'scoped immutable destination required')
check(row.newPreviewUrl === `https://s3.jiansun.vip/gongxiangpan/${row.destinationKey}`, 'canonical destination required')
}
return manifest
}
function verifyActor(actor) {
check(actor && actor.isActive === true && actor.isSuperAdmin === true && actor.status === 'active' && actor.archivedAt === null
&& actor.employmentStatus === 'active', 'active authorized recovery actor required')
}
function verifyRecord(record, row) {
check(record?.id === row.materialId && record.productModel === row.name && record.materialType === '材质'
&& record.deletedAt === null && record.previewUrl === row.oldPreviewUrl, 'source record changed')
}
export async function planRecovery(manifest, db) {
validateRecovery(manifest)
verifyActor(await db.actor(manifest.actorId))
const records = await db.records(manifest.materials.map(r => r.materialId))
check(records.length === 11, 'eleven source records required')
const ordered = manifest.materials.map(row => {
const record = records.find(r => r.id === row.materialId)
verifyRecord(record, row)
return record
})
return { version: 1, operationId: OPERATION_ID, manifestSha256: digest(json(manifest)), capturedAt: new Date().toISOString(), records: ordered }
}
export async function applyRecovery({ manifest, plan, persistedPlanSha256, db, objects }) {
validateRecovery(manifest)
check(plan.operationId === OPERATION_ID && plan.manifestSha256 === digest(json(manifest))
&& persistedPlanSha256 === digest(json(plan)) && plan.records?.length === 11, 'durably persisted matching plan required')
const originalPlan = await planRecovery(manifest, db)
check(json(originalPlan.records) === json(plan.records), 'source snapshot changed before upload')
check(await db.operationCount(OPERATION_ID) === 0, 'operation already recorded; inspect before continuing')
const uploaded = []
// Every object is publicly readable and hash-verified before the DB transaction.
// A conflict leaves harmless, named unreferenced objects, never deletes anything.
for (const row of manifest.materials) {
const body = Buffer.from(row.bodyBase64, 'base64')
const existing = await objects.get(row.destinationKey)
if (existing === null) {
await objects.putIfAbsent(row.destinationKey, body, { operationId: OPERATION_ID, materialId: row.materialId, sha256: row.cacheSha256, quality: manifest.previewQuality })
uploaded.push(row.destinationKey)
} else check(digest(existing) === row.cacheSha256, 'existing destination differs; refusing overwrite')
check(digest(await objects.get(row.destinationKey)) === row.cacheSha256, 'internal object readback mismatch')
check(digest(await objects.publicGet(row.newPreviewUrl)) === row.cacheSha256, 'public object readback mismatch')
}
await db.transaction(async tx => {
verifyActor(await tx.actor(manifest.actorId))
check(await tx.operationCount(OPERATION_ID) === 0, 'operation concurrently recorded')
const current = await tx.records(manifest.materials.map(r => r.materialId))
const ordered = manifest.materials.map(row => current.find(r => r.id === row.materialId))
check(json(ordered) === json(plan.records), 'source snapshot changed; transaction aborted')
for (const row of manifest.materials) {
const before = plan.records.find(r => r.id === row.materialId)
check(await tx.compareAndSet(row, before.updatedAt) === 1, 'concurrent record change; transaction aborted')
await tx.log({ materialId: row.materialId, userId: manifest.actorId, action: 'PREVIEW_CACHE_RECOVERY', changes: json({ operationId: OPERATION_ID, previewQuality: manifest.previewQuality, oldPreviewUrl: row.oldPreviewUrl, newPreviewUrl: row.newPreviewUrl, originalSourceSha256: row.sourceSha256, cacheSha256: row.cacheSha256, width: 160, height: 160, downloadsAndAssetsUnchanged: true, snapshotSha256: persistedPlanSha256 }) })
}
})
const after = await db.records(manifest.materials.map(r => r.materialId))
for (const before of plan.records) {
const actual = after.find(r => r.id === before.id)
const row = manifest.materials.find(r => r.materialId === before.id)
check(actual?.previewUrl === row.newPreviewUrl, 'post-commit preview mismatch; inspect operation logs')
const immutable = ({ previewUrl, updatedAt, ...rest }) => rest
check(json(immutable(actual)) === json(immutable(before)), 'post-commit unrelated data mismatch; inspect operation logs')
}
return { operationId: OPERATION_ID, updated: 11, uploaded, after, temporaryPreviewSize: 160, originalSourceRestored: false, downloadsAndAssetsUnchanged: true, modelCalls: 0 }
}