110 lines
6.3 KiB
JavaScript
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
|
|
}
|