fix: 修复在线更新暂存链路并增加全局 API 限流备底

- 新增 server/rate-limit.ts:进程内固定窗口限流器,无数据库写入
- server/app.ts 注册全局 preHandler,仅作用于 /api/*,超限返回 429 与 Retry-After
- 提取 isApiPath 统一 onSend、preHandler 与 404 的路径判断
- 更新任务冲突判定改用 ACTIVE_UPDATE_CONFLICT_SQL,staged/download 产物不再阻塞新任务
- cancelUpdateJob 调用补上 await,避免结果恒为 pending Promise
- server/cli/update.ts 增加特权工作区所有权校验与暂存路径重建逻辑
- 新增 tests/rate-limit.test.ts 与 tests/update-apply-staging.test.ts
This commit is contained in:
Qiufeng
2026-09-17 13:12:20 +08:00
parent 5afcd98ebd
commit ae8966baf6
12 changed files with 1145 additions and 71 deletions
+43 -8
View File
@@ -54,9 +54,11 @@ import {
validateNewPassword,
verifyPassword,
} from "./security.js";
import { createRateLimiter } from "./rate-limit.js";
import { isNewerVersion } from "./update.js";
import {
ACTIVE_UPDATE_STATUSES,
ACTIVE_UPDATE_CONFLICT_SQL,
checkForUpdate,
currentReleaseVersion,
publicCheckFromCache,
@@ -100,6 +102,11 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const sessionCookie = "tally_session";
const csrfCookie = "tally_csrf";
/** API paths are the only requests the global rate limiter and cache rules own. */
function isApiPath(url: string): boolean {
return url.split("?", 1)[0]!.startsWith("/api/");
}
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
@@ -644,7 +651,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
// 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/")) {
if (isApiPath(request.url)) {
reply.header("Cache-Control", "no-store");
reply.header("Pragma", "no-cache");
reply.header("Vary", "Cookie");
@@ -1028,7 +1035,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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;
// A reusable `staged/download` artifact must never block applying a
// *different* staged job: otherwise a leftover row keeps the queue
// permanently busy and the operator can never apply an update.
const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} 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", "更新任务正在处理中,请稍候");
@@ -1053,9 +1063,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
}
// Preserve the actionable in-progress response for duplicate clicks before
// applying the per-admin cooldown.
// Same rule as the download path: a reusable staged download is an
// artifact, not a running task, and must not block apply.
const activeBeforeCheck = database.sqlite.prepare(`
SELECT id FROM update_jobs
WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")})
WHERE ${ACTIVE_UPDATE_CONFLICT_SQL}
ORDER BY created_at DESC LIMIT 1
`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (activeBeforeCheck) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
@@ -1079,7 +1091,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
const active = database.sqlite.transaction(() => {
const existing = database.sqlite.prepare(`
SELECT id, status FROM update_jobs
WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")})
WHERE ${ACTIVE_UPDATE_CONFLICT_SQL}
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", "已有更新任务正在进行,请等待完成");
@@ -1169,7 +1181,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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;
// A finished `staged/download` row is a reusable artifact, not a running
// task, so it does not block a new download. Apply-phase rows still do.
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} 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);
@@ -1181,7 +1195,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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;
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} 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 } });
@@ -1201,7 +1215,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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);
// `cancelUpdateJob` awaits its staging-workspace cleanup, so the caller must
// await it too; without the await `result` is a pending Promise and this
// branch would always report failure even after a successful cancel.
const result = await cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId);
if (!result.cancelled) {
throw new AppError(409, "CANNOT_CANCEL", result.message || "无法取消当前更新任务");
}
@@ -1870,6 +1887,24 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return reply.send(await safeReadStream(config.exportsDir, job.filePath));
});
// Global anti-flood backstop for the API surface. It is registered after all
// /api/* routes so it covers every one of them, but the path check keeps
// static assets, `/health` and the SPA fallback out of the limiter. The
// per-feature limits (login lockout, dangerous-operation re-auth, update
// cooldowns) stay authoritative; this only bounds raw request volume.
const apiRateLimiter = createRateLimiter({ limit: config.apiRateLimitPerMinute, windowMs: 60 * 1000 });
app.addHook("preHandler", async (request, reply) => {
if (!isApiPath(request.url)) return;
// `request.ip` already honours the validated trustProxy configuration, so
// the counted address is the one the deployment declared. The limiter must
// never parse X-Forwarded-For itself, otherwise a client could spoof its
// way around the limit.
const decision = apiRateLimiter.check(request.ip);
if (decision.allowed) return;
reply.header("Retry-After", decision.retryAfterSeconds);
throw new AppError(429, "RATE_LIMITED", "请求过于频繁,请稍后再试");
});
const hasWeb = existsSync(config.webDir);
if (hasWeb) {
// Serve the Vite asset graph as well as the SPA entry. API routes are
@@ -1879,7 +1914,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
// 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/")) {
if (isApiPath(request.url)) {
return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在")));
}
if (hasWeb) return reply.sendFile("index.html");