gongxiangp2/scripts/material-image-hotfix.mjs

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))
}