import { createHash, randomUUID } from "node:crypto"; import { constants as fsConstants, createWriteStream } from "node:fs"; import { open, readdir, rename, rm, stat, unlink } from "node:fs/promises"; import path from "node:path"; import { ZipArchive } from "archiver"; import type Database from "better-sqlite3"; import ExcelJS from "exceljs"; import type { AppConfig } from "./config.js"; import { readStorageFile, safeStoragePath, sanitizeOriginalName } from "./files.js"; export type ExportAttachment = { id: string; kind: "payment_proof" | "invoice"; originalName: string; mimeType: string; storagePath: string; sizeBytes: number; sha256: string; }; export type ExportExpense = { id: string; paidAt: number; amountCents: number; note: string; invoiceMissingReason: string | null; status: "unreimbursed" | "reimbursed"; attachments: ExportAttachment[]; }; export type ExportSnapshot = { expenses: ExportExpense[]; includeManifest?: boolean }; // The application is intentionally single-instance, but a queued export can // still be triggered twice by a retry or two browser tabs. Keep one builder // per job so both calls cannot write the same .part file concurrently. const activeExportBuilds = new Set(); export function safeExcelText(value: string): string { const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").slice(0, 32_767); return /^[\s\u0000-\u001f]*[=+\-@]/.test(cleaned) ? `'${cleaned}` : cleaned; } function dateParts(timestamp: number, timezone: string): { display: string; compact: string } { const formatter = new Intl.DateTimeFormat("zh-CN", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }); const pieces = Object.fromEntries(formatter.formatToParts(timestamp).map((part) => [part.type, part.value])); return { display: `${pieces.year}-${pieces.month}-${pieces.day} ${pieces.hour}:${pieces.minute}`, compact: `${pieces.year}${pieces.month}${pieces.day}`, }; } function uniqueAttachmentName(attachment: ExportAttachment, seen: Set): string { const parsed = path.parse(sanitizeOriginalName(attachment.originalName)); const fallbackExtension = path.extname(attachment.storagePath); const extension = (parsed.ext || fallbackExtension).slice(0, 16); const base = (parsed.name || attachment.kind).slice(0, 100); if ([base, extension].some((part) => part.includes("/") || part.includes("\\") || part === "." || part === "..")) { throw new Error("附件文件名包含非法路径片段"); } let candidate = `${base}${extension}`; let counter = 2; while (seen.has(candidate.toLocaleLowerCase("und"))) candidate = `${base}_${counter++}${extension}`; seen.add(candidate.toLocaleLowerCase("und")); return candidate; } async function workbookBuffer(snapshot: ExportSnapshot, config: AppConfig): Promise { const workbook = new ExcelJS.Workbook(); workbook.creator = "TallyNote"; workbook.created = new Date(); const sheet = workbook.addWorksheet("报销清单", { views: [{ state: "frozen", ySplit: 1 }] }); sheet.columns = [ { header: "序号", key: "sequence", width: 8 }, { header: "支付时间", key: "paidAt", width: 22 }, { header: "金额(元)", key: "amount", width: 16 }, { header: "备注", key: "note", width: 44 }, { header: "状态", key: "status", width: 14 }, { header: "记录 ID", key: "id", width: 38 }, { header: "付款凭证", key: "proofs", width: 38 }, { header: "发票", key: "invoices", width: 38 }, { header: "无发票原因", key: "invoiceMissingReason", width: 44 }, ]; sheet.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } }; sheet.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF175CD3" } }; sheet.getRow(1).height = 24; snapshot.expenses.forEach((expense, index) => { const row = sheet.addRow({ sequence: index + 1, paidAt: dateParts(expense.paidAt, config.timezone).display, amount: expense.amountCents / 100, note: safeExcelText(expense.note), status: expense.status === "reimbursed" ? "已报销" : "未报销", id: expense.id, proofs: safeExcelText(expense.attachments.filter((item) => item.kind === "payment_proof").map((item) => item.originalName).join(";")), invoices: safeExcelText(expense.attachments.filter((item) => item.kind === "invoice").map((item) => item.originalName).join(";")), invoiceMissingReason: safeExcelText(expense.invoiceMissingReason || ""), }); row.getCell("amount").numFmt = '¥#,##0.00'; row.alignment = { vertical: "top", wrapText: true }; }); const totalRow = sheet.addRow({ sequence: "合计", amount: snapshot.expenses.reduce((sum, expense) => sum + expense.amountCents, 0) / 100, }); totalRow.font = { bold: true }; totalRow.getCell("amount").numFmt = '¥#,##0.00'; sheet.autoFilter = { from: "A1", to: "I1" }; return Buffer.from(await workbook.xlsx.writeBuffer()); } export async function buildExportJob(sqlite: Database.Database, config: AppConfig, jobId: string): Promise { if (activeExportBuilds.has(jobId)) return; activeExportBuilds.add(jobId); try { await buildExportJobOnce(sqlite, config, jobId); } finally { activeExportBuilds.delete(jobId); } } async function buildExportJobOnce(sqlite: Database.Database, config: AppConfig, jobId: string): Promise { const job = sqlite.prepare("SELECT snapshot_json AS snapshotJson FROM export_jobs WHERE id=? AND status IN ('queued','building')").get(jobId) as { snapshotJson: string } | undefined; if (!job) return; sqlite.prepare("UPDATE export_jobs SET status='building', error_message=NULL WHERE id=?").run(jobId); // Use a fresh O_EXCL path for every build. A deterministic `.part` path can // be pre-created as a symlink by another local process and then followed by // createWriteStream. The final rename remains atomic and replaces only the // destination entry itself. const partialPath = path.join(config.exportsDir, `${jobId}.zip.part-${randomUUID()}`); const finalPath = path.join(config.exportsDir, `${jobId}.zip`); try { const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot; const output = createWriteStream(partialPath, { flags: "wx", mode: 0o600 }); const archive = new ZipArchive({ zlib: { level: 6 } }); const completed = new Promise((resolve, reject) => { output.on("close", resolve); output.on("error", reject); archive.on("warning", reject); archive.on("error", reject); }); archive.pipe(output); archive.append(await workbookBuffer(snapshot, config), { name: "报销清单.xlsx" }); if (snapshot.includeManifest === true) { const manifest = { generatedAt: new Date().toISOString(), records: snapshot.expenses.map((expense) => ({ id: expense.id, paidAt: dateParts(expense.paidAt, config.timezone).display, amountCents: expense.amountCents, invoiceMissingReason: expense.invoiceMissingReason || null, attachments: expense.attachments.map((attachment) => ({ id: attachment.id, kind: attachment.kind, originalName: attachment.originalName, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, sha256: attachment.sha256, })), })), }; archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" }); } for (const [index, expense] of snapshot.expenses.entries()) { const date = dateParts(expense.paidAt, config.timezone).compact; const folder = `${String(index + 1).padStart(3, "0")}_${date}_${(expense.amountCents / 100).toFixed(2)}_${expense.id.slice(0, 8)}`; const seen = new Set(); for (const attachment of expense.attachments) { const group = attachment.kind === "payment_proof" ? "付款凭证" : "发票"; const fileName = uniqueAttachmentName(attachment, seen); const absolute = safeStoragePath(config.filesDir, attachment.storagePath); const bytes = await readStorageFile(config, attachment.storagePath); const digest = createHash("sha256").update(bytes).digest("hex"); if (bytes.length !== attachment.sizeBytes || digest !== attachment.sha256) { throw new Error(`附件校验失败:${attachment.id}`); } archive.append(bytes, { name: `${folder}/${group}/${fileName}` }); } } await archive.finalize(); await completed; await rename(partialPath, finalPath); // Open without following symlinks and keep the descriptor for the digest // and size read. This closes the check/use gap around the published file. const handle = await open(finalPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); let bytes: Buffer; let info; try { info = await handle.stat(); if (!info.isFile()) throw new Error("导出文件类型无效"); bytes = await handle.readFile(); } finally { await handle.close(); } const published = sqlite.prepare(` UPDATE export_jobs SET status='ready', file_path=?, size_bytes=?, sha256=?, ready_at=? WHERE id=? AND status='building' `).run(path.basename(finalPath), info.size, createHash("sha256").update(bytes).digest("hex"), Date.now(), jobId); if (published.changes !== 1) await rm(finalPath, { force: true }); } catch (error) { await rm(partialPath, { force: true }); await rm(finalPath, { force: true }); // Never expose filesystem paths, attachment IDs, or raw OS errors through // the export status API. Keep a small allowlist of actionable messages. const raw = error instanceof Error ? error.message : ""; const safe = (error as { code?: unknown } | null)?.code === "ATTACHMENT_MISSING" || raw.startsWith("附件校验失败") || raw.includes("ENOENT") ? "导出失败:附件文件缺失或校验不通过" : "导出失败:服务器无法生成导出文件"; sqlite.prepare("UPDATE export_jobs SET status='failed', error_message=? WHERE id=? AND status='building'").run(safe, jobId); } } export function insertExportJob( sqlite: Database.Database, config: AppConfig, input: { adminId: string; sessionHash: string; selection: unknown; snapshot: ExportSnapshot }, ): string { const id = randomUUID(); const now = Date.now(); sqlite.prepare(` INSERT INTO export_jobs ( id, admin_id, session_hash, status, selection_json, snapshot_json, file_name, created_at, expires_at ) VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?) `).run( id, input.adminId, input.sessionHash, JSON.stringify(input.selection), JSON.stringify(input.snapshot), `TallyNote_报销资料_${id.slice(0, 8)}.zip`, now, now + config.exportTtlMs, ); return id; } export async function resumeExports(sqlite: Database.Database, config: AppConfig): Promise { const jobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>; for (const job of jobs) await buildExportJob(sqlite, config, job.id); } export async function expireExports(sqlite: Database.Database, config: AppConfig): Promise { const rows = sqlite.prepare("SELECT id, file_path AS filePath FROM export_jobs WHERE status != 'expired' AND expires_at <= ?").all(Date.now()) as Array<{ id: string; filePath: string | null }>; for (const row of rows) { if (row.filePath) { await unlink(safeStoragePath(config.exportsDir, row.filePath)).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); } sqlite.prepare("UPDATE export_jobs SET status='expired', file_path=NULL WHERE id=?").run(row.id); } } export async function cleanupOrphanedExports(sqlite: Database.Database, config: AppConfig): Promise { const referenced = new Set((sqlite.prepare("SELECT file_path AS filePath FROM export_jobs WHERE status='ready' AND file_path IS NOT NULL AND expires_at > ?").all(Date.now()) as Array<{ filePath: string }>).map((row) => row.filePath)); const activeJobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>; for (const job of activeJobs) { referenced.add(`${job.id}.zip`); } const cutoff = Date.now() - 10 * 60 * 1000; for (const entry of await readdir(config.exportsDir, { withFileTypes: true })) { if (!entry.isFile() && !entry.isSymbolicLink()) continue; const target = path.join(config.exportsDir, entry.name); const info = await stat(target).catch(() => null); const belongsToActiveBuild = activeJobs.some((job) => entry.name.startsWith(`${job.id}.zip.part-`)); if (info && info.mtimeMs < cutoff && !referenced.has(entry.name) && !belongsToActiveBuild) await rm(target, { force: true }); } }