save-report.mjs 14 KB

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