1655 lines
78 KiB
TypeScript
1655 lines
78 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { lstat, 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,
|
|
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 {
|
|
ACTIVE_UPDATE_STATUSES,
|
|
checkForUpdate,
|
|
publicCheckFromCache,
|
|
publicUpdateJob,
|
|
readCachedRelease,
|
|
writeUpdateRequest,
|
|
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; appliedAt: number };
|
|
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
|
|
|
|
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, appliedAt: 0 };
|
|
states.set(adminId, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function enforceUpdateCooldown(
|
|
database: DatabaseContext["sqlite"],
|
|
config: AppConfig,
|
|
adminId: string,
|
|
operation: "check" | "apply",
|
|
reply: FastifyReply,
|
|
): void {
|
|
const state = updateRateState(database, adminId);
|
|
const now = Date.now();
|
|
const previous = operation === "check" ? state.checkedAt : state.appliedAt;
|
|
const cooldown = operation === "check" ? config.updateCheckCooldownMs : 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 state.appliedAt = 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<typeof listQuerySchema>): 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<?
|
|
AND e.note LIKE ? ESCAPE '\\'
|
|
${query.missingInvoice ? "AND NOT EXISTS (SELECT 1 FROM attachments ai WHERE ai.expense_id=e.id AND ai.kind='invoice')" : ""}
|
|
GROUP BY e.id ORDER BY e.paid_at DESC, e.id DESC
|
|
`).all(query.status, start, end, `%${escaped}%`) as ExpenseRow[];
|
|
}
|
|
|
|
function enqueueFileDeletion(database: DatabaseContext, storagePath: string, reason: string): void {
|
|
database.sqlite.prepare(`
|
|
INSERT INTO file_deletions(id, storage_path, reason, status, attempts, created_at)
|
|
VALUES (?, ?, ?, 'pending', 0, ?)
|
|
`).run(randomUUID(), storagePath, reason, Date.now());
|
|
}
|
|
|
|
function reauthKey(request: FastifyRequest, adminId: string): string {
|
|
return sha256(`reauth:${adminId}:${request.ip}`);
|
|
}
|
|
|
|
function assertReauthAllowed(database: DatabaseContext, request: FastifyRequest, adminId: string): void {
|
|
const row = database.sqlite.prepare("SELECT blocked_until AS blockedUntil FROM login_attempts WHERE key_hash=?").get(reauthKey(request, adminId)) as { blockedUntil: number | null } | undefined;
|
|
if (row?.blockedUntil && row.blockedUntil > 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): Promise<{ fields: Record<string, string>; files: StagedFile[] }> {
|
|
const fields: Record<string, string> = {};
|
|
const files: StagedFile[] = [];
|
|
try {
|
|
for await (const part of request.parts()) {
|
|
if (part.type === "field") {
|
|
if (!["paidAt", "amount", "note", "invoiceMissingReason"].includes(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;
|
|
}
|
|
}
|
|
|
|
async function promoteAll(config: AppConfig, files: StagedFile[]): Promise<Array<StagedFile & { storagePath: string }>> {
|
|
const promoted: Array<StagedFile & { storagePath: string }> = [];
|
|
try {
|
|
for (const file of files) promoted.push({ ...file, storagePath: await promoteStagedFile(config, file) });
|
|
return promoted;
|
|
} catch (error) {
|
|
await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined)));
|
|
await discardStaged(files);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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, {
|
|
...(config.isLocalOrigin ? { hsts: false } : {}),
|
|
frameguard: { action: "deny" },
|
|
referrerPolicy: { policy: "no-referrer" },
|
|
crossOriginOpenerPolicy: { policy: "same-origin" },
|
|
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'"],
|
|
...(config.isLocalOrigin ? { "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.addHook("onRequest", async (request) => {
|
|
if (!unsafeMethods.has(request.method) || !request.url.startsWith("/api/")) return;
|
|
const origin = request.headers.origin;
|
|
const allowed = new Set([config.publicOrigin]);
|
|
if (!config.isProduction) {
|
|
allowed.add("http://127.0.0.1:5173");
|
|
allowed.add("http://localhost:5173");
|
|
}
|
|
if (typeof origin !== "string" || !allowed.has(origin)) {
|
|
throw new AppError(403, "ORIGIN_FORBIDDEN", "请求来源不受信任");
|
|
}
|
|
});
|
|
|
|
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");
|
|
const cached = publicCheckFromCache(database.sqlite, config);
|
|
const row = database.sqlite.prepare(`
|
|
SELECT id, status, version, platform, asset_name AS assetName,
|
|
size_bytes AS sizeBytes, error_message AS errorMessage,
|
|
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
|
|
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
|
|
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
|
|
return {
|
|
...cached,
|
|
strategy: config.updateStrategy,
|
|
job: publicUpdateJob(row),
|
|
};
|
|
});
|
|
|
|
app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
|
|
try {
|
|
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) {
|
|
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 {
|
|
if (config.updateStrategy !== "systemd") {
|
|
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
|
|
}
|
|
// 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,
|
|
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 } });
|
|
} 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.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
|
|
const id = z.string().uuid().parse((request.params as { id: string }).id);
|
|
const row = database.sqlite.prepare(`
|
|
SELECT id, status, version, platform, asset_name AS assetName,
|
|
size_bytes AS sizeBytes, error_message AS errorMessage,
|
|
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
|
|
FROM update_jobs WHERE id=? AND admin_id=?
|
|
`).get(id, request.auth!.admin.id) as Record<string, unknown> | 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<typeof expenseInputSchema>;
|
|
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);
|
|
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 Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined)));
|
|
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);
|
|
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<string, string> = {};
|
|
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);
|
|
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 Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined)));
|
|
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, 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("附件不存在");
|
|
try {
|
|
const fileInfo = await lstat(safeStoragePath(config.filesDir, attachment.storagePath));
|
|
if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) throw new Error("attachment type");
|
|
} catch {
|
|
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: "missing" },
|
|
});
|
|
throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
|
|
}
|
|
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(await fileReadStream(config, attachment.storagePath));
|
|
});
|
|
|
|
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, 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));
|
|
});
|
|
|
|
if (existsSync(config.webDir)) {
|
|
await app.register(fastifyStatic, { root: config.webDir, wildcard: false });
|
|
app.setNotFoundHandler((request, reply) => {
|
|
if (request.url.startsWith("/api/")) return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在")));
|
|
return reply.sendFile("index.html");
|
|
});
|
|
} else {
|
|
app.get("/", async () => ({ name: "TallyNote", mode: "api", hint: "开发界面运行在 Vite 端口" }));
|
|
}
|
|
|
|
return app;
|
|
}
|