import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import vm from 'node:vm' import { fileURLToPath } from 'node:url' import { MEDIA_TYPES as TYPES, isMediaFile, readSourceManifest, validateSourceSnapshot, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs' export { isMediaFile, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs' const buildDirectory = path.dirname(fileURLToPath(import.meta.url)) export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json') const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js') const SHA256 = /^[a-f0-9]{64}$/ const SERVER_DESTINATION = { bucket: 'gz-1349751149', region: 'ap-guangzhou', baseUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' } const hash = value => crypto.createHash('sha256').update(value).digest('hex') const json = value => `${JSON.stringify(value, null, 2)}\n` function assert(condition, message) { if (!condition) throw new Error(`Invalid Tang Detective COS manifest: ${message}`) } function safeRelative(value) { return typeof value === 'string' && value.length > 0 && !value.startsWith('/') && !/[\\?#\u0000-\u0020]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..') } function httpsUrl(value) { try { assert(typeof value === 'string' && !/["'`<>\\\s]/.test(value), 'URL contains unsafe literal characters') const parsed = new URL(value) assert(parsed.protocol === 'https:' && !parsed.username && !parsed.password && !parsed.search && !parsed.hash, 'URLs must use unsigned HTTPS') return parsed } catch (error) { throw new Error(`Invalid Tang Detective COS manifest: invalid HTTPS URL (${error.message})`) } } function validUploadDate(value) { if (!/^20\d{6}$/.test(value)) return false const isoDate = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` const parsed = new Date(`${isoDate}T00:00:00.000Z`) return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === isoDate } function confirmedServerObject(entry, kind) { if (kind === 'image' && path.extname(entry.sourcePath) === '.jpg') { // UploadService::image and storage/engine/Server::buildSaveName. const match = /^uploads\/images\/(20\d{6})\/(20\d{6})([0-2]\d)([0-5]\d)([0-5]\d)[a-f0-9]{5}\d{4}\.jpg$/.exec(entry.objectKey) return Boolean(match && validUploadDate(match[1]) && match[1] === match[2] && Number(match[3]) < 24) } if (kind === 'audio' && path.extname(entry.sourcePath) === '.mp3') { // Actual authenticated voice upload, using the admin direct-uploader's // Date.now() + Math.random().toString(36).slice(2, 10) naming. const match = /^uploads\/voice\/(20\d{6})\/([1-9]\d{12})-[a-z0-9]{8}\.mp3$/.exec(entry.objectKey) return Boolean(match && validUploadDate(match[1])) } return false } export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH, pruneReceipt, pruneReceiptPath } = {}) { assert(mediaManifest && [1, 2].includes(mediaManifest.schemaVersion), 'schemaVersion must be 1 or 2') const serverManaged = mediaManifest.schemaVersion === 2 if (serverManaged) { assert(typeof mediaManifest.uploadRunId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(mediaManifest.uploadRunId), 'uploadRunId is required') } const source = readSourceManifest(sourceManifestPath) assert(mediaManifest.sourceManifestSha256 === source.manifestSha256, 'source manifest SHA-256 mismatch') const sourceManifest = source.manifest const expected = new Map(sourceManifest.files.filter(file => isMediaFile(file.path)).map(file => [file.path, file])) const destination = mediaManifest.destination assert(destination && typeof destination.bucket === 'string' && destination.bucket.trim() && typeof destination.region === 'string' && destination.region.trim(), 'destination bucket and region are required') const base = httpsUrl(destination.baseUrl) if (serverManaged) { assert(Object.entries(SERVER_DESTINATION).every(([key, value]) => destination[key] === value), 'schema 2 destination must be the confirmed xuetang COS bucket, region and origin') } assert(Array.isArray(mediaManifest.entries) && mediaManifest.entries.length === expected.size, 'media coverage is incomplete') const entries = new Map() const objects = new Map() for (const entry of mediaManifest.entries) { assert(entry && safeRelative(entry.sourcePath) && expected.has(entry.sourcePath), 'unknown or unsafe sourcePath') assert(!entries.has(entry.sourcePath), `duplicate sourcePath: ${entry.sourcePath}`) const recorded = expected.get(entry.sourcePath) const [kind, contentType] = TYPES[path.extname(entry.sourcePath).toLowerCase()] assert(entry.kind === kind && entry.contentType === contentType, `media type mismatch: ${entry.sourcePath}`) assert(SHA256.test(entry.sha256) && entry.sha256 === recorded.sha256 && Number.isSafeInteger(entry.bytes) && entry.bytes === recorded.bytes, `source metadata mismatch: ${entry.sourcePath}`) assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`) assert(entry.publicReadVerified === true, `public read verification missing: ${entry.sourcePath}`) if (serverManaged) { // Only these two media routes have current upload evidence. New kinds // require their own verified route; schema 1 stays content-addressed. assert(confirmedServerObject(entry, kind), `object key does not match confirmed server upload contract: ${entry.sourcePath}`) } else { const expectedObjectKey = `tang-detective/season-01/media-v1/${entry.sha256}${path.extname(entry.sourcePath)}` assert(entry.objectKey === expectedObjectKey, `object key does not match immutable media contract: ${entry.sourcePath}`) } if (kind === 'audio' || kind === 'video') { assert(entry.rangeVerified === true, `media range verification missing: ${entry.sourcePath}`) } const url = httpsUrl(entry.url) const expectedUrl = `${base.href.replace(/\/$/, '')}/${entry.objectKey.split('/').map(encodeURIComponent).join('/')}` assert(url.href === expectedUrl && entry.url === url.href, `URL does not match destination and object key: ${entry.sourcePath}`) const identity = `${entry.sha256}:${entry.bytes}:${entry.contentType}` assert(!objects.has(entry.url) || objects.get(entry.url) === identity, `conflicting object contents: ${entry.sourcePath}`) objects.set(entry.url, identity) entries.set(entry.sourcePath, Object.freeze({ ...entry })) } const media = { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256, manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size, uploadRunId: mediaManifest.uploadRunId } // Validate all surviving bytes, and permit omissions only after validating a // complete local prune receipt bound to this exact verified upload manifest. media.sourceSnapshot = validateSourceSnapshot({ sourceDirectory, sourceManifestPath, media, pruneReceipt, pruneReceiptPath }) return media } export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath, pruneReceipt, pruneReceiptPath } = {}) { // Explicit null is useful for a local/offline comparison without touching a // verified manifest owned by another task. Undefined uses automatic discovery. if (mediaManifest === null) return null if (mediaManifest === undefined) { if (!fs.existsSync(mediaManifestPath)) return null const stat = fs.lstatSync(mediaManifestPath) assert(stat.isFile() && !stat.isSymbolicLink(), 'media manifest must be a regular file') mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8')) } return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath, pruneReceipt, pruneReceiptPath }) } function relativeHelper(relative, helper = 'utils/cosMedia.js') { const result = path.posix.relative(path.posix.dirname(relative), helper) return result.startsWith('.') ? result : `./${result}` } function replaceOnce(source, before, after, filename) { assert(source.split(before).length === 2, `conversion anchor changed: ${filename}`) return source.replace(before, after) } function evaluateData(source, filename) { const context = { module: { exports: {} } } vm.runInNewContext(source, context, { filename, timeout: 1000 }) return context.module.exports } export function applyCosMediaOutput(files, media, { namespace = 'tang-detective' } = {}) { if (!media) return const byUrl = new Map([...media.entries.values()].map(entry => [entry.url, entry])) const lookup = value => { if (byUrl.has(value)) return byUrl.get(value) let relative = value.replace(/^\//, '') if (relative.startsWith(`${namespace}/`)) relative = relative.slice(namespace.length + 1) return media.entries.get(relative) } const staticPath = /(["'`])(\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/[^"'`\n$]*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov))\1/gi for (const [relative, bytes] of files) { if (isMediaFile(relative)) { files.delete(relative); continue } if (!/\.(?:js|json|wxml|wxss|wxs)$/.test(relative) || relative === 'utils/cosMedia.js') continue let source = bytes.toString('utf8') const helper = `require(${JSON.stringify(relativeHelper(relative))})` if (relative.endsWith('/data/releaseAssetManifest.js')) { const exported = evaluateData(source, relative) for (const asset of Object.values(exported.releaseAssets)) { if (!asset.localSeed) continue const entry = lookup(asset.localSeed) assert(entry && entry.sha256 === asset.sha256 && entry.kind === asset.kind, `release asset mismatch: ${relative}`) asset.localSeed = '' asset.remoteUrl = entry.url } const share = media.entries.get('assets/share/guixiang-story-share-preview-v1.jpg') assert(share, 'share preview entry is missing') exported.releaseAssets['image.tang.share-preview'] = { kind: 'image', localSeed: '', remoteUrl: share.url, remotePath: share.objectKey, sha256: share.sha256, } files.set(relative, Buffer.from(`module.exports = ${json(exported)}`)) continue } source = source.replace(staticPath, (match, quote, value) => { const entry = lookup(value) assert(entry, `unregistered static media: ${relative}: ${value}`) return `${quote}${entry.url}${quote}` }) if (relative.endsWith('/pages/chapter/chapterPages.js')) { source = replaceOnce(source, ' pageSequence = attachPlayableVisuals(pageSequence, chapter)', ` pageSequence = ${helper}.mapMedia(pageSequence)\n pageSequence = attachPlayableVisuals(pageSequence, chapter)`, relative) } if (relative.endsWith('/data/playableVisualPolicy.js')) { const previous = ' && clean(releaseAsset.localSeed)\n && clean(releaseAsset.localSeed) === clean(page.illustrationAsset),' source = replaceOnce(source, previous, ` && ${helper}.verifiedUrl(releaseAsset.remoteUrl, releaseAsset.sha256, 'image')\n && clean(releaseAsset.remoteUrl) === clean(page.illustrationAsset),`, relative) } if (relative.endsWith('/utils/comicPageModel.js')) { source = replaceOnce(source, ' if (localSeed) {\n return {\n src: localSeed,', ` const remoteUrl = releaseAsset && ${helper}.verifiedUrl(\n releaseAsset.remoteUrl, releaseAsset.sha256, 'image',\n )\n if (localSeed || remoteUrl) {\n return {\n src: localSeed || remoteUrl,`, relative) source = replaceOnce(source, " source: 'local-seed',", " source: localSeed ? 'local-seed' : 'remote-url',", relative) // Keep getReviewedAudioSrc and isPackagedPath fail-closed. Approved // remote full-page audio uses the existing asynchronous verified player. } if (relative.endsWith('/utils/assetManager.js')) { source = replaceOnce(source, ' if (!cdnBaseUrl || !asset.remotePath) {', ` const remoteUrl = asset.remoteUrl\n ? ${helper}.verifiedUrl(asset.remoteUrl, asset.sha256, asset.kind) : ''\n if (asset.remoteUrl && !remoteUrl) return fallback(assetId, 'remote-integrity-failed')\n if (!remoteUrl && (!cdnBaseUrl || !asset.remotePath)) {`, relative) source = replaceOnce(source, 'download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))', 'download(assetPlatform, remoteUrl || joinPath(cdnBaseUrl, asset.remotePath))', relative) } if (relative.endsWith('/pages/chapter/chapter.js')) { const start = source.indexOf(' prepareSharePreview() {') const end = source.indexOf(' loadChapter(', start) assert(start !== -1 && end > start, `share conversion anchor changed: ${relative}`) source = source.slice(0, start) + ` prepareSharePreview() {\n return ${helper}.prepareSharePreview(this, this.getAssetManager())\n },\n\n` + source.slice(end) source = replaceOnce(source, " const localPath = String(this.data.sharePreviewLocalPath || '').trim()", " const localPath = '' // Cached share bytes are revalidated in prepareSharePreview.", relative) source = replaceOnce(source, ' if (!pageData.currentPage) return', ` if (!pageData.currentPage) return\n ${helper}.beginComicImage(this, pageData)`, relative) source = replaceOnce(source, ' onComicImageLoad() {', ` onComicImageLoad(event) {\n if (!${helper}.isCurrentComicImageEvent(this, event)) return`, relative) const errorStart = source.indexOf(' onComicImageError() {') const errorEnd = source.indexOf(' applyLayoutMetrics(', errorStart) assert(errorStart !== -1 && errorEnd > errorStart, `image error conversion anchor changed: ${relative}`) let handler = source.slice(errorStart, errorEnd) handler = replaceOnce(handler, ' onComicImageError() {', ` onComicImageError(event) {\n if (!${helper}.recordComicImageError(this, event)) return`, relative) handler = replaceOnce(handler, ' actorFallback\n', ` actorFallback\n && ${helper}.canUseComicFallback(this, actorFallback)\n`, relative) handler = replaceOnce(handler, ' fallback\n', ` fallback\n && ${helper}.canUseComicFallback(this, fallback)\n`, relative) handler = handler.replaceAll('this.setData({', `${helper}.applyComicImageFallback(this, {`) source = source.slice(0, errorStart) + handler + source.slice(errorEnd) } if (relative.endsWith('/pages/chapter/chapter.wxml')) { assert(source.includes('binderror="onComicImageError"'), `image event conversion anchor changed: ${relative}`) source = source.replaceAll('binderror="onComicImageError"', 'data-cos-page-id="{{currentPageId}}" data-cos-image-src="{{comicImageSrc}}" data-cos-image-generation="{{comicImageGeneration}}" binderror="onComicImageError"') } files.set(relative, Buffer.from(source)) } files.set('utils/cosMedia.js', fs.readFileSync(RUNTIME_HELPER_PATH)) files.set('utils/cosMediaManifest.js', Buffer.from(`module.exports = ${json({ namespace, entries: [...media.entries.values()].map(({ sourcePath, kind, bytes, sha256, url }) => ({ sourcePath, kind, bytes, sha256, url })), })}`)) }