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