253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
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("<?xml") || prefix.startsWith("<")) return "xml";
|
|
return null;
|
|
}
|
|
|
|
async function validateOfd(buffer: Buffer): Promise<void> {
|
|
await new Promise<void>((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 (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new AppError(415, "UNSAFE_XML", "XML 不允许 DTD 或实体声明");
|
|
if (XMLValidator.validate(xml) !== true) throw new AppError(415, "INVALID_XML", "XML 内容无效");
|
|
new XMLParser({ processEntities: false, ignoreAttributes: false }).parse(xml);
|
|
return { mimeType: "application/xml", extension: "xml" };
|
|
}
|
|
|
|
export async function stageMultipartFile(config: AppConfig, part: MultipartFile, kind: AttachmentKind): Promise<StagedFile> {
|
|
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<string> {
|
|
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<void> {
|
|
await Promise.all(files.map((file) => rm(file.stagingPath, { force: true })));
|
|
}
|
|
|
|
export async function processFileDeletions(sqlite: Database.Database, config: AppConfig): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> => {
|
|
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<Buffer> {
|
|
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();
|
|
}
|
|
}
|