import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { DELIVERY_SCHEMA, validateReportDelivery, REPORT_ATTACHMENT_TYPES, REPORT_ATTACHMENT_LIMITS } from './report-contract.mjs'; import { renderReportHtml } from './render-report-html.mjs'; const reportSaveSelf = fileURLToPath(import.meta.url); const reportSaveHash = x => crypto.createHash('sha256').update(x).digest('hex'); const reportSaveCanonical = x => x && typeof x === 'object' ? Array.isArray(x) ? '[' + x.map(reportSaveCanonical).join(',') + ']' : '{' + Object.keys(x).sort().map(k => JSON.stringify(k) + ':' + reportSaveCanonical(x[k])).join(',') + '}' : JSON.stringify(x); const reportSaveFail = code => { throw Object.assign(new Error(code), { code }); }; /** Optional local-file manifest stays outside the API payload. No credential handling. */ export function attachLocalReportFiles(task, localFiles = []) { if (!Array.isArray(localFiles) || localFiles.length > REPORT_ATTACHMENT_LIMITS.count) reportSaveFail('INVALID_REPORT_FILES'); if (!localFiles.length) return structuredClone(task); if (task.report?.attachments?.length) reportSaveFail('AMBIGUOUS_REPORT_FILES'); let total = 0; const attachments = localFiles.map(file => { if (!file || Object.keys(file).some(k => !['path', 'name', 'contentType'].includes(k)) || !path.isAbsolute(file.path || '')) reportSaveFail('INVALID_REPORT_FILES'); const stat = fs.statSync(file.path); if (!stat.isFile() || stat.size < 1 || stat.size > REPORT_ATTACHMENT_LIMITS.perFileBytes || (total += stat.size) > REPORT_ATTACHMENT_LIMITS.totalBytes) reportSaveFail('REPORT_FILE_TOO_LARGE'); const name = file.name || path.basename(file.path); if (/[\\/\u0000-\u001f]/.test(name) || name.length > 150 || !REPORT_ATTACHMENT_TYPES[file.contentType]?.includes(name.split('.').at(-1).toLowerCase())) reportSaveFail('INVALID_REPORT_FILES'); const bytes = fs.readFileSync(file.path); return { name, contentType: file.contentType, sizeBytes: bytes.length, sha256: reportSaveHash(bytes), dataBase64: bytes.toString('base64') }; }); return { ...structuredClone(task), report: { ...structuredClone(task.report), attachments } }; } /** Default classroom delivery: deterministic HTML from the same report JSON. */ export function prepareHtmlReport(task, { directory, localFiles = [], dataMode } = {}) { if (!path.isAbsolute(directory || '')) reportSaveFail('ABSOLUTE_PATH_REQUIRED'); if (validateReportDelivery(task.report, task.artifactType)?.schema !== DELIVERY_SCHEMA) reportSaveFail('REPORT_DELIVERY_REQUIRED'); const prepared = attachLocalReportFiles(task, localFiles); const bytes = Buffer.from(renderReportHtml({ ...prepared, ...(dataMode ? { dataMode } : {}) }), 'utf8'); if (bytes.length > REPORT_ATTACHMENT_LIMITS.perFileBytes) reportSaveFail('REPORT_HTML_TOO_LARGE'); const name = 'ksf-report-' + reportSaveHash(JSON.stringify([task.sessionId, task.taskKey, task.version])).slice(0, 16) + '.html'; const htmlPath = path.join(directory, name), sha256 = reportSaveHash(bytes); const prior = prepared.report.attachments || []; if (prior.some(file => file.name === name && file.sha256 !== sha256)) reportSaveFail('REPORT_HTML_VERSION_CONFLICT'); const attachments = [{ name, contentType: 'text/html', sizeBytes: bytes.length, sha256, dataBase64: bytes.toString('base64') }, ...prior.filter(file => file.name !== name)]; if (attachments.length > REPORT_ATTACHMENT_LIMITS.count || attachments.reduce((n, file) => n + file.sizeBytes, 0) > REPORT_ATTACHMENT_LIMITS.totalBytes) reportSaveFail('REPORT_FILE_TOO_LARGE'); if (fs.existsSync(htmlPath)) { if (!fs.readFileSync(htmlPath).equals(bytes)) reportSaveFail('REPORT_HTML_VERSION_CONFLICT'); } else fs.writeFileSync(htmlPath, bytes, { flag: 'wx', mode: 0o600 }); prepared.report.attachments = attachments; return { task: prepared, htmlPath, htmlSha256: sha256, employeeConfirmed: false }; } /** Save once. A read/visibility failure retains the saved ID and never repeats the write. */ export async function saveAndVerifyReport(task, { requestId, invoke, publicRead, readAttachment }) { if (!requestId || task.requestId && task.requestId !== requestId) reportSaveFail('BAD_REQUEST_ID'); if (validateReportDelivery(task.report, task.artifactType)?.schema !== DELIVERY_SCHEMA) reportSaveFail('REPORT_DELIVERY_REQUIRED'); const contract = await invoke('workshop-contract', {}, requestId + ':contract'); if (contract.workshop?.delivery?.schema !== DELIVERY_SCHEMA || task.report.attachments?.length && !contract.workshop.delivery.attachments?.enabled) reportSaveFail('REPORT_DELIVERY_CAPABILITY_UNAVAILABLE'); const session = (await invoke('workshop-read', { className: 'KfSession', objectId: task.sessionId }, requestId + ':session')).record; if (!session?.meta?.attribution?.participantKey || !session.workshopKey) reportSaveFail('REPORT_SESSION_UNVERIFIED'); let receipt; try { receipt = await invoke('workshop-save', task, requestId); if (!receipt?.artifactId) reportSaveFail('REPORT_SAVE_RECEIPT_INVALID'); const record = (await invoke('workshop-read', { className: 'KfArtifact', objectId: receipt.artifactId }, requestId + ':read')).record; if (!record || record.objectId !== receipt.artifactId || record.reportContentHash !== reportSaveHash(reportSaveCanonical(task.report)) || record.rawText !== task.rawText || record.version !== task.version || record.reportSeriesKey !== task.taskKey || record.stageCode !== task.stageCode || record.artifactType !== task.artifactType || record.dataMode !== session.dataMode || record.ownerSession !== task.sessionId || record.workshopKey !== session.workshopKey || record.uploadedByKey !== session.meta.attribution.participantKey || reportSaveCanonical(record.report?.chapters) !== reportSaveCanonical(task.report.chapters) || reportSaveCanonical(record.report?.delivery) !== reportSaveCanonical(task.report.delivery) || reportSaveCanonical(record.meta?.workshop?.sourceIds) !== reportSaveCanonical(task.sourceIds)) reportSaveFail('REPORT_READBACK_MISMATCH'); const body = report => Object.fromEntries(Object.entries(report).filter(([key]) => !['title','generatedAt','executive','attachments'].includes(key))); if (reportSaveCanonical(body(record.report)) !== reportSaveCanonical(body(task.report)) || reportSaveCanonical(record.report.executive?.slice(1)) !== reportSaveCanonical(task.report.executive.map(String))) reportSaveFail('REPORT_READBACK_MISMATCH'); let cursor, projected, pages = 0; const seen = new Set(); do { const page = await publicRead({ limit: 500, ...(cursor ? { cursor } : {}) }); projected = page.artifact?.find(a => a.objectId === receipt.artifactId); cursor = page.nextCursor; if (cursor && seen.has(cursor) || ++pages > 200) reportSaveFail('REPORT_PUBLIC_SCAN_INCOMPLETE'); seen.add(cursor); } while (!projected && cursor); if (!projected || ['version','reportContentHash','rawText','workshopKey','uploadedByKey','ownerSession','reportSeriesKey'].some(k => projected[k] !== record[k]) || reportSaveCanonical(projected.report) !== reportSaveCanonical(record.report)) reportSaveFail('REPORT_PUBLIC_READBACK_MISMATCH'); const attachments = record.report.attachments || [], inputs = task.report.attachments || []; if (attachments.length !== inputs.length) reportSaveFail('REPORT_FILE_READBACK_MISMATCH'); for (const [index, file] of attachments.entries()) { const input = inputs[index], url = new URL(file.url); if (url.origin !== 'https://kangshifu.fmode.cn' || url.pathname !== '/api/functions' || url.searchParams.get('path') !== '/ksf/file' || url.searchParams.get('fileId') !== file.id || url.searchParams.get('artifactId') !== record.objectId || ['name','sha256','sizeBytes','contentType'].some(k => file[k] !== input[k])) reportSaveFail('REPORT_FILE_READBACK_MISMATCH'); const bytes = await readAttachment(file.url); if (bytes.length !== file.sizeBytes || reportSaveHash(bytes) !== file.sha256) reportSaveFail('REPORT_FILE_READBACK_MISMATCH'); } return { ...receipt, deliverySchema: DELIVERY_SCHEMA, contentStatus: task.report.delivery.contentStatus, recordVerified: true, publicDataVerified: true, filesVerified: attachments.length, browserVerified: false, employeeConfirmation: 'pending', htmlFiles: attachments.filter(file => file.contentType === 'text/html').map(({ name, url, sha256 }) => { const preview = new URL(url); preview.searchParams.set('preview', '1'); return { name, url, previewUrl: preview.href, sha256 }; }), reportUrl: 'https://kangshifu.fmode.cn/report/' + encodeURIComponent(receipt.artifactId) }; } catch (error) { error.partial = { requestId, sessionId: task.sessionId, taskKey: task.taskKey, version: task.version, ...(receipt ? { receipt } : {}), saveAttempted: true, automaticRetry: false }; throw error; } } if (process.argv[1] && path.resolve(process.argv[1]) === reportSaveSelf) { let args = {}, work, prepared, outputOwned = false; try { const argv = process.argv.slice(2); for (let i = 0; i < argv.length; i += 2) { if (!['--input','--request-id','--agent-profile','--out','--files'].includes(argv[i]) || !argv[i + 1] || args[argv[i]]) reportSaveFail('USAGE'); args[argv[i]] = argv[i + 1]; } for (const key of ['--input','--out']) if (!path.isAbsolute(args[key] || '')) reportSaveFail('ABSOLUTE_PATH_REQUIRED'); if (!args['--request-id'] || !args['--agent-profile'] || fs.existsSync(args['--out'])) reportSaveFail('NEW_RECEIPT_PATH_REQUIRED'); if (args['--files'] && !path.isAbsolute(args['--files'])) reportSaveFail('ABSOLUTE_PATH_REQUIRED'); const inputTask = JSON.parse(fs.readFileSync(args['--input'], 'utf8')); work = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-save-report-'));fs.chmodSync(work, 0o700); const invoke = async (command, input, requestId) => { const inputPath = path.join(work, 'input.json'); fs.writeFileSync(inputPath, JSON.stringify(input), { mode: 0o600 }); const child = spawnSync(process.execPath, [path.join(path.dirname(reportSaveSelf), 'ksf-secure.mjs'), command, '--input', inputPath, '--request-id', requestId, '--agent-profile', args['--agent-profile']], { encoding: 'utf8', timeout: 600000, maxBuffer: 8 * 1024 * 1024, windowsHide: true }); if (child.error || ![0,1].includes(child.status)) reportSaveFail('SECURE_ENTRY_UNCERTAIN'); let output;try { output = JSON.parse(child.status === 0 ? child.stdout : child.stderr); } catch { reportSaveFail('SECURE_ENTRY_INVALID_RESPONSE'); } if (output.ok !== true || output.entryResult?.ok !== true) reportSaveFail(output.entryResult?.error?.code || 'SECURE_ENTRY_FAILED'); return output.entryResult.result; }; const publicRead = async params => { const response = await fetch('https://kangshifu.fmode.cn/api/functions', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(45000), headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'FKieRbIoGt_main' }, body: JSON.stringify({ path: '/ksfGetPublicBoard', params }) }); const result = (await response.json())?.result; if (!response.ok || result?.ok !== true) reportSaveFail('REPORT_PUBLIC_READ_FAILED'); return result.data; }; const readAttachment = async url => { const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(45000) }); if (!response.ok) reportSaveFail('REPORT_FILE_READ_FAILED'); const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.length > REPORT_ATTACHMENT_LIMITS.perFileBytes) reportSaveFail('REPORT_FILE_TOO_LARGE'); return bytes; }; // Sample/real attribution comes from the actual session, not an extra API input field. const htmlSession = (await invoke('workshop-read', { className: 'KfSession', objectId: inputTask.sessionId }, args['--request-id'] + ':html-session')).record; if (!htmlSession?.meta?.attribution?.participantKey || !htmlSession.workshopKey) reportSaveFail('REPORT_SESSION_UNVERIFIED'); prepared = prepareHtmlReport(inputTask, { directory: path.dirname(args['--out']), dataMode: htmlSession.dataMode, localFiles: args['--files'] ? JSON.parse(fs.readFileSync(args['--files'], 'utf8')) : [] }); const task = prepared.task; // Write intent before calling the save service; crashes retain the stable request ID. fs.writeFileSync(args['--out'], JSON.stringify({ ok: false, status: 'pending', requestId: args['--request-id'], taskKey: task.taskKey, version: task.version, htmlPath: prepared.htmlPath, htmlSha256: prepared.htmlSha256, employeeConfirmed: false }), { mode: 0o600, flag: 'wx' }); outputOwned = true; const result = await saveAndVerifyReport(task, { requestId: args['--request-id'], invoke, publicRead, readAttachment }); const output = { ok: true, ...result, htmlPath: prepared.htmlPath };fs.writeFileSync(args['--out'], JSON.stringify(output, null, 2));console.log(JSON.stringify(output)); } catch (error) { const output = { ok: false, errorCode: /^[A-Z][A-Z0-9_]+$/.test(error.code || '') ? error.code : 'REPORT_DELIVERY_FAILED', ...(error.partial ? { partial: error.partial } : {}), ...(prepared ? { htmlPath: prepared.htmlPath, htmlSha256: prepared.htmlSha256, employeeConfirmed: false } : {}), automaticRetry: false }; if (outputOwned) fs.writeFileSync(args['--out'], JSON.stringify(output, null, 2)); console.error(JSON.stringify(output));process.exitCode = 1; } finally { if (work) { const inputPath = path.join(work, 'input.json');if (fs.existsSync(inputPath)) fs.unlinkSync(inputPath);fs.rmdirSync(work); } } }