This commit is contained in:
+78
-12
@@ -23,6 +23,7 @@ import {
|
||||
permanentDeleteSchema,
|
||||
statusUpdateSchema,
|
||||
updateApplySchema,
|
||||
updateDownloadSchema,
|
||||
versionSchema,
|
||||
type AttachmentKind,
|
||||
type ExpenseStatus,
|
||||
@@ -94,7 +95,7 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const sessionCookie = "tally_session";
|
||||
const csrfCookie = "tally_csrf";
|
||||
|
||||
type UpdateRateState = { checkedAt: number; appliedAt: number };
|
||||
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
|
||||
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
|
||||
|
||||
function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState {
|
||||
@@ -105,7 +106,7 @@ function updateRateState(database: DatabaseContext["sqlite"], adminId: string):
|
||||
}
|
||||
let state = states.get(adminId);
|
||||
if (!state) {
|
||||
state = { checkedAt: 0, appliedAt: 0 };
|
||||
state = { checkedAt: 0, downloadedAt: 0, appliedAt: 0 };
|
||||
states.set(adminId, state);
|
||||
}
|
||||
return state;
|
||||
@@ -115,13 +116,13 @@ function enforceUpdateCooldown(
|
||||
database: DatabaseContext["sqlite"],
|
||||
config: AppConfig,
|
||||
adminId: string,
|
||||
operation: "check" | "apply",
|
||||
operation: "check" | "download" | "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;
|
||||
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);
|
||||
@@ -130,6 +131,7 @@ function enforceUpdateCooldown(
|
||||
: "更新操作过于频繁,请稍后再试");
|
||||
}
|
||||
if (operation === "check") state.checkedAt = now;
|
||||
else if (operation === "download") state.downloadedAt = now;
|
||||
else state.appliedAt = now;
|
||||
}
|
||||
|
||||
@@ -932,9 +934,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
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,
|
||||
SELECT id, operation, 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
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
|
||||
requested_at AS applyQueuedAt
|
||||
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
|
||||
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
return {
|
||||
@@ -988,6 +991,37 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
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", "更新任务尚未完成下载");
|
||||
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(`
|
||||
@@ -1055,8 +1089,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
return { id, now };
|
||||
}).immediate();
|
||||
applyAuditTarget = active.id;
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
operation: "apply",
|
||||
version: requestedVersion,
|
||||
metadataUrl: cached.metadataUrl,
|
||||
assetUrl: releaseAsset.url,
|
||||
@@ -1084,7 +1119,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
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 } });
|
||||
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, {
|
||||
@@ -1101,12 +1136,43 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
|
||||
const input = updateDownloadSchema.parse(request.body);
|
||||
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.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,
|
||||
SELECT id, operation, 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
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
|
||||
requested_at AS applyQueuedAt
|
||||
FROM update_jobs WHERE id=? AND admin_id=?
|
||||
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
if (!row) notFound("更新任务不存在");
|
||||
|
||||
Reference in New Issue
Block a user