diff --git a/.env.example b/.env.example index 3809d80..77c8ecd 100644 --- a/.env.example +++ b/.env.example @@ -32,4 +32,5 @@ TALLYNOTE_UPDATE_MAX_MB=512 TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false # TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60 +TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15 TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15 diff --git a/README.md b/README.md index 4d40843..e890081 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ tallynote installer: 查看服务状态:systemctl status tallynote.service 升级有两种方式: -1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。 +1. 后台进入“系统更新”,点击“检查更新”后可先“下载更新包”,等待校验完成,再点击“立即更新”。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。下载阶段主服务保持运行;应用阶段才会停机、备份、切换和健康检查,页面会显示重启倒计时并自动重试连接。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。 2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。 更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。 diff --git a/docs/release.md b/docs/release.md index 4962745..d85d6d1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -93,13 +93,15 @@ sudo /usr/local/sbin/tallynote-uninstall 将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos///releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。 -浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata 和清单,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚: +后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。 + +Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚: ```bash sudo /usr/local/sbin/tallynote-update --rollback ``` -更新检查和应用接口带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。 +更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。 更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。 diff --git a/migrations/0004_update_download_apply.sql b/migrations/0004_update_download_apply.sql new file mode 100644 index 0000000..9e06e6a --- /dev/null +++ b/migrations/0004_update_download_apply.sql @@ -0,0 +1,2 @@ +ALTER TABLE update_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'apply' CHECK(operation IN ('download','apply')); +CREATE INDEX IF NOT EXISTS update_jobs_operation_idx ON update_jobs(operation, status, created_at); diff --git a/package.json b/package.json index f1a1723..691706e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.2", + "version": "1.1.3", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/tallynote-update-runner.sh b/scripts/tallynote-update-runner.sh index 4c97577..4e134a7 100755 --- a/scripts/tallynote-update-runner.sh +++ b/scripts/tallynote-update-runner.sh @@ -22,6 +22,26 @@ die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; } old_target=$(readlink -f -- "$CURRENT_LINK") [[ "$old_target" == "$PREFIX/releases/"* && -d "$old_target" ]] || die 'current release target is invalid' +request_operation='apply' +if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then + request_operation=$(sed -n 's/.*"operation"[[:space:]]*:[[:space:]]*"\(download\|apply\)".*/\1/p' "$REQUEST_FILE" | head -n 1) + [[ "$request_operation" == download || "$request_operation" == apply ]] || request_operation='apply' +fi + +# Downloading is intentionally handled while the main service remains up. +# The CLI persists the validated payload under the root-owned workspace and +# leaves the job staged for a later apply request. +if [[ "$request_operation" == download ]]; then + node_bin="$CURRENT_LINK/runtime/bin/node" + [[ -x "$node_bin" ]] || node_bin=$(command -v node || true) + [[ -n "$node_bin" ]] || die 'node runtime not found' + cli="$CURRENT_LINK/dist/server/cli/update.js" + [[ -f "$cli" ]] || die 'update CLI not found in current release' + "$node_bin" "$cli" --request-file "$REQUEST_FILE" || exit $? + rm -f -- "$REQUEST_FILE" + exit 0 +fi + was_active=0 if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi # shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below diff --git a/server/app.ts b/server/app.ts index 33310e1..3d9e27b 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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>(); 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 | 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 | undefined; if (!row) notFound("更新任务不存在"); diff --git a/server/cli/update.ts b/server/cli/update.ts index 3f78d36..61819de 100644 --- a/server/cli/update.ts +++ b/server/cli/update.ts @@ -31,6 +31,7 @@ import type { UpdateJobStatus } from "../../shared/contracts.js"; const updateRequestFileSchema = z.object({ jobId: z.string().uuid(), + operation: z.enum(["download", "apply"]).default("apply"), version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/), metadataUrl: z.string().url(), assetUrl: z.string().url(), @@ -96,6 +97,8 @@ export type UpdateRunOptions = UrlPolicy & { jobId?: string | undefined; publicKey?: string | undefined; requireSignature?: boolean | undefined; + operation?: "download" | "apply" | undefined; + stagedPath?: string | undefined; }; export type UpdateRunResult = { @@ -140,21 +143,24 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values: requestId?: string | undefined; requestedAt?: number | undefined; startedAt?: number | undefined; + operation?: "download" | "apply" | undefined; }): void { if (!sqlite) return; const now = Date.now(); + const effectiveOperation = values.operation ?? (sqlite.prepare("SELECT operation FROM update_jobs WHERE id=?").get(jobId) as { operation?: "download" | "apply" } | undefined)?.operation ?? "apply"; sqlite.prepare(` INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at, - status, version, platform, release_url, asset_name, asset_url, + operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message, created_at, updated_at, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id), session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash), request_id=COALESCE(excluded.request_id, update_jobs.request_id), requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at), started_at=COALESCE(excluded.started_at, update_jobs.started_at), + operation=excluded.operation, status=excluded.status, version=excluded.version, platform=excluded.platform, release_url=COALESCE(excluded.release_url, update_jobs.release_url), asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name), @@ -174,6 +180,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values: values.requestId ?? null, values.requestedAt ?? null, values.startedAt ?? null, + effectiveOperation, values.status, values.version, values.platform, @@ -238,9 +245,28 @@ async function ensurePrivilegedWorkspace(directory: string): Promise { return resolved; } +/** Validate a queued staged directory before a root process consumes it. */ +async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise { + const rootResolved = path.resolve(workspaceRoot); + const rootInfo = await lstat(rootResolved).catch(() => null); + const uid = typeof process.getuid === "function" ? process.getuid() : -1; + if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) { + throw new Error("更新工作目录权限无效"); + } + const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); }); + const resolved = path.resolve(candidate); + if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效"); + const info = await lstat(resolved).catch(() => null); + if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效"); + const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); }); + if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效"); + return real; +} + export async function runUpdate(options: UpdateRunOptions): Promise { const platform = options.platform ?? detectPlatform(); const jobId = options.jobId ?? randomUUID(); + const operation = options.operation ?? "apply"; let resolved: Awaited> | undefined; try { resolved = await resolveRelease(options, platform); @@ -250,27 +276,36 @@ export async function runUpdate(options: UpdateRunOptions): Promise null); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录"); - updateJob(options.sqlite, jobId, { status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath }); + updateJob(options.sqlite, jobId, { operation, status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: workspace }); + + if (operation === "download") { + keepWorkspace = true; + return { jobId, version: resolved.version, asset: resolved.asset, archivePath }; + } let backupArchivePath: string | undefined; if (options.dataBackupArchivePath && options.dataBackupSource) { @@ -295,8 +330,10 @@ export async function runUpdate(options: UpdateRunOptions): Promise { + const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record | undefined; + if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态"); + if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致"); + const stagedPath = options.workspaceRoot + ? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot) + : options.stagedPath; + const payload = path.join(stagedPath, "payload"); + const payloadInfo = await lstat(payload).catch(() => null); + if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效"); + await normalizeReleasePermissions(payload); + let switchedBackup: string | undefined; + let committed = false; + try { + if (options.dataBackupArchivePath && options.dataBackupSource) { + updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.dataBackupArchivePath }); + await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, { maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024 }); + } + if (options.backupArchivePath) { + updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath }); + const source = await realpath(options.currentDir).catch(() => options.currentDir); + await createSafeArchive(source, options.backupArchivePath, { maxBytes: options.maxBytes ?? 512 * 1024 * 1024 }); + } + updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath, startedAt: Date.now() }); + switchedBackup = (await atomicSwitchRelease(payload, options.currentLink, options.releasesDir, options.version)).previousTarget; + committed = true; + await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined); + } catch (error) { + if (!committed) { + await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined); + updateJob(options.sqlite, options.jobId, { operation: "apply", status: "failed", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), errorMessage: safeErrorMessage(error) }); + clearTransientJobPath(options.sqlite, options.jobId); + } + throw error; + } + updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, backupPath: switchedBackup ?? options.backupArchivePath }); + clearTransientJobPath(options.sqlite, options.jobId); +} + function arg(name: string): string | undefined { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; @@ -379,9 +469,36 @@ export async function main(config: AppConfig = loadConfig()): Promise { prepareDataDirectories(config); if (request) await ensurePrivilegedWorkspace(stagingDir); else await mkdir(stagingDir, { recursive: true, mode: 0o700 }); - const release = acquireInstanceLock(config); + // The download phase intentionally runs beside the live app so users keep + // access while the archive is fetched and staged. SQLite WAL plus the + // configured busy timeout serializes writes; the exclusive process lock is + // reserved for apply/rollback, when the service is stopped by systemd. + const release = request?.operation === "download" ? () => undefined : acquireInstanceLock(config); const database = openDatabase(config); try { + if (request?.operation === "apply") { + const staged = database.sqlite.prepare("SELECT download_path AS downloadPath, version FROM update_jobs WHERE id=? AND status='staged' AND operation='apply'").get(request.jobId) as { downloadPath: string | null; version: string } | undefined; + if (!staged?.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效"); + const root = path.resolve(config.updateWorkspaceDir); + const candidate = await validateStagedWorkspacePath(staged.downloadPath, root); + await applyStagedUpdate({ + sqlite: database.sqlite, + jobId: request.jobId, + version: request.version, + stagedPath: candidate, + currentDir, + currentLink: request.currentLink, + releasesDir: request.releasesDir, + workspaceRoot: root, + ...(backupArchive ? { backupArchivePath: backupArchive } : {}), + ...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}), + dataBackupSource: config.dataDir, + maxBytes: config.updateMaxBytes, + dataBackupMaxBytes: config.maxTotalBytes, + }); + console.log(`更新已切换:${request.version}`); + return; + } const result = await runUpdate({ ...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}), ...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}), @@ -398,6 +515,7 @@ export async function main(config: AppConfig = loadConfig()): Promise { dataBackupMaxBytes: config.maxTotalBytes, currentVersion: config.appVersion, ...(deferCompletion ? { deferCompletion: true } : {}), + ...(request?.operation === "download" ? { operation: "download" as const } : {}), ...(request ? { jobId: request.jobId } : {}), publicKey: config.updatePublicKey, requireSignature: config.updateRequireSignature, diff --git a/server/config.ts b/server/config.ts index b0ac98b..15a05b4 100644 --- a/server/config.ts +++ b/server/config.ts @@ -142,6 +142,7 @@ export function loadConfig() { // cooldown so an authenticated account cannot turn the endpoint into an // outbound request flood; set to 0 only for controlled test environments. updateCheckCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS", 60) * 1000, + updateDownloadCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS", 15) * 1000, updateApplyCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS", 15) * 1000, isLocalOrigin: localOrigin, dataDir, diff --git a/server/db/schema.ts b/server/db/schema.ts index 0d02824..d64751f 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -136,6 +136,7 @@ export const updateJobs = sqliteTable("update_jobs", { adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }), sessionHash: text("session_hash"), requestId: text("request_id"), + operation: text("operation", { enum: ["download", "apply"] }).notNull().default("apply"), status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(), version: text("version").notNull(), platform: text("platform").notNull(), diff --git a/server/update-service.ts b/server/update-service.ts index 7debb2a..9f38c25 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -14,6 +14,7 @@ import { sanitizeAssetName, selectReleaseAsset, validateHttpsUrl, + RELEASE_NOTES_MAX_BYTES, type ReleaseAsset, type ReleaseMetadata, } from "./update.js"; @@ -34,7 +35,10 @@ export type CachedRelease = { metadataUrl: string; version: string; tagName?: string; + releaseName?: string; publishedAt?: string; + notes?: string; + releaseUrl?: string; platform: string; signatureVerified?: boolean; asset?: { @@ -53,7 +57,10 @@ export type UpdateCheckResult = { latest: { version: string; tagName?: string; + releaseName?: string; publishedAt?: string; + notes?: string; + releaseUrl?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; @@ -65,6 +72,7 @@ export type UpdateCheckResult = { export type UpdateRequest = { jobId: string; + operation?: "download" | "apply"; version: string; metadataUrl: string; assetUrl: string; @@ -76,6 +84,7 @@ export type UpdateRequest = { currentLink: string; releasesDir: string; dataDir: string; + stagedPath?: string; }; function setting(database: Database.Database, key: string): string | undefined { @@ -206,7 +215,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon metadataUrl, version: safeVersion, ...(metadata.tagName ? { tagName: metadata.tagName } : {}), + ...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}), ...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}), + ...(metadata.notes ? { notes: metadata.notes } : {}), + ...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}), platform: platform.target, signatureVerified, ...(asset ? { @@ -227,7 +239,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon latest: { version: safeVersion, ...(metadata.tagName ? { tagName: metadata.tagName } : {}), + ...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}), ...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}), + ...(metadata.notes ? { notes: metadata.notes } : {}), + ...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}), compatible: Boolean(asset), integrityReady: Boolean(asset?.sha256 && (!config.updateRequireSignature || signatureVerified)), signatureReady: !config.updateRequireSignature || signatureVerified, @@ -245,6 +260,9 @@ export function readCachedRelease(database: Database.Database, config: AppConfig if (!value || typeof value !== "object" || typeof value.version !== "string" || typeof value.metadataUrl !== "string" || typeof value.platform !== "string") return null; parseSemver(value.version); const metadataUrl = validateHttpsUrl(value.metadataUrl, policy(config)).toString(); + if (value.releaseName !== undefined && (typeof value.releaseName !== "string" || value.releaseName.length > 200 || /[\u0000-\u001f\u007f]/.test(value.releaseName))) return null; + if (value.notes !== undefined && (typeof value.notes !== "string" || Buffer.byteLength(value.notes, "utf8") > RELEASE_NOTES_MAX_BYTES)) return null; + if (value.releaseUrl !== undefined) validateHttpsUrl(value.releaseUrl, policy(config)); if (value.signatureVerified !== undefined && typeof value.signatureVerified !== "boolean") return null; if (value.asset) { if (typeof value.asset.name !== "string" || typeof value.asset.url !== "string") return null; @@ -266,7 +284,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon return { configured: config.updateStrategy !== "disabled", currentVersion: config.appVersion, platform, checkedAt: cached?.checkedAt ?? 0, latest: cached ? { version: cached.version, ...(cached.tagName ? { tagName: cached.tagName } : {}), + ...(cached.releaseName ? { releaseName: cached.releaseName } : {}), ...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}), + ...(cached.notes ? { notes: cached.notes } : {}), + ...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}), compatible, integrityReady: compatible && Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true), signatureReady: !config.updateRequireSignature || cached.signatureVerified === true, @@ -282,7 +303,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon latest: { version: cached.version, ...(cached.tagName ? { tagName: cached.tagName } : {}), + ...(cached.releaseName ? { releaseName: cached.releaseName } : {}), ...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}), + ...(cached.notes ? { notes: cached.notes } : {}), + ...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}), compatible: Boolean(cached.asset), integrityReady: Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true), signatureReady: !config.updateRequireSignature || cached.signatureVerified === true, @@ -309,8 +333,11 @@ export async function writeUpdateRequest(config: AppConfig, request: UpdateReque export function publicUpdateJob(row: Record | undefined): Record | null { if (!row) return null; const hasError = typeof row.errorMessage === "string" && row.errorMessage.length > 0; + const updatedAt = typeof row.updatedAt === "number" ? row.updatedAt : null; + const expectedRecoveryAt = row.status === "applying" && updatedAt !== null ? updatedAt + 30_000 : null; return { id: row.id, + operation: row.operation ?? "apply", status: row.status, version: row.version, platform: row.platform, @@ -323,5 +350,8 @@ export function publicUpdateJob(row: Record | undefined): Recor createdAt: row.createdAt, updatedAt: row.updatedAt, completedAt: row.completedAt ?? null, + ...(row.applyQueuedAt ? { applyQueuedAt: row.applyQueuedAt } : {}), + ...(expectedRecoveryAt ? { expectedRecoveryAt } : {}), + ...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}), }; } diff --git a/server/update.ts b/server/update.ts index 5464f5e..b8d0f8b 100644 --- a/server/update.ts +++ b/server/update.ts @@ -35,7 +35,11 @@ export type ReleaseAsset = { export type ReleaseMetadata = { version: string; tagName?: string; + releaseName?: string; publishedAt?: string; + /** Plain-text release notes, bounded to keep API/cache payloads small. */ + notes?: string; + releaseUrl?: string; assets: ReleaseAsset[]; }; @@ -143,6 +147,57 @@ function metadataError(): Error { } const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024; +export const RELEASE_NOTES_MAX_BYTES = 64 * 1024; + +function releaseNotesText(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + // Gitea exposes both Markdown (body/body_html) and releaseNotes depending on + // endpoint/version. Keep the browser contract text-only and bounded. + const text = value + .replace(//gi, "\n") + .replace(/<\/p\s*>/gi, "\n\n") + .replace(/<[^>]*>/g, "") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\r\n?/g, "\n") + .trim(); + const bytes = Buffer.from(text, "utf8"); + if (bytes.length <= RELEASE_NOTES_MAX_BYTES) return text; + return bytes.subarray(0, RELEASE_NOTES_MAX_BYTES).toString("utf8").replace(/\uFFFD$/u, "") + "\n[内容已截断]"; +} + +function releaseNameText(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim(); + return text.length > 0 ? text.slice(0, 200) : undefined; +} + +/** Gitea installations behind a reverse proxy sometimes emit internal HTTP + * asset URLs. Rebind those URLs to the already trusted HTTPS release origin, + * while continuing to reject arbitrary HTTPS hosts and credentials. */ +function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): string { + let candidate: URL; + try { + candidate = new URL(value, current); + } catch { + throw new Error("更新地址无效"); + } + if (candidate.username || candidate.password) throw new Error("更新地址不允许携带凭据"); + try { + return validateHttpsUrl(candidate, { ...options, baseUrl: current }).toString(); + } catch { + if (candidate.protocol !== "http:") throw new Error("更新地址必须使用 HTTPS"); + const rebound = new URL(current); + rebound.pathname = candidate.pathname; + rebound.search = candidate.search; + rebound.hash = ""; + return validateHttpsUrl(rebound, { ...options, baseUrl: current }).toString(); + } +} /** Read a fetch body without ever buffering more than the caller's bound. */ async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise { @@ -227,12 +282,24 @@ export async function fetchReleaseMetadata( const candidate = digest.replace(/^sha256:/i, "").toLowerCase(); if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate; } - assets.push({ name, url: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) }); + assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) }); + } + const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html); + const releaseName = releaseNameText(item.name ?? item.releaseName); + let releaseUrl: string | undefined; + if (typeof item.html_url === "string" || typeof item.url === "string") { + try { + const candidate = typeof item.html_url === "string" ? item.html_url : item.url as string; + releaseUrl = releaseResourceUrl(candidate, current, options); + } catch { /* omit invalid optional release page URL */ } } return { version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`, ...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}), + ...(releaseName ? { releaseName } : {}), ...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}), + ...(notes ? { notes } : {}), + ...(releaseUrl ? { releaseUrl } : {}), assets, }; } diff --git a/shared/contracts.ts b/shared/contracts.ts index a286567..19143e0 100644 --- a/shared/contracts.ts +++ b/shared/contracts.ts @@ -93,12 +93,21 @@ export const updateJobStatusSchema = z.enum([ ]); export type UpdateJobStatus = z.infer; +export const updateOperationSchema = z.enum(["download", "apply"]); +export type UpdateOperation = z.infer; + /** The browser never supplies release URLs or filesystem paths. */ export const updateApplySchema = z.object({ // Keep the browser contract aligned with server/update.ts' SemVer parser, // including optional prerelease and build metadata segments. version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/), confirm: z.literal(true), + jobId: z.string().uuid().optional(), +}).strict(); + +export const updateDownloadSchema = z.object({ + version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/), + confirm: z.literal(true), }).strict(); export type ApiError = { diff --git a/systemd/tallynote.env.example b/systemd/tallynote.env.example index 8dae5c1..0e80b5b 100644 --- a/systemd/tallynote.env.example +++ b/systemd/tallynote.env.example @@ -10,6 +10,7 @@ TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNo TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60 +TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15 TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15 # Optional: configure a root-managed Ed25519 public key and set # TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures. diff --git a/tests/migration.test.ts b/tests/migration.test.ts index 2881057..245b8b9 100644 --- a/tests/migration.test.ts +++ b/tests/migration.test.ts @@ -41,9 +41,10 @@ describe("数据库迁移", () => { { name: "0001_invoice_missing_reason.sql" }, { name: "0002_update_jobs.sql" }, { name: "0003_update_job_ownership.sql" }, + { name: "0004_update_download_apply.sql" }, ]); const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>; - expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at"])); + expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation"])); expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null }); migrated.sqlite.close(); migrated = openDatabase(config); diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 5259f18..c6afa52 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -93,6 +93,35 @@ describe("更新 API", () => { expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"])); }); + it("先下载并暂存更新包,再由同一管理员认领应用", async () => { + const session = await login("update-staged"); + mockRelease(); + const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); + expect(checked.statusCode).toBe(200); + const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.3", confirm: true } }); + expect(downloaded.statusCode).toBe(202); + const downloadJobId = downloaded.json().job.id as string; + expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.3" }); + const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string }; + expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" }); + expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" }); + database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message='test', updated_at=? WHERE id=?").run(Date.now(), downloadJobId); + + const stagedId = randomUUID(); + const now = Date.now(); + 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, actual_sha256, download_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.1.3", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.3", confirm: true } }); + expect(applied.statusCode).toBe(202); + expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" }); + expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" }); + const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string }; + expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) }); + const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.3", confirm: true } }); + expect(duplicate.statusCode).toBe(409); + expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); + }); + it("缺少确认或未启用 systemd 时不接受更新", async () => { const session = await login(); const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.3" } }); diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 9eed1a3..6876d6b 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal, Zap } from "lucide-react"; import { Button, Dialog, Tag } from "tdesign-react"; import { ApiError, api } from "../../services/api"; import { dateText } from "../expenses/date"; @@ -7,10 +7,23 @@ import { ErrorBanner, Page, Surface } from "../common"; import type { Notify } from "../expenses/types"; type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled"; -type UpdateJob = { id: string; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt: number; updatedAt: number; completedAt?: number | null }; -type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: { version: string; tagName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number } | null; job: UpdateJob | null }; +type UpdateJob = { id: string; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null }; +type LatestRelease = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number; notes?: string | null; releaseNotes?: string | null; body?: string | null; htmlUrl?: string | null }; +type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: LatestRelease | null; job: UpdateJob | null }; const active = new Set(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]); -const labels: Record = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "准备完成", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" }; +const pollable = new Set(["queued", "downloading", "verifying", "backing_up", "applying"]); +const labels: Record = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "下载完成,等待应用", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" }; + +function timestamp(value: number | string | null | undefined): number | null { + if (value === null || value === undefined || value === "") return null; + const n = typeof value === "number" ? value : Date.parse(value); + if (!Number.isFinite(n)) return null; + return n < 10_000_000_000 ? n * 1000 : n; +} +function notesFor(latest: LatestRelease): string | null { + const value = latest.notes ?? latest.releaseNotes ?? latest.body; + return typeof value === "string" && value.trim() ? value.trim() : null; +} export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) { const [info, setInfo] = useState(null); @@ -19,95 +32,91 @@ export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { tim const [error, setError] = useState(""); const [pollError, setPollError] = useState(""); const [confirmVersion, setConfirmVersion] = useState(null); - const [applying, setApplying] = useState(false); + const [confirmAction, setConfirmAction] = useState<"download" | "apply">("download"); + const [actionBusy, setActionBusy] = useState(false); const [reloadReady, setReloadReady] = useState(false); + const [now, setNow] = useState(() => Date.now()); const announced = useRef(null); const checkInFlight = useRef(false); - const applyInFlight = useRef(false); + const actionInFlight = useRef(false); + const disconnected = useRef(false); + const recoveredNotice = useRef(false); const load = async () => { setLoading(true); setError(""); try { setInfo(await api("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }; useEffect(() => { void load(); }, []); + const job = info?.job; + const applyQueuedAt = timestamp(job?.applyQueuedAt); + const restartAt = timestamp(job?.restartDeadline) + ?? timestamp(job?.restartAt) + ?? timestamp(job?.expectedRecoveryAt) + ?? (job?.operation === "apply" && applyQueuedAt ? applyQueuedAt + (job.restartWindowSeconds ?? 30) * 1000 : null) + ?? (job?.status === "applying" && job.updatedAt ? timestamp(job.updatedAt)! + 30_000 : null); + const restartSeconds = restartAt ? Math.max(0, Math.ceil((restartAt - now) / 1000)) : null; + useEffect(() => { if (!restartAt) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [restartAt]); + useEffect(() => { - const job = info?.job; - if (!job) { setPollError(""); return; } - const announceCompletion = (completedJob: UpdateJob) => { - if (completedJob.status === "completed" && announced.current !== completedJob.id) { - announced.current = completedJob.id; - setReloadReady(true); - notify?.("更新完成,请重新加载页面", "success"); - } - }; - if (job.status === "completed") { - setPollError(""); - announceCompletion(job); - return; - } - if (!active.has(job.status)) { setPollError(""); return; } - let disposed = false; - let timer: number | undefined; - let failureCount = 0; + const shouldPoll = Boolean(job && (pollable.has(job.status) || (job.status === "staged" && job.operation === "apply"))); + if (!shouldPoll || !job) { setPollError(""); return; } + let disposed = false; let timer: number | undefined; let failures = 0; const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); }; const poll = async () => { try { const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`); if (disposed) return; - failureCount = 0; - setPollError(""); + failures = 0; + if (disconnected.current && !recoveredNotice.current) { recoveredNotice.current = true; notify?.("服务已恢复,更新状态已刷新", "success"); } + disconnected.current = false; setPollError(""); setInfo(current => current ? { ...current, job: result.job } : current); - announceCompletion(result.job); - if (active.has(result.job.status)) schedule(1500); - } catch (caught) { + if (result.job.status === "completed" && announced.current !== result.job.id) { announced.current = result.job.id; setReloadReady(true); notify?.("更新完成,请重新加载页面", "success"); } + if (pollable.has(result.job.status) || (result.job.status === "staged" && result.job.operation === "apply")) schedule(1500); + } catch { if (disposed) return; - failureCount += 1; - setPollError(`${(caught as Error).message}。更新任务仍在后台运行,页面会自动重试。`); - schedule(Math.min(1500 * (2 ** Math.min(failureCount, 3)), 12_000)); + failures += 1; disconnected.current = true; recoveredNotice.current = false; + setPollError(`服务暂时不可用${restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面会自动重试"}。更新任务仍在后台运行。`); + schedule(Math.min(1500 * (2 ** Math.min(failures, 3)), 12_000)); } }; void poll(); return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); }; - }, [info?.job?.id, info?.job?.status, notify]); + }, [job?.id, job?.status, job?.operation, notify]); + const check = async () => { if (checkInFlight.current) return; - checkInFlight.current = true; - setChecking(true); - setError(""); + checkInFlight.current = true; setChecking(true); setError(""); try { const result = await api & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" }); - setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null })); - notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success"); + setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null })); notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success"); } catch (caught) { - // A configured release source is intentionally rate-limited. A repeated - // click should still be useful: show the cached status instead of a - // blocking error, while preserving the server-side flood protection. - if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") { - try { - await load(); - const seconds = caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查"; - notify?.(`已显示最近一次检查结果${seconds}`, "info"); - return; - } catch { - // Fall through to the normal error surface if the status read fails. - } - } + if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") { try { await load(); notify?.(`已显示最近一次检查结果${caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查"}`, "info"); return; } catch { /* fall through */ } } setError((caught as Error).message); - } finally { - checkInFlight.current = false; - setChecking(false); - } + } finally { checkInFlight.current = false; setChecking(false); } + }; + const submitAction = async () => { + if (!confirmVersion || actionInFlight.current) return; + actionInFlight.current = true; setActionBusy(true); setError(""); + try { + const endpoint = confirmAction === "download" ? "/api/update/download" : "/api/update/apply"; + const body = confirmAction === "download" ? { version: confirmVersion, confirm: true } : { jobId: job?.id, version: confirmVersion, confirm: true }; + const result = await api<{ job: UpdateJob }>(endpoint, { method: "POST", body: JSON.stringify(body) }); + setConfirmVersion(null); setReloadReady(false); setInfo(current => current ? { ...current, job: result.job } : current); notify?.(confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启", "info"); + } catch (caught) { setError((caught as Error).message); } finally { actionInFlight.current = false; setActionBusy(false); } }; - const apply = async () => { if (!confirmVersion || applyInFlight.current) return; applyInFlight.current = true; setApplying(true); setError(""); try { const result = await api<{ job: UpdateJob }>("/api/update/apply", { method: "POST", body: JSON.stringify({ version: confirmVersion, confirm: true }) }); setConfirmVersion(null); setInfo(current => current ? { ...current, job: result.job } : current); notify?.("更新请求已提交,服务会短暂重启", "info"); } catch (e) { setError((e as Error).message); } finally { applyInFlight.current = false; setApplying(false); } }; - const latest = info?.latest; const job = info?.job; const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !job || info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job && !active.has(job.status)); - const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial>)[job.status] ?? 100 : 0; - return void check()} disabled={checking || loading} icon={}>{checking ? "检查中…" : "检查更新"}}> - {error && void load()} />} - {pollError && } + const latest = info?.latest; const hasActiveJob = Boolean(job && active.has(job.status)); + const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version); + const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && !sameCompleted && (!job || job.version !== latest.version || job.status === "failed" || job.status === "cancelled")); + const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job?.operation === "download" && job.status === "staged" && job.version === latest.version); + 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 ? "检查中…" : "检查更新"}}> + {error && void load()} />}{pollError && } {loading ?
正在读取版本信息…
: info && <>
当前版本v{info.currentVersion}运行平台:{info.platform.target}更新方式{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}
- {latest ?
最新 Release

{latest.tagName || `v${latest.version}`}

{latest.publishedAt && 发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}}
{latest.isNewer ? "有新版本" : "已是最新"}
平台文件{latest.compatible ? latest.assetName : "无匹配文件"}
完整性{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}
文件大小{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}
{latest.isNewer && !latest.compatible &&
当前平台没有可安装的 Release 文件。
}{latest.isNewer && latest.compatible && !latest.integrityReady &&
发布文件缺少完整校验,已禁用更新。
}
{canApply && }{reloadReady && }
:
点击“检查更新”获取最新 Release。
} + {latest ?
最新 Release

{latest.releaseName || latest.tagName || `v${latest.version}`}

{latest.publishedAt && 发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}}
{latest.isNewer ? "有新版本" : "已是最新"}
{notes &&
Release notes
{notes}
}
平台文件{latest.compatible ? latest.assetName : "无匹配文件"}
完整性{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}
文件大小{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}
{latest.isNewer && !latest.compatible &&
当前平台没有可安装的 Release 文件。
}{latest.isNewer && latest.compatible && !latest.integrityReady &&
发布文件缺少完整校验,已禁用更新。
}
{canDownload && }{canApply && }{reloadReady && }
:
点击“检查更新”获取最新 Release。
} {!info.configured &&
当前为手动更新模式

源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。

} - {job && 更新任务状态:{labels[job.status]}
最近任务

v{job.version}

{labels[job.status]}
{active.has(job.status) && <>
更新服务正在后台运行,页面会自动刷新状态。}{job.status === "failed" && job.errorMessage &&
{job.errorMessage}
}{job.status === "completed" &&
新版本已通过健康检查,数据和附件保持不变。
}
} + {job && 更新任务状态:{labels[job.status]}
最近任务

v{job.version}

{labels[job.status]}
{active.has(job.status) && <>
{job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}{restartSeconds !== null && (job.status === "applying" || disconnected.current) &&
服务正在重启,预计 {restartSeconds} 秒后恢复
}}{job.status === "failed" && job.errorMessage &&
{job.errorMessage}
}{job.status === "completed" &&
新版本已通过健康检查,数据和附件保持不变。
}
} } - { if (!applying) setConfirmVersion(null); }} onConfirm={() => void apply()} onCancel={() => { if (!applying) setConfirmVersion(null); }}>将更新到 v{confirmVersion}。服务会短暂重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。 + { if (!actionBusy) setConfirmVersion(null); }} onConfirm={() => void submitAction()} onCancel={() => { if (!actionBusy) setConfirmVersion(null); }}>{confirmAction === "download" ? `将下载并校验 v${confirmVersion},完成后可选择立即更新。` : `将应用已下载的 v${confirmVersion}。服务会短暂重启,更新前会备份数据目录。`}
; } diff --git a/web-next/src/styles/theme.css b/web-next/src/styles/theme.css index 29ccd4e..083e61b 100644 --- a/web-next/src/styles/theme.css +++ b/web-next/src/styles/theme.css @@ -278,6 +278,8 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { .tn-update-release .t-card__body { padding: 20px; } .tn-update-release-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } .tn-update-release h2 { margin: 3px 0 5px; color: var(--tn-text); font-size: 21px; } +.tn-release-notes { margin: 16px 0; padding: 12px 14px; border-left: 3px solid var(--td-brand-color-3); background: #f7f9fc; color: var(--tn-text-secondary); font-size: 13px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } +.tn-release-notes .tn-eyebrow { display: block; margin-bottom: 4px; color: var(--tn-navy-900); } .tn-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0; padding: 14px 0; border-top: 1px solid var(--tn-border-subtle); border-bottom: 1px solid var(--tn-border-subtle); } .tn-facts span { display: block; margin-bottom: 4px; color: var(--tn-text-secondary); font-size: 12px; } .tn-facts strong { overflow-wrap: anywhere; color: #344054; font-size: 14px; } @@ -344,6 +346,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { .tn-inline-error, .tn-inline-info { display: flex; align-items: center; gap: 7px; margin-top: 10px; padding: 9px 11px; border-radius: 3px; font-size: 13px; } .tn-inline-error { color: #a33a3a; background: #fff0f0; } .tn-inline-info { color: #246044; background: #eaf7ef; } +.tn-restart-countdown { margin-top: 10px; color: var(--tn-warning); font-size: 12px; font-variant-numeric: tabular-nums; } .tn-update-explainer { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 14px; padding: 13px 15px; border: 1px solid var(--td-brand-color-2); border-radius: 4px; color: var(--tn-navy-900); background: var(--td-brand-color-1); } .tn-update-explainer > svg { flex: 0 0 auto; margin-top: 1px; color: var(--td-brand-color); } .tn-update-explainer strong { display: block; font-size: 13px; }