save-report.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import os from 'node:os';
  4. import crypto from 'node:crypto';
  5. import { spawnSync } from 'node:child_process';
  6. import { fileURLToPath } from 'node:url';
  7. import { DELIVERY_SCHEMA, validateReportDelivery, REPORT_ATTACHMENT_TYPES, REPORT_ATTACHMENT_LIMITS } from './report-contract.mjs';
  8. import { renderReportHtml } from './render-report-html.mjs';
  9. const reportSaveSelf = fileURLToPath(import.meta.url);
  10. const reportSaveHash = x => crypto.createHash('sha256').update(x).digest('hex');
  11. const reportSaveCanonical = x => x && typeof x === 'object' ? Array.isArray(x) ? '[' + x.map(reportSaveCanonical).join(',') + ']'
  12. : '{' + Object.keys(x).sort().map(k => JSON.stringify(k) + ':' + reportSaveCanonical(x[k])).join(',') + '}' : JSON.stringify(x);
  13. const reportSaveFail = code => { throw Object.assign(new Error(code), { code }); };
  14. /** Optional local-file manifest stays outside the API payload. No credential handling. */
  15. export function attachLocalReportFiles(task, localFiles = []) {
  16. if (!Array.isArray(localFiles) || localFiles.length > REPORT_ATTACHMENT_LIMITS.count) reportSaveFail('INVALID_REPORT_FILES');
  17. if (!localFiles.length) return structuredClone(task);
  18. if (task.report?.attachments?.length) reportSaveFail('AMBIGUOUS_REPORT_FILES');
  19. let total = 0;
  20. const attachments = localFiles.map(file => {
  21. if (!file || Object.keys(file).some(k => !['path', 'name', 'contentType'].includes(k)) || !path.isAbsolute(file.path || '')) reportSaveFail('INVALID_REPORT_FILES');
  22. const stat = fs.statSync(file.path);
  23. if (!stat.isFile() || stat.size < 1 || stat.size > REPORT_ATTACHMENT_LIMITS.perFileBytes || (total += stat.size) > REPORT_ATTACHMENT_LIMITS.totalBytes) reportSaveFail('REPORT_FILE_TOO_LARGE');
  24. const name = file.name || path.basename(file.path);
  25. if (/[\\/\u0000-\u001f]/.test(name) || name.length > 150 || !REPORT_ATTACHMENT_TYPES[file.contentType]?.includes(name.split('.').at(-1).toLowerCase())) reportSaveFail('INVALID_REPORT_FILES');
  26. const bytes = fs.readFileSync(file.path);
  27. return { name, contentType: file.contentType, sizeBytes: bytes.length, sha256: reportSaveHash(bytes), dataBase64: bytes.toString('base64') };
  28. });
  29. return { ...structuredClone(task), report: { ...structuredClone(task.report), attachments } };
  30. }
  31. /** Default classroom delivery: deterministic HTML from the same report JSON. */
  32. export function prepareHtmlReport(task, { directory, localFiles = [], dataMode } = {}) {
  33. if (!path.isAbsolute(directory || '')) reportSaveFail('ABSOLUTE_PATH_REQUIRED');
  34. if (validateReportDelivery(task.report, task.artifactType)?.schema !== DELIVERY_SCHEMA) reportSaveFail('REPORT_DELIVERY_REQUIRED');
  35. const prepared = attachLocalReportFiles(task, localFiles);
  36. const bytes = Buffer.from(renderReportHtml({ ...prepared, ...(dataMode ? { dataMode } : {}) }), 'utf8');
  37. if (bytes.length > REPORT_ATTACHMENT_LIMITS.perFileBytes) reportSaveFail('REPORT_HTML_TOO_LARGE');
  38. const name = 'ksf-report-' + reportSaveHash(JSON.stringify([task.sessionId, task.taskKey, task.version])).slice(0, 16) + '.html';
  39. const htmlPath = path.join(directory, name), sha256 = reportSaveHash(bytes);
  40. const prior = prepared.report.attachments || [];
  41. if (prior.some(file => file.name === name && file.sha256 !== sha256)) reportSaveFail('REPORT_HTML_VERSION_CONFLICT');
  42. const attachments = [{ name, contentType: 'text/html', sizeBytes: bytes.length, sha256, dataBase64: bytes.toString('base64') }, ...prior.filter(file => file.name !== name)];
  43. if (attachments.length > REPORT_ATTACHMENT_LIMITS.count || attachments.reduce((n, file) => n + file.sizeBytes, 0) > REPORT_ATTACHMENT_LIMITS.totalBytes) reportSaveFail('REPORT_FILE_TOO_LARGE');
  44. if (fs.existsSync(htmlPath)) {
  45. if (!fs.readFileSync(htmlPath).equals(bytes)) reportSaveFail('REPORT_HTML_VERSION_CONFLICT');
  46. } else fs.writeFileSync(htmlPath, bytes, { flag: 'wx', mode: 0o600 });
  47. prepared.report.attachments = attachments;
  48. return { task: prepared, htmlPath, htmlSha256: sha256, employeeConfirmed: false };
  49. }
  50. /** Save once. A read/visibility failure retains the saved ID and never repeats the write. */
  51. export async function saveAndVerifyReport(task, { requestId, invoke, publicRead, readAttachment }) {
  52. if (!requestId || task.requestId && task.requestId !== requestId) reportSaveFail('BAD_REQUEST_ID');
  53. if (validateReportDelivery(task.report, task.artifactType)?.schema !== DELIVERY_SCHEMA) reportSaveFail('REPORT_DELIVERY_REQUIRED');
  54. const contract = await invoke('workshop-contract', {}, requestId + ':contract');
  55. if (contract.workshop?.delivery?.schema !== DELIVERY_SCHEMA
  56. || task.report.attachments?.length && !contract.workshop.delivery.attachments?.enabled) reportSaveFail('REPORT_DELIVERY_CAPABILITY_UNAVAILABLE');
  57. const session = (await invoke('workshop-read', { className: 'KfSession', objectId: task.sessionId }, requestId + ':session')).record;
  58. if (!session?.meta?.attribution?.participantKey || !session.workshopKey) reportSaveFail('REPORT_SESSION_UNVERIFIED');
  59. let receipt;
  60. try {
  61. receipt = await invoke('workshop-save', task, requestId);
  62. if (!receipt?.artifactId) reportSaveFail('REPORT_SAVE_RECEIPT_INVALID');
  63. const record = (await invoke('workshop-read', { className: 'KfArtifact', objectId: receipt.artifactId }, requestId + ':read')).record;
  64. if (!record || record.objectId !== receipt.artifactId || record.reportContentHash !== reportSaveHash(reportSaveCanonical(task.report))
  65. || record.rawText !== task.rawText || record.version !== task.version || record.reportSeriesKey !== task.taskKey
  66. || record.stageCode !== task.stageCode || record.artifactType !== task.artifactType || record.dataMode !== session.dataMode
  67. || record.ownerSession !== task.sessionId || record.workshopKey !== session.workshopKey
  68. || record.uploadedByKey !== session.meta.attribution.participantKey
  69. || reportSaveCanonical(record.report?.chapters) !== reportSaveCanonical(task.report.chapters)
  70. || reportSaveCanonical(record.report?.delivery) !== reportSaveCanonical(task.report.delivery)
  71. || reportSaveCanonical(record.meta?.workshop?.sourceIds) !== reportSaveCanonical(task.sourceIds)) reportSaveFail('REPORT_READBACK_MISMATCH');
  72. const body = report => Object.fromEntries(Object.entries(report).filter(([key]) => !['title','generatedAt','executive','attachments'].includes(key)));
  73. if (reportSaveCanonical(body(record.report)) !== reportSaveCanonical(body(task.report))
  74. || reportSaveCanonical(record.report.executive?.slice(1)) !== reportSaveCanonical(task.report.executive.map(String))) reportSaveFail('REPORT_READBACK_MISMATCH');
  75. let cursor, projected, pages = 0;
  76. const seen = new Set();
  77. do {
  78. const page = await publicRead({ limit: 500, ...(cursor ? { cursor } : {}) });
  79. projected = page.artifact?.find(a => a.objectId === receipt.artifactId);
  80. cursor = page.nextCursor;
  81. if (cursor && seen.has(cursor) || ++pages > 200) reportSaveFail('REPORT_PUBLIC_SCAN_INCOMPLETE');
  82. seen.add(cursor);
  83. } while (!projected && cursor);
  84. if (!projected || ['version','reportContentHash','rawText','workshopKey','uploadedByKey','ownerSession','reportSeriesKey'].some(k => projected[k] !== record[k])
  85. || reportSaveCanonical(projected.report) !== reportSaveCanonical(record.report)) reportSaveFail('REPORT_PUBLIC_READBACK_MISMATCH');
  86. const attachments = record.report.attachments || [], inputs = task.report.attachments || [];
  87. if (attachments.length !== inputs.length) reportSaveFail('REPORT_FILE_READBACK_MISMATCH');
  88. for (const [index, file] of attachments.entries()) {
  89. const input = inputs[index], url = new URL(file.url);
  90. if (url.origin !== 'https://kangshifu.fmode.cn' || url.pathname !== '/api/functions'
  91. || url.searchParams.get('path') !== '/ksf/file' || url.searchParams.get('fileId') !== file.id
  92. || url.searchParams.get('artifactId') !== record.objectId
  93. || ['name','sha256','sizeBytes','contentType'].some(k => file[k] !== input[k])) reportSaveFail('REPORT_FILE_READBACK_MISMATCH');
  94. const bytes = await readAttachment(file.url);
  95. if (bytes.length !== file.sizeBytes || reportSaveHash(bytes) !== file.sha256) reportSaveFail('REPORT_FILE_READBACK_MISMATCH');
  96. }
  97. return { ...receipt, deliverySchema: DELIVERY_SCHEMA, contentStatus: task.report.delivery.contentStatus,
  98. recordVerified: true, publicDataVerified: true, filesVerified: attachments.length,
  99. browserVerified: false, employeeConfirmation: 'pending',
  100. htmlFiles: attachments.filter(file => file.contentType === 'text/html').map(({ name, url, sha256 }) => {
  101. const preview = new URL(url); preview.searchParams.set('preview', '1');
  102. return { name, url, previewUrl: preview.href, sha256 };
  103. }),
  104. reportUrl: 'https://kangshifu.fmode.cn/report/' + encodeURIComponent(receipt.artifactId) };
  105. } catch (error) {
  106. error.partial = { requestId, sessionId: task.sessionId, taskKey: task.taskKey, version: task.version,
  107. ...(receipt ? { receipt } : {}), saveAttempted: true, automaticRetry: false };
  108. throw error;
  109. }
  110. }
  111. if (process.argv[1] && path.resolve(process.argv[1]) === reportSaveSelf) {
  112. let args = {}, work, prepared, outputOwned = false;
  113. try {
  114. const argv = process.argv.slice(2);
  115. for (let i = 0; i < argv.length; i += 2) {
  116. if (!['--input','--request-id','--agent-profile','--out','--files'].includes(argv[i]) || !argv[i + 1] || args[argv[i]]) reportSaveFail('USAGE');
  117. args[argv[i]] = argv[i + 1];
  118. }
  119. for (const key of ['--input','--out']) if (!path.isAbsolute(args[key] || '')) reportSaveFail('ABSOLUTE_PATH_REQUIRED');
  120. if (!args['--request-id'] || !args['--agent-profile'] || fs.existsSync(args['--out'])) reportSaveFail('NEW_RECEIPT_PATH_REQUIRED');
  121. if (args['--files'] && !path.isAbsolute(args['--files'])) reportSaveFail('ABSOLUTE_PATH_REQUIRED');
  122. const inputTask = JSON.parse(fs.readFileSync(args['--input'], 'utf8'));
  123. work = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-save-report-'));fs.chmodSync(work, 0o700);
  124. const invoke = async (command, input, requestId) => {
  125. const inputPath = path.join(work, 'input.json');
  126. fs.writeFileSync(inputPath, JSON.stringify(input), { mode: 0o600 });
  127. const child = spawnSync(process.execPath, [path.join(path.dirname(reportSaveSelf), 'ksf-secure.mjs'), command,
  128. '--input', inputPath, '--request-id', requestId, '--agent-profile', args['--agent-profile']],
  129. { encoding: 'utf8', timeout: 600000, maxBuffer: 8 * 1024 * 1024, windowsHide: true });
  130. if (child.error || ![0,1].includes(child.status)) reportSaveFail('SECURE_ENTRY_UNCERTAIN');
  131. let output;try { output = JSON.parse(child.status === 0 ? child.stdout : child.stderr); } catch { reportSaveFail('SECURE_ENTRY_INVALID_RESPONSE'); }
  132. if (output.ok !== true || output.entryResult?.ok !== true) reportSaveFail(output.entryResult?.error?.code || 'SECURE_ENTRY_FAILED');
  133. return output.entryResult.result;
  134. };
  135. const publicRead = async params => {
  136. const response = await fetch('https://kangshifu.fmode.cn/api/functions', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(45000),
  137. headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'FKieRbIoGt_main' }, body: JSON.stringify({ path: '/ksfGetPublicBoard', params }) });
  138. const result = (await response.json())?.result;
  139. if (!response.ok || result?.ok !== true) reportSaveFail('REPORT_PUBLIC_READ_FAILED');
  140. return result.data;
  141. };
  142. const readAttachment = async url => {
  143. const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(45000) });
  144. if (!response.ok) reportSaveFail('REPORT_FILE_READ_FAILED');
  145. const bytes = Buffer.from(await response.arrayBuffer());
  146. if (bytes.length > REPORT_ATTACHMENT_LIMITS.perFileBytes) reportSaveFail('REPORT_FILE_TOO_LARGE');
  147. return bytes;
  148. };
  149. // Sample/real attribution comes from the actual session, not an extra API input field.
  150. const htmlSession = (await invoke('workshop-read', { className: 'KfSession', objectId: inputTask.sessionId }, args['--request-id'] + ':html-session')).record;
  151. if (!htmlSession?.meta?.attribution?.participantKey || !htmlSession.workshopKey) reportSaveFail('REPORT_SESSION_UNVERIFIED');
  152. prepared = prepareHtmlReport(inputTask, { directory: path.dirname(args['--out']), dataMode: htmlSession.dataMode,
  153. localFiles: args['--files'] ? JSON.parse(fs.readFileSync(args['--files'], 'utf8')) : [] });
  154. const task = prepared.task;
  155. // Write intent before calling the save service; crashes retain the stable request ID.
  156. fs.writeFileSync(args['--out'], JSON.stringify({ ok: false, status: 'pending', requestId: args['--request-id'], taskKey: task.taskKey, version: task.version,
  157. htmlPath: prepared.htmlPath, htmlSha256: prepared.htmlSha256, employeeConfirmed: false }), { mode: 0o600, flag: 'wx' });
  158. outputOwned = true;
  159. const result = await saveAndVerifyReport(task, { requestId: args['--request-id'], invoke, publicRead, readAttachment });
  160. const output = { ok: true, ...result, htmlPath: prepared.htmlPath };fs.writeFileSync(args['--out'], JSON.stringify(output, null, 2));console.log(JSON.stringify(output));
  161. } catch (error) {
  162. const output = { ok: false, errorCode: /^[A-Z][A-Z0-9_]+$/.test(error.code || '') ? error.code : 'REPORT_DELIVERY_FAILED',
  163. ...(error.partial ? { partial: error.partial } : {}), ...(prepared ? { htmlPath: prepared.htmlPath, htmlSha256: prepared.htmlSha256, employeeConfirmed: false } : {}), automaticRetry: false };
  164. if (outputOwned) fs.writeFileSync(args['--out'], JSON.stringify(output, null, 2));
  165. console.error(JSON.stringify(output));process.exitCode = 1;
  166. } finally {
  167. if (work) { const inputPath = path.join(work, 'input.json');if (fs.existsSync(inputPath)) fs.unlinkSync(inputPath);fs.rmdirSync(work); }
  168. }
  169. }