Files
2026-09-09 14:47:29 +08:00

110 lines
6.3 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import { inventory, hash, verifyPublicObject, PROJECT } from './upload.mjs'
import { validateCosMediaManifest, loadCosMediaManifest } from '../../build/tang-detective-cos-media.mjs'
const json = value => JSON.stringify(value, null, 2) + '\n'
const write = (file, value) => {
const temp = `${file}.${process.pid}.tmp`
fs.writeFileSync(temp, json(value), { flag: 'wx' })
fs.renameSync(temp, file)
}
const destination = { bucket: 'gz-1349751149', region: 'ap-guangzhou',
baseUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' }
const mode = process.argv[2]
if (!['activate', 'audit'].includes(mode)) throw new Error('USE_ACTIVATE_OR_AUDIT')
const sourceDirectory = path.join(PROJECT, 'native/tang-detective')
const manifestPath = path.join(PROJECT, 'build/tang-detective-cos-manifest.json')
let manifest
if (mode === 'activate') {
if (fs.existsSync(manifestPath)) throw new Error('ACTIVATION_MANIFEST_EXISTS_USE_AUDIT')
const input = inventory(PROJECT) // No missing source or changed original can activate a new mapping.
const plan = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-plan.json')))
const observed = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-observations.json')))
if (plan.uploadRunId !== observed.uploadRunId || plan.sourceManifestSha256 !== input.sourceManifestSha256
|| observed.baseUrl !== destination.baseUrl || observed.images.length !== 135 || observed.audio.length !== 44
|| observed.imageDirectory !== 'uploads/images/20260908/' || observed.audioDirectory !== 'uploads/voice/20260908/') {
throw new Error('OBSERVATION_PLAN_BINDING_FAILED')
}
const byHash = new Map()
for (const entry of plan.entries) {
let filename, directory
if (!entry.stagedName) {
filename = observed.firstImage
directory = observed.imageDirectory
if (entry.observedUrl !== `${observed.baseUrl}/${directory}${filename}`) throw new Error('FIRST_SAMPLE_CHANGED')
} else {
const match = /^tang-20260908-1714-(image|audio)-(\d{3})\.(jpg|mp3)$/.exec(entry.stagedName)
if (!match || match[1] !== entry.kind) throw new Error('INVALID_STAGED_NAME')
const i = Number(match[2]) - 1
filename = (entry.kind === 'image' ? observed.images : observed.audio)[i]
directory = entry.kind === 'image' ? observed.imageDirectory : observed.audioDirectory
}
if (!/^[a-z0-9.-]+$/.test(filename || '')) throw new Error('UNSAFE_OBSERVED_FILENAME')
byHash.set(entry.sha256, { ...entry, objectKey: directory + filename, url: `${observed.baseUrl}/${directory}${filename}` })
}
if (byHash.size !== 180) throw new Error('INCOMPLETE_PLAN')
manifest = { schemaVersion: 2, uploadRunId: plan.uploadRunId, sourceManifestSha256: input.sourceManifestSha256,
destination, entries: input.entries.map(entry => {
const mapping = byHash.get(entry.sha256)
if (!mapping || mapping.bytes !== entry.bytes || mapping.contentType !== entry.contentType) throw new Error('PLAN_BYTES_CHANGED')
return { ...entry, objectKey: mapping.objectKey, url: mapping.url }
}) }
} else {
loadCosMediaManifest({ sourceDirectory }) // Validate source, receipt and complete existing binding first.
manifest = JSON.parse(fs.readFileSync(manifestPath))
}
const unique = [...new Map(manifest.entries.map(entry => [entry.url, entry])).values()]
const receipt = { schemaVersion: 1, runId: manifest.uploadRunId, mode, startedAt: new Date().toISOString(),
phase: 'public-readback', complete: false, uploadTransport: 'authenticated-existing-admin-ui',
destination, sourceManifestSha256: manifest.sourceManifestSha256, bucketPermissionsChanged: false,
originalFilesChanged: false, objects: [] }
const receiptPath = path.join(PROJECT, `build/tang-detective-admin-${mode === 'activate' ? 'upload' : 'audit'}-receipt.json`)
write(receiptPath, receipt)
try {
// Each unsigned GET is independent. Four workers, no access token, database connection or upload here.
let cursor = 0
const outcomes = await Promise.allSettled(Array.from({ length: 4 }, async () => {
for (;;) {
const i = cursor++
if (i >= unique.length) return
const entry = unique[i]
const url = new URL(entry.url)
if (url.origin !== destination.baseUrl || url.search || url.hash || url.username || url.password
|| !/^\/uploads\/(images|voice)\/20260908\/[a-z0-9.-]+$/.test(url.pathname)) throw new Error('UNSAFE_READBACK_URL')
const verified = await verifyPublicObject(entry)
receipt.objects.push({ ...entry, ...verified, uploaded: true, publicReadVerified: true })
write(receiptPath, receipt)
if (receipt.objects.length % 20 === 0) console.log(JSON.stringify({ verified: receipt.objects.length, total: unique.length }))
}
}))
const failure = outcomes.find(outcome => outcome.status === 'rejected')
if (failure) throw failure.reason // All task-owned requests have settled before recording failure.
const verified = new Map(receipt.objects.map(entry => [entry.url, entry]))
const completeManifest = { ...manifest, entries: manifest.entries.map(entry => {
const proof = verified.get(entry.url)
return { ...entry, uploaded: true, publicReadVerified: true, remoteVerifiedSha256: proof.remoteVerifiedSha256,
rangeVerified: proof.rangeVerified, verifiedAt: proof.verifiedAt }
}) }
// Canonical receipt binds the original activation manifest. Audit never changes it or prune hashes.
if (mode === 'activate') {
validateCosMediaManifest(completeManifest, { sourceDirectory })
inventory(PROJECT)
fs.writeFileSync(manifestPath, json(completeManifest), { flag: 'wx' })
}
receipt.complete = true
receipt.phase = 'complete'
receipt.mediaManifestSha256 = hash(fs.readFileSync(manifestPath))
receipt.completedAt = new Date().toISOString()
write(receiptPath, receipt)
console.log(JSON.stringify({ complete: true, uniqueObjects: unique.length, sourceMediaPaths: manifest.entries.length,
allBytesHashesTypesVerified: true, audioRangeVerified: unique.filter(e => e.kind === 'audio').length,
mode, manifestPath }))
} catch (error) {
receipt.phase = 'failed'
receipt.failedAt = new Date().toISOString()
receipt.errorCode = 'PUBLIC_READBACK_OR_MANIFEST_VALIDATION_FAILED'
write(receiptPath, receipt)
throw error
}