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' const buildDirectory = path.dirname(fileURLToPath(import.meta.url)) export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json') export const SOURCE_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-source-manifest.json') const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js') const SHA256 = /^[a-f0-9]{64}$/ const TYPES = { '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'], '.png': ['image', 'image/png'], '.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], '.avif': ['image', 'image/avif'], '.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], '.aac': ['audio', 'audio/aac'], '.m4a': ['audio', 'audio/mp4'], '.ogg': ['audio', 'audio/ogg'], '.mp4': ['video', 'video/mp4'], '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'], } export const isMediaFile = value => Boolean(TYPES[path.extname(value).toLowerCase()]) 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})`) } } export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH } = {}) { assert(mediaManifest && mediaManifest.schemaVersion === 1, 'schemaVersion must be 1') const sourceBytes = fs.readFileSync(sourceManifestPath) assert(mediaManifest.sourceManifestSha256 === hash(sourceBytes), 'source manifest SHA-256 mismatch') const sourceManifest = JSON.parse(sourceBytes) 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) 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}`) const actual = fs.readFileSync(path.join(sourceDirectory, entry.sourcePath)) assert(actual.length === entry.bytes && hash(actual) === entry.sha256, `source bytes changed: ${entry.sourcePath}`) assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`) 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 })) } return { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256, manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size } } export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath } = {}) { // 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 mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8')) } return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath }) } 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 })), })}`)) }