diff --git a/package.json b/package.json index daa8314..af9b90f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.33", + "version": "1.1.34", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/server/app.ts b/server/app.ts index 5a1b78c..a078fa7 100644 --- a/server/app.ts +++ b/server/app.ts @@ -62,6 +62,7 @@ import { reconcileOrphanedUpdateJobs, readCachedRelease, writeUpdateRequest, + cancelUpdateJob, type UpdateRequest, } from "./update-service.js"; @@ -1182,6 +1183,17 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } }); }); + app.post("/api/update/cancel", { preHandler: guard(database, config) }, async (request, reply) => { + reconcileOrphanedUpdateJobs(database.sqlite, config); + const body = (request.body && typeof request.body === "object" ? request.body : {}) as { jobId?: string }; + const result = cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId); + if (!result.cancelled) { + throw new AppError(409, "CANNOT_CANCEL", result.message || "无法取消当前更新任务"); + } + reply.header("Cache-Control", "no-store"); + return reply.send({ success: true, message: "已取消更新任务" }); + }); + app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => { const id = z.string().uuid().parse((request.params as { id: string }).id); reconcileOrphanedUpdateJobs(database.sqlite, config); diff --git a/server/update-service.ts b/server/update-service.ts index 5b8b680..3b40ba3 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -38,6 +38,7 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [ // a lease while doing long downloads/backups; only an expired lease permits // the server to reclaim an active row. export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000; +export const QUEUED_UPDATE_TIMEOUT_MS = 25 * 1000; export type CachedRelease = { checkedAt: number; @@ -384,6 +385,14 @@ function markerMtime(filePath: string): number | null { } } +function forceRemoveRequest(filePath: string): void { + try { + const info = lstatSync(filePath); + if (!info.isFile() && !info.isSymbolicLink()) return; + unlinkSync(filePath); + } catch {} +} + function removeExpiredRequest(filePath: string, now: number): void { try { const info = lstatSync(filePath); @@ -521,10 +530,48 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: && !reconciledIds.has(queuedRequest.id) && !(queuedRequest.status === "staged" && queuedRequest.operation === "download"), ); - if (!stateFresh - && (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS)) - && !requestStillNeeded) { + if (!stateFresh && !requestStillNeeded) { + forceRemoveRequest(config.updateRequestPath); + } else if (!stateFresh && (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS))) { removeExpiredRequest(config.updateRequestPath, now); } return reconciled; } + +export function cancelUpdateJob( + database: Database.Database, + config: AppConfig, + adminId: string, + requestId: string, + jobId?: string, +): { cancelled: boolean; message?: string } { + const job = jobId + ? database.prepare("SELECT id, status, operation, version, admin_id AS adminId FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null } | undefined + : database.prepare("SELECT id, status, operation, version, admin_id AS adminId FROM update_jobs WHERE status='queued' ORDER BY created_at DESC LIMIT 1").get() as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null } | undefined; + + if (!job) return { cancelled: false, message: "当前没有处于等待调度的更新任务" }; + if (job.status !== "queued") return { cancelled: false, message: "任务已开始处理,无法取消" }; + + const now = Date.now(); + const changed = database.transaction(() => { + const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新排队', completed_at=?, updated_at=? WHERE id=? AND status='queued'").run(now, now, job.id); + if (result.changes !== 1) return false; + writeAudit(database, { + requestId, + actorAdminId: adminId, + action: "update.cancelled", + targetType: "update", + targetId: job.id, + outcome: "success", + before: { status: job.status, operation: job.operation, version: job.version }, + after: { status: "cancelled", version: job.version }, + }); + return true; + })(); + + if (changed) { + forceRemoveRequest(config.updateRequestPath); + return { cancelled: true }; + } + return { cancelled: false, message: "取消失败,任务状态可能已改变" }; +} diff --git a/systemd/tallynote-update.service b/systemd/tallynote-update.service index b1c8ff0..aa8ee4e 100644 --- a/systemd/tallynote-update.service +++ b/systemd/tallynote-update.service @@ -16,8 +16,6 @@ Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin # enough to finish or reach its own health-check/recovery path. TimeoutStartSec=30min NoNewPrivileges=true -CapabilityBoundingSet= -AmbientCapabilities= # Keep the updater compatible with the same Node/libuv interface discovery # path while retaining an explicit socket-family allowlist. RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK @@ -28,7 +26,6 @@ ProtectSystem=strict ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true -ProtectControlGroups=true ProtectClock=true LockPersonality=true RestrictRealtime=true diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index f630898..8a3e9de 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { chmodSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -190,4 +190,21 @@ describe("更新 API", () => { const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined; expect(audit?.outcome).toBe("failure"); }); + it("管理员可主动取消排队中的更新任务并清理请求文件", async () => { + const session = await login("update-cancel"); + mockRelease(); + await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } }); + expect(applied.statusCode).toBe(202); + expect(existsSync(config.updateRequestPath)).toBe(true); + + const cancelRes = await app.inject({ method: "POST", url: "/api/update/cancel", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); + expect(cancelRes.statusCode).toBe(200); + expect(cancelRes.json().success).toBe(true); + expect(existsSync(config.updateRequestPath)).toBe(false); + + const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); + expect(statusRes.statusCode).toBe(200); + expect(statusRes.json().job).toBeNull(); + }); }); diff --git a/web-next/src/pages/admins/AdminsPage.tsx b/web-next/src/pages/admins/AdminsPage.tsx index cf725d3..84ad97b 100644 --- a/web-next/src/pages/admins/AdminsPage.tsx +++ b/web-next/src/pages/admins/AdminsPage.tsx @@ -85,10 +85,10 @@ export default function AdminsPage({ currentAdmin, timezone = "Asia/Shanghai", n { if (!busy) requestDiscard(() => setShowCreate(false)); }} footer={}>
{ void create(); }}> { setForm({ ...form, username: value }); setFormFields(current => ({ ...current, username: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={64} autocomplete="off" /> { setForm({ ...form, displayName: value }); setFormFields(current => ({ ...current, displayName: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={80} />{formError &&
{formError}
}
- { if (!busy) setAction(null); }} onConfirm={() => void (action?.kind === "reset" ? reset() : toggle())} onCancel={() => { if (!busy) setAction(null); }}> + { if (!busy) setAction(null); }} onConfirm={() => void (action?.kind === "reset" ? reset() : toggle())} onCancel={() => { if (!busy) setAction(null); }}> {action?.kind === "reset" ? <>将生成一次性临时密码,同时让管理员“{action.admin.displayName}”已登录的会话安全退出。 : action?.admin.status === "active" ? "停用后该管理员将无法访问系统,已登录的会话会立即注销。" : "启用后该管理员可恢复系统访问并正常登录。"} - setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}>
{secret}

请妥善保管并将临时密码交付给管理员,该密码在首次登录时会被强制更新。

+ setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}>
{secret}

请妥善保管并将临时密码交付给管理员,该密码在首次登录时会被强制更新。

; async function copySecret(value: string) { diff --git a/web-next/src/pages/expenses/ExpenseDetail.tsx b/web-next/src/pages/expenses/ExpenseDetail.tsx index 6b54813..57badaf 100644 --- a/web-next/src/pages/expenses/ExpenseDetail.tsx +++ b/web-next/src/pages/expenses/ExpenseDetail.tsx @@ -64,9 +64,9 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC {timeline.length > 0 && <>

操作记录

{timeline.slice(0, 12).map(t =>
{dateText(t.occurredAt, timezone)}{TIMELINE_LABELS[t.action] || t.action}{t.actorUsername || "系统"}
)}
} } - { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "确认将该笔账目恢复为未报销状态?" : "确认该笔账目已完成报销审批与结算?"} - { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>移入回收站后将不在正常列表中展示,关联附件会完整保留,可随时前往回收站恢复。 - { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <>

{removeTarget.kind === "payment_proof" ? "每笔账目至少需要保留一张有效的付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "当前为该账目唯一的发票附件,删除后请补充说明无发票原因。" : "确认删除该发票附件?"}

{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() &&