diff --git a/package.json b/package.json index 74c0aff..288c42a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.18", + "version": "1.1.19", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/tallynote-update-runner.sh b/scripts/tallynote-update-runner.sh index 908f31b..43217a0 100755 --- a/scripts/tallynote-update-runner.sh +++ b/scripts/tallynote-update-runner.sh @@ -190,6 +190,15 @@ recover_stale_state() { [[ "$state_job" =~ ^[0-9a-f-]{36}$ ]] || die 'update state job id is invalid' [[ "$state_old" == "$PREFIX/releases/"* && -d "$state_old" && ! -L "$state_old" ]] || die 'update state target is invalid' current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true) + if [[ "$state_phase" == download && "$current_target" == "$state_old" ]]; then + # Downloading never changes the active release. If the runner was killed + # after the CLI staged its payload but before it removed the recovery + # marker, keep the request available for an idempotent retry. Treating + # every stale download marker as a failed apply would discard a usable + # staged payload and leave the browser showing a misleading failure. + clear_update_state || true + return 0 + fi if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then recovery_node="$CURRENT_LINK/runtime/bin/node" [[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true) diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 3d4c9f0..2cbcf8f 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -195,6 +195,40 @@ grep -q -- '--finalize-job' "$runner_root/node.log" [[ ! -e "$runner_data/update-request.json" ]] [[ ! -e "$runner_prefix/.update-state" ]] +# A stale download marker must be recoverable without finalizing the staged +# download as a failed apply. The next runner invocation should retry the +# request and let the CLI preserve/refresh its staged workspace. +download_runner_root="$tmp/download-runner" +download_runner_prefix="$download_runner_root/prefix" +download_runner_data="$download_runner_root/data" +download_runner_tools="$download_runner_root/tools" +mkdir -p "$download_runner_prefix/releases/1.0.0/runtime/bin" "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools" +ln -s "$download_runner_prefix/releases/1.0.0" "$download_runner_prefix/current" +# The request has already been consumed; only the stale download marker is +# left, which is the narrow recovery window covered by this fixture. +download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P) +download_runner_data_physical=$(cd "$download_runner_data" && pwd -P) +printf '%s\n' 'job_id=00000000-0000-4000-8000-000000000002' "old_target=$download_runner_prefix_physical/releases/1.0.0" 'phase=download' > "$download_runner_prefix/.update-state" +printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"' 'exit 0' > "$download_runner_prefix/releases/1.0.0/runtime/bin/node" +printf '%s\n' cli > "$download_runner_prefix/releases/1.0.0/dist/server/cli/update.js" +printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$download_runner_tools/systemctl" +printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$download_runner_tools/readlink" +cat >"$download_runner_tools/stat" <<'EOF' +#!/usr/bin/env bash +case "$*" in + *"-c %u"*|*"-f %u"*) printf '0\n' ;; + *"-c %a"*|*"-f %Lp"*) printf '600\n' ;; + *) /usr/bin/stat "$@" ;; +esac +EOF +chmod 755 "$download_runner_prefix/releases/1.0.0/runtime/bin/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat" +download_runner_script="$download_runner_root/runner.sh" +sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$download_runner_tools:/usr/sbin:/usr/bin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script" +chmod 755 "$download_runner_script" +env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script" +[[ ! -e "$download_runner_root/node.log" ]] +[[ ! -e "$download_runner_prefix/.update-state" ]] + # A RETURN trap installed by install_release must be cleared while its local # temporary variables still exist; otherwise set -u fails at the end of main. release_fixture="$tmp/release-fixture" diff --git a/server/app.ts b/server/app.ts index 8364043..398e4e3 100644 --- a/server/app.ts +++ b/server/app.ts @@ -119,7 +119,7 @@ function enforceUpdateCooldown( adminId: string, operation: "check" | "download" | "apply", reply: FastifyReply, -): void { +): number { const state = updateRateState(database, adminId); const now = Date.now(); const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt; @@ -134,6 +134,7 @@ function enforceUpdateCooldown( if (operation === "check") state.checkedAt = now; else if (operation === "download") state.downloadedAt = now; else state.appliedAt = now; + return now; } function adminSelect(alias = ""): string { @@ -957,12 +958,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { }); app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { + const rateState = updateRateState(database.sqlite, request.auth!.admin.id); + const previousCheckedAt = rateState.checkedAt; + let reservedCheckedAt: number | null = null; try { reconcileOrphanedUpdateJobs(database.sqlite, config); // Disabled/dev installs do not contact a release endpoint, so repeated // checks are local status reads and should remain immediately usable. if (config.updateStrategy !== "disabled") { - enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply); + reservedCheckedAt = enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply); } const result = await checkForUpdate(database.sqlite, config); writeAudit(database.sqlite, { @@ -981,6 +985,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { reply.header("Cache-Control", "no-store"); return { ...result, strategy: config.updateStrategy }; } catch (error) { + // A failed upstream request is not a successful check. Release the + // reservation only when this request still owns it, so a concurrent + // successful check cannot have its cooldown overwritten. + if (reservedCheckedAt !== null && rateState.checkedAt === reservedCheckedAt) rateState.checkedAt = previousCheckedAt; writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, diff --git a/server/update-service.ts b/server/update-service.ts index 703f800..21d298b 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -1,4 +1,4 @@ -import { lstatSync, realpathSync, unlinkSync } from "node:fs"; +import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs"; import { chmod, mkdir, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto"; @@ -385,6 +385,17 @@ function removeExpiredRequest(filePath: string, now: number): void { } } +function requestJobId(filePath: string): string | null { + try { + const info = lstatSync(filePath); + if (!info.isFile() || info.isSymbolicLink()) return null; + const value = JSON.parse(readFileSync(filePath, "utf8")) as { jobId?: unknown }; + return typeof value.jobId === "string" && /^[0-9a-f-]{36}$/.test(value.jobId) ? value.jobId : null; + } catch { + return null; + } +} + function currentReleaseVersion(config: AppConfig): string | null { try { const target = realpathSync(config.currentLink); @@ -425,6 +436,7 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: // row is still safe to retry and must not block the queue forever. const releaseVersion = currentReleaseVersion(config); let reconciled = 0; + const reconciledIds = new Set(); for (const row of rows) { if (typeof row.updatedAt !== "number" || now - row.updatedAt < ORPHANED_UPDATE_TIMEOUT_MS) continue; // The runner refreshes the state marker while a download is in flight. @@ -451,7 +463,10 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: }); return true; })(); - if (changed) reconciled += 1; + if (changed) { + reconciled += 1; + reconciledIds.add(row.id); + } continue; } if (requestFresh || stateFresh) continue; @@ -476,7 +491,10 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: }); return true; })(); - if (changed) reconciled += 1; + if (changed) { + reconciled += 1; + reconciledIds.add(row.id); + } } // Prevent a stale request from being replayed after its DB row has been // marked failed. The path is fixed by the server configuration and the @@ -484,7 +502,17 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: // A download runner refreshes the state marker while it is still using the // request. Keep the request until that lease also expires; otherwise a // long download can lose its job id and fail to finalize its row. - if (!stateFresh && (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS))) { + const queuedRequestId = requestPresent ? requestJobId(config.updateRequestPath) : null; + const queuedRequest = queuedRequestId ? rows.find((row) => row.id === queuedRequestId) : undefined; + const requestStillNeeded = Boolean( + queuedRequest + && ACTIVE_UPDATE_STATUSES.includes(queuedRequest.status) + && !reconciledIds.has(queuedRequest.id) + && !(queuedRequest.status === "staged" && queuedRequest.operation === "download"), + ); + if (!stateFresh + && (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS)) + && !requestStillNeeded) { removeExpiredRequest(config.updateRequestPath, now); } return reconciled; diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 3aedb7d..e09a70e 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -76,6 +76,12 @@ describe("更新 API", () => { expect(tooSoon.statusCode).toBe(429); expect(tooSoon.headers["retry-after"]).toBeDefined(); + // Cooldown is scoped to the authenticated administrator, not the whole + // database or release endpoint. + const otherSession = await login("update-admin-other"); + const otherChecked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: otherSession.cookies, "x-csrf-token": otherSession.csrf }, payload: {} }); + expect(otherChecked.statusCode).toBe(200); + 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); const jobId = applied.json().job.id as string; @@ -157,6 +163,12 @@ describe("更新 API", () => { globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch; const response = 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(response.statusCode).toBe(502); + // A failed upstream check must not reserve the per-admin cooldown; an + // operator can retry immediately after fixing the release endpoint. + const check = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); + expect(check.statusCode).toBe(502); + const retry = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); + expect(retry.statusCode).toBe(502); 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"); }); diff --git a/tests/update.test.ts b/tests/update.test.ts index 001e424..ac1e225 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -372,6 +372,47 @@ describe("更新安全工具", () => { } }); + it("队列任务有新请求标记时可被重新检查,标记过期后才回收", async () => { + const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-queued-marker-")); + let database: ReturnType | undefined; + try { + const dataDir = path.join(root, "data"); + const installPrefix = path.join(root, "install"); + process.env.TALLYNOTE_DATA_DIR = dataDir; + process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix; + process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998"; + process.env.TALLYNOTE_COOKIE_SECURE = "false"; + process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; + process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest"; + process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; + process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; + const config = loadConfig(); + prepareDataDirectories(config); + database = openDatabase(config); + const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1; + const jobId = randomUUID(); + database.sqlite.prepare(` + INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at) + VALUES (?, 'apply', 'queued', '1.2.0', 'linux-x64', ?, ?, ?) + `).run(jobId, "https://updates.example/queued.tar.gz", staleAt, staleAt); + await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "apply" })); + const now = Date.now(); + await utimes(config.updateRequestPath, new Date(now), new Date(now)); + + expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0); + expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "queued" }); + expect(await stat(config.updateRequestPath)).toBeTruthy(); + + const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1; + expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1); + expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" }); + await expect(stat(config.updateRequestPath)).rejects.toThrow(); + } finally { + if (database) database.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } + }); + it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-")); try { diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 6876d6b..573616c 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -109,7 +109,7 @@ export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { tim const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial>)[job.status] ?? 100 : 0; const notes = latest ? notesFor(latest) : null; - return void check()} disabled={checking || loading || hasActiveJob} icon={}>{checking ? "检查中…" : "检查更新"}}> + return void check()} disabled={checking || loading} icon={}>{checking ? "检查中…" : "检查更新"}}> {error && void load()} />}{pollError && } {loading ?
正在读取版本信息…
: info && <>
当前版本v{info.currentVersion}运行平台:{info.platform.target}更新方式{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}
diff --git a/web/src/main.tsx b/web/src/main.tsx index c0becec..bd314fc 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -771,7 +771,7 @@ function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) = const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && (!job || job.status === "failed" || job.version !== latest.version)); return
-
系统

系统更新

+
系统

系统更新

{error &&
{error}
} {loading ?
正在读取版本信息
: info && <>