From 340d9b5245992374e855d8140b8ae0c47fdbe8a5 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Thu, 3 Sep 2026 23:32:16 +0800 Subject: [PATCH] feat: add lightweight application updates --- docs/release.md | 4 ++-- package.json | 2 +- scripts/build-release.sh | 26 +++++++++++++++++++++++++- scripts/publish-gitea-release.sh | 12 ++++++++++-- server/cli/update.ts | 24 ++++++++++++++++++++++-- server/update-service.ts | 10 +++++++++- server/update.ts | 22 ++++++++++++++++++++-- tests/update.test.ts | 13 +++++++++++++ 8 files changed, 102 insertions(+), 11 deletions(-) diff --git a/docs/release.md b/docs/release.md index 00f53fb..5b340e9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -28,7 +28,7 @@ GITEA_TOKEN=... \ ./scripts/publish-gitea-release.sh v1.1.2 ./release ``` -发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。 +发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。构建脚本会同时生成完整安装包和轻量更新包:`tallynote-1.1.2-linux-x64-glibc.tar.gz` 用于首次安装,`tallynote-1.1.2-linux-x64-glibc.update-<锁文件 SHA256>.tar.gz` 仅用于复用现有运行时的后台更新。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。 ## curl 安装 @@ -104,7 +104,7 @@ 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 签名。 -后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。 +后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。 Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚: diff --git a/package.json b/package.json index 541d70c..513942d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.24", + "version": "1.1.25", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 3efa92f..8ce00ab 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -27,7 +27,11 @@ pnpm build stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin" -cp -a dist/. "$stage/dist/" +# Copy only the production build outputs. In particular, do not carry a +# stale dist/web-next directory from a previous local preview build. +cp -a dist/server "$stage/dist/" +cp -a dist/shared "$stage/dist/" +cp -a dist/web "$stage/dist/" cp -a migrations/. "$stage/migrations/" cp package.json pnpm-lock.yaml "$stage/" cp -a bin/. "$stage/bin/" @@ -46,6 +50,26 @@ find "$stage" -type l -delete mkdir -p "$OUT_DIR" archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz" tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner . + +# The application-only asset is used by the online updater. It deliberately +# excludes the stable runtime (Node and production dependencies), systemd +# helpers and installer files; the updater overlays it on the currently +# installed, already-validated runtime before atomically switching releases. +app_stage=$(mktemp -d) +trap 'rm -rf "$stage" "$app_stage"' EXIT +mkdir -p "$app_stage/dist" "$app_stage/migrations" "$app_stage/bin" "$app_stage/scripts" "$app_stage/systemd" +cp -a "$stage/dist/server" "$app_stage/dist/" +cp -a "$stage/dist/shared" "$app_stage/dist/" +cp -a "$stage/dist/web" "$app_stage/dist/" +cp -a "$stage/migrations/." "$app_stage/migrations/" +cp -a "$stage/bin/." "$app_stage/bin/" +cp -a "$stage/scripts/." "$app_stage/scripts/" +cp -a "$stage/systemd/." "$app_stage/systemd/" +cp "$stage/package.json" "$app_stage/package.json" +cp "$stage/uninstall.sh" "$app_stage/uninstall.sh" +runtime_hash=$(sha256sum pnpm-lock.yaml | awk '{print $1}') +app_archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.update-${runtime_hash}.tar.gz" +tar -C "$app_stage" -czf "$app_archive" --owner=0 --group=0 --numeric-owner . # Keep the sidecar useful when a caller builds more than one architecture into # the same directory. The publishing script recomputes this list immediately # before signing, so stale or hand-edited entries can never reach a Release. diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index b1fe19e..a30bf3f 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -128,7 +128,8 @@ fi [[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid' command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required' -assets=() +full_assets=() +update_assets=() for file in "$ASSET_DIR"/*.tar.gz; do [[ -f "$file" && ! -L "$file" ]] || continue name=$(basename -- "$file") @@ -136,9 +137,16 @@ for file in "$ASSET_DIR"/*.tar.gz; do asset_version=${name#tallynote-} asset_version=${asset_version%%-linux-*} [[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name" - assets+=("$file") + if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then + update_assets+=("$file") + else + full_assets+=("$file") + fi done +assets=("${full_assets[@]}") +if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi (( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found' +(( ${#full_assets[@]} > 0 )) || die 'no full release asset found' SUMS_FILE="$ASSET_DIR/SHA256SUMS" SIG_FILE="$ASSET_DIR/SHA256SUMS.sig" diff --git a/server/cli/update.ts b/server/cli/update.ts index e03b6e1..97ade20 100644 --- a/server/cli/update.ts +++ b/server/cli/update.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type Database from "better-sqlite3"; @@ -10,6 +10,7 @@ import { writeAudit } from "../audit.js"; import { atomicSwitchDirectory, atomicSwitchRelease, + applicationUpdateRuntimeHash, compareSemver, createSafeArchive, detectPlatform, @@ -19,6 +20,7 @@ import { isNewerVersion, normalizeReleasePermissions, parseSemver, + runtimeHashFromLockfile, selectReleaseAsset, sanitizeAssetName, validateHttpsUrl, @@ -212,9 +214,16 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType { throw new Error("当前安装目录无效"); }); + const currentInfo = await lstat(currentRelease).catch(() => null); + if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效"); + for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) { + const source = path.join(currentRelease, entry); + const sourceInfo = await lstat(source).catch(() => null); + if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新"); + await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }); + } + } await normalizeReleasePermissions(stagedDir); const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录"); diff --git a/server/update-service.ts b/server/update-service.ts index 87e3473..5b8b680 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -13,6 +13,7 @@ import { fetchReleaseText, isNewerVersion, parseSemver, + runtimeHashFromLockfile, sanitizeAssetName, selectReleaseAsset, validateHttpsUrl, @@ -204,7 +205,14 @@ export async function checkForUpdate(database: Database.Database, config: AppCon } catch { throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试"); } - let asset = selectReleaseAsset(metadata, platform); + let runtimeHash: string | undefined; + try { + runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml"))); + } catch { + // Legacy or source installations may not contain the lockfile. They stay + // on the full release asset instead of risking an incompatible runtime. + } + let asset = selectReleaseAsset(metadata, platform, runtimeHash); let signatureVerified = false; if (asset) { const integrity = await attachSidecarHash(metadata, asset, { diff --git a/server/update.ts b/server/update.ts index 3bc3dc8..d4d02e5 100644 --- a/server/update.ts +++ b/server/update.ts @@ -43,6 +43,16 @@ export type ReleaseMetadata = { assets: ReleaseAsset[]; }; +const APPLICATION_UPDATE_ASSET = /\.update-([a-f0-9]{64})\.tar\.gz$/i; + +export function applicationUpdateRuntimeHash(assetName: string): string | undefined { + return APPLICATION_UPDATE_ASSET.exec(assetName)?.[1]?.toLowerCase(); +} + +export function runtimeHashFromLockfile(lockfile: string | Buffer): string { + return createHash("sha256").update(lockfile).digest("hex"); +} + export type UrlPolicy = { /** Host names or HTTPS URLs which are allowed for requests. */ allowedHosts?: readonly string[] | undefined; @@ -379,7 +389,7 @@ export async function fetchReleaseBytes( } } -export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): ReleaseAsset | undefined { +export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform(), runtimeHash?: string): ReleaseAsset | undefined { const platformCandidates = release.assets.filter((asset) => { const name = asset.name.toLowerCase(); return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase())); @@ -398,7 +408,15 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl const target = platform.target.toLowerCase(); return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target)); }); - return candidates[0]; + const normalizedRuntimeHash = runtimeHash?.trim().toLowerCase(); + if (normalizedRuntimeHash && /^[a-f0-9]{64}$/.test(normalizedRuntimeHash)) { + const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash); + if (applicationUpdate) return applicationUpdate; + } + // Older clients choose the first matching asset. Releases therefore keep + // the traditional full archive first, while current clients explicitly + // opt into a compatible application-only asset. + return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name)); } export function sanitizeAssetName(value: string): string { diff --git a/tests/update.test.ts b/tests/update.test.ts index ac1e225..3ac6ffd 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto"; import { atomicSwitchRelease, + applicationUpdateRuntimeHash, createSafeArchive, detectPlatform, downloadReleaseAsset, @@ -16,6 +17,7 @@ import { normalizeReleasePermissions, sanitizeAssetName, selectReleaseAsset, + runtimeHashFromLockfile, validateHttpsUrl, } from "../server/update.js"; import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js"; @@ -50,6 +52,17 @@ describe("更新安全工具", () => { expect(() => sanitizeAssetName("../release.tar.gz")).toThrow(); }); + it("优先选择运行时匹配的轻量更新包,并对旧客户端保留完整包回退", () => { + const runtimeHash = runtimeHashFromLockfile("lockfile-v1\n"); + const full = { name: "tallynote-1.2.0-linux-x64-glibc.tar.gz", url: "https://updates.example/full" }; + const app = { name: `tallynote-1.2.0-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" }; + const release = { version: "1.2.0", assets: [full, app] }; + expect(applicationUpdateRuntimeHash(app.name)).toBe(runtimeHash); + expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"), runtimeHash)).toEqual(app); + expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))).toEqual(full); + expect(applicationUpdateRuntimeHash(full.name)).toBeUndefined(); + }); + it("验证 SHA256SUMS 的 Ed25519 detached signature", () => { const { publicKey, privateKey } = generateKeyPairSync("ed25519"); const payload = "a".repeat(64) + " tallynote.tar.gz\n";