Files
TallyNote/server/files.ts
T
Qiufeng 12495fb6a4
TallyNote release / linux-x64 (push) Successful in 6m24s
release: 1.1.0
2026-08-31 20:11:34 +08:00

274 lines
12 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);
}
function mapReadError(error: unknown): unknown {
if (error instanceof AppError) return error;
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") {
return new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
}
return error;
}
/** 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) {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(safeStoragePath(root, relativePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
} catch (error) {
throw mapReadError(error);
}
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 mapReadError(error);
}
}
export async function readStorageFile(config: AppConfig, storagePath: string): Promise<Buffer> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(safeStoragePath(config.filesDir, storagePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
} catch (error) {
throw mapReadError(error);
}
try {
const info = await handle.stat();
if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
return await handle.readFile();
} catch (error) {
throw mapReadError(error);
} finally {
await handle.close();
}
}