This commit is contained in:
大哥大哥的大哥哥
2026-09-09 14:47:29 +08:00
parent 4d9da40abd
commit 65755c9e96
832 changed files with 412085 additions and 0 deletions
@@ -0,0 +1,251 @@
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 })),
})}`))
}