Compare commits
5
Commits
v1.1.23
...
f060f917a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f060f917a0 | ||
|
|
704740182a | ||
|
|
a61860fcb3 | ||
|
|
340d9b5245 | ||
|
|
5d02fa5769 |
+2
-2
@@ -28,7 +28,7 @@ GITEA_TOKEN=... \
|
|||||||
./scripts/publish-gitea-release.sh v1.1.2 ./release
|
./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 安装
|
## 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/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
|
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/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 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
|
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tallynote",
|
"name": "tallynote",
|
||||||
"version": "1.1.23",
|
"version": "1.1.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@9.0.6",
|
"packageManager": "pnpm@9.0.6",
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ pnpm build
|
|||||||
stage=$(mktemp -d)
|
stage=$(mktemp -d)
|
||||||
trap 'rm -rf "$stage"' EXIT
|
trap 'rm -rf "$stage"' EXIT
|
||||||
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
|
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 -a migrations/. "$stage/migrations/"
|
||||||
cp package.json pnpm-lock.yaml "$stage/"
|
cp package.json pnpm-lock.yaml "$stage/"
|
||||||
cp -a bin/. "$stage/bin/"
|
cp -a bin/. "$stage/bin/"
|
||||||
@@ -46,6 +50,26 @@ find "$stage" -type l -delete
|
|||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
|
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
|
||||||
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
|
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
|
# Keep the sidecar useful when a caller builds more than one architecture into
|
||||||
# the same directory. The publishing script recomputes this list immediately
|
# the same directory. The publishing script recomputes this list immediately
|
||||||
# before signing, so stale or hand-edited entries can never reach a Release.
|
# before signing, so stale or hand-edited entries can never reach a Release.
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ fi
|
|||||||
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
|
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
|
||||||
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
|
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
|
for file in "$ASSET_DIR"/*.tar.gz; do
|
||||||
[[ -f "$file" && ! -L "$file" ]] || continue
|
[[ -f "$file" && ! -L "$file" ]] || continue
|
||||||
name=$(basename -- "$file")
|
name=$(basename -- "$file")
|
||||||
@@ -136,9 +137,16 @@ for file in "$ASSET_DIR"/*.tar.gz; do
|
|||||||
asset_version=${name#tallynote-}
|
asset_version=${name#tallynote-}
|
||||||
asset_version=${asset_version%%-linux-*}
|
asset_version=${asset_version%%-linux-*}
|
||||||
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
|
[[ "$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
|
done
|
||||||
|
assets=("${full_assets[@]}")
|
||||||
|
if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi
|
||||||
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
|
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
|
||||||
|
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
|
||||||
|
|
||||||
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
|
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
|
||||||
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
|
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
|
||||||
|
|||||||
+9
-2
@@ -930,6 +930,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
|||||||
reply.header("Cache-Control", "no-store");
|
reply.header("Cache-Control", "no-store");
|
||||||
reconcileOrphanedUpdateJobs(database.sqlite, config);
|
reconcileOrphanedUpdateJobs(database.sqlite, config);
|
||||||
const cached = publicCheckFromCache(database.sqlite, config);
|
const cached = publicCheckFromCache(database.sqlite, config);
|
||||||
|
// Status is a live control surface, not an update history endpoint.
|
||||||
|
// Terminal failures/cancellations from a previous attempt must not be
|
||||||
|
// replayed as if the operator had just started an update. They remain in
|
||||||
|
// the database/audit log, while this endpoint exposes only an actionable
|
||||||
|
// task (or the latest successful completion for confirmation).
|
||||||
const row = database.sqlite.prepare(`
|
const row = database.sqlite.prepare(`
|
||||||
SELECT id, operation, 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,
|
size_bytes AS sizeBytes, error_message AS errorMessage,
|
||||||
@@ -937,8 +942,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
|||||||
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
|
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
|
||||||
download_speed_bps AS downloadSpeedBps,
|
download_speed_bps AS downloadSpeedBps,
|
||||||
requested_at AS applyQueuedAt
|
requested_at AS applyQueuedAt
|
||||||
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
|
FROM update_jobs
|
||||||
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
|
WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")})
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
`).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record<string, unknown> | undefined;
|
||||||
return {
|
return {
|
||||||
...cached,
|
...cached,
|
||||||
strategy: config.updateStrategy,
|
strategy: config.updateStrategy,
|
||||||
|
|||||||
+22
-2
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
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 path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import type Database from "better-sqlite3";
|
import type Database from "better-sqlite3";
|
||||||
@@ -10,6 +10,7 @@ import { writeAudit } from "../audit.js";
|
|||||||
import {
|
import {
|
||||||
atomicSwitchDirectory,
|
atomicSwitchDirectory,
|
||||||
atomicSwitchRelease,
|
atomicSwitchRelease,
|
||||||
|
applicationUpdateRuntimeHash,
|
||||||
compareSemver,
|
compareSemver,
|
||||||
createSafeArchive,
|
createSafeArchive,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
isNewerVersion,
|
isNewerVersion,
|
||||||
normalizeReleasePermissions,
|
normalizeReleasePermissions,
|
||||||
parseSemver,
|
parseSemver,
|
||||||
|
runtimeHashFromLockfile,
|
||||||
selectReleaseAsset,
|
selectReleaseAsset,
|
||||||
sanitizeAssetName,
|
sanitizeAssetName,
|
||||||
validateHttpsUrl,
|
validateHttpsUrl,
|
||||||
@@ -212,9 +214,16 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
|
|||||||
if (options.metadataUrl) {
|
if (options.metadataUrl) {
|
||||||
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
|
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
|
||||||
const release = await fetchReleaseMetadata(metadataUrl, options);
|
const release = await fetchReleaseMetadata(metadataUrl, options);
|
||||||
|
let runtimeHash: string | undefined;
|
||||||
|
try {
|
||||||
|
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
|
||||||
|
} catch {
|
||||||
|
// Fall back to the full archive when the current installation predates
|
||||||
|
// runtime fingerprints or is missing deployment provenance.
|
||||||
|
}
|
||||||
let asset = options.assetUrl && !options.requireSignature
|
let asset = options.assetUrl && !options.requireSignature
|
||||||
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
|
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
|
||||||
: selectReleaseAsset(release, platform);
|
: selectReleaseAsset(release, platform, runtimeHash);
|
||||||
if (!asset) throw new Error("没有匹配当前平台的更新文件");
|
if (!asset) throw new Error("没有匹配当前平台的更新文件");
|
||||||
const integrity = await attachSidecarHash(release, asset, {
|
const integrity = await attachSidecarHash(release, asset, {
|
||||||
allowedHosts: options.allowedHosts ?? [],
|
allowedHosts: options.allowedHosts ?? [],
|
||||||
@@ -314,6 +323,17 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
|||||||
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
|
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
|
||||||
const stagedDir = path.join(workspace, "payload");
|
const stagedDir = path.join(workspace, "payload");
|
||||||
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
|
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
|
||||||
|
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
|
||||||
|
const currentRelease = await realpath(options.currentDir).catch(() => { 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);
|
await normalizeReleasePermissions(stagedDir);
|
||||||
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
||||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
fetchReleaseText,
|
fetchReleaseText,
|
||||||
isNewerVersion,
|
isNewerVersion,
|
||||||
parseSemver,
|
parseSemver,
|
||||||
|
runtimeHashFromLockfile,
|
||||||
sanitizeAssetName,
|
sanitizeAssetName,
|
||||||
selectReleaseAsset,
|
selectReleaseAsset,
|
||||||
validateHttpsUrl,
|
validateHttpsUrl,
|
||||||
@@ -204,7 +205,14 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
|||||||
} catch {
|
} catch {
|
||||||
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
|
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;
|
let signatureVerified = false;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
const integrity = await attachSidecarHash(metadata, asset, {
|
const integrity = await attachSidecarHash(metadata, asset, {
|
||||||
|
|||||||
+20
-2
@@ -43,6 +43,16 @@ export type ReleaseMetadata = {
|
|||||||
assets: ReleaseAsset[];
|
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 = {
|
export type UrlPolicy = {
|
||||||
/** Host names or HTTPS URLs which are allowed for requests. */
|
/** Host names or HTTPS URLs which are allowed for requests. */
|
||||||
allowedHosts?: readonly string[] | undefined;
|
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 platformCandidates = release.assets.filter((asset) => {
|
||||||
const name = asset.name.toLowerCase();
|
const name = asset.name.toLowerCase();
|
||||||
return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.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();
|
const target = platform.target.toLowerCase();
|
||||||
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
|
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 {
|
export function sanitizeAssetName(value: string): string {
|
||||||
|
|||||||
@@ -137,6 +137,24 @@ describe("更新 API", () => {
|
|||||||
expect(disabledConfig.updateStrategy).toBe("disabled");
|
expect(disabledConfig.updateStrategy).toBe("disabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => {
|
||||||
|
const session = await login("update-history");
|
||||||
|
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string };
|
||||||
|
const now = Date.now();
|
||||||
|
database.sqlite.prepare(`
|
||||||
|
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?)
|
||||||
|
`).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000);
|
||||||
|
const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||||
|
expect(initial.statusCode).toBe(200);
|
||||||
|
expect(initial.json().job).toBeNull();
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(checked.json().latest).toMatchObject({ version: "1.2.0", isNewer: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
|
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
|
||||||
const owner = await login("update-owner");
|
const owner = await login("update-owner");
|
||||||
const other = await login("update-other");
|
const other = await login("update-other");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
|||||||
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
atomicSwitchRelease,
|
atomicSwitchRelease,
|
||||||
|
applicationUpdateRuntimeHash,
|
||||||
createSafeArchive,
|
createSafeArchive,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
downloadReleaseAsset,
|
downloadReleaseAsset,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
normalizeReleasePermissions,
|
normalizeReleasePermissions,
|
||||||
sanitizeAssetName,
|
sanitizeAssetName,
|
||||||
selectReleaseAsset,
|
selectReleaseAsset,
|
||||||
|
runtimeHashFromLockfile,
|
||||||
validateHttpsUrl,
|
validateHttpsUrl,
|
||||||
} from "../server/update.js";
|
} from "../server/update.js";
|
||||||
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
|
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
|
||||||
@@ -50,6 +52,17 @@ describe("更新安全工具", () => {
|
|||||||
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
|
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", () => {
|
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
|
||||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||||
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
|
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export default function UpdatePage({
|
|||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const [reloadReady, setReloadReady] = useState(false);
|
const [reloadReady, setReloadReady] = useState(false);
|
||||||
const [showNotesDialog, setShowNotesDialog] = useState(false);
|
const [showNotesDialog, setShowNotesDialog] = useState(false);
|
||||||
|
const [showPipelineDialog, setShowPipelineDialog] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const announced = useRef<string | null>(null);
|
const announced = useRef<string | null>(null);
|
||||||
@@ -337,7 +338,14 @@ export default function UpdatePage({
|
|||||||
else setLoading(false);
|
else setLoading(false);
|
||||||
}, [isMock]);
|
}, [isMock]);
|
||||||
|
|
||||||
const job = info?.job;
|
// The status endpoint intentionally hides terminal failures/cancellations.
|
||||||
|
// Keep this guard in the UI as well so a stale response from an older
|
||||||
|
// server cannot turn a fresh page visit into a false failure state.
|
||||||
|
const job = info?.job && info.job.status !== "failed" && info.job.status !== "cancelled" ? info.job : null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (job && activeStatuses.has(job.status)) setShowPipelineDialog(true);
|
||||||
|
}, [job?.id, job?.status]);
|
||||||
|
|
||||||
const applyQueuedAt = timestamp(job?.applyQueuedAt);
|
const applyQueuedAt = timestamp(job?.applyQueuedAt);
|
||||||
const restartAt =
|
const restartAt =
|
||||||
timestamp(job?.restartDeadline) ??
|
timestamp(job?.restartDeadline) ??
|
||||||
@@ -436,7 +444,10 @@ export default function UpdatePage({
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
});
|
});
|
||||||
setLiveInfo((current) => ({ ...result, job: result.job ?? current?.job ?? null }));
|
setLiveInfo((current) => ({
|
||||||
|
...result,
|
||||||
|
job: result.job ?? (current?.job && activeStatuses.has(current.job.status) ? current.job : null),
|
||||||
|
}));
|
||||||
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
|
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
|
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
|
||||||
@@ -468,6 +479,7 @@ export default function UpdatePage({
|
|||||||
mockTimer.current = null;
|
mockTimer.current = null;
|
||||||
setActionBusy(false);
|
setActionBusy(false);
|
||||||
setConfirmVersion(null);
|
setConfirmVersion(null);
|
||||||
|
setShowPipelineDialog(true);
|
||||||
if (confirmAction === "download") {
|
if (confirmAction === "download") {
|
||||||
handleScenarioChange("downloading_30");
|
handleScenarioChange("downloading_30");
|
||||||
notify?.("Mock:开始模拟下载,状态已流转至 [下载中]", "info");
|
notify?.("Mock:开始模拟下载,状态已流转至 [下载中]", "info");
|
||||||
@@ -496,6 +508,7 @@ export default function UpdatePage({
|
|||||||
setConfirmVersion(null);
|
setConfirmVersion(null);
|
||||||
setReloadReady(false);
|
setReloadReady(false);
|
||||||
setLiveInfo((current) => (current ? { ...current, job: result.job } : current));
|
setLiveInfo((current) => (current ? { ...current, job: result.job } : current));
|
||||||
|
setShowPipelineDialog(true);
|
||||||
notify?.(
|
notify?.(
|
||||||
confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启",
|
confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启",
|
||||||
"info"
|
"info"
|
||||||
@@ -690,9 +703,6 @@ export default function UpdatePage({
|
|||||||
<div className="tn-metric-content">
|
<div className="tn-metric-content">
|
||||||
<span className="tn-metric-label">运行目标架构</span>
|
<span className="tn-metric-label">运行目标架构</span>
|
||||||
<div className="tn-metric-value">{info.platform.target}</div>
|
<div className="tn-metric-value">{info.platform.target}</div>
|
||||||
<div className="tn-metric-foot">
|
|
||||||
<small>{info.platform.os} / {info.platform.arch}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
@@ -702,13 +712,6 @@ export default function UpdatePage({
|
|||||||
<div className="tn-metric-value">
|
<div className="tn-metric-value">
|
||||||
{info.strategy === "systemd" ? "systemd 守护" : "手动源码模式"}
|
{info.strategy === "systemd" ? "systemd 守护" : "手动源码模式"}
|
||||||
</div>
|
</div>
|
||||||
<div className="tn-metric-foot">
|
|
||||||
<small>
|
|
||||||
{info.strategy === "systemd"
|
|
||||||
? (info.configured ? "支持后台一键更新" : "尚未配置发布源")
|
|
||||||
: "仅支持命令行维护"}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
@@ -725,11 +728,18 @@ export default function UpdatePage({
|
|||||||
</Surface>
|
</Surface>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stepper Process Card */}
|
{/* Stepper Process Dialog */}
|
||||||
<Surface className="tn-stepper-surface">
|
<Dialog
|
||||||
|
visible={showPipelineDialog}
|
||||||
|
header="版本升级流水线"
|
||||||
|
confirmBtn={{ content: "关闭", theme: "default" }}
|
||||||
|
cancelBtn={null}
|
||||||
|
onConfirm={() => setShowPipelineDialog(false)}
|
||||||
|
onClose={() => setShowPipelineDialog(false)}
|
||||||
|
>
|
||||||
|
<div className="tn-stepper-surface">
|
||||||
<div className="tn-stepper-head">
|
<div className="tn-stepper-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="tn-section-heading">版本升级流水线</h3>
|
|
||||||
<small className="tn-section-subheading">
|
<small className="tn-section-subheading">
|
||||||
标准化发布流程:版本发现 → 校验签名与清单 → 安全暂存 → 原子切换并重启
|
标准化发布流程:版本发现 → 校验签名与清单 → 安全暂存 → 原子切换并重启
|
||||||
</small>
|
</small>
|
||||||
@@ -844,7 +854,8 @@ export default function UpdatePage({
|
|||||||
<span>更新成功完成!新版本已通过内置端点健康检查,账本数据与附件完整无损。</span>
|
<span>更新成功完成!新版本已通过内置端点健康检查,账本数据与附件完整无损。</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Surface>
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Release Details Section */}
|
{/* Release Details Section */}
|
||||||
{latest ? (
|
{latest ? (
|
||||||
@@ -866,32 +877,8 @@ export default function UpdatePage({
|
|||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="tn-release-header-actions">
|
|
||||||
{notes && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="medium"
|
|
||||||
onClick={() => setShowNotesDialog(true)}
|
|
||||||
icon={<FileCode size={15} />}
|
|
||||||
>
|
|
||||||
查看完整更新日志
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Release Notes Preview snippet */}
|
|
||||||
{notes && (
|
|
||||||
<div className="tn-release-notes-box">
|
|
||||||
<div className="tn-release-notes-heading">
|
|
||||||
<span>更新内容概览</span>
|
|
||||||
</div>
|
|
||||||
<div className="tn-release-notes-content">
|
|
||||||
<MarkdownNotes value={notes} compact />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Integrity & compatibility facts grid */}
|
{/* Integrity & compatibility facts grid */}
|
||||||
<div className="tn-facts-grid">
|
<div className="tn-facts-grid">
|
||||||
<div className="tn-fact-item">
|
<div className="tn-fact-item">
|
||||||
@@ -973,6 +960,30 @@ export default function UpdatePage({
|
|||||||
</Surface>
|
</Surface>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{latest && notes && (
|
||||||
|
<Surface className="tn-release-notes-surface">
|
||||||
|
<div className="tn-release-notes-header">
|
||||||
|
<div>
|
||||||
|
<span className="tn-eyebrow">更新日志</span>
|
||||||
|
<h3 className="tn-section-heading">本次版本更新内容</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="medium"
|
||||||
|
onClick={() => setShowNotesDialog(true)}
|
||||||
|
icon={<FileCode size={15} />}
|
||||||
|
>
|
||||||
|
查看完整日志
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="tn-release-notes-box">
|
||||||
|
<div className="tn-release-notes-content">
|
||||||
|
<MarkdownNotes value={notes} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Manual source warning if not systemd */}
|
{/* Manual source warning if not systemd */}
|
||||||
{!info.configured && (
|
{!info.configured && (
|
||||||
<div className="tn-update-explainer">
|
<div className="tn-update-explainer">
|
||||||
|
|||||||
@@ -847,6 +847,17 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
.tn-release-notes-surface {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.tn-release-notes-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
.tn-release-header {
|
.tn-release-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -870,7 +881,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
|||||||
color: var(--tn-text-secondary);
|
color: var(--tn-text-secondary);
|
||||||
}
|
}
|
||||||
.tn-release-notes-box {
|
.tn-release-notes-box {
|
||||||
margin: 16px 0;
|
margin: 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border: 1px solid var(--tn-border);
|
border: 1px solid var(--tn-border);
|
||||||
@@ -1064,6 +1075,13 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
|||||||
.tn-release-header {
|
.tn-release-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.tn-release-notes-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.tn-release-notes-header .t-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.tn-release-card-actions {
|
.tn-release-card-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user