// WHERE A REPORT'S EXPORTS LIVE, and which of them may be published. // // `archilyzer reports export` (./reportExports.ts) writes a report's exports // to `.export-index/sites//report-exports//` — staging, not // served — beside a manifest, `export.json`, naming each file with its size // and checksum and the sha256 of the report.json it was made from. Compose // (./composeReports.ts) asks `publishableReportExports` which to copy into the // site: a file is published only when // - the manifest's report sha256 is the report.json's NOW (an export of an // earlier version of the report is never shipped beside the new one), and // - the file is within the publish limit (lib/builtExport.ts, // PUBLISH_MAX_FILE_BYTES) — an evidence pack over it stays local. // // Kept apart from ./reportExports.ts, which renders the exports from the // resolved reports and so imports compose: compose imports this. import { createHash } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { publishFileSizeProblem } from "../lib/builtExport"; import { readJsonFile } from "../lib/jsonFile-server"; import type { Paths } from "../lib/paths"; import type { ReportExportFooter } from "../lib/report/exportHtml"; import { REPORT_EXPORT_FILENAMES, REPORT_EXPORT_FORMATS, type ReportExportFormat } from "../lib/report/views"; import { siteIndexDir } from "../lib/site"; import { siteReportFile } from "./reportMedia"; export const REPORT_EXPORTS_DIRNAME = "report-exports"; export const REPORT_EXPORT_MANIFEST_FILENAME = "export.json"; export const REPORT_EXPORT_MANIFEST_FORMAT = "archilyzer-report-export"; export const REPORT_EXPORT_MANIFEST_VERSION = 1; // `.export-index/sites//report-exports/`. export function reportExportsDir(paths: Paths, siteId: string): string { return path.join(siteIndexDir(paths, siteId), REPORT_EXPORTS_DIRNAME); } // `.export-index/sites//report-exports//`. export function reportExportDir(paths: Paths, siteId: string, reportId: string): string { return path.join(reportExportsDir(paths, siteId), reportId); } export type ReportExportFileEntry = { // The file's name in the report's export dir (REPORT_EXPORT_FILENAMES). file: string; bytes: number; sha256: string; // Why it may not be published (over the limit), or absent when it may. localOnly?: string; }; export type ReportExportManifest = { format: typeof REPORT_EXPORT_MANIFEST_FORMAT; version: typeof REPORT_EXPORT_MANIFEST_VERSION; siteId: string; reportId: string; // The sha256 of the report.json the exports were made from. reportSha256: string; exportedAt: string; footer: ReportExportFooter; // The revision these exports belong to (publish/reportHistory.ts): its // number, commit, date and change summary, and whether this export made // it. Absent when the history could not be read or written. revision?: { revision: number; commit: string; date: string; summary: string[]; committed: boolean; }; files: Partial>; // What was skipped, and why (a PDF with no browser, a pack with no `zip`). notes: string[]; }; export function sha256Hex(data: string | Uint8Array): string { return createHash("sha256").update(data).digest("hex"); } // The sha256 of a report.json as it is on disk, or null when it is unreadable. export async function reportFileSha256(paths: Paths, siteId: string, reportId: string): Promise { try { return sha256Hex(await readFile(siteReportFile(paths, siteId, reportId))); } catch { return null; } } export async function readReportExportManifest( paths: Paths, siteId: string, reportId: string, ): Promise { const read = await readJsonFile(path.join(reportExportDir(paths, siteId, reportId), REPORT_EXPORT_MANIFEST_FILENAME)); if (!read.ok) return null; const v = read.value as Partial | null; if (!v || v.format !== REPORT_EXPORT_MANIFEST_FORMAT || v.version !== REPORT_EXPORT_MANIFEST_VERSION) return null; return v as ReportExportManifest; } export type PublishableReportExports = { // format → the local file to publish. files: Partial>; // Why an export is not published, one sentence each. notes: string[]; }; // The exports of a report compose may publish: made from the report.json as // it is now, each file present and within the publish limit. A report never // exported has none, and no note. export async function publishableReportExports( paths: Paths, siteId: string, reportId: string, ): Promise { const manifest = await readReportExportManifest(paths, siteId, reportId); if (!manifest) return { files: {}, notes: [] }; const current = await reportFileSha256(paths, siteId, reportId); if (current !== manifest.reportSha256) { return { files: {}, notes: ["its exports were made from another version of report.json — export again; none are published"], }; } const dir = reportExportDir(paths, siteId, reportId); const files: PublishableReportExports["files"] = {}; const notes: string[] = []; for (const f of REPORT_EXPORT_FORMATS) { if (!manifest.files[f]) continue; const file = path.join(dir, REPORT_EXPORT_FILENAMES[f]); const st = await stat(file).catch(() => null); if (!st?.isFile()) { notes.push(`${REPORT_EXPORT_FILENAMES[f]} is named in export.json but missing — export again`); continue; } const tooBig = publishFileSizeProblem(REPORT_EXPORT_FILENAMES[f], st.size); if (tooBig) { notes.push(`${tooBig}; it stays local`); continue; } files[f] = file; } return { files, notes }; }