Compare commits

..
5 Commits
Author SHA1 Message Date
Qiufeng 32e0057bad fix: set operation to apply when staging update completes
TallyNote release / linux-x64 (push) Successful in 6m40s
downloadAndStageUpdate set status=staged but left operation=download,
causing the root runner CLI to reject the apply (operation check failed)
and silently skip the version switch. The symlink stayed on the old version
while the runner reported SUCCESS.
release: 1.3.2
2026-09-11 08:11:27 +08:00
Qiufeng c55ce25939 fix: use version 9.9.9 in test mocks to avoid version comparison failures
TallyNote release / linux-x64 (push) Successful in 6m20s
2026-09-11 01:55:48 +08:00
Qiufeng d01ad74121 release: 1.3.1
TallyNote release / linux-x64 (push) Failing after 2m54s
- online update refactor: synchronous web-process download
- real-time download progress visible in frontend
- eliminates 'waiting for system scheduler' stuck state
release: 1.3.1
2026-09-11 01:35:16 +08:00
Qiufeng 9e5b2c48e2 fix: bump test mock version to 1.3.1 for version comparison
TallyNote release / linux-x64 (push) Successful in 6m15s
2026-09-11 00:06:54 +08:00
Qiufeng ae5892d81c feat: refactor online update to synchronous web-process download
TallyNote release / linux-x64 (push) Failing after 3m12s
- Download happens in web process (non-root) with real-time progress
- Root runner only handles privileged apply (stop/backup/switch/restart)
- Eliminates 'waiting for system scheduler' stuck state
- Frontend shows download bytes/speed/percentage with cancel button
- Staged download triggers apply request file for root runner
- systemd timeout reduced from 32min to 5min (no download phase)
- Tests adapted for synchronous download flow
release: 1.3.0
2026-09-10 23:18:33 +08:00
9 changed files with 546 additions and 69 deletions
+2
View File
@@ -182,3 +182,5 @@ docker compose run --rm --no-deps tallynote node dist/server/cli/admin-init.js -
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256 的归档、路径穿越、特殊文件和符号链接;启用签名要求时也会拒绝无有效签名的归档。附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
<!-- v1.3.1: online update refactor — synchronous web-process download -->
+303
View File
@@ -0,0 +1,303 @@
# 在线更新重构方案
## 一、问题背景
当前在线更新使用 4 次进程交接链路:
```
web 进程 → 写 update-request.json → tallynote-update.path 触发
→ tallynote-update.service → tallynote-update-runner.sh (root)
→ 下载 + 校验 + 暂存 + 停服 + 备份 + 切换 + 重启 + 健康检查
```
下载在 root runner 中执行,前端只能轮询 DB 状态,看不到实时进度。
多次出现"等待系统调度"卡死,根因是链路中任一环节出错都会断链。
## 二、目标
将下载移入 web 进程同步执行,root runner 只负责特权应用(停服/备份/切换/重启)。
链路从 4 次交接缩减为 1 次。
## 三、当前架构(需改动的文件清单)
| 文件 | 行数 | 职责 | 改动级别 |
|---|---|---|---|
| server/update-service.ts | ~420 | checkForUpdate, writeUpdateRequest, reconcileOrphanedUpdateJobs, cancelUpdateJob, publicUpdateJob | 大改 |
| server/update.ts | ~300 | fetchReleaseMetadata, fetchReleaseBytes, selectReleaseAsset, validateHttpsUrl | 小改 |
| server/app.ts (930-1230) | ~300 | 6 个 API 路由 | 大改 |
| scripts/tallynote-update-runner.sh | ~200 | root runner: flock+心跳+恢复+下载+校验+暂存+应用 | 大改 |
| scripts/tallynote-update.sh | ~100 | 手动更新/回滚入口 | 小改 |
| systemd/tallynote-update.service | ~30 | oneshot root 服务 | 小改 |
| systemd/tallynote-update.path | ~20 | 监听请求文件触发 | 不变 |
| web/src/main.tsx (697-790) | ~90 | UpdateCenter 组件 | 大改 |
| shared/contracts.ts (85-100) | ~15 | UpdateJobStatus 枚举 | 小改 |
| server/db/schema.ts (134-163) | ~30 | update_jobs 表 | 不变 |
| server/config.ts | ~100 | TALLYNOTE_UPDATE_* 配置 | 小改 |
| tests/update-api.test.ts | ~450 | 更新 API 测试 | 大改 |
## 四、改动后的架构
```
用户点"下载更新包"
↓
web 进程 (tallynote 用户, 非 root)
├── 创建 job 行 (status=downloading)
├── HTTPS 流式下载归档到 /var/lib/tallynote/staging/update-<jobId>.tar.gz
├── 边下载边更新 DB: downloadedBytes, downloadSpeedBps
├── 下载完成 → SHA-256 校验 → status=staged
└── 写 update-request.json (operation=apply, 含暂存路径)
↓
tallynote-update.path 触发 → tallynote-update.service (root)
├── 读请求文件
├── 停服 → 备份 → 原子切换 → 重启 → 健康检查
└── 更新 DB: status=completed/failed
```
## 五、详细代码修改
### 5.1 server/update-service.ts
**新增函数:**
```ts
// 同步下载归档,流式写入暂存目录,实时更新 DB 进度
export async function downloadReleaseAsset(
database: Database.Database,
config: AppConfig,
jobId: string,
assetUrl: string,
expectedSha256: string,
assetName: string,
): Promise<{ actualSha256: string; sizeBytes: number; downloadPath: string }>;
```
逻辑:
- 用 fetchReleaseBytes (已存在于 update.ts) 发起 HTTPS 请求
- 创建可写流到 config.dataDir/staging/update-<jobId>.tar.gz (tallynote 用户可写)
- pipeline(response.body → createHash('sha256') → fileStream),边算 hash 边写盘
- 每秒更新 DB: downloadedBytes, downloadSpeedBps, status=downloading
- 完成后比对 expectedSha256 vs actualSha256,不匹配 → status=failed
- 匹配 → status=staged, 写 downloadPath 到 DB
- 然后写 update-request.json (operation=apply)
**修改函数:**
- `reconcileOrphanedUpdateJobs`: 保留,但 queued 状态不再出现(下载在 web 进程内)
- `publicUpdateJob`: 保留,已支持 downloadedBytes/downloadSpeedBps 字段
- `cancelUpdateJob`: 增加 abort 下载流的能力
- `writeUpdateRequest`: 增加 stagedPath 字段传递暂存文件路径
**删除/简化:**
- QUEUED_UPDATE_TIMEOUT_MS 逻辑不再需要(下载不在 systemd 队列中等待)
### 5.2 server/app.ts — API 路由修改
**POST /api/update/download → 改为同步下载**
当前:创建 job → 写请求文件 → 返回 202
改为:
1. 创建 job (status=downloading)
2. 在请求处理函数内同步执行 downloadReleaseAsset
3. 下载完成后写 apply 请求文件
4. 返回 { job: { status: "staged", ... } }
5. 如果下载中客户端断开,设置 AbortController 取消下载
注意:Fastify 请求超时需配置为足够长(115MB / 最低网速)。设置路由级
bodyLimit=0 (不读 body) 并配置 reply 的 connectionTimeout。
**新增 SSE 端点:GET /api/update/progress**
返回 Server-Sent Events 流,推送实时下载进度:
```
event: progress
data: {"downloadedBytes": 12345678, "speedBps": 5242880, "sizeBytes": 120586240}
```
前端用 EventSource 监听。下载完成后关闭 SSE。
**POST /api/update/apply — 不变**
仍然读请求文件触发 root runner。
**GET /api/update/status — 不变**
仍然返回 job 状态。
### 5.3 scripts/tallynote-update-runner.sh
**删除:**
- 下载逻辑 (约 80 行)
- 校验 SHA-256 逻辑 (约 30 行)
- 暂存逻辑
- 心跳 (heartbeat) — 下载不再在 root 中,apply 很快不需要心跳
- QUEUED 状态处理
**保留:**
- flock 锁
- 恢复状态 (.update-state) — apply 阶段仍需要
- 停服 → 备份 → 原子切换 → 重启 → 健康检查
- 回滚逻辑
**简化后:** runner 只做 apply:读暂存路径 → 停服 → 备份 → 切换 → 启动 → 健康检查
约从 200 行缩减到 80 行。
### 5.4 scripts/tallynote-update.sh
手动入口不变,但 runner 已不下载,所以手动入口也跳过下载阶段。
`--rollback` 逻辑完全不变。
### 5.5 systemd/tallynote-update.service
```ini
# 简化:不再需要 32 分钟超时(无下载阶段)
TimeoutStartSec=5min
# 其余安全约束不变
```
### 5.6 systemd/tallynote-update.path
不变。仍然监听 update-request.json 触发 runner。
但请求文件的 operation 现在只有 "apply"。
### 5.7 web/src/main.tsx — UpdateCenter 组件
**当前流程(前端):**
1. 进入页面 → GET /api/update/status
2. 点"检查更新" → POST /api/update/check
3. 点"更新到 vX.X.X" → POST /api/update/download → 轮询 /api/update/jobs/:id
4. staged 后 → POST /api/update/apply → 轮询
5. completed → 显示"重新加载"
**改为:**
1. 进入页面 → GET /api/update/status(自动检查最新版本)
2. 点"检查更新" → POST /api/update/check
3. 点"下载更新包" → POST /api/update/download(同步)
- 同时打开 EventSource(/api/update/progress) 监听实时进度
- 显示:下载进度条 + 已下载/总量 + 网速 + 剩余时间
- 下载完成 → 自动切换到"立即更新"按钮
4. 点"立即更新" → POST /api/update/apply
- 弹窗显示:正在应用更新 → 倒计时 → 自动重连
5. 重连成功 → 显示"更新完成" + 版本号变化
**UI 状态机:**
```
idle → checking → hasUpdate
→ downloading (实时进度, 可取消)
→ verifying (校验中, 短暂)
→ staged (显示"立即更新"按钮)
→ applying (倒计时弹窗)
→ completed (显示"重新加载")
→ failed (显示错误 + 重试)
```
**取消下载:** 下载中显示"取消"按钮 → POST /api/update/cancel → abort 流
### 5.8 shared/contracts.ts
UpdateJobStatus 不变(仍包含所有状态)。
新增 downloadProgress 的事件类型定义。
### 5.9 server/config.ts
新增:
- `stagingDir`: path.join(dataDir, "staging") — 暂存目录
- `updateDownloadTimeoutMs`: 下载超时 (默认 10 分钟)
### 5.10 tests/update-api.test.ts
重写下载测试:
- mock HTTPS 响应,验证流式下载 + SHA-256 校验
- 验证下载进度写入 DB
- 验证下载完成后写 apply 请求文件
- 验证取消下载清理暂存文件
- apply 测试不变
## 六、不修改的部分
- 后端 API 契约语义不变(check/apply/cancel/status 接口签名不变)
- update_jobs 表结构不变
- 数据目录布局不变
- 安装/卸载逻辑不变
- 权限语义不变(web 非 root, runner root)
- SHA-256 强制校验不变
- 原子切换 + 自动回滚不变
- 版本号比较逻辑不变
- Release 元数据获取逻辑不变
## 七、向后兼容
- 旧版本安装(v1.2.9 及之前)升级到新版本后:
- 已有的 systemd 单元仍能工作
- 如果有遗留的 queued 状态 job,reconcileOrphanedUpdateJobs 会清理
- runner 简化后仍能处理 apply 请求
- 数据库迁移:不需要(表结构不变)
- 请求文件格式:增加 stagedPath 字段,旧 runner 忽略未知字段
## 八、验收标准
### 功能验收
1. 进入更新页面 → 自动检查最新版本 → 显示 Release 信息
2. 点"下载更新包" → 实时显示进度条、已下载字节数、网速
3. 下载完成 → 自动校验 SHA-256 → 显示"立即更新"
4. 点"立即更新" → 弹窗倒计时 → 服务重启 → 自动重连 → 显示新版本号
5. 更新失败 → 显示错误 → 可重试
6. 下载中可取消 → 暂存文件清理干净
7. 不出现"等待系统调度"状态
8. 不显示直链下载地址
9. 更新日志 markdown 正确渲染
10. 通知弹窗在右下角,使用柔和语义双层卡片样式
11. 无 emoji,使用 Lucide 图标
### 安全验收
12. 下载必须 HTTPS
13. SHA-256 校验不匹配时拒绝应用
14. web 进程不执行 systemctl
15. root runner 仍用 flock 防并发
16. 路径穿越、符号链接仍被拒绝
### 回滚验收
17. 应用失败 → 自动回滚到上一版本
18. 数据目录不被替换
19. 手动回滚 `sudo /usr/local/sbin/tallynote-update --rollback` 仍可用
### 测试验收
20. pnpm check 通过
21. pnpm test 全量通过
22. pnpm test:installer 通过
23. pnpm run build 通过
24. CI 构建通过(python3 pty 测试不依赖 expect)
### 前端验收
25. 页面切换过渡丝滑,无延迟感
26. 下载进度条垂直水平居中
27. 弹窗内图标与文字水平对齐
28. 响应式:窄屏不溢出、不遮挡
29. 键盘可操作核心流程
30. prefers-reduced-motion 下功能完整
## 九、实施顺序
1. 后端:server/update-service.ts 新增 downloadReleaseAsset
2. 后端:server/app.ts 改 download 路由 + 新增 progress SSE
3. 后端:server/config.ts 新增 stagingDir
4. 脚本:scripts/tallynote-update-runner.sh 简化(删下载/心跳)
5. systemd:tallynote-update.service 调整超时
6. 前端:web/src/main.tsx UpdateCenter 组件重写
7. 测试:tests/update-api.test.ts 重写下载测试
8. 全量验证:check + test + test:installer + build
9. 发布新版本
## 十、风险评估
| 风险 | 级别 | 缓解 |
|---|---|---|
| 长时间 HTTP 请求占用 Fastify 连接 | 中 | 路由级超时 + SSE 独立连接 |
| 下载中途 web 进程崩溃 | 低 | job 行标记 failed,暂存文件下次清理 |
| 并发下载 | 低 | DB 级活跃 job 检查 + 文件锁 |
| 暂存目录磁盘空间不足 | 低 | 下载前检查可用空间 |
| 旧版本残留的 queued job | 低 | reconcileOrphanedUpdateJobs 清理 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.2.9",
"version": "1.3.2",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+7 -4
View File
@@ -65,6 +65,7 @@ import {
readCachedRelease,
writeUpdateRequest,
cancelUpdateJob,
downloadAndStageUpdate,
type UpdateRequest,
} from "./update-service.js";
@@ -1186,13 +1187,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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 });
// Download happens synchronously in the web process (non-root). The
// root runner only receives an apply request after staging completes.
void downloadAndStageUpdate(database.sqlite, config, id, request.auth!.admin.id, version, cachedAsset.url, cachedAsset.name, cachedAsset.sha256, cached.metadataUrl);
} 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", "无法提交更新请求,请检查服务安装权限");
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=?").run("无法启动下载", Date.now(), id);
throw new AppError(503, "UPDATE_DOWNLOAD_FAILED", "无法启动下载,请稍后重试");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } });
return reply.code(200).send({ job: { id, status: "downloading", operation: "download", version } });
});
app.post("/api/update/cancel", { preHandler: guard(database, config) }, async (request, reply) => {
+137 -1
View File
@@ -1,5 +1,5 @@
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, rename, rm, 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";
@@ -8,10 +8,13 @@ import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
detectPlatform,
downloadReleaseAsset,
extractSafeArchive,
fetchReleaseBytes,
fetchReleaseMetadata,
fetchReleaseText,
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName,
@@ -679,3 +682,136 @@ export function cancelUpdateJob(
}
return { cancelled: false, message: "取消失败,任务状态可能已改变" };
}
/**
* Download, verify and stage a release archive in the web process (non-root).
* The root runner only needs to apply (stop/backup/switch/restart) afterwards.
*
* This function runs asynchronously outside the request lifecycle. It updates
* the job row in the database so the frontend can poll progress. On success it
* writes an apply request file so the systemd path unit triggers the runner.
*/
export async function downloadAndStageUpdate(
database: Database.Database,
config: AppConfig,
jobId: string,
adminId: string,
version: string,
assetUrl: string,
assetName: string,
expectedSha256: string,
metadataUrl: string,
): Promise<void> {
const stagingBase = path.resolve(config.stagingDir);
const workspace = path.join(stagingBase, `update-${jobId}`);
try {
await mkdir(workspace, { recursive: true, mode: 0o700 });
const archiveName = assetName.endsWith(".tar.gz") || assetName.endsWith(".tgz") ? assetName : `${assetName}.tar.gz`;
const archivePath = path.join(workspace, archiveName);
// Claim the job: transition queued -> downloading. If the job was
// cancelled or claimed by another caller, abort immediately.
const claim = database.prepare(
"UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'",
).run(Date.now(), Date.now(), path.basename(archivePath), Date.now(), jobId);
if (claim.changes !== 1) return;
const progressStartedAt = Date.now();
let lastProgressWrite = 0;
const downloaded = await downloadReleaseAsset(assetUrl, archivePath, {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
maxBytes: config.updateMaxBytes,
timeoutMs: config.updateTimeoutMs,
onProgress: (downloadedBytes, totalBytes) => {
const now = Date.now();
if (now - lastProgressWrite < 250) return;
lastProgressWrite = now;
const elapsed = Math.max(1, now - progressStartedAt);
const speedBps = Math.round(downloadedBytes * 1000 / elapsed);
database.prepare(
"UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'",
).run(downloadedBytes, totalBytes, speedBps, now, jobId);
},
});
// Final progress write
const finishedAt = Date.now();
const elapsed = Math.max(1, finishedAt - progressStartedAt);
database.prepare(
"UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'",
).run(downloaded.size, downloaded.size, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId);
// SHA-256 verification
database.prepare(
"UPDATE update_jobs SET status='verifying', actual_sha256=?, size_bytes=?, updated_at=? WHERE id=? AND status='downloading'",
).run(downloaded.sha256, downloaded.size, Date.now(), jobId);
if (expectedSha256 && downloaded.sha256 !== expectedSha256) {
throw new Error("更新文件 SHA-256 校验失败");
}
// Extract archive to payload directory
const payloadDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, payloadDir);
await normalizeReleasePermissions(payloadDir);
// Verify payload contains dist directory
const { lstat } = await import("node:fs/promises");
const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) {
throw new Error("发布包缺少 dist 目录");
}
// Transition to staged
const staged = database.prepare(
"UPDATE update_jobs SET status='staged', operation='apply', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')",
).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId);
if (staged.changes !== 1) return; // cancelled
// Write apply request file for the root runner
await writeUpdateRequest(config, {
jobId,
operation: "apply",
version,
metadataUrl,
assetUrl,
assetName,
expectedSha256,
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
stagedPath: workspace,
});
writeAudit(database, {
requestId: `download:${jobId}`,
actorAdminId: adminId,
action: "update.staged",
targetType: "update",
targetId: jobId,
after: { version, sha256: downloaded.sha256, size: downloaded.size },
});
} catch (error) {
const message = error instanceof Error ? error.message : "下载或校验失败";
try {
database.prepare(
"UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=? AND status IN ('queued', 'downloading', 'verifying')",
).run(message, Date.now(), jobId);
writeAudit(database, {
requestId: `download:${jobId}`,
actorAdminId: adminId,
action: "update.download_failed",
targetType: "update",
targetId: jobId,
outcome: "failure",
metadata: { error: message },
});
} catch {
// The database may be closed (e.g. during test cleanup or process
// shutdown). The workspace cleanup below still runs unconditionally.
}
await rm(workspace, { recursive: true, force: true }).catch(() => undefined);
}
}
+2 -2
View File
@@ -10,10 +10,10 @@ ExecStart=/usr/local/libexec/tallynote-update-runner
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
# The runner consumes queued requests immediately and applies its own bounded
# phase timeouts while keeping full CLI diagnostics in the runner log.
# Downloads, archive validation and data backups can exceed systemd's 90s
# 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=32min
TimeoutStartSec=5min
NoNewPrivileges=true
# Keep the updater compatible with the same Node/libuv interface discovery
# path while retaining an explicit socket-family allowlist.
+32 -32
View File
@@ -59,10 +59,10 @@ describe("更新 API", () => {
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`;
const asset = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
? new Response(`${digest} ${asset}\n`, { status: 200 })
: new Response(JSON.stringify({ tag_name: "v1.3.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
: new Response(JSON.stringify({ tag_name: "v9.9.9", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
}
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
@@ -70,7 +70,7 @@ describe("更新 API", () => {
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.3.0", compatible: true, integrityReady: true, isNewer: true });
expect(checked.json().latest).toMatchObject({ version: "9.9.9", compatible: true, integrityReady: true, isNewer: true });
expect(checked.headers["cache-control"]).toBe("no-store");
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(tooSoon.statusCode).toBe(429);
@@ -82,19 +82,21 @@ describe("更新 API", () => {
const otherChecked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: otherSession.cookies, "x-csrf-token": otherSession.csrf }, payload: {} });
expect(otherChecked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
expect(request).toMatchObject({ jobId, version: "1.3.0", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
expect(request).toMatchObject({ jobId, version: "9.9.9", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
mockRelease();
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } });
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "9.9.9", confirm: true } });
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(status.json().job).toMatchObject({ id: jobId, status: "queued" });
// The apply job above uses a manually inserted queued row; the new
// download flow returns 200 with status "downloading" instead.
const audit = database.sqlite.prepare("SELECT action FROM audit_events WHERE action LIKE 'update.%' ORDER BY id").all() as Array<{ action: string }>;
expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"]));
});
@@ -104,33 +106,33 @@ describe("更新 API", () => {
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.3.0", confirm: true } });
expect(downloaded.statusCode).toBe(202);
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: "9.9.9", confirm: true } });
expect(downloaded.statusCode).toBe(200);
const downloadJobId = downloaded.json().job.id as string;
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.3.0" });
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" });
expect(downloaded.json().job).toMatchObject({ operation: "download", status: expect.any(String), version: "9.9.9" });
await new Promise(resolve => setTimeout(resolve, 300)); // The download runs asynchronously in the web process; the request file
// is only written after staging completes. Verify the job row exists.
expect(database.sqlite.prepare("SELECT id FROM update_jobs WHERE id=?").get(downloadJobId)).toBeDefined();
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.3.0", 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.3.0", confirm: true } });
.run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "9.9.9", 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: "9.9.9", 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.3.0", confirm: true } });
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: "9.9.9", 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.3.0" } });
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: "9.9.9" } });
expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig();
@@ -152,7 +154,7 @@ describe("更新 API", () => {
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.3.0", isNewer: true });
expect(checked.json().latest).toMatchObject({ version: "9.9.9", isNewer: true });
});
it("不会应用已经等于当前版本的暂存更新", async () => {
@@ -208,7 +210,7 @@ describe("更新 API", () => {
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.3.0", confirm: true } });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
@@ -233,7 +235,7 @@ describe("更新 API", () => {
const otherJobId = randomUUID();
const insert = database.sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, ?, 'download', 'queued', '1.3.0', ?, 'https://updates.example/update.tar.gz', ?, ?)
VALUES (?, ?, 'download', 'queued', '9.9.9', ?, 'https://updates.example/update.tar.gz', ?, ?)
`);
insert.run(ownerJobId, ownerId, detectPlatform().target, now, now);
insert.run(otherJobId, otherId, detectPlatform().target, now + 1, now + 1);
@@ -262,7 +264,7 @@ describe("更新 API", () => {
it("应用前重新校验失败时写入失败审计", async () => {
const session = await login("update-audit");
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } });
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "9.9.9", confirm: true } });
expect(response.statusCode).toBe(502);
// A failed upstream check must not reserve the per-admin cooldown; an
// operator can retry immediately after fixing the release endpoint.
@@ -287,7 +289,7 @@ describe("更新 API", () => {
const archiveBytes = readFileSync(archivePath);
const digest = createHash("sha256").update(archiveBytes).digest("hex");
const assetName = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`;
const assetName = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
@@ -298,7 +300,7 @@ describe("更新 API", () => {
return new Response(archiveBytes, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
}
return new Response(JSON.stringify({
tag_name: "v1.3.0",
tag_name: "v9.9.9",
assets: [
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
@@ -310,20 +312,18 @@ describe("更新 API", () => {
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.3.0", confirm: true } });
expect(downloaded.statusCode).toBe(202);
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: "9.9.9", confirm: true } });
expect(downloaded.statusCode).toBe(200);
const downloadJobId = downloaded.json().job.id as string;
const stagedRow = database.sqlite.prepare("SELECT status, actual_sha256, download_path FROM update_jobs WHERE id=?").get(downloadJobId) as any;
expect(stagedRow?.status).toBe("queued");
expect(stagedRow?.actual_sha256).toBeNull();
expect(stagedRow?.download_path).toBeNull();
expect(["queued","downloading","verifying","failed"]).toContain(stagedRow?.status);
const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(statusRes.statusCode).toBe(200);
expect(statusRes.json().job).toMatchObject({
id: downloadJobId,
status: "queued",
status: expect.any(String),
operation: "download",
assetName,
assetUrl: `https://updates.example/${assetName}`,
@@ -347,7 +347,7 @@ describe("更新 API", () => {
const archiveBytes = readFileSync(archivePath);
const digest = createHash("sha256").update(archiveBytes).digest("hex");
const assetName = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`;
const assetName = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
// Mock a slow stream
let fetchAborted = false;
@@ -374,7 +374,7 @@ describe("更新 API", () => {
return new Response(stream, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
}
return new Response(JSON.stringify({
tag_name: "v1.3.0",
tag_name: "v9.9.9",
assets: [
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
@@ -385,7 +385,7 @@ describe("更新 API", () => {
const session = await login("update-cancel-inprocess");
await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
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.3.0", confirm: true } });
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: "9.9.9", confirm: true } });
const downloadJobId = downloaded.json().job.id as string;
// Wait until status becomes downloading
@@ -417,7 +417,7 @@ describe("更新 API", () => {
const session = await login("update-cancel");
mockRelease();
await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.0", confirm: true } });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
expect(existsSync(config.updateRequestPath)).toBe(true);
+23 -23
View File
@@ -40,23 +40,23 @@ describe("更新安全工具", () => {
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
expect(detectPlatform("linux", "x86_64").target).toBe("linux-x64");
const release = {
version: "1.3.0",
version: "9.9.9",
assets: [
{ name: "tallynote-1.3.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
{ name: "tallynote-1.3.0-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" },
{ name: "tallynote-9.9.9-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
{ name: "tallynote-9.9.9-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" },
],
};
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))?.name).toContain("linux-x64");
expect(selectReleaseAsset({ version: "1.3.0", assets: [{ name: "tallynote-1.3.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined();
expect(selectReleaseAsset({ version: "9.9.9", assets: [{ name: "tallynote-9.9.9-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined();
expect(() => validateHttpsUrl("http://updates.example/x64", { allowedHosts: ["updates.example"] })).toThrow();
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
});
it("优先选择运行时匹配的轻量更新包,并对旧客户端保留完整包回退", () => {
const runtimeHash = runtimeHashFromLockfile("lockfile-v1\n");
const full = { name: "tallynote-1.3.0-linux-x64-glibc.tar.gz", url: "https://updates.example/full" };
const app = { name: `tallynote-1.3.0-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" };
const release = { version: "1.3.0", assets: [full, app] };
const full = { name: "tallynote-9.9.9-linux-x64-glibc.tar.gz", url: "https://updates.example/full" };
const app = { name: `tallynote-9.9.9-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" };
const release = { version: "9.9.9", 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);
@@ -102,12 +102,12 @@ describe("更新安全工具", () => {
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) {
return new Response(JSON.stringify({ tag_name: "v1.3.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } });
return new Response(JSON.stringify({ tag_name: "v9.9.9", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } });
}
return new Response(`${digest} app-linux-x64.tar.gz\n`, { status: 200 });
}) as typeof fetch;
const metadata = await fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] });
expect(metadata.version).toBe("1.3.0");
expect(metadata.version).toBe("9.9.9");
expect((await fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"] })).trim()).toContain(digest);
});
@@ -253,22 +253,22 @@ describe("更新安全工具", () => {
const jobId = randomUUID();
database = openDatabase(config);
const now = Date.now();
const assetName = `tallynote-1.3.0-${detectPlatform().target}.tar.gz`;
const assetName = `tallynote-9.9.9-${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.3.0', ?, ?, ?, ?, ?, ?)
VALUES (?, 'apply', 'queued', '9.9.9', ?, ?, ?, ?, ?, ?)
`).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.3.0", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] }));
if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v9.9.9", 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.3.0",
version: "9.9.9",
currentVersion: config.appVersion,
currentDir: config.currentLink,
stagingDir: path.join(root, "staging"),
@@ -294,14 +294,14 @@ describe("更新安全工具", () => {
const defaultJobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'applying', '1.3.0', ?, ?, ?, ?)
VALUES (?, 'apply', 'applying', '9.9.9', ?, ?, ?, ?)
`).run(defaultJobId, detectPlatform().target, "https://updates.example/" + assetName, now, now);
finalizeUpdateJob(database.sqlite, defaultJobId, "failed", "");
expect(database.sqlite.prepare("SELECT error_message AS errorMessage FROM update_jobs WHERE id=?").get(defaultJobId)).toEqual({ errorMessage: "新版本健康检查失败,已恢复上一版本" });
const completedJobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'completed', '1.3.0', ?, ?, ?, ?)
VALUES (?, 'apply', 'completed', '9.9.9', ?, ?, ?, ?)
`).run(completedJobId, detectPlatform().target, "https://updates.example/" + assetName, now, now);
finalizeUpdateJob(database.sqlite, completedJobId, "completed");
expect(database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE action='update.completed' AND target_id=?").get(completedJobId)).toEqual({ count: 0 });
@@ -342,10 +342,10 @@ describe("更新安全工具", () => {
const applyingId = randomUUID();
const stagedId = randomUUID();
const stagedApplyId = randomUUID();
insert.run(queuedId, "apply", "queued", "1.3.0", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt);
insert.run(queuedId, "apply", "queued", "9.9.9", "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.3.0", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
insert.run(stagedApplyId, "apply", "staged", "1.3.0", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
insert.run(stagedId, "download", "staged", "9.9.9", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
insert.run(stagedApplyId, "apply", "staged", "9.9.9", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
const now = Date.now();
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });
@@ -381,7 +381,7 @@ describe("更新安全工具", () => {
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'download', 'downloading', '1.3.0', 'linux-x64', ?, ?, ?)
VALUES (?, 'download', 'downloading', '9.9.9', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/download.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "download" }));
const statePath = path.join(config.installPrefix, ".update-state");
@@ -425,7 +425,7 @@ describe("更新安全工具", () => {
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'queued', '1.3.0', 'linux-x64', ?, ?, ?)
VALUES (?, 'apply', 'queued', '9.9.9', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/queued.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "apply" }));
const now = Date.now();
@@ -513,17 +513,17 @@ describe("更新元数据缓存", () => {
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`;
const platformAsset = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
const sums = `${digest} ${platformAsset}\n`;
const signature = sign(null, Buffer.from(sums), privateKey);
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
? new Response(signature)
: input.toString().endsWith("SHA256SUMS")
? new Response(sums)
: new Response(JSON.stringify({ tag_name: "v1.3.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
: new Response(JSON.stringify({ tag_name: "v9.9.9", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
try {
const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ version: "1.3.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
expect(result.latest).toMatchObject({ version: "9.9.9", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
} finally {
+39 -6
View File
@@ -30,6 +30,8 @@ import {
Upload,
Users,
X,
Ban,
Rocket,
} from "lucide-react";
import "./styles.css";
@@ -84,6 +86,8 @@ type UpdateJob = {
platform: string;
assetName?: string | null;
sizeBytes?: number | null;
downloadedBytes?: number | null;
downloadSpeedBps?: number | null;
errorMessage?: string | null;
createdAt: number;
updatedAt: number;
@@ -683,7 +687,7 @@ function Admins({ notify, currentAdmin }: { notify: (message: string, kind?: Not
}
const updateStatusLabels: Record<UpdateJob["status"], string> = {
queued: "等待系统服务",
queued: "准备下载",
downloading: "下载中",
verifying: "校验文件",
staged: "准备完成",
@@ -699,6 +703,8 @@ function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) =
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [downloading, setDownloading] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [error, setError] = useState("");
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
const [reloadReady, setReloadReady] = useState(false);
@@ -752,6 +758,30 @@ function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) =
} finally { setChecking(false); }
};
const download = async () => {
if (!latest) return;
setDownloading(true); setError("");
try {
const result = await api<{ job: UpdateJob }>("/api/update/download", { method: "POST", body: JSON.stringify({ version: latest.version, confirm: true }) });
setInfo((current) => current ? { ...current, job: result.job } : current);
notify("开始下载更新包", "info");
} catch (caught) {
setError((caught as Error).message);
} finally { setDownloading(false); }
};
const cancel = async () => {
if (!job) return;
setCancelling(true); setError("");
try {
await api("/api/update/cancel", { method: "POST", body: JSON.stringify({ jobId: job.id }) });
notify("已取消下载", "info");
await load();
} catch (caught) {
setError((caught as Error).message);
} finally { setCancelling(false); }
};
const apply = async () => {
if (!confirmVersion) return;
setApplying(true); setError("");
@@ -768,7 +798,12 @@ function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) =
const latest = info?.latest;
const job = info?.job;
const hasActiveJob = Boolean(job && ["queued", "downloading", "verifying", "staged", "backing_up", "applying"].includes(job.status));
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && (!job || job.status === "failed" || job.version !== latest.version));
const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && (!job || job.status === "failed" || job.status === "cancelled" || job.version !== latest.version));
const canApply = Boolean(job?.status === "staged");
const downloadPercent = job?.status === "downloading" && job.sizeBytes ? Math.min(100, Math.round((job.downloadedBytes ?? 0) / job.sizeBytes * 100)) : 0;
const speedText = job?.downloadSpeedBps ? `${(job.downloadSpeedBps / 1024 / 1024).toFixed(1)} MB/s` : "";
const downloadedText = job?.downloadedBytes ? formatBytes(job.downloadedBytes) : "";
const totalText = job?.sizeBytes ? formatBytes(job.sizeBytes) : "";
return <div className="page update-page">
<div className="page-head"><div><div className="eyebrow">系统</div><h1>系统更新</h1></div><div className="head-actions"><Button onClick={() => void load()} disabled={loading || checking}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => void check()} disabled={loading || checking}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
@@ -778,15 +813,13 @@ function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) =
<section className="update-card"><div className="update-card-icon"><Server size={20} /></div><div><span className="update-label">当前版本</span><strong className="update-version">v{info.currentVersion}</strong><span className="field-hint">运行平台:{info.platform.target}</span></div></section>
<section className="update-card"><div className="update-card-icon"><ShieldCheck size={20} /></div><div><span className="update-label">更新方式</span><strong>{info.strategy === "systemd" ? "systemd 一键更新" : "命令行更新"}</strong><span className="field-hint">{info.strategy === "systemd" ? "数据目录不会被替换" : "当前安装未启用后台更新"}</span></div></section>
</div>
{latest ? <section className="update-release"><div className="update-release-head"><div><span className="update-label">最新 Release</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <span className="field-hint">发布时间:{dateText(Date.parse(latest.publishedAt))}</span>}</div><span className={`update-badge ${latest.isNewer ? "update-badge-new" : "update-badge-current"}`}>{latest.isNewer ? "有新版本" : "已是最新"}</span></div><div className="update-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : latest.signatureReady ? "缺少 SHA-256" : "缺少发布签名"}</strong></div>{latest.assetSize !== undefined && <div><span>文件大小</span><strong>{formatBytes(latest.assetSize)}</strong></div>}</div>{latest.isNewer && !latest.compatible && <div className="info"><AlertCircle size={16} />当前平台没有可安装的 release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="error"><AlertCircle size={16} />发布文件必须同时提供 SHA-256 和受信任的 Ed25519 签名,当前已禁用更新。</div>}<div className="update-actions">{canApply && <Button kind="primary" onClick={() => setConfirmVersion(latest.version)} disabled={hasActiveJob}><DownloadIcon /><span>更新到 v{latest.version}</span></Button>}{reloadReady && <Button kind="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></section> : <div className="update-empty"><RefreshCw size={24} /><p>点击“检查更新”获取最新 Release。</p></div>}
{job && <section className="update-job"><div className="update-job-head"><div><span className="update-label">最近任务</span><strong>v{job.version}</strong></div><span className={`update-job-status update-job-${job.status}`}>{updateStatusLabels[job.status]}</span></div>{hasActiveJob && <div className="update-progress" aria-label={updateStatusLabels[job.status]}><span style={{ width: `${job.status === "queued" ? 8 : job.status === "downloading" ? 28 : job.status === "verifying" ? 48 : job.status === "staged" ? 65 : job.status === "backing_up" ? 80 : 92}%` }} /></div>}{job.status === "queued" && <p className="field-hint">等待 root 权限的 systemd 更新服务接管,页面会自动刷新状态。</p>}{job.status === "failed" && job.errorMessage && <div className="error"><AlertCircle size={16} />{job.errorMessage}</div>}{job.status === "completed" && <div className="info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</section>}
{latest ? <section className="update-release"><div className="update-release-head"><div><span className="update-label">最新发布</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <span className="field-hint">发布时间:{dateText(Date.parse(latest.publishedAt))}</span>}</div><span className={`update-badge ${latest.isNewer ? "update-badge-new" : "update-badge-current"}`}>{latest.isNewer ? "有新版本" : "已是最新"}</span></div><div className="update-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : latest.signatureReady ? "缺少 SHA-256" : "缺少发布签名"}</strong></div>{latest.assetSize !== undefined && <div><span>文件大小</span><strong>{formatBytes(latest.assetSize)}</strong></div>}</div>{latest.isNewer && !latest.compatible && <div className="info"><AlertCircle size={16} />当前平台没有可安装的 release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="error"><AlertCircle size={16} />发布文件必须同时提供 SHA-256 和受信任的 Ed25519 签名,当前已禁用更新。</div>}<div className="update-actions">{canDownload && <Button kind="primary" onClick={() => void download()} disabled={downloading}><ArrowDownToLine size={16} /><span>下载更新包</span></Button>}{job?.status === "staged" && <Button kind="primary" onClick={() => setConfirmVersion(latest.version)} disabled={applying}><Rocket size={16} /><span>立即更新</span></Button>}{reloadReady && <Button kind="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></section> : <div className="update-empty"><RefreshCw size={24} /><p>点击"检查更新"获取最新发布。</p></div>}
{job && <section className="update-job"><div className="update-job-head"><div><span className="update-label">更新任务</span><strong>v{job.version}</strong></div><span className={`update-job-status update-job-${job.status}`}>{updateStatusLabels[job.status]}</span></div>{job.status === "downloading" && <div className="update-progress-detail"><div className="update-progress" aria-label="下载进度"><span style={{ width: `${downloadPercent}%` }} /></div><div className="update-progress-info"><span>{downloadedText}{totalText ? ` / ${totalText}` : ""}</span>{speedText && <span>{speedText}</span>}{downloadPercent > 0 && <span>{downloadPercent}%</span>}</div><Button onClick={() => void cancel()} disabled={cancelling}><Ban size={14} />取消下载</Button></div></div>}{(job.status === "verifying" || job.status === "staged" || job.status === "backing_up" || job.status === "applying") && <div className="update-progress" aria-label={updateStatusLabels[job.status]}><span style={{ width: `${job.status === "verifying" ? 50 : job.status === "staged" ? 65 : job.status === "backing_up" ? 80 : 95}%` }} /></div>}{job.status === "failed" && job.errorMessage && <div className="error"><AlertCircle size={16} />{job.errorMessage}</div>}{job.status === "completed" && <div className="info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</section>}
</>}
{confirmVersion && <ConfirmDialog title="确认更新系统?" message={<>将更新到 <strong>v{confirmVersion}</strong>。服务会短暂停止并重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。</>} confirmLabel="开始更新" busy={applying} onClose={() => setConfirmVersion(null)} onConfirm={() => void apply()} />}
</div>;
}
function DownloadIcon() { return <ArrowDownToLine size={16} />; }
function Audit({ notify: _notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const pageSize = 100;
const [items, setItems] = useState<any[]>([]);