// Incident-only bounded release; normal releases build the complete Git tree. import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { createRequire } from 'node:module' import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' const require = createRequire(import.meta.url) const { parse } = require('next/dist/compiled/acorn') export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex') const key = node => node?.key?.name ?? node?.key?.value ?? node?.property?.name ?? node?.property?.value const isFunction = node => ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type) const messages = ['素材尚未完成上传,暂不能编辑图片', '素材尚未完成上传,暂不能修改图片'] const ownSources = ['app/api/external/download/route.ts', 'app/api/external/product-assets/route.ts', 'lib/task-detail-asset-edit.ts', 'lib/task-detail-change-approval.ts'] export function walk(node, visit, ancestors = []) { if (!node || typeof node.type !== 'string') return visit(node, ancestors) for (const value of Object.values(node)) { if (Array.isArray(value)) for (const child of value) walk(child, visit, [...ancestors, node]) else if (value && typeof value === 'object' && value.type) walk(value, visit, [...ancestors, node]) } } function insist(value, message) { if (!value) throw new Error(message) } export function patchCompiled(name, source) { const ast = parse(source, { ecmaVersion: 'latest' }), edits = [] let guards = 0, selectors = 0, getters = 0, fetches = 0 const insert = (start, text) => edits.push({ start, end: start, text }) if (name === 'chunks/29.js') { walk(ast, (node, ancestors) => { if (node.type !== 'Literal' || !messages.includes(node.value)) return const condition = ancestors.findLast(item => item.type === 'IfStatement') const owner = ancestors.findLast(isFunction), test = condition?.test insist(test?.type === 'BinaryExpression' && test.operator === '!==', 'Unexpected upload guard') const member = test.left.type === 'MemberExpression' ? test.left : test.right const literal = member === test.left ? test.right : test.left insist(key(member) === 'taskStatus' && member.object.type === 'Identifier' && literal.type === 'Literal' && literal.value === 'completed', 'Unexpected guard operands') const object = member.object.name edits.push({ start: test.start, end: test.end, text: '(' + object + '.isIncompleteShell||(' + source.slice(test.start, test.end) + '&&' + object + '.taskStatus!=="fetched"))' }) guards++ let queries = 0 walk(owner, call => { if (call.type !== 'CallExpression' || call.callee.type !== 'MemberExpression' || key(call.callee) !== 'findFirst' || key(call.callee.object) !== 'material') return const select = call.arguments[0]?.properties?.find(item => key(item) === 'select')?.value const status = select?.properties?.find(item => key(item) === 'taskStatus') if (!status) return insist(!select.properties.some(item => key(item) === 'isIncompleteShell'), 'Selector already patched') const value = source.slice(status.value.start, status.value.end) insist(value === '!0' || value === 'true', 'Unexpected selector value') insert(status.start, 'isIncompleteShell:!0,') queries++ }) insist(queries === 1, 'Material selector missing or ambiguous') selectors += queries }) insist(guards === 2 && selectors === 2, 'Both upload gates must be patched exactly once') } else { insist(['app/api/external/download/route.js', 'app/api/external/product-assets/route.js'].includes(name), 'Unapproved compiled target') walk(ast, (node, ancestors) => { if (node.type !== 'Property' || key(node) !== 'GET') return insist(node.value.type === 'ArrowFunctionExpression' && node.value.body.type === 'Identifier', 'Unexpected GET export') const factory = ancestors.findLast(isFunction), exportedName = node.value.body.name const functions = [] walk(factory, (item, parents) => { if (item.type === 'FunctionDeclaration' && item.id.name === exportedName && parents.findLast(isFunction) === factory) functions.push(item) }) insist(functions.length === 1 && functions[0].async, 'GET implementation missing or ambiguous') const body = functions[0].body insist(!source.includes('__materialLatestResponse'), 'GET already patched') insert(body.start + 1, 'const __materialLatestResponse=await(async()=>{') insert(body.end - 1, '})();__materialLatestResponse.headers.set("Cache-Control","private, no-store");return __materialLatestResponse;') getters++ }) insist(getters === 1, 'Exactly one GET export is required') if (name === 'app/api/external/download/route.js') { walk(ast, (node, ancestors) => { const owner = ancestors.findLast(isFunction) if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier' || node.callee.name !== 'fetch' || owner?.id?.name !== 'z') return const options = node.arguments[1] insist(options?.type === 'ObjectExpression' && options.properties.some(item => key(item) === 'headers') && !options.properties.some(item => key(item) === 'cache'), 'Unexpected download fetch') insert(options.start + 1, 'cache:"no-store",') fetches++ }) insist(fetches === 1, 'Exactly one original single-file fetch is required') } } edits.sort((a, b) => b.start - a.start) let boundary = source.length, output = source for (const edit of edits) { insist(edit.end <= boundary, 'Overlapping source edits') output = output.slice(0, edit.start) + edit.text + output.slice(edit.end) boundary = edit.start } parse(output, { ecmaVersion: 'latest' }) return { output, counts: { guards, selectors, getters, fetches }, edits } } export function createOverlay(input, lock, outputDirectory, sourceRoot) { insist(lock.version === 1 && /^[0-9a-f]{40}$/.test(lock.sourceCommit) && /^[0-9a-f]{40}$/.test(lock.baseCommit), 'Invalid versioned source lock') insist(JSON.stringify(Object.keys(lock.compiled).sort()) === JSON.stringify([ 'app/api/external/download/route.js', 'app/api/external/product-assets/route.js', 'chunks/29.js', ]), 'Unapproved compiled lock set') insist(input.baseImageId === lock.baseImageId, 'Production image identity changed') insist(JSON.stringify(Object.keys(input.files).sort()) === JSON.stringify(Object.keys(lock.compiled).sort()), 'Compiled input set changed') insist(!fs.existsSync(outputDirectory), 'Output directory already exists') const files = [], results = {} for (const [name, expected] of Object.entries(lock.compiled)) { const bytes = Buffer.from(input.files[name].base64, 'base64') insist(sha256(bytes) === expected, 'Compiled source hash changed: ' + name) const patched = patchCompiled(name, bytes.toString('utf8')) results['.next/server/' + name] = Buffer.from(patched.output) files.push({ path: '.next/server/' + name, before: expected, after: sha256(patched.output), normalizeLf: false, counts: patched.counts }) } for (const name of ownSources) { const bytes = execFileSync('git', ['show', lock.sourceCommit + ':' + name], { cwd: sourceRoot }) const normalized = bytes.toString('utf8').replace(/\r\n/g, '\n') results[name] = bytes files.push({ path: name, before: lock.sources[name], after: sha256(normalized), normalizeLf: true }) } const plan = { version: 1, baseImageId: lock.baseImageId, baseCommit: lock.baseCommit, sourceCommit: lock.sourceCommit, schemaHash: lock.schemaHash, files } fs.mkdirSync(outputDirectory) for (const [name, bytes] of Object.entries(results)) { const destination = path.join(outputDirectory, 'overlay', name) fs.mkdirSync(path.dirname(destination), { recursive: true }) fs.writeFileSync(destination, bytes, { flag: 'wx' }) } fs.writeFileSync(path.join(outputDirectory, 'verified-plan.json'), JSON.stringify(plan, null, 2) + '\n', { flag: 'wx' }) const scripts = path.dirname(fileURLToPath(import.meta.url)) fs.copyFileSync(path.join(scripts, 'material-image-hotfix.Dockerfile'), path.join(outputDirectory, 'Dockerfile'), fs.constants.COPYFILE_EXCL) fs.copyFileSync(path.join(scripts, 'material-image-hotfix-verify.cjs'), path.join(outputDirectory, 'verify.cjs'), fs.constants.COPYFILE_EXCL) return plan } if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { const [input, output] = process.argv.slice(2) insist(input && output, 'Usage: node scripts/material-image-hotfix.mjs ') const lock = JSON.parse(fs.readFileSync(new URL('./material-image-hotfix-base.json', import.meta.url), 'utf8')) const plan = createOverlay(JSON.parse(fs.readFileSync(input, 'utf8')), lock, path.resolve(output), path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) console.log(JSON.stringify(plan, null, 2)) }