213 lines
13 KiB
JavaScript
213 lines
13 KiB
JavaScript
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 })),
|
|
})}`))
|
|
}
|