diff --git a/install.sh b/install.sh index fda3422..16aecd4 100755 --- a/install.sh +++ b/install.sh @@ -1395,7 +1395,7 @@ main() { unit_tmp=$(mktemp -d) sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service" sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service" - sed "s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path" + sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path" sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$unit_tmp/tallynote-admin-init" install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service diff --git a/package.json b/package.json index 70ed133..9f256fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.13", + "version": "1.1.14", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/tallynote-update-runner.sh b/scripts/tallynote-update-runner.sh index 9e7e0c6..900326e 100755 --- a/scripts/tallynote-update-runner.sh +++ b/scripts/tallynote-update-runner.sh @@ -41,7 +41,25 @@ if [[ "$request_operation" == download ]]; then [[ -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 $? + set +e + "$node_bin" "$cli" --request-file "$REQUEST_FILE" + download_result=$? + set -e + if (( download_result != 0 )); then + # The CLI normally records failed itself. Retry the explicit finalization + # for failures that happen before its catch handler can persist the row, + # then remove the one-shot request so a failed download cannot keep the + # path unit in a permanently triggered state. + download_job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1) + if [[ "$download_job_id" =~ ^[0-9a-f-]{36}$ ]]; then + for _ in 1 2 3; do + if "$node_bin" "$cli" --finalize-job "$download_job_id" --finalize-status failed --message '更新下载失败' >/dev/null 2>&1; then break; fi + sleep 1 + done + fi + rm -f -- "$REQUEST_FILE" + exit "$download_result" + fi rm -f -- "$REQUEST_FILE" exit 0 fi @@ -112,6 +130,27 @@ recover_stale_state() { done return 1 fi + if [[ "$current_target" == "$state_old" ]]; then + # The process may have restored the old release before it was killed. In + # that case the old link is already safe to serve, but the database row + # can still be `applying`; finish it as failed before clearing recovery + # markers so the UI does not poll forever. + recovery_node="$CURRENT_LINK/runtime/bin/node" + [[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true) + if finalize_state_job "$recovery_node" failed "$state_job"; then + rm -f -- "$REQUEST_FILE" 2>/dev/null || true + clear_update_state || true + return 11 + fi + # A crash before the CLI created its job row is safe to retry. Preserve + # the request while dropping only the stale state marker. + if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then + clear_update_state || true + return 0 + fi + clear_update_state || true + return 0 + fi if [[ "$current_target" != "$state_old" ]]; then rollback_link="$PREFIX/.current-recovery-$$-${RANDOM}.tmp" [[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1 @@ -159,7 +198,12 @@ fi rollback_current() { local current_target rollback_link current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true) - [[ "$current_target" == "$old_target" ]] && return 0 + if [[ "$current_target" == "$old_target" ]]; then + # An earlier failure branch may already have restored the link. Keep the + # marker truthful so the EXIT trap can still finalize the job. + switched=0 + return 0 + fi rollback_link="$PREFIX/.current-rollback-$$-${RANDOM}.tmp" [[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1 ln -s -- "$old_target" "$rollback_link" || return 1 @@ -172,8 +216,16 @@ rollback_current() { finalize_failed_job() { [[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0 - [[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 0 - "$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1 + [[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 1 + # Give SQLite a moment to release a transient lock before declaring the + # recovery itself failed. + for _ in 1 2 3; do + if "$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 } finalize_completed_job() { @@ -187,7 +239,10 @@ cleanup_after_update() { local result=$? rollback_ok=1 if (( result != 0 && handled == 0 )); then if ! rollback_current; then rollback_ok=0; fi - if (( rollback_ok == 1 && switched == 0 )); then + # Once the old release is active again, always try to close the job. The + # previous marker could remain set when an earlier branch had already + # rolled back before entering this EXIT trap, leaving `applying` forever. + if (( rollback_ok == 1 )); then if finalize_failed_job; then rm -f -- "$REQUEST_FILE" clear_update_state || true diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 6546267..92271a5 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -4,6 +4,13 @@ root=$(cd "$(dirname "$0")/.." && pwd) bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh" grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service" grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service" +grep -Eq '^PathExists=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path" +grep -Eq '^PathChanged=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path" +grep -Eq '^PathChanged=/opt/tallynote$' "$root/systemd/tallynote-update.path" +if grep -Eq '^ConditionPathExists=' "$root/systemd/tallynote-update.service"; then + echo 'update service must not require only the request file' >&2 + exit 1 +fi output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases) grep -q 'dry-run' <<<"$output" grep -q '\[阶段\] 检查运行环境' <<<"$output" @@ -192,6 +199,17 @@ bash -c ' set_env_key test value ' _ "$installer_lib" "$release_archive" "$tmp/install-release" +# The installed path unit must watch both the data-directory request and the +# release-prefix recovery marker after custom paths are substituted. +rendered_path="$tmp/rendered-update.path" +sed "s#/opt/tallynote#$tmp/custom-prefix#g; s#/var/lib/tallynote#$tmp/custom-data#g" \ + "$root/systemd/tallynote-update.path" > "$rendered_path" +grep -Fxq "PathExists=$tmp/custom-data/update-request.json" "$rendered_path" +grep -Fxq "PathChanged=$tmp/custom-data/update-request.json" "$rendered_path" +grep -Fxq "PathExists=$tmp/custom-prefix/.update-state" "$rendered_path" +grep -Fxq "PathChanged=$tmp/custom-prefix/.update-state" "$rendered_path" +grep -Fxq "PathChanged=$tmp/custom-prefix" "$rendered_path" + # The production admin wrapper must load a release-relative runtime, change to # the release root, and forward CLI arguments without requiring pnpm. wrapper_prefix="$tmp/wrapper-prefix" diff --git a/server/app.ts b/server/app.ts index ce760ba..8364043 100644 --- a/server/app.ts +++ b/server/app.ts @@ -59,6 +59,7 @@ import { checkForUpdate, publicCheckFromCache, publicUpdateJob, + reconcileOrphanedUpdateJobs, readCachedRelease, writeUpdateRequest, type UpdateRequest, @@ -939,6 +940,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { // Release metadata and task state should never be stored by an upstream // proxy or a shared browser cache. reply.header("Cache-Control", "no-store"); + reconcileOrphanedUpdateJobs(database.sqlite, config); const cached = publicCheckFromCache(database.sqlite, config); const row = database.sqlite.prepare(` SELECT id, operation, status, version, platform, asset_name AS assetName, @@ -956,6 +958,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { 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") { @@ -995,6 +998,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { let applyAuditRecorded = false; let applyAuditTarget: string | undefined; try { + reconcileOrphanedUpdateJobs(database.sqlite, config); if (config.updateStrategy !== "systemd") { throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新"); } @@ -1145,6 +1149,7 @@ 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); + reconcileOrphanedUpdateJobs(database.sqlite, config); if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新"); const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); @@ -1175,6 +1180,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { 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); const row = database.sqlite.prepare(` SELECT id, operation, status, version, platform, asset_name AS assetName, size_bytes AS sizeBytes, error_message AS errorMessage, diff --git a/server/cli/update.ts b/server/cli/update.ts index 61819de..8d06ef2 100644 --- a/server/cli/update.ts +++ b/server/cli/update.ts @@ -26,7 +26,7 @@ import { type ReleaseMetadata, type UrlPolicy, } from "../update.js"; -import { attachSidecarHash } from "../update-service.js"; +import { ACTIVE_UPDATE_STATUSES, attachSidecarHash } from "../update-service.js"; import type { UpdateJobStatus } from "../../shared/contracts.js"; const updateRequestFileSchema = z.object({ @@ -153,7 +153,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values: 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), @@ -355,7 +355,9 @@ export function finalizeUpdateJob( FROM update_jobs WHERE id=? `).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined; if (!row) throw new Error("更新任务不存在"); - if (row.status !== "applying" && row.status !== "completed" && row.status !== "failed") throw new Error("更新任务状态不允许完成"); + const canComplete = row.status === "applying" || row.status === "completed"; + const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed"; + if (status === "completed" ? !canComplete : !canFail) throw new Error("更新任务状态不允许完成"); const now = Date.now(); const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null; sqlite.transaction(() => { @@ -477,27 +479,34 @@ export async function main(config: AppConfig = loadConfig()): Promise { 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 staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined; + if (staged?.status === "staged" && staged.operation === "apply") { + 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; + } + if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效"); + // A direct one-click request starts in queued/apply. Older clients do + // not have a separate download step, so fall through to runUpdate, + // which downloads, verifies, backs up, and switches the release in one + // transaction. A staged request still takes the branch above. } const result = await runUpdate({ ...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}), diff --git a/server/update-service.ts b/server/update-service.ts index 9f38c25..4850ef9 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -1,7 +1,9 @@ +import { lstatSync, realpathSync } 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"; import type Database from "better-sqlite3"; +import { writeAudit } from "./audit.js"; import { AppError } from "./errors.js"; import type { AppConfig } from "./config.js"; import { @@ -30,6 +32,12 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [ "applying", ]; +// A queued job normally starts within seconds and an applying job completes +// after the service health check. This grace period only applies when both +// hand-off markers are gone, so an active runner is never reclaimed midway +// through a download, backup, or switch. +export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000; + export type CachedRelease = { checkedAt: number; metadataUrl: string; @@ -355,3 +363,72 @@ export function publicUpdateJob(row: Record | undefined): Recor ...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}), }; } + +function markerExists(filePath: string): boolean { + try { + const info = lstatSync(filePath); + return info.isFile() || info.isSymbolicLink(); + } catch { + return false; + } +} + +function currentReleaseVersion(config: AppConfig): string | null { + try { + const target = realpathSync(config.currentLink); + const releases = realpathSync(config.releasesDir); + if (!target.startsWith(`${releases}${path.sep}`)) return null; + return path.basename(target); + } catch { + return null; + } +} + +/** + * Release an update row left behind after both privileged hand-off markers + * disappeared. This is deliberately conservative: staged downloads remain + * available for an explicit apply, and any visible marker means the runner + * still owns recovery. + */ +export function reconcileOrphanedUpdateJobs(database: Database.Database, config: AppConfig, now = Date.now()): number { + const placeholders = ACTIVE_UPDATE_STATUSES.map(() => "?").join(","); + const rows = database.prepare(` + SELECT id, status, version, admin_id AS adminId, request_id AS requestId, + updated_at AS updatedAt + FROM update_jobs + WHERE status IN (${placeholders}) + ORDER BY updated_at ASC + `).all(...ACTIVE_UPDATE_STATUSES) as Array<{ id: string; status: UpdateJobStatus; version: string; adminId: string | null; requestId: string | null; updatedAt: number | null }>; + if (rows.length === 0) return 0; + const requestPresent = markerExists(config.updateRequestPath); + const statePresent = markerExists(path.join(config.installPrefix, ".update-state")); + if (requestPresent || statePresent) return 0; + const releaseVersion = currentReleaseVersion(config); + let reconciled = 0; + for (const row of rows) { + if (row.status === "staged" || typeof row.updatedAt !== "number" || now - row.updatedAt < ORPHANED_UPDATE_TIMEOUT_MS) continue; + const status: "completed" | "failed" = row.status === "applying" && releaseVersion === row.version ? "completed" : "failed"; + const errorMessage = status === "failed" ? "更新任务超时,已释放更新队列" : null; + const changed = database.transaction(() => { + const result = database.prepare(` + UPDATE update_jobs + SET status=?, error_message=?, completed_at=?, updated_at=? + WHERE id=? AND status=? AND updated_at=? + `).run(status, errorMessage, now, now, row.id, row.status, row.updatedAt); + if (result.changes !== 1) return false; + writeAudit(database, { + requestId: row.requestId || randomUUID(), + actorAdminId: row.adminId, + action: "update.reconciled", + targetType: "update", + targetId: row.id, + outcome: status === "completed" ? "success" : "failure", + before: { status: row.status, version: row.version }, + after: { status, version: row.version, reason: "orphaned_timeout" }, + }); + return true; + })(); + if (changed) reconciled += 1; + } + return reconciled; +} diff --git a/systemd/tallynote-update.path b/systemd/tallynote-update.path index 260d4ce..cfddc32 100644 --- a/systemd/tallynote-update.path +++ b/systemd/tallynote-update.path @@ -4,6 +4,15 @@ Description=Watch for TallyNote release update requests [Path] PathExists=/var/lib/tallynote/update-request.json PathChanged=/var/lib/tallynote/update-request.json +# The recovery marker lives beside the release link. Watching it as well +# allows systemd to resume reconciliation when the runner is interrupted +# after consuming the request but before clearing its state file. +PathExists=/opt/tallynote/.update-state +PathChanged=/opt/tallynote/.update-state +# Keep a directory-level fallback because some systemd/inotify versions skip +# dotfiles when watching an individual path. State writes are atomic renames, +# so the containing directory changes even when the marker itself is hidden. +PathChanged=/opt/tallynote Unit=tallynote-update.service [Install] diff --git a/systemd/tallynote-update.service b/systemd/tallynote-update.service index 9f4230a..b1c8ff0 100644 --- a/systemd/tallynote-update.service +++ b/systemd/tallynote-update.service @@ -2,7 +2,6 @@ Description=TallyNote privileged release updater After=network-online.target Wants=network-online.target -ConditionPathExists=/var/lib/tallynote/update-request.json [Service] Type=oneshot @@ -12,6 +11,10 @@ WorkingDirectory=/opt/tallynote/current EnvironmentFile=-/etc/tallynote/tallynote.env ExecStart=/usr/local/libexec/tallynote-update-runner Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin +# Downloads, archive validation and data backups can exceed systemd's 90s +# default start timeout on a slower server. Keep one update job alive long +# enough to finish or reach its own health-check/recovery path. +TimeoutStartSec=30min NoNewPrivileges=true CapabilityBoundingSet= AmbientCapabilities= diff --git a/systemd/tallynote.service b/systemd/tallynote.service index 0d01755..8a0b428 100644 --- a/systemd/tallynote.service +++ b/systemd/tallynote.service @@ -14,6 +14,8 @@ Environment=PATH=/opt/tallynote/current/runtime/bin:/usr/sbin:/usr/bin:/sbin:/bi ExecStart=/opt/tallynote/current/bin/tallynote Restart=on-failure RestartSec=5s +# Do not let a wedged Node process hold an update stop forever. +TimeoutStopSec=30s NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict diff --git a/tests/update.test.ts b/tests/update.test.ts index 6616742..d9872f1 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -3,7 +3,7 @@ import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "no import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto"; import { atomicSwitchRelease, createSafeArchive, @@ -18,13 +18,13 @@ import { selectReleaseAsset, validateHttpsUrl, } from "../server/update.js"; -import { runUpdate } from "../server/cli/update.js"; +import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js"; import { validateUpdateRequest } from "../server/cli/update.js"; -import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js"; +import { checkForUpdate, ORPHANED_UPDATE_TIMEOUT_MS, reconcileOrphanedUpdateJobs, verifyReleaseSignature } from "../server/update-service.js"; import { loadConfig, prepareDataDirectories } from "../server/config.js"; import { openDatabase } from "../server/db/index.js"; -const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"]; +const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_INSTALL_PREFIX", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"]; const originalFetch = globalThis.fetch; afterEach(() => { @@ -207,6 +207,122 @@ describe("更新安全工具", () => { } }); + it("更新器支持旧客户端创建的 queued/apply 直接更新请求", async () => { + const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-direct-")); + const previousFetch = globalThis.fetch; + 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); + await mkdir(config.releasesDir, { recursive: true, mode: 0o755 }); + const oldRelease = path.join(config.releasesDir, config.appVersion); + await mkdir(path.join(oldRelease, "dist"), { recursive: true, mode: 0o755 }); + await writeFile(path.join(oldRelease, "dist", "marker"), "old"); + await symlink(oldRelease, config.currentLink); + + const source = path.join(root, "source"); + await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o755 }); + await writeFile(path.join(source, "dist", "marker"), "new"); + const archive = path.join(root, "release.tar.gz"); + await createSafeArchive(source, archive); + const bytes = await readFile(archive); + const digest = createHash("sha256").update(bytes).digest("hex"); + const jobId = randomUUID(); + database = openDatabase(config); + const now = Date.now(); + const assetName = `tallynote-1.2.0-${detectPlatform().target}.tar.gz`; + database.sqlite.prepare(` + INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, + expected_sha256, created_at, updated_at, requested_at) + VALUES (?, 'apply', 'queued', '1.2.0', ?, ?, ?, ?, ?, ?) + `).run(jobId, detectPlatform().target, "https://updates.example/" + assetName, digest, now, now, now); + globalThis.fetch = (async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] })); + if (url.endsWith("SHA256SUMS")) return new Response(`${digest} ${assetName}\n`); + return new Response(bytes, { headers: { "content-length": String(bytes.length) } }); + }) as typeof fetch; + + await runUpdate({ + metadataUrl: config.updateMetadataUrl, + version: "1.2.0", + currentVersion: config.appVersion, + currentDir: config.currentLink, + stagingDir: path.join(root, "staging"), + currentLink: config.currentLink, + releasesDir: config.releasesDir, + allowedHosts: config.updateAllowedHosts, + maxBytes: config.updateMaxBytes, + dataBackupMaxBytes: config.maxTotalBytes, + deferCompletion: true, + operation: "apply", + jobId, + sqlite: database.sqlite, + fetchImpl: globalThis.fetch, + }); + expect(await readFile(path.join(config.currentLink, "dist", "marker"), "utf8")).toBe("new"); + const row = database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(jobId); + expect(row).toEqual({ operation: "apply", status: "applying" }); + finalizeUpdateJob(database.sqlite, jobId, "failed"); + expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" }); + } finally { + globalThis.fetch = previousFetch; + if (database) database.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + it("在请求和恢复标记丢失后收敛孤儿任务,但保留 staged 下载", async () => { + const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-reconcile-")); + 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); + await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true }); + await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink); + database = openDatabase(config); + const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1; + const insert = database.sqlite.prepare(` + INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + const queuedId = randomUUID(); + const applyingId = randomUUID(); + const stagedId = randomUUID(); + insert.run(queuedId, "apply", "queued", "1.2.0", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt); + insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt); + insert.run(stagedId, "download", "staged", "1.2.0", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt); + const now = Date.now(); + expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(2); + expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" }); + expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(applyingId)).toEqual({ status: "completed" }); + expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "staged" }); + } 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 {