159 lines
9.0 KiB
JavaScript
159 lines
9.0 KiB
JavaScript
// 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 <observed-input.json> <new-output-directory>')
|
|
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))
|
|
}
|