import { existsSync } from "node:fs"; import { rm, stat, unlink } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; import cookie from "@fastify/cookie"; import helmet from "@fastify/helmet"; import multipart from "@fastify/multipart"; import fastifyStatic from "@fastify/static"; import { z, ZodError } from "zod"; import { adminStatusSchema, amountToCents, attachmentKindSchema, attachmentDeleteSchema, changePasswordSchema, createAdminSchema, expenseInputSchema, expenseStatusSchema, expenseUpdateSchema, exportRequestSchema, loginSchema, permanentDeleteSchema, statusUpdateSchema, updateApplySchema, updateDownloadSchema, versionSchema, type AttachmentKind, type ExpenseStatus, type UpdateJobStatus, } from "../shared/contracts.js"; import { writeAudit } from "./audit.js"; import type { AppConfig } from "./config.js"; import type { DatabaseContext } from "./db/index.js"; import { AppError, errorPayload, notFound } from "./errors.js"; import { buildExportJob, expireExports, insertExportJob, type ExportExpense, type ExportSnapshot } from "./exporter.js"; import { discardStaged, fileReadStream, processFileDeletions, promoteStagedFile, safeReadStream, safeStoragePath, stageMultipartFile, type StagedFile, } from "./files.js"; import { constantTimeEqual, hashPassword, normalizeUsername, randomToken, sha256, temporaryPassword, validateNewPassword, verifyPassword, } from "./security.js"; import { isNewerVersion } from "./update.js"; import { ACTIVE_UPDATE_STATUSES, checkForUpdate, currentReleaseVersion, publicCheckFromCache, publicUpdateJob, reconcileOrphanedUpdateJobs, readCachedRelease, writeUpdateRequest, cancelUpdateJob, type UpdateRequest, } from "./update-service.js"; type AdminRow = { id: string; username: string; usernameNorm: string; displayName: string; passwordHash: string; status: "active" | "disabled"; mustChangePassword: number; authVersion: number; version: number; createdAt: number; lastLoginAt: number | null; disabledAt: number | null; }; type AuthContext = { tokenHash: string; csrfHash: string; admin: AdminRow; }; declare module "fastify" { interface FastifyRequest { auth?: AuthContext; } } const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); const sessionCookie = "tally_session"; const csrfCookie = "tally_csrf"; type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number }; const updateRateStates = new WeakMap>(); function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState { let states = updateRateStates.get(database); if (!states) { states = new Map(); updateRateStates.set(database, states); } let state = states.get(adminId); if (!state) { state = { checkedAt: 0, downloadedAt: 0, appliedAt: 0 }; states.set(adminId, state); } return state; } function enforceUpdateCooldown( database: DatabaseContext["sqlite"], config: AppConfig, adminId: string, operation: "check" | "download" | "apply", reply: FastifyReply, ): number { const state = updateRateState(database, adminId); const now = Date.now(); const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt; const cooldown = operation === "check" ? config.updateCheckCooldownMs : operation === "download" ? config.updateDownloadCooldownMs : config.updateApplyCooldownMs; if (cooldown > 0 && previous > 0 && now - previous < cooldown) { const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000)); reply.header("Retry-After", retryAfter); throw new AppError(429, "UPDATE_RATE_LIMITED", operation === "check" ? "检查更新过于频繁,请稍后再试" : "更新操作过于频繁,请稍后再试"); } if (operation === "check") state.checkedAt = now; else if (operation === "download") state.downloadedAt = now; else state.appliedAt = now; return now; } function adminSelect(alias = ""): string { const column = (name: string) => alias ? `${alias}.${name}` : name; return ` ${column("id")}, ${column("username")}, ${column("username_norm")} AS usernameNorm, ${column("display_name")} AS displayName, ${column("password_hash")} AS passwordHash, ${column("status")}, ${column("must_change_password")} AS mustChangePassword, ${column("auth_version")} AS authVersion, ${column("version")}, ${column("created_at")} AS createdAt, ${column("last_login_at")} AS lastLoginAt, ${column("disabled_at")} AS disabledAt `; } function publicAdmin(admin: AdminRow) { return { id: admin.id, username: admin.username, displayName: admin.displayName, status: admin.status, mustChangePassword: Boolean(admin.mustChangePassword), version: admin.version, createdAt: admin.createdAt, lastLoginAt: admin.lastLoginAt, disabledAt: admin.disabledAt, }; } function cookieOptions(config: AppConfig, httpOnly: boolean) { return { path: "/", httpOnly, secure: config.cookieSecure, sameSite: "strict" as const, }; } function clearSessionCookies(reply: FastifyReply, config: AppConfig): void { reply.clearCookie(sessionCookie, cookieOptions(config, true)); reply.clearCookie(csrfCookie, cookieOptions(config, false)); } function createSession(database: DatabaseContext, config: AppConfig, admin: AdminRow, reply: FastifyReply): AuthContext { const token = randomToken(); const csrf = randomToken(); const tokenHash = sha256(token); const csrfHash = sha256(csrf); const now = Date.now(); database.sqlite.prepare(` INSERT INTO sessions ( token_hash, admin_id, csrf_hash, auth_version, created_at, last_seen_at, idle_expires_at, absolute_expires_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run(tokenHash, admin.id, csrfHash, admin.authVersion, now, now, now + config.sessionIdleMs, now + config.sessionAbsoluteMs); reply.setCookie(sessionCookie, token, { ...cookieOptions(config, true), maxAge: Math.floor(config.sessionAbsoluteMs / 1000) }); reply.setCookie(csrfCookie, csrf, { ...cookieOptions(config, false), maxAge: Math.floor(config.sessionAbsoluteMs / 1000) }); return { tokenHash, csrfHash, admin }; } function authenticate(request: FastifyRequest, database: DatabaseContext, config: AppConfig): AuthContext { const token = request.cookies[sessionCookie]; if (!token || token.length > 128) throw new AppError(401, "AUTH_REQUIRED", "请先登录"); const tokenHash = sha256(token); const row = database.sqlite.prepare(` SELECT s.token_hash AS tokenHash, s.csrf_hash AS csrfHash, s.auth_version AS sessionAuthVersion, s.last_seen_at AS lastSeenAt, s.idle_expires_at AS idleExpiresAt, s.absolute_expires_at AS absoluteExpiresAt, ${adminSelect("a")} FROM sessions s JOIN admins a ON a.id=s.admin_id WHERE s.token_hash=? `).get(tokenHash) as (AdminRow & { tokenHash: string; csrfHash: string; sessionAuthVersion: number; lastSeenAt: number; idleExpiresAt: number; absoluteExpiresAt: number; }) | undefined; const now = Date.now(); if (!row || row.status !== "active" || row.sessionAuthVersion !== row.authVersion || row.idleExpiresAt <= now || row.absoluteExpiresAt <= now) { if (row) database.sqlite.prepare("DELETE FROM sessions WHERE token_hash=?").run(tokenHash); throw new AppError(401, "AUTH_REQUIRED", "登录已失效,请重新登录"); } if (now - row.lastSeenAt >= 5 * 60 * 1000) { database.sqlite.prepare("UPDATE sessions SET last_seen_at=?, idle_expires_at=? WHERE token_hash=?") .run(now, Math.min(now + config.sessionIdleMs, row.absoluteExpiresAt), tokenHash); } const auth = { tokenHash: row.tokenHash, csrfHash: row.csrfHash, admin: row }; request.auth = auth; return auth; } function assertCsrf(request: FastifyRequest, auth: AuthContext): void { const cookieToken = request.cookies[csrfCookie] ?? ""; const headerToken = typeof request.headers["x-csrf-token"] === "string" ? request.headers["x-csrf-token"] : ""; if (!cookieToken || !headerToken || !constantTimeEqual(cookieToken, headerToken) || !constantTimeEqual(sha256(cookieToken), auth.csrfHash)) { throw new AppError(403, "CSRF_INVALID", "请求安全令牌无效,请刷新页面后重试"); } } function guard(database: DatabaseContext, config: AppConfig, options: { allowPasswordChange?: boolean } = {}) { return async (request: FastifyRequest) => { const auth = authenticate(request, database, config); if (unsafeMethods.has(request.method)) assertCsrf(request, auth); if (!options.allowPasswordChange && auth.admin.mustChangePassword) { throw new AppError(403, "PASSWORD_CHANGE_REQUIRED", "首次登录需要先修改密码"); } }; } function expenseBaseSelect(): string { return ` e.id, e.paid_at AS paidAt, e.amount_cents AS amountCents, e.note, e.invoice_missing_reason AS invoiceMissingReason, e.status, e.version, e.created_at AS createdAt, e.updated_at AS updatedAt, e.reimbursed_at AS reimbursedAt, e.deleted_at AS deletedAt, creator.display_name AS createdByName, updater.display_name AS updatedByName, SUM(CASE WHEN a.kind='payment_proof' THEN 1 ELSE 0 END) AS paymentProofCount, SUM(CASE WHEN a.kind='invoice' THEN 1 ELSE 0 END) AS invoiceCount `; } type ExpenseRow = { id: string; paidAt: number; amountCents: number; note: string; invoiceMissingReason: string | null; status: ExpenseStatus; version: number; createdAt: number; updatedAt: number; reimbursedAt: number | null; deletedAt: number | null; createdByName: string; updatedByName: string; paymentProofCount: number; invoiceCount: number; }; type AttachmentRow = { id: string; expenseId: string; kind: AttachmentKind; storagePath: string; originalName: string; mimeType: string; sizeBytes: number; sha256: string; createdAt: number; }; function listAttachments(database: DatabaseContext, expenseId: string): AttachmentRow[] { return database.sqlite.prepare(` SELECT id, expense_id AS expenseId, kind, storage_path AS storagePath, original_name AS originalName, mime_type AS mimeType, size_bytes AS sizeBytes, sha256, created_at AS createdAt FROM attachments WHERE expense_id=? ORDER BY created_at, id `).all(expenseId) as AttachmentRow[]; } function publicAttachment(row: AttachmentRow) { return { id: row.id, expenseId: row.expenseId, kind: row.kind, originalName: row.originalName, mimeType: row.mimeType, sizeBytes: row.sizeBytes, sha256: row.sha256, createdAt: row.createdAt, previewable: row.mimeType.startsWith("image/") || row.mimeType === "application/pdf", }; } function getExpense(database: DatabaseContext, id: string, includeDeleted = false): ExpenseRow | undefined { return database.sqlite.prepare(` SELECT ${expenseBaseSelect()} FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by WHERE e.id=? ${includeDeleted ? "" : "AND e.deleted_at IS NULL"} GROUP BY e.id `).get(id) as ExpenseRow | undefined; } function publicExpense(database: DatabaseContext, row: ExpenseRow, withAttachments = false) { return { ...row, paymentProofCount: Number(row.paymentProofCount), invoiceCount: Number(row.invoiceCount), ...(withAttachments ? { attachments: listAttachments(database, row.id).map(publicAttachment) } : {}), }; } function normalizeInvoiceMissingReason(value: string | null | undefined): string | null { const normalized = typeof value === "string" ? value.trim() : ""; return normalized || null; } function assertInvoiceCoverage(invoiceCount: number, reason: string | null, missingStatus = 400): void { const normalizedReason = normalizeInvoiceMissingReason(reason); if (invoiceCount > 0 && normalizedReason) { throw new AppError(400, "INVOICE_REASON_WITH_INVOICE", "已有发票时不能填写无发票原因"); } if (invoiceCount < 1 && !normalizedReason) { throw new AppError(missingStatus, "INVOICE_OR_REASON_REQUIRED", "请上传发票,或勾选“无发票”并填写原因"); } } export function zonedMonthBounds(month: string, timezone: string): [number, number] { const match = /^(\d{4})-(\d{2})$/.exec(month); if (!match) throw new AppError(400, "VALIDATION_ERROR", "月份格式无效"); const year = Number(match[1]); const monthIndex = Number(match[2]) - 1; if (monthIndex < 0 || monthIndex > 11) throw new AppError(400, "VALIDATION_ERROR", "月份格式无效"); const toUtc = (targetYear: number, targetMonth: number) => { const target = Date.UTC(targetYear, targetMonth, 1, 0, 0, 0); let guess = target; const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", }); for (let iteration = 0; iteration < 4; iteration += 1) { const parts = Object.fromEntries(formatter.formatToParts(guess).map((part) => [part.type, part.value])); const observed = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second)); const difference = target - observed; guess += difference; if (difference === 0) break; } return guess; }; return [toUtc(year, monthIndex), toUtc(year, monthIndex + 1)]; } const listQuerySchema = z.object({ month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/), status: expenseStatusSchema.default("unreimbursed"), query: z.string().max(200).default(""), missingInvoice: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), }).strict(); function filteredExpenses(database: DatabaseContext, config: AppConfig, query: z.infer): ExpenseRow[] { const [start, end] = zonedMonthBounds(query.month, config.timezone); const escaped = query.query.replace(/[\\%_]/g, "\\$&"); return database.sqlite.prepare(` SELECT ${expenseBaseSelect()} FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by WHERE e.deleted_at IS NULL AND e.status=? AND e.paid_at>=? AND e.paid_at Date.now()) throw new AppError(429, "REAUTH_RATE_LIMITED", "密码确认尝试过多,请稍后再试"); } function recordReauthFailure(database: DatabaseContext, request: FastifyRequest, adminId: string): void { const key = reauthKey(request, adminId); const now = Date.now(); const current = database.sqlite.prepare("SELECT window_start AS windowStart, failures FROM login_attempts WHERE key_hash=?").get(key) as { windowStart: number; failures: number } | undefined; const fresh = !current || current.windowStart <= now - 15 * 60 * 1000; const failures = fresh ? 1 : current.failures + 1; const blockedUntil = failures >= 5 ? now + 15 * 60 * 1000 : null; database.sqlite.prepare(` INSERT INTO login_attempts(key_hash, window_start, failures, blocked_until) VALUES (?, ?, ?, ?) ON CONFLICT(key_hash) DO UPDATE SET window_start=excluded.window_start, failures=excluded.failures, blocked_until=excluded.blocked_until `).run(key, fresh ? now : current.windowStart, failures, blockedUntil); } function clearReauthFailures(database: DatabaseContext, request: FastifyRequest, adminId: string): void { database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash=?").run(reauthKey(request, adminId)); } async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig, options: { allowVersion?: boolean } = {}): Promise<{ fields: Record; files: StagedFile[] }> { const fields: Record = {}; const files: StagedFile[] = []; const allowedFields = new Set(["paidAt", "amount", "note", "invoiceMissingReason", ...(options.allowVersion ? ["version"] : [])]); try { for await (const part of request.parts()) { if (part.type === "field") { if (!allowedFields.has(part.fieldname)) { throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段"); } if (part.fieldname in fields) { throw new AppError(400, "DUPLICATE_FIELD", "表单字段不能重复"); } fields[part.fieldname] = String(part.value); continue; } if (files.length >= config.maxFilesPerRequest) throw new AppError(413, "TOO_MANY_FILES", "一次请求上传的文件过多"); const kind = part.fieldname === "paymentProofs" ? "payment_proof" : part.fieldname === "invoices" ? "invoice" : null; if (!kind) { part.file.resume(); throw new AppError(400, "UNKNOWN_FILE_FIELD", "未知的附件字段"); } files.push(await stageMultipartFile(config, part, kind)); } return { fields, files }; } catch (error) { await discardStaged(files); throw error; } } function promotedRelativePath(file: StagedFile): string { return path.join(file.id.slice(0, 2), `${file.id}.${file.extension}`); } async function cleanupPromotedFiles(sqlite: DatabaseContext["sqlite"] | undefined, config: AppConfig, files: Array): Promise { await Promise.all(files.map(async (file) => { const relative = file.storagePath || promotedRelativePath(file); try { await unlink(safeStoragePath(config.filesDir, relative)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") return; if (sqlite) { try { enqueueFileDeletion(sqlite, relative, "attachment_rollback"); } catch { /* database may already be closing */ } } } })); } async function promoteAll(config: AppConfig, files: StagedFile[], sqlite?: DatabaseContext["sqlite"]): Promise> { const promoted: Array = []; try { for (const file of files) promoted.push({ ...file, storagePath: await promoteStagedFile(config, file) }); return promoted; } catch (error) { // Include the file currently being promoted: rename() may have succeeded // before a directory sync/close error was raised. await cleanupPromotedFiles(sqlite, config, files); await discardStaged(files); throw error; } } async function updateExpenseMultipart(database: DatabaseContext, config: AppConfig, request: FastifyRequest, id: string) { const { fields, files } = await parseExpenseMultipart(request, config, { allowVersion: true }); let input: z.infer; try { input = expenseUpdateSchema.parse({ paidAt: fields.paidAt, amount: fields.amount, note: fields.note ?? "", invoiceMissingReason: fields.invoiceMissingReason, version: fields.version === undefined ? undefined : Number(fields.version), }); } catch (error) { await discardStaged(files); throw error; } const before = getExpense(database, id); if (!before) { await discardStaged(files); notFound("账目不存在"); } if (before.version !== input.version) { await discardStaged(files); conflict(database, id); } const paymentProofs = files.filter((file) => file.kind === "payment_proof"); const invoiceFiles = files.filter((file) => file.kind === "invoice"); // Adding an invoice supersedes the previous no-invoice explanation. This // mirrors the standalone attachment endpoint and keeps the two states // mutually exclusive even when a client omits the optional field. const requestedReason = invoiceFiles.length > 0 ? null : input.invoiceMissingReason === undefined ? before.invoiceMissingReason : normalizeInvoiceMissingReason(input.invoiceMissingReason); const nextInvoiceCount = Number(before.invoiceCount) + invoiceFiles.length; const nextProofCount = Number(before.paymentProofCount) + paymentProofs.length; if (nextProofCount < 1) { await discardStaged(files); throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证"); } try { assertInvoiceCoverage(nextInvoiceCount, requestedReason); } catch (error) { await discardStaged(files); throw error; } const addedBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0); const currentBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total; if (currentBytes + addedBytes > config.maxRecordBytes) { await discardStaged(files); throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); } const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; if (globalBytes + addedBytes > config.maxTotalBytes) { await discardStaged(files); throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); } const paidAt = Date.parse(input.paidAt); if (!Number.isFinite(paidAt)) { await discardStaged(files); throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); } let amountCents: number; try { amountCents = amountToCents(input.amount); } catch { await discardStaged(files); throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数"); } const promoted = await promoteAll(config, files, database.sqlite); const now = Date.now(); try { database.sqlite.transaction(() => { const current = getExpense(database, id); if (!current) notFound("账目不存在"); if (current.version !== input.version) conflict(database, id); const invoiceCount = Number(current.invoiceCount) + invoiceFiles.length; const proofCount = Number(current.paymentProofCount) + paymentProofs.length; if (proofCount < 1) throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证"); const invoiceMissingReason = invoiceFiles.length > 0 ? null : (input.invoiceMissingReason === undefined ? current.invoiceMissingReason : normalizeInvoiceMissingReason(input.invoiceMissingReason)); assertInvoiceCoverage(invoiceCount, invoiceMissingReason); const liveBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total; if (liveBytes + addedBytes > config.maxRecordBytes) throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); const allBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; if (allBytes + addedBytes > config.maxTotalBytes) throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); const updated = database.sqlite.prepare("UPDATE expenses SET paid_at=?, amount_cents=?, note=?, invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL") .run(paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, id, input.version); if (updated.changes !== 1) conflict(database, id); const insert = database.sqlite.prepare("INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); for (const file of promoted) insert.run(file.id, id, file.kind, file.storagePath, file.originalName, file.mimeType, file.sizeBytes, file.sha256, now, request.auth!.admin.id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.updated", targetType: "expense", targetId: id, before: { paidAt: current.paidAt, amountCents: current.amountCents, note: current.note, invoiceMissingReason: current.invoiceMissingReason, version: current.version }, after: { paidAt, amountCents, note: input.note, invoiceMissingReason, version: input.version + 1, attachmentCount: promoted.length }, }); if (promoted.length > 0) writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.attachments_added", targetType: "expense", targetId: id, before: { invoiceCount: Number(current.invoiceCount), paymentProofCount: Number(current.paymentProofCount), version: current.version }, after: { invoiceCount, paymentProofCount: proofCount, version: input.version + 1, files: promoted.map((file) => ({ id: file.id, name: file.originalName, size: file.sizeBytes })) }, }); }).immediate(); } catch (error) { await cleanupPromotedFiles(database.sqlite, config, promoted); throw error; } return { expense: publicExpense(database, getExpense(database, id)!, true) }; } function conflict(database: DatabaseContext, id: string): never { const current = getExpense(database, id, true); if (!current) notFound("账目不存在"); throw new AppError(409, "VERSION_CONFLICT", "记录已被其他管理员修改", { currentVersion: current.version, current: publicExpense(database, current, true), }); } export async function buildApp(database: DatabaseContext, config: AppConfig) { // Helmet's defaults include `upgrade-insecure-requests`, HSTS, COOP and // Origin-Agent-Cluster. Those headers are appropriate for HTTPS, but an // explicitly opted-in HTTP deployment must remain HTTP all the way through // the asset graph; otherwise browsers upgrade `/assets/*` to HTTPS and the // plain HTTP listener appears as a blank page. Keep the transport-sensitive // headers protocol-aware while retaining the other hardening headers. const secureOrigin = config.publicOrigin.startsWith("https:"); const app = Fastify({ logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false, // Fastify's runtime accepts a numeric hop count, while its v5 typings do // not expose that overload. Keep the validated numeric value and bridge // the declaration at this boundary. trustProxy: config.trustProxy as any, bodyLimit: config.maxRecordBytes + 2 * 1024 * 1024, requestIdHeader: false, genReqId: () => randomUUID(), }); const dummyPasswordHash = await hashPassword(randomToken()); await app.register(cookie); await app.register(helmet, { ...(!secureOrigin || config.isLocalOrigin ? { hsts: false } : {}), frameguard: { action: "deny" }, referrerPolicy: { policy: "no-referrer" }, ...(secureOrigin ? { crossOriginOpenerPolicy: { policy: "same-origin" }, originAgentCluster: true } : { crossOriginOpenerPolicy: false, originAgentCluster: false }), crossOriginResourcePolicy: { policy: "same-origin" }, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], imgSrc: ["'self'", "blob:", "data:"], objectSrc: ["'none'"], frameSrc: ["'self'"], "frame-ancestors": ["'none'"], "base-uri": ["'none'"], "form-action": ["'self'"], ...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}), }, }, }); await app.register(multipart, { limits: { fileSize: config.maxFileBytes, files: config.maxFilesPerRequest, fields: 20, parts: config.maxFilesPerRequest + 20 }, throwFileSizeLimit: true, }); // Financial records, attachment bytes and update metadata must never be // retained by a browser, reverse proxy or shared cache. Keep this global so // future authenticated routes inherit the same privacy boundary. app.addHook("onSend", async (request, reply, payload) => { if (request.url.split("?", 1)[0]!.startsWith("/api/")) { reply.header("Cache-Control", "no-store"); reply.header("Pragma", "no-cache"); reply.header("Vary", "Cookie"); } return payload; }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error)); if (error instanceof ZodError) { const appError = new AppError(400, "VALIDATION_ERROR", "提交内容不符合要求", error.issues); return reply.code(400).send(errorPayload(request, appError)); } if ((error as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") { const appError = new AppError(413, "FILE_TOO_LARGE", "单个文件超过大小限制"); return reply.code(413).send(errorPayload(request, appError)); } const fastifyError = error as { code?: string; statusCode?: number }; if (fastifyError.code === "FST_ERR_CTP_INVALID_JSON_BODY" || fastifyError.code === "FST_ERR_CTP_EMPTY_JSON_BODY") { const appError = new AppError(400, "INVALID_JSON", "请求 JSON 格式无效"); return reply.code(400).send(errorPayload(request, appError)); } if (fastifyError.statusCode === 413 || ["FST_REQ_BODY_TOO_LARGE", "FST_ERR_CTP_BODY_TOO_LARGE", "FST_FIELDS_LIMIT", "FST_FILES_LIMIT", "FST_PARTS_LIMIT"].includes(fastifyError.code ?? "")) { const appError = new AppError(413, "REQUEST_TOO_LARGE", "请求内容超过大小或数量限制"); return reply.code(413).send(errorPayload(request, appError)); } if (typeof fastifyError.statusCode === "number" && fastifyError.statusCode >= 400 && fastifyError.statusCode < 500) { const appError = new AppError(fastifyError.statusCode, "BAD_REQUEST", "请求无法处理"); return reply.code(fastifyError.statusCode).send(errorPayload(request, appError)); } request.log.error(error); return reply.code(500).send(errorPayload(request, new AppError(500, "INTERNAL_ERROR", "服务器处理请求时发生错误"))); }); app.get("/health", async () => ({ status: "ok", initialized: Boolean(database.sqlite.prepare("SELECT 1 FROM admins LIMIT 1").get()) })); app.get("/api/auth/status", async () => ({ initialized: Boolean(database.sqlite.prepare("SELECT 1 FROM admins LIMIT 1").get()), timezone: config.timezone })); app.post("/api/auth/login", { bodyLimit: 16 * 1024 }, async (request, reply) => { const input = loginSchema.parse(request.body); const normalized = normalizeUsername(input.username); const now = Date.now(); const ipKey = sha256(`ip:${request.ip}`); const pairKey = sha256(`pair:${request.ip}:${normalized}`); const limits = [ { key: ipKey, maximum: 20 }, { key: pairKey, maximum: 5 }, ]; for (const limit of limits) { const row = database.sqlite.prepare("SELECT failures, blocked_until AS blockedUntil FROM login_attempts WHERE key_hash=?").get(limit.key) as { failures: number; blockedUntil: number | null } | undefined; if (row?.blockedUntil && row.blockedUntil > now) { reply.header("Retry-After", Math.ceil((row.blockedUntil - now) / 1000)); throw new AppError(429, "LOGIN_RATE_LIMITED", "登录尝试过多,请稍后再试"); } } const admin = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE username_norm=?`).get(normalized) as AdminRow | undefined; const valid = await verifyPassword(admin?.passwordHash ?? dummyPasswordHash, input.password); if (!admin || admin.status !== "active" || !valid) { const updateLimit = database.sqlite.transaction(() => { for (const limit of limits) { const current = database.sqlite.prepare("SELECT window_start AS windowStart, failures FROM login_attempts WHERE key_hash=?").get(limit.key) as { windowStart: number; failures: number } | undefined; const freshWindow = !current || current.windowStart <= now - 15 * 60 * 1000; const failures = freshWindow ? 1 : current.failures + 1; const blockedUntil = failures >= limit.maximum ? now + 15 * 60 * 1000 : null; database.sqlite.prepare(` INSERT INTO login_attempts(key_hash, window_start, failures, blocked_until) VALUES (?, ?, ?, ?) ON CONFLICT(key_hash) DO UPDATE SET window_start=excluded.window_start, failures=excluded.failures, blocked_until=excluded.blocked_until `).run(limit.key, freshWindow ? now : current.windowStart, failures, blockedUntil); } writeAudit(database.sqlite, { requestId: request.id, actorUsername: normalized, action: "auth.login", targetType: "session", outcome: "denied", metadata: { ipHash: sha256(request.ip) }, }); }); updateLimit(); throw new AppError(401, "INVALID_CREDENTIALS", "用户名或密码不正确"); } database.sqlite.transaction(() => { database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash IN (?, ?)").run(ipKey, pairKey); database.sqlite.prepare("UPDATE admins SET last_login_at=? WHERE id=?").run(now, admin.id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: admin.id, actorUsername: admin.username, action: "auth.login", targetType: "session", outcome: "success", }); })(); const updatedAdmin = { ...admin, lastLoginAt: now }; createSession(database, config, updatedAdmin, reply); reply.header("Cache-Control", "no-store"); return { admin: publicAdmin(updatedAdmin) }; }); app.get("/api/auth/session", { preHandler: guard(database, config, { allowPasswordChange: true }) }, async (request, reply) => { reply.header("Cache-Control", "no-store"); return { admin: publicAdmin(request.auth!.admin) }; }); app.post("/api/auth/logout", { preHandler: guard(database, config, { allowPasswordChange: true }) }, async (request, reply) => { database.sqlite.transaction(() => { database.sqlite.prepare("DELETE FROM sessions WHERE token_hash=?").run(request.auth!.tokenHash); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "auth.logout", targetType: "session", targetId: request.auth!.tokenHash.slice(0, 12), }); })(); clearSessionCookies(reply, config); return reply.code(204).send(); }); app.post("/api/auth/change-password", { preHandler: guard(database, config, { allowPasswordChange: true }), bodyLimit: 16 * 1024 }, async (request, reply) => { const input = changePasswordSchema.parse(request.body); const policyError = validateNewPassword(input.newPassword); if (policyError) throw new AppError(400, "PASSWORD_POLICY_FAILED", policyError); assertReauthAllowed(database, request, request.auth!.admin.id); if (!await verifyPassword(request.auth!.admin.passwordHash, input.currentPassword)) { recordReauthFailure(database, request, request.auth!.admin.id); throw new AppError(401, "CURRENT_PASSWORD_INVALID", "当前密码不正确"); } clearReauthFailures(database, request, request.auth!.admin.id); if (await verifyPassword(request.auth!.admin.passwordHash, input.newPassword)) { throw new AppError(409, "PASSWORD_REUSE_NOT_ALLOWED", "新密码不能与当前密码相同"); } const passwordHash = await hashPassword(input.newPassword); const now = Date.now(); database.sqlite.transaction(() => { database.sqlite.prepare(` UPDATE admins SET password_hash=?, must_change_password=0, auth_version=auth_version+1, version=version+1, password_changed_at=? WHERE id=? `).run(passwordHash, now, request.auth!.admin.id); database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(request.auth!.admin.id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "admin.password_changed", targetType: "admin", targetId: request.auth!.admin.id, }); })(); const updated = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(request.auth!.admin.id) as AdminRow; createSession(database, config, updated, reply); reply.header("Cache-Control", "no-store"); return { admin: publicAdmin(updated) }; }); app.get("/api/admins", { preHandler: guard(database, config) }, async () => { const rows = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins ORDER BY created_at`).all() as AdminRow[]; return { items: rows.map(publicAdmin) }; }); app.post("/api/admins", { preHandler: guard(database, config) }, async (request, reply) => { const input = createAdminSchema.parse(request.body); const usernameNorm = normalizeUsername(input.username); if ([...usernameNorm].length < 3) throw new AppError(400, "VALIDATION_ERROR", "用户名至少需要 3 个字符"); const password = temporaryPassword(); const passwordHash = await hashPassword(password); const id = randomUUID(); const now = Date.now(); try { database.sqlite.transaction(() => { database.sqlite.prepare(` INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, must_change_password, auth_version, version, created_at, created_by) VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?, ?) `).run(id, input.username.normalize("NFKC").trim(), usernameNorm, input.displayName, passwordHash, now, request.auth!.admin.id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "admin.created", targetType: "admin", targetId: id, after: { username: input.username, displayName: input.displayName, status: "active" }, }); }).immediate(); } catch (error) { if (String(error).includes("UNIQUE")) throw new AppError(409, "USERNAME_TAKEN", "用户名已存在"); throw error; } const created = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; reply.header("Cache-Control", "no-store"); return reply.code(201).send({ admin: publicAdmin(created), temporaryPassword: password }); }); app.patch("/api/admins/:id", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const input = z.object({ displayName: z.string().trim().min(1).max(80), version: z.number().int().positive() }).strict().parse(request.body); const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined; if (!existing) notFound("管理员不存在"); const result = database.sqlite.transaction(() => { const update = database.sqlite.prepare("UPDATE admins SET display_name=?, version=version+1 WHERE id=? AND version=?").run(input.displayName, id, input.version); if (update.changes !== 1) throw new AppError(409, "VERSION_CONFLICT", "管理员资料已被更新"); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "admin.updated", targetType: "admin", targetId: id, before: { displayName: existing.displayName }, after: { displayName: input.displayName }, }); return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; })(); return { admin: publicAdmin(result) }; }); app.put("/api/admins/:id/status", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const input = adminStatusSchema.parse(request.body); const result = database.sqlite.transaction(() => { const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined; if (!existing) notFound("管理员不存在"); if (existing.version !== input.version) throw new AppError(409, "VERSION_CONFLICT", "管理员状态已被更新"); if (existing.status === input.status) return existing; if (id === request.auth!.admin.id && input.status === "disabled") { throw new AppError(409, "SELF_DISABLE_FORBIDDEN", "不能停用当前登录的管理员账号"); } if (input.status === "disabled") { const active = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins WHERE status='active'").get() as { count: number }; if (active.count <= 1) throw new AppError(409, "LAST_ACTIVE_ADMIN", "不能停用最后一个有效管理员"); } const now = Date.now(); database.sqlite.prepare(` UPDATE admins SET status=?, version=version+1, auth_version=auth_version+1, disabled_at=?, disabled_by=? WHERE id=? AND version=? `).run(input.status, input.status === "disabled" ? now : null, input.status === "disabled" ? request.auth!.admin.id : null, id, input.version); if (input.status === "disabled") database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: input.status === "disabled" ? "admin.disabled" : "admin.enabled", targetType: "admin", targetId: id, before: { status: existing.status }, after: { status: input.status }, }); return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; }).immediate(); return { admin: publicAdmin(result) }; }); app.post("/api/admins/:id/reset-password", { preHandler: guard(database, config) }, async (request, reply) => { const id = z.string().uuid().parse((request.params as { id: string }).id); if (id === request.auth!.admin.id) throw new AppError(409, "SELF_RESET_FORBIDDEN", "不能重置当前登录管理员的密码,请使用修改密码功能"); const input = versionSchema.parse(request.body); const password = temporaryPassword(); const passwordHash = await hashPassword(password); const updated = database.sqlite.transaction(() => { const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined; if (!existing) notFound("管理员不存在"); const result = database.sqlite.prepare(` UPDATE admins SET password_hash=?, must_change_password=1, auth_version=auth_version+1, version=version+1, password_changed_at=NULL WHERE id=? AND version=? `).run(passwordHash, id, input.version); if (result.changes !== 1) throw new AppError(409, "VERSION_CONFLICT", "管理员资料已被更新"); database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "admin.password_reset", targetType: "admin", targetId: id, }); return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; })(); reply.header("Cache-Control", "no-store"); return { admin: publicAdmin(updated), temporaryPassword: password }; }); app.get("/api/update/status", { preHandler: guard(database, config) }, async (request, reply) => { // Release metadata and task state should never be stored by an upstream // proxy or a shared browser cache. reply.header("Cache-Control", "no-store"); reconcileOrphanedUpdateJobs(database.sqlite, config); const cached = publicCheckFromCache(database.sqlite, config); // Status is a live control surface, not an update history endpoint. // Terminal failures/cancellations from a previous attempt must not be // replayed as if the operator had just started an update. They remain in // the database/audit log, while this endpoint exposes only an actionable // task (or the latest successful completion for confirmation). const row = database.sqlite.prepare(` SELECT id, operation, status, version, platform, asset_name AS assetName, asset_url AS assetUrl, release_url AS releaseUrl, size_bytes AS sizeBytes, error_message AS errorMessage, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt, downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt, download_speed_bps AS downloadSpeedBps, requested_at AS applyQueuedAt FROM update_jobs WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")}) ORDER BY created_at DESC LIMIT 1 `).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record | undefined; return { ...cached, strategy: config.updateStrategy, job: publicUpdateJob(row), }; }); app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { const rateState = updateRateState(database.sqlite, request.auth!.admin.id); const previousCheckedAt = rateState.checkedAt; let reservedCheckedAt: number | null = null; try { reconcileOrphanedUpdateJobs(database.sqlite, config); // Disabled/dev installs do not contact a release endpoint, so repeated // checks are local status reads and should remain immediately usable. if (config.updateStrategy !== "disabled") { reservedCheckedAt = enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply); } const result = await checkForUpdate(database.sqlite, config); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.checked", targetType: "system", metadata: { currentVersion: result.currentVersion, latestVersion: result.latest?.version ?? null, compatible: result.latest?.compatible ?? false, integrityReady: result.latest?.integrityReady ?? false, }, }); reply.header("Cache-Control", "no-store"); return { ...result, strategy: config.updateStrategy }; } catch (error) { // A failed upstream request is not a successful check. Release the // reservation only when this request still owns it, so a concurrent // successful check cannot have its cooldown overwritten. if (reservedCheckedAt !== null && rateState.checkedAt === reservedCheckedAt) rateState.checkedAt = previousCheckedAt; writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.checked", targetType: "system", outcome: "failure", }); throw error; } }); app.post("/api/update/apply", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { const input = updateApplySchema.parse(request.body); let applyAuditRecorded = false; let applyAuditTarget: string | undefined; try { reconcileOrphanedUpdateJobs(database.sqlite, config); if (config.updateStrategy !== "systemd") { throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新"); } if (input.jobId) { const stagedJobId = input.jobId; const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined; if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载"); // A package may have been downloaded before the host was upgraded by // another path. Never apply a staged archive that is no longer newer // than the release currently serving traffic. const effectiveCurrentVersion = currentReleaseVersion(config) ?? config.appVersion; if (!isNewerVersion(effectiveCurrentVersion, staged.version)) { const now = Date.now(); database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=?, updated_at=? WHERE id=? AND status='staged'").run("暂存更新已过期,当前版本无需再次升级", now, now, stagedJobId); throw new AppError(409, "UPDATE_NOT_AVAILABLE", "暂存更新已过期,请重新检查更新"); } if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候"); enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply); const now = Date.now(); const active = database.sqlite.transaction(() => { const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined; if (conflictRow) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); const changed = database.sqlite.prepare("UPDATE update_jobs SET operation='apply', error_message=NULL, requested_at=?, request_id=?, updated_at=? WHERE id=? AND status='staged' AND operation='download'").run(now, request.id, now, stagedJobId); if (changed.changes !== 1) throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候"); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: stagedJobId, after: { version: staged.version, staged: true } }); return { id: stagedJobId, now }; }).immediate(); applyAuditTarget = stagedJobId; if (!staged.expectedSha256 || !/^[a-f0-9]{64}$/i.test(staged.expectedSha256)) { database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("暂存更新缺少有效校验值", Date.now(), active.id); throw new AppError(409, "UPDATE_NOT_VERIFIED", "暂存更新缺少有效校验值,请重新下载"); } try { await writeUpdateRequest(config, { jobId: active.id, operation: "apply", version: staged.version, metadataUrl: config.updateMetadataUrl, assetUrl: staged.assetUrl, assetName: staged.assetName ?? "staged", expectedSha256: staged.expectedSha256, requestedAt: active.now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir }); } catch { database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("无法创建系统更新请求", Date.now(), active.id); applyAuditRecorded = true; writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: active.id, outcome: "failure" }); throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限"); } reply.header("Cache-Control", "no-store"); return reply.code(202).send({ job: { id: active.id, status: "staged", version: staged.version, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } }); } // Preserve the actionable in-progress response for duplicate clicks before // applying the per-admin cooldown. const activeBeforeCheck = database.sqlite.prepare(` SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) ORDER BY created_at DESC LIMIT 1 `).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; if (activeBeforeCheck) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply); // Re-fetch before applying. A cached tag is only a display hint; the // server must verify the release and digest immediately before queuing it. const checked = await checkForUpdate(database.sqlite, config); const requestedVersion = input.version.replace(/^v/i, ""); if (!checked.latest || checked.latest.version !== requestedVersion || !checked.latest.isNewer) { throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新"); } if (!checked.latest.compatible || !checked.latest.integrityReady) { throw new AppError(409, "UPDATE_NOT_VERIFIED", "该版本没有匹配当前平台且可验证的发布文件"); } const cached = readCachedRelease(database.sqlite, config); const releaseAsset = cached?.asset; if (!cached || !releaseAsset?.sha256 || !releaseAsset.url || cached.version !== requestedVersion) { throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新"); } const expectedSha256 = releaseAsset.sha256; const active = database.sqlite.transaction(() => { const existing = database.sqlite.prepare(` SELECT id, status FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) ORDER BY created_at DESC LIMIT 1 `).get(...ACTIVE_UPDATE_STATUSES) as { id: string; status: UpdateJobStatus } | undefined; if (existing) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); const id = randomUUID(); const now = Date.now(); database.sqlite.prepare(` INSERT INTO update_jobs( id, admin_id, session_hash, request_id, requested_at, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, requestedVersion, checked.platform.target, cached.metadataUrl, releaseAsset.name, releaseAsset.url, expectedSha256, now, now, ); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: id, after: { version: requestedVersion, platform: checked.platform.target, assetName: releaseAsset.name }, }); return { id, now }; }).immediate(); applyAuditTarget = active.id; const updateRequest: UpdateRequest = { jobId: active.id, operation: "apply", version: requestedVersion, metadataUrl: cached.metadataUrl, assetUrl: releaseAsset.url, assetName: releaseAsset.name, expectedSha256, requestedAt: active.now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir, }; try { await writeUpdateRequest(config, updateRequest); } catch { database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=? AND status='queued'").run("无法创建系统更新请求", Date.now(), active.id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: active.id, outcome: "failure", }); applyAuditRecorded = true; throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限"); } reply.header("Cache-Control", "no-store"); return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } }); } catch (error) { if (!applyAuditRecorded) { writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", ...(applyAuditTarget ? { targetId: applyAuditTarget } : {}), outcome: "failure", }); } throw error; } }); app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { const input = updateDownloadSchema.parse(request.body); reconcileOrphanedUpdateJobs(database.sqlite, config); if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新"); const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply); const checked = await checkForUpdate(database.sqlite, config); const version = input.version.replace(/^v/i, ""); if (!checked.latest || checked.latest.version !== version || !checked.latest.isNewer || !checked.latest.compatible || !checked.latest.integrityReady) throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新"); const cached = readCachedRelease(database.sqlite, config); const cachedAsset = cached?.asset; if (!cached || !cachedAsset?.sha256 || cached.version !== version) throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新"); const now = Date.now(); const id = randomUUID(); database.sqlite.transaction(() => { const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.download_requested", targetType: "update", targetId: id, after: { version } }); }).immediate(); try { await writeUpdateRequest(config, { jobId: id, operation: "download", version, metadataUrl: cached.metadataUrl, assetUrl: cachedAsset.url, assetName: cachedAsset.name, expectedSha256: cachedAsset.sha256, requestedAt: now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir }); } catch { database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=?").run("无法创建系统更新请求", Date.now(), id); throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限"); } reply.header("Cache-Control", "no-store"); return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } }); }); app.post("/api/update/cancel", { preHandler: guard(database, config) }, async (request, reply) => { reconcileOrphanedUpdateJobs(database.sqlite, config); const body = (request.body && typeof request.body === "object" ? request.body : {}) as { jobId?: string }; const result = cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId); if (!result.cancelled) { throw new AppError(409, "CANNOT_CANCEL", result.message || "无法取消当前更新任务"); } reply.header("Cache-Control", "no-store"); return reply.send({ success: true, message: "已取消更新任务" }); }); app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => { const id = z.string().uuid().parse((request.params as { id: string }).id); reconcileOrphanedUpdateJobs(database.sqlite, config); const row = database.sqlite.prepare(` SELECT id, operation, status, version, platform, asset_name AS assetName, asset_url AS assetUrl, release_url AS releaseUrl, size_bytes AS sizeBytes, error_message AS errorMessage, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt, downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt, download_speed_bps AS downloadSpeedBps, requested_at AS applyQueuedAt FROM update_jobs WHERE id=? AND admin_id=? `).get(id, request.auth!.admin.id) as Record | undefined; if (!row) notFound("更新任务不存在"); reply.header("Cache-Control", "no-store"); return { job: publicUpdateJob(row) }; }); app.get("/api/expenses", { preHandler: guard(database, config) }, async (request) => { const query = listQuerySchema.parse(request.query); const rows = filteredExpenses(database, config, query); return { items: rows.map((row) => publicExpense(database, row)), summary: { count: rows.length, amountCents: rows.reduce((sum, row) => sum + row.amountCents, 0) }, }; }); app.get("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const row = getExpense(database, id); if (!row) notFound("账目不存在"); const timeline = database.sqlite.prepare(` SELECT id, occurred_at AS occurredAt, actor_username AS actorUsername, action, before_json AS beforeJson, after_json AS afterJson, metadata_json AS metadataJson FROM audit_events WHERE target_type='expense' AND target_id=? ORDER BY occurred_at DESC, id DESC `).all(id); return { expense: publicExpense(database, row, true), timeline }; }); app.post("/api/expenses", { preHandler: guard(database, config) }, async (request, reply) => { if (!request.isMultipart()) throw new AppError(415, "MULTIPART_REQUIRED", "新建账目必须使用附件表单提交"); const { fields, files } = await parseExpenseMultipart(request, config); let input: z.infer; try { input = expenseInputSchema.parse({ paidAt: fields.paidAt, amount: fields.amount, note: fields.note ?? "", invoiceMissingReason: fields.invoiceMissingReason, }); } catch (error) { await discardStaged(files); throw error; } const paymentProofs = files.filter((file) => file.kind === "payment_proof"); if (paymentProofs.length < 1) { await discardStaged(files); throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证"); } const invoiceFiles = files.filter((file) => file.kind === "invoice"); const requestedInvoiceMissingReason = normalizeInvoiceMissingReason(input.invoiceMissingReason); try { assertInvoiceCoverage(invoiceFiles.length, requestedInvoiceMissingReason); } catch (error) { await discardStaged(files); throw error; } const invoiceMissingReason = invoiceFiles.length > 0 ? null : requestedInvoiceMissingReason; const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0); if (totalBytes > config.maxRecordBytes) { await discardStaged(files); throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); } const paidAt = Date.parse(input.paidAt); if (!Number.isFinite(paidAt)) { await discardStaged(files); throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); } let amountCents: number; try { amountCents = amountToCents(input.amount); } catch { await discardStaged(files); throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数"); } const promoted = await promoteAll(config, files, database.sqlite); const id = randomUUID(); const now = Date.now(); try { database.sqlite.transaction(() => { database.sqlite.prepare(` INSERT INTO expenses(id, paid_at, amount_cents, note, invoice_missing_reason, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, 'unreimbursed', 1, ?, ?, ?, ?) `).run(id, paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, now, request.auth!.admin.id); const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; if (globalBytes + totalBytes > config.maxTotalBytes) { throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); } const insertAttachment = database.sqlite.prepare(` INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const file of promoted) { insertAttachment.run(file.id, id, file.kind, file.storagePath, file.originalName, file.mimeType, file.sizeBytes, file.sha256, now, request.auth!.admin.id); } writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.created", targetType: "expense", targetId: id, after: { paidAt, amountCents, note: input.note, invoiceMissingReason, status: "unreimbursed", attachmentCount: promoted.length, paymentProofCount: paymentProofs.length, invoiceCount: invoiceFiles.length, attachmentKinds: promoted.map((file) => file.kind), }, }); }).immediate(); } catch (error) { await cleanupPromotedFiles(database.sqlite, config, promoted); throw error; } const created = getExpense(database, id)!; return reply.code(201).send({ expense: publicExpense(database, created, true) }); }); app.patch("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); if (request.isMultipart()) { return updateExpenseMultipart(database, config, request, id); } const input = expenseUpdateSchema.parse(request.body); const paidAt = Date.parse(input.paidAt); if (!Number.isFinite(paidAt)) throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); let amountCents: number; try { amountCents = amountToCents(input.amount); } catch { throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数"); } const requestedInvoiceMissingReason = input.invoiceMissingReason === undefined ? undefined : normalizeInvoiceMissingReason(input.invoiceMissingReason); const now = Date.now(); database.sqlite.transaction(() => { const before = getExpense(database, id); if (!before) notFound("账目不存在"); if (before.version !== input.version) conflict(database, id); const invoiceMissingReason = requestedInvoiceMissingReason === undefined ? before.invoiceMissingReason : requestedInvoiceMissingReason; assertInvoiceCoverage(Number(before.invoiceCount), invoiceMissingReason); const result = database.sqlite.prepare(` UPDATE expenses SET paid_at=?, amount_cents=?, note=?, invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL `).run(paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, id, input.version); if (result.changes !== 1) conflict(database, id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.updated", targetType: "expense", targetId: id, before: { paidAt: before.paidAt, amountCents: before.amountCents, note: before.note, invoiceMissingReason: before.invoiceMissingReason, version: before.version, }, after: { paidAt, amountCents, note: input.note, invoiceMissingReason, version: input.version + 1 }, }); }).immediate(); return { expense: publicExpense(database, getExpense(database, id)!, true) }; }); app.post("/api/expenses/:id/status", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const input = statusUpdateSchema.parse(request.body); const before = getExpense(database, id); if (!before) notFound("账目不存在"); if (before.status === input.status) { if (before.version !== input.version) conflict(database, id); return { expense: publicExpense(database, before, true) }; } const now = Date.now(); database.sqlite.transaction(() => { const result = database.sqlite.prepare(` UPDATE expenses SET status=?, version=version+1, updated_at=?, updated_by=?, reimbursed_at=?, reimbursed_by=? WHERE id=? AND version=? AND deleted_at IS NULL `).run(input.status, now, request.auth!.admin.id, input.status === "reimbursed" ? now : null, input.status === "reimbursed" ? request.auth!.admin.id : null, id, input.version); if (result.changes !== 1) conflict(database, id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.status_changed", targetType: "expense", targetId: id, before: { status: before.status, version: before.version }, after: { status: input.status, version: input.version + 1 }, }); })(); return { expense: publicExpense(database, getExpense(database, id)!, true) }; }); app.post("/api/expenses/:id/attachments", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const kind = attachmentKindSchema.parse((request.query as { kind?: string }).kind); if (!request.isMultipart()) throw new AppError(415, "MULTIPART_REQUIRED", "附件必须使用文件表单提交"); const existing = getExpense(database, id); if (!existing) notFound("账目不存在"); const fields: Record = {}; const staged: StagedFile[] = []; try { for await (const part of request.parts()) { if (part.type === "field") { if (part.fieldname !== "version") throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段"); if (part.fieldname in fields) throw new AppError(400, "DUPLICATE_FIELD", "表单字段不能重复"); fields[part.fieldname] = String(part.value); } else { const expectedField = kind === "payment_proof" ? "paymentProofs" : "invoices"; if (part.fieldname !== expectedField) throw new AppError(400, "UNKNOWN_FILE_FIELD", "附件字段与类型不匹配"); if (staged.length >= config.maxFilesPerRequest) throw new AppError(413, "TOO_MANY_FILES", "单次上传文件数量超过限制"); staged.push(await stageMultipartFile(config, part, kind)); } } } catch (error) { await discardStaged(staged); throw error; } let version: number; try { version = z.coerce.number().int().positive().parse(fields.version); } catch (error) { await discardStaged(staged); throw error; } if (staged.length < 1) throw new AppError(400, "FILE_REQUIRED", "请选择至少一个附件"); const promoted = await promoteAll(config, staged, database.sqlite); const now = Date.now(); try { database.sqlite.transaction(() => { const current = getExpense(database, id); if (!current) notFound("账目不存在"); if (current.version !== version) conflict(database, id); const nextInvoiceMissingReason = kind === "invoice" ? null : normalizeInvoiceMissingReason(current.invoiceMissingReason); const nextInvoiceCount = Number(current.invoiceCount) + (kind === "invoice" ? promoted.length : 0); assertInvoiceCoverage(nextInvoiceCount, nextInvoiceMissingReason); const currentBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total; if (currentBytes + promoted.reduce((sum, file) => sum + file.sizeBytes, 0) > config.maxRecordBytes) { throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); } const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; if (globalBytes + promoted.reduce((sum, file) => sum + file.sizeBytes, 0) > config.maxTotalBytes) { throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); } const updated = database.sqlite.prepare("UPDATE expenses SET invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL") .run(nextInvoiceMissingReason, now, request.auth!.admin.id, id, version); if (updated.changes !== 1) conflict(database, id); const insert = database.sqlite.prepare(` INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const file of promoted) insert.run(file.id, id, kind, file.storagePath, file.originalName, file.mimeType, file.sizeBytes, file.sha256, now, request.auth!.admin.id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.attachments_added", targetType: "expense", targetId: id, before: { invoiceMissingReason: current.invoiceMissingReason, invoiceCount: Number(current.invoiceCount), version: current.version, }, after: { kind, invoiceMissingReason: nextInvoiceMissingReason, invoiceCount: nextInvoiceCount, version: version + 1, files: promoted.map((file) => ({ id: file.id, name: file.originalName, size: file.sizeBytes })), }, }); }).immediate(); } catch (error) { await cleanupPromotedFiles(database.sqlite, config, promoted); throw error; } return { expense: publicExpense(database, getExpense(database, id)!, true) }; }); app.delete("/api/attachments/:id", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const input = attachmentDeleteSchema.parse(request.body); const attachment = database.sqlite.prepare(` SELECT id, expense_id AS expenseId, kind, storage_path AS storagePath, original_name AS originalName, mime_type AS mimeType, size_bytes AS sizeBytes, sha256, created_at AS createdAt FROM attachments WHERE id=? `).get(id) as AttachmentRow | undefined; if (!attachment) notFound("附件不存在"); database.sqlite.transaction(() => { const expense = getExpense(database, attachment.expenseId); if (!expense) notFound("账目不存在"); if (expense.version !== input.version) conflict(database, expense.id); if (attachment.kind === "payment_proof") { const count = database.sqlite.prepare("SELECT COUNT(*) AS count FROM attachments WHERE expense_id=? AND kind='payment_proof'").get(expense.id) as { count: number }; if (count.count <= 1) throw new AppError(409, "LAST_PAYMENT_PROOF", "必须先上传替代凭证,才能删除最后一张付款凭证"); } const requestedReason = input.invoiceMissingReason === undefined ? undefined : normalizeInvoiceMissingReason(input.invoiceMissingReason); const remainingInvoiceCount = attachment.kind === "invoice" ? Number((database.sqlite.prepare("SELECT COUNT(*) AS count FROM attachments WHERE expense_id=? AND kind='invoice' AND id<>?").get(expense.id, id) as { count: number }).count) : Number(expense.invoiceCount); const nextInvoiceMissingReason = requestedReason === undefined ? normalizeInvoiceMissingReason(expense.invoiceMissingReason) : requestedReason; assertInvoiceCoverage(remainingInvoiceCount, nextInvoiceMissingReason, 409); const deleted = database.sqlite.prepare("DELETE FROM attachments WHERE id=? AND expense_id=?").run(id, expense.id); if (deleted.changes !== 1) notFound("附件不存在"); const now = Date.now(); const updated = database.sqlite.prepare("UPDATE expenses SET invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL") .run(nextInvoiceMissingReason, now, request.auth!.admin.id, expense.id, input.version); if (updated.changes !== 1) conflict(database, expense.id); enqueueFileDeletion(database.sqlite, attachment.storagePath, "attachment_deleted"); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.attachment_deleted", targetType: "expense", targetId: expense.id, before: { id: attachment.id, kind: attachment.kind, name: attachment.originalName, sha256: attachment.sha256, invoiceMissingReason: expense.invoiceMissingReason, invoiceCount: Number(expense.invoiceCount), version: expense.version, }, after: { invoiceMissingReason: nextInvoiceMissingReason, invoiceCount: remainingInvoiceCount, version: input.version + 1, }, }); })(); // Finish the queued byte-deletion attempt before responding. Missing // bytes are treated as already gone; retryable filesystem failures remain // visible in the deletion queue for the janitor. await processFileDeletions(database.sqlite, config); return { expense: publicExpense(database, getExpense(database, attachment.expenseId)!, true) }; }); app.get("/api/attachments/:id/content", { preHandler: guard(database, config) }, async (request, reply) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const attachment = database.sqlite.prepare(` SELECT a.id, a.expense_id AS expenseId, a.kind, a.storage_path AS storagePath, a.original_name AS originalName, a.mime_type AS mimeType, a.size_bytes AS sizeBytes, a.sha256, a.created_at AS createdAt FROM attachments a JOIN expenses e ON e.id=a.expense_id WHERE a.id=? AND e.deleted_at IS NULL `).get(id) as AttachmentRow | undefined; if (!attachment) notFound("附件不存在"); let stream: Awaited>; try { stream = await fileReadStream(config, attachment.storagePath); } catch (error) { writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.attachment_read", targetType: "expense", targetId: attachment.expenseId, outcome: "failure", metadata: { attachmentId: attachment.id, mode: "unavailable" }, }); throw error; } const download = (request.query as { download?: string }).download === "1"; const inline = !download && (attachment.mimeType.startsWith("image/") || attachment.mimeType === "application/pdf"); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: download ? "expense.attachment_downloaded" : "expense.attachment_previewed", targetType: "expense", targetId: attachment.expenseId, metadata: { attachmentId: attachment.id, kind: attachment.kind, sizeBytes: attachment.sizeBytes }, }); reply.header("Content-Type", attachment.mimeType); reply.header("Content-Length", attachment.sizeBytes); reply.header("X-Content-Type-Options", "nosniff"); reply.header("Cache-Control", "private, no-store"); if (inline) { // PDF previews are rendered in the authenticated same-origin dialog; // keep downloads unframeable while allowing this narrow preview case. reply.header("Content-Security-Policy", "sandbox"); reply.header("X-Frame-Options", "SAMEORIGIN"); } reply.header("Content-Disposition", `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`); return reply.send(stream); }); app.delete("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const input = versionSchema.parse(request.body); const before = getExpense(database, id); if (!before) notFound("账目不存在"); const now = Date.now(); database.sqlite.transaction(() => { const result = database.sqlite.prepare(` UPDATE expenses SET deleted_at=?, deleted_by=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL `).run(now, request.auth!.admin.id, now, request.auth!.admin.id, id, input.version); if (result.changes !== 1) conflict(database, id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.trashed", targetType: "expense", targetId: id, before: { status: before.status, version: before.version }, after: { deletedAt: now, version: input.version + 1 }, }); })(); return { expense: publicExpense(database, getExpense(database, id, true)!, true) }; }); app.get("/api/trash", { preHandler: guard(database, config) }, async () => { const rows = database.sqlite.prepare(` SELECT ${expenseBaseSelect()} FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by WHERE e.deleted_at IS NOT NULL GROUP BY e.id ORDER BY e.deleted_at DESC `).all() as ExpenseRow[]; return { items: rows.map((row) => publicExpense(database, row)) }; }); app.post("/api/trash/:id/restore", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const input = versionSchema.parse(request.body); const existing = getExpense(database, id, true); if (!existing || !existing.deletedAt) notFound("回收站中没有该账目"); const now = Date.now(); database.sqlite.transaction(() => { const result = database.sqlite.prepare(` UPDATE expenses SET deleted_at=NULL, deleted_by=NULL, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NOT NULL `).run(now, request.auth!.admin.id, id, input.version); if (result.changes !== 1) conflict(database, id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.restored", targetType: "expense", targetId: id, before: { deletedAt: existing.deletedAt, version: existing.version }, after: { deletedAt: null, version: input.version + 1 }, }); })(); return { expense: publicExpense(database, getExpense(database, id)!, true) }; }); app.delete("/api/trash/:id", { preHandler: guard(database, config) }, async (request, reply) => { const id = z.string().uuid().parse((request.params as { id: string }).id); const input = permanentDeleteSchema.parse(request.body); assertReauthAllowed(database, request, request.auth!.admin.id); if (!await verifyPassword(request.auth!.admin.passwordHash, input.password)) { recordReauthFailure(database, request, request.auth!.admin.id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.purge", targetType: "expense", targetId: id, outcome: "denied", }); throw new AppError(401, "CURRENT_PASSWORD_INVALID", "密码确认失败"); } clearReauthFailures(database, request, request.auth!.admin.id); const existing = getExpense(database, id, true); if (!existing || !existing.deletedAt) notFound("回收站中没有该账目"); const attachmentRows = listAttachments(database, id); const exportFiles: string[] = []; database.sqlite.transaction(() => { const current = database.sqlite.prepare("SELECT version, deleted_at AS deletedAt FROM expenses WHERE id=?").get(id) as { version: number; deletedAt: number | null } | undefined; if (!current || !current.deletedAt) notFound("回收站中没有该账目"); if (current.version !== input.version) conflict(database, id); for (const attachment of attachmentRows) enqueueFileDeletion(database.sqlite, attachment.storagePath, "expense_purged"); const jobs = database.sqlite.prepare("SELECT id, status, file_path AS filePath, snapshot_json AS snapshotJson FROM export_jobs WHERE status IN ('queued','building','ready')").all() as Array<{ id: string; status: string; filePath: string | null; snapshotJson: string }>; for (const job of jobs) { const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot; if (!snapshot.expenses.some((expense) => expense.id === id)) continue; database.sqlite.prepare("UPDATE export_jobs SET status='expired', file_path=NULL WHERE id=?").run(job.id); if (job.filePath) exportFiles.push(job.filePath); } database.sqlite.prepare("DELETE FROM expenses WHERE id=?").run(id); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "expense.purged", targetType: "expense", targetId: id, before: { paidAt: existing.paidAt, amountCents: existing.amountCents, note: existing.note, invoiceMissingReason: existing.invoiceMissingReason, status: existing.status, deletedAt: existing.deletedAt, attachments: attachmentRows.map((item) => ({ kind: item.kind, name: item.originalName, size: item.sizeBytes, sha256: item.sha256 })), }, after: { permanentlyDeleted: true }, }); }).immediate(); await Promise.all(exportFiles.map((file) => unlink(safeStoragePath(config.exportsDir, file)).catch(() => undefined))); await processFileDeletions(database.sqlite, config); return reply.code(204).send(); }); app.get("/api/audit", { preHandler: guard(database, config) }, async (request) => { const query = z.object({ actorId: z.string().uuid().optional(), action: z.string().max(100).optional(), targetType: z.string().max(50).optional(), targetId: z.string().max(100).optional(), limit: z.coerce.number().int().min(1).max(500).default(200), offset: z.coerce.number().int().min(0).max(100_000).default(0), }).strict().parse(request.query); const clauses: string[] = []; const values: unknown[] = []; if (query.actorId) { clauses.push("actor_admin_id=?"); values.push(query.actorId); } if (query.action) { clauses.push("action=?"); values.push(query.action); } if (query.targetType) { clauses.push("target_type=?"); values.push(query.targetType); } if (query.targetId) { clauses.push("target_id=?"); values.push(query.targetId); } values.push(query.limit, query.offset); const rows = database.sqlite.prepare(` SELECT id, occurred_at AS occurredAt, request_id AS requestId, actor_admin_id AS actorAdminId, actor_username AS actorUsername, action, target_type AS targetType, target_id AS targetId, outcome, before_json AS beforeJson, after_json AS afterJson, metadata_json AS metadataJson FROM audit_events ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY occurred_at DESC, id DESC LIMIT ? OFFSET ? `).all(...values); return { items: rows }; }); app.post("/api/exports", { preHandler: guard(database, config) }, async (request, reply) => { const selection = exportRequestSchema.parse(request.body); let rows: ExpenseRow[]; if ("ids" in selection) { const placeholders = selection.ids.map(() => "?").join(","); rows = database.sqlite.prepare(` SELECT ${expenseBaseSelect()} FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by WHERE e.deleted_at IS NULL AND e.id IN (${placeholders}) GROUP BY e.id ORDER BY e.paid_at DESC, e.id DESC `).all(...selection.ids) as ExpenseRow[]; if (rows.length !== new Set(selection.ids).size) throw new AppError(404, "EXPORT_RECORD_NOT_FOUND", "部分勾选记录不存在或已进入回收站"); } else { rows = filteredExpenses(database, config, { ...selection, missingInvoice: selection.missingInvoice }); } if (rows.length === 0) throw new AppError(400, "EMPTY_EXPORT", "没有可导出的记录"); if (rows.length > config.maxExportRecords) throw new AppError(413, "EXPORT_TOO_LARGE", "导出记录数超过限制"); const snapshot: ExportSnapshot = { expenses: rows.map((row): ExportExpense => ({ id: row.id, paidAt: row.paidAt, amountCents: row.amountCents, note: row.note, invoiceMissingReason: row.invoiceMissingReason, status: row.status, attachments: listAttachments(database, row.id), })), includeManifest: selection.includeManifest, }; const snapshotBytes = snapshot.expenses.reduce((sum, expense) => sum + expense.attachments.reduce((attachmentSum, attachment) => attachmentSum + attachment.sizeBytes, 0), 0); if (snapshotBytes > config.maxExportBytes) throw new AppError(413, "EXPORT_TOO_LARGE", "导出附件总大小超过限制"); const jobId = database.sqlite.transaction(() => { const activeJobs = database.sqlite.prepare("SELECT COUNT(*) AS count FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").get(Date.now()) as { count: number }; if (activeJobs.count >= config.maxConcurrentExports) throw new AppError(429, "EXPORT_BUSY", "当前导出任务较多,请稍后再试"); const storedBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM export_jobs WHERE status='ready' AND expires_at > ?").get(Date.now()) as { total: number }).total; if (storedBytes + snapshotBytes > config.maxExportStorageBytes) { throw new AppError(413, "EXPORT_STORAGE_LIMIT", "导出缓存空间已满,请先下载或等待旧导出过期"); } const id = insertExportJob(database.sqlite, config, { adminId: request.auth!.admin.id, sessionHash: request.auth!.tokenHash, selection, snapshot, }); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "export.created", targetType: "export", targetId: id, metadata: { expenseIds: rows.map((row) => row.id), count: rows.length, amountCents: rows.reduce((sum, row) => sum + row.amountCents, 0), includeManifest: selection.includeManifest }, }); return id; }).immediate(); void buildExportJob(database.sqlite, config, jobId); return reply.code(202).send({ job: { id: jobId, status: "queued" } }); }); app.get("/api/exports/:id", { preHandler: guard(database, config) }, async (request) => { await expireExports(database.sqlite, config); const id = z.string().uuid().parse((request.params as { id: string }).id); const job = database.sqlite.prepare(` SELECT id, status, file_name AS fileName, size_bytes AS sizeBytes, error_message AS errorMessage, created_at AS createdAt, ready_at AS readyAt, expires_at AS expiresAt FROM export_jobs WHERE id=? AND session_hash=? `).get(id, request.auth!.tokenHash); if (!job) notFound("导出任务不存在"); return { job }; }); app.get("/api/exports/:id/download", { preHandler: guard(database, config) }, async (request, reply) => { await expireExports(database.sqlite, config); const id = z.string().uuid().parse((request.params as { id: string }).id); const job = database.sqlite.prepare(` SELECT status, file_path AS filePath, file_name AS fileName, size_bytes AS sizeBytes FROM export_jobs WHERE id=? AND session_hash=? `).get(id, request.auth!.tokenHash) as { status: string; filePath: string | null; fileName: string; sizeBytes: number | null } | undefined; if (!job) notFound("导出任务不存在"); if (job.status !== "ready" || !job.filePath) throw new AppError(409, "EXPORT_NOT_READY", "导出文件尚未生成完成"); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "export.downloaded", targetType: "export", targetId: id, metadata: { sizeBytes: job.sizeBytes ?? null }, }); reply.header("Content-Type", "application/zip"); if (job.sizeBytes) reply.header("Content-Length", job.sizeBytes); reply.header("Cache-Control", "private, no-store"); reply.header("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(job.fileName)}`); return reply.send(await safeReadStream(config.exportsDir, job.filePath)); }); const hasWeb = existsSync(config.webDir); if (hasWeb) { // Serve the Vite asset graph as well as the SPA entry. API routes are // registered above and remain authoritative for /api/* paths. await app.register(fastifyStatic, { root: config.webDir, wildcard: true, index: "index.html" }); } // Keep API errors structured even when the production frontend has not been // built yet (for example in a clean CI checkout or an API-only process). app.setNotFoundHandler((request, reply) => { if (request.url.split("?", 1)[0]!.startsWith("/api/")) { return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在"))); } if (hasWeb) return reply.sendFile("index.html"); return reply.code(404).send({ message: `Route ${request.method}:${request.url} not found`, error: "Not Found", statusCode: 404, }); }); if (!hasWeb) { app.get("/", async () => ({ name: "TallyNote", mode: "api", hint: "开发界面运行在 Vite 端口" })); } return app; }