import { createHash, randomUUID } from "node:crypto"; import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs"; import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises"; import path from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import type { MultipartFile } from "@fastify/multipart"; import type Database from "better-sqlite3"; import { XMLParser, XMLValidator } from "fast-xml-parser"; import { PDFDocument } from "pdf-lib"; import sharp from "sharp"; import yauzl from "yauzl"; import type { AttachmentKind } from "../shared/contracts.js"; import type { AppConfig } from "./config.js"; import { AppError } from "./errors.js"; export type StagedFile = { id: string; originalName: string; stagingPath: string; sizeBytes: number; sha256: string; mimeType: string; extension: string; kind: AttachmentKind; }; const imageTypes = new Map([ ["jpeg", { mimeType: "image/jpeg", extension: "jpg" }], ["png", { mimeType: "image/png", extension: "png" }], ["webp", { mimeType: "image/webp", extension: "webp" }], ]); export function sanitizeOriginalName(value: string): string { const normalized = path.basename(value.normalize("NFKC").replaceAll("\\", "/")).replace(/[\u0000-\u001f\u007f]/g, "").trim(); return (normalized || "未命名文件").slice(0, 200); } function detectBasic(buffer: Buffer): "jpeg" | "png" | "webp" | "pdf" | "ofd" | "xml" | null { if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "jpeg"; if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png"; if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "webp"; if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") return "pdf"; if (buffer[0] === 0x50 && buffer[1] === 0x4b) return "ofd"; const prefix = buffer.subarray(0, 256).toString("utf8").trimStart(); if (prefix.startsWith(" { await new Promise((resolve, reject) => { yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => { if (error || !zip) return reject(error ?? new Error("无法读取 OFD")); let entries = 0; let total = 0; let hasRoot = false; let settled = false; const fail = (reason: Error) => { if (settled) return; settled = true; zip.close(); reject(reason); }; zip.on("entry", (entry) => { entries += 1; total += entry.uncompressedSize; const name = entry.fileName.replaceAll("\\", "/"); if (name === "OFD.xml") hasRoot = true; if (entries > 2000 || total > 200 * 1024 * 1024 || name.startsWith("/") || name.split("/").includes("..")) { fail(new Error("OFD 结构超出安全限制")); return; } zip.readEntry(); }); zip.on("end", () => { if (settled) return; settled = true; hasRoot ? resolve() : reject(new Error("缺少 OFD.xml")); }); zip.on("error", fail); zip.readEntry(); }); }); } async function validateContent(buffer: Buffer, kind: AttachmentKind): Promise<{ mimeType: string; extension: string }> { const detected = detectBasic(buffer); if (!detected) throw new AppError(415, "UNSUPPORTED_MEDIA_TYPE", "无法识别文件格式"); if (imageTypes.has(detected)) { const metadata = await sharp(buffer, { failOn: "error", limitInputPixels: 40_000_000 }).metadata(); if (!metadata.width || !metadata.height || !metadata.format || !imageTypes.has(metadata.format)) { throw new AppError(415, "INVALID_IMAGE", "图片内容无效"); } return imageTypes.get(metadata.format)!; } if (kind === "payment_proof") { throw new AppError(415, "PAYMENT_PROOF_MUST_BE_IMAGE", "付款凭证仅支持 JPEG、PNG 或 WebP 图片"); } if (detected === "pdf") { // Inspect the binary token stream case-insensitively. PDF names are // case-sensitive in theory, but rejecting common active-content aliases // avoids browser/plugin execution surprises across viewers. const pdfTokens = buffer.toString("latin1"); const suspicious = /\/(?:JavaScript|JS|Launch|EmbeddedFile|OpenAction|AA)\b/i.test(pdfTokens); if (suspicious) throw new AppError(415, "UNSAFE_PDF", "PDF 包含不受支持的活动内容"); const document = await PDFDocument.load(buffer, { ignoreEncryption: false, throwOnInvalidObject: true }); if (document.getPageCount() < 1 || document.getPageCount() > 2000) throw new Error("PDF 页数无效"); return { mimeType: "application/pdf", extension: "pdf" }; } if (detected === "ofd") { await validateOfd(buffer); return { mimeType: "application/ofd", extension: "ofd" }; } const xml = buffer.toString("utf8"); if (/ { const id = randomUUID(); const stagingPath = path.join(config.stagingDir, `${id}.part`); let sizeBytes = 0; const hash = createHash("sha256"); const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) { sizeBytes += chunk.length; if (sizeBytes > config.maxFileBytes) return callback(new AppError(413, "FILE_TOO_LARGE", "单个文件超过大小限制")); hash.update(chunk); callback(null, chunk); }, }); try { await pipeline(part.file, meter, createWriteStream(stagingPath, { flags: "wx", mode: 0o600 })); if (part.file.truncated || sizeBytes === 0) throw new AppError(413, "FILE_TOO_LARGE", "文件为空或超过大小限制"); const buffer = await readFile(stagingPath); const type = await validateContent(buffer, kind); return { id, originalName: sanitizeOriginalName(part.filename), stagingPath, sizeBytes, sha256: hash.digest("hex"), mimeType: type.mimeType, extension: type.extension, kind, }; } catch (error) { await rm(stagingPath, { force: true }); if (error instanceof AppError) throw error; throw new AppError(415, "INVALID_FILE", "文件内容校验失败"); } } export async function promoteStagedFile(config: AppConfig, file: StagedFile): Promise { const relative = path.join(file.id.slice(0, 2), `${file.id}.${file.extension}`); const destination = safeStoragePath(config.filesDir, relative); await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); await chmod(path.dirname(destination), 0o700); await rename(file.stagingPath, destination); const directory = await open(path.dirname(destination), "r"); await directory.sync(); await directory.close(); return relative; } export function safeStoragePath(root: string, relative: string): string { if (path.isAbsolute(relative)) throw new AppError(500, "INVALID_STORAGE_PATH", "附件路径无效"); const resolvedRoot = path.resolve(root); const resolved = path.resolve(root, relative); if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new AppError(500, "INVALID_STORAGE_PATH", "附件路径无效"); return resolved; } export async function discardStaged(files: StagedFile[]): Promise { await Promise.all(files.map((file) => rm(file.stagingPath, { force: true }))); } export async function processFileDeletions(sqlite: Database.Database, config: AppConfig): Promise { const rows = sqlite.prepare(` SELECT id, storage_path AS storagePath FROM file_deletions WHERE status IN ('pending','failed') AND attempts < 10 ORDER BY created_at LIMIT 100 `).all() as Array<{ id: string; storagePath: string }>; for (const row of rows) { try { await unlink(safeStoragePath(config.filesDir, row.storagePath)).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); sqlite.prepare("UPDATE file_deletions SET status='complete', attempts=attempts+1, last_error=NULL, completed_at=? WHERE id=?").run(Date.now(), row.id); } catch (error) { sqlite.prepare("UPDATE file_deletions SET status='failed', attempts=attempts+1, last_error=? WHERE id=?").run(String(error).slice(0, 500), row.id); } } } export async function cleanupStaging(config: AppConfig): Promise { const cutoff = Date.now() - 24 * 60 * 60 * 1000; for (const entry of await readdir(config.stagingDir, { withFileTypes: true })) { const target = path.join(config.stagingDir, entry.name); const info = await stat(target).catch(() => null); if (info && info.mtimeMs < cutoff) await rm(target, { recursive: true, force: true }); } } export async function cleanupOrphanedFiles(sqlite: Database.Database, config: AppConfig): Promise { const referenced = new Set((sqlite.prepare("SELECT storage_path AS storagePath FROM attachments").all() as Array<{ storagePath: string }>).map((row) => row.storagePath)); const cutoff = Date.now() - 24 * 60 * 60 * 1000; const walk = async (directory: string, prefix: string): Promise => { for (const entry of await readdir(directory, { withFileTypes: true })) { const relative = path.join(prefix, entry.name); const target = path.join(directory, entry.name); if (entry.isDirectory()) { await walk(target, relative); continue; } if (!entry.isFile() && !entry.isSymbolicLink()) continue; const info = await stat(target).catch(() => null); if (info && info.mtimeMs < cutoff && !referenced.has(relative)) await rm(target, { force: true }); } }; await walk(config.filesDir, ""); } export async function fileReadStream(config: AppConfig, storagePath: string) { return safeReadStream(config.filesDir, storagePath); } /** Open a private file by descriptor and keep the no-follow guarantee through * the subsequent read. Used for both attachment and export downloads. */ export async function safeReadStream(root: string, relativePath: string) { const handle = await open(safeStoragePath(root, relativePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); try { const info = await handle.stat(); if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用"); return handle.createReadStream({ autoClose: true }); } catch (error) { await handle.close().catch(() => undefined); throw error; } } export async function readStorageFile(config: AppConfig, storagePath: string): Promise { const handle = await open(safeStoragePath(config.filesDir, storagePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); try { const info = await handle.stat(); if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用"); return await handle.readFile(); } finally { await handle.close(); } }