Compare commits

...
11 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
Qiufeng ab2d24a5c7 fix: keep manually set admin password, echo SSH input
TallyNote release / linux-x64 (push) Successful in 6m11s
- manual admin password no longer forces first-login change
- --generate still requires password change on first login
- add --mark-password-configured to repair legacy flag
- echo interactive username/password input in SSH terminal
- installer prints absolute admin-init path (sudo secure_path compat)
- use python3 pty helper for CI tests (no expect on Linux)
release: 1.2.9
2026-09-10 18:04:06 +08:00
Qiufeng a070ad0434 fix: move notifications to bottom right
TallyNote release / linux-x64 (push) Successful in 6m55s
2026-09-05 17:06:23 +08:00
Qiufeng 3ab3e5e180 fix: sync update status after checks
TallyNote release / linux-x64 (push) Successful in 6m54s
2026-09-05 16:41:18 +08:00
Qiufeng 6c96cddd4e fix: reconcile stale staged updates
TallyNote release / linux-x64 (push) Successful in 6m50s
2026-09-05 16:31:53 +08:00
Qiufeng efbd0e0d87 fix: make update center state consistent
TallyNote release / linux-x64 (push) Successful in 6m53s
2026-09-05 16:26:34 +08:00
Qiufeng ed0b492461 fix: make online updates recoverable
TallyNote release / linux-x64 (push) Successful in 7m45s
2026-09-05 14:56:15 +08:00
22 changed files with 1489 additions and 274 deletions
+2
View File
@@ -31,6 +31,8 @@ TALLYNOTE_INSTALL_PREFIX=./
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
TALLYNOTE_UPDATE_MAX_MB=512
# Per-request timeout for update metadata, checksums, signatures, and archives.
TALLYNOTE_UPDATE_TIMEOUT_SECONDS=30
# SHA-256 is always required. Detached Ed25519 signatures are optional; set
# this to true only when a root-managed public key is configured below.
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
+10 -2
View File
@@ -42,7 +42,7 @@ pnpm build:next
`build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。
本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo tallynote-admin-init` 即可。也可以使用 `sudo tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。
本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo /usr/local/sbin/tallynote-admin-init` 即可。也可以使用 `sudo /usr/local/sbin/tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。
默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。
@@ -62,7 +62,13 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入;选择稍后创建也不会阻塞服务启动,之后执行 `sudo tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入,并会直接回显当前输入内容;密码不会写入安装日志、配置文件或命令行参数。选择稍后创建也不会阻塞服务启动,之后执行 `sudo /usr/local/sbin/tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。
如果账号是在旧版本中用正式密码创建、但仍被标记为“首次登录需要修改密码”,可以在服务器上用当前密码修复标志位(不会更换密码):
```bash
sudo /usr/local/sbin/tallynote-admin-init --mark-password-configured --username <用户名>
```
监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动后,安装器会先请求本机 `/health`;只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时该链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。
@@ -176,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 清理 |
+9 -7
View File
@@ -230,26 +230,26 @@ run_initial_admin_wizard() {
return 0
fi
if (( NON_INTERACTIVE )); then
log '非交互模式:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
log "非交互模式:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
return 0
fi
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || {
log '未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
log "未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
return 0
}
[[ -x "$ADMIN_INIT_PATH" ]] || die '管理员初始化命令未安装'
local status choice
if ! status=$("$ADMIN_INIT_PATH" --check 2>/dev/null); then
log '无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo tallynote-admin-init'
log "无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
return 0
fi
[[ "$status" == empty ]] || return 0
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请稍后执行 sudo tallynote-admin-init'
exec 9<"$PROMPT_INPUT" || die "无法打开终端输入;请稍后执行 sudo $ADMIN_INIT_PATH"
{
printf '\n首次安装还差一步:请创建管理员账号。\n'
printf '管理员账号用于登录 TallyNote,首次登录后需要设置正式密码。\n'
printf '管理员账号用于登录 TallyNote;这里输入的密码会直接作为正式密码。\n'
} > "$PROMPT_OUTPUT"
while :; do
prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes'
@@ -258,7 +258,7 @@ run_initial_admin_wizard() {
yes|YES|Yes|y|Y) break ;;
no|NO|No|n|N|'')
exec 9<&-
log '已跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
log "已跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
return 0
;;
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
@@ -267,7 +267,7 @@ run_initial_admin_wizard() {
stage '创建首位管理员(密码不会写入安装日志)'
if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then
exec 9<&-
log '管理员初始化未完成;基础安装已完成,稍后可执行 sudo tallynote-admin-init'
log "管理员初始化未完成;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
return 0
fi
exec 9<&-
@@ -1538,5 +1538,7 @@ main() {
fi
log "访问地址:$access_url"
log '查看服务状态:systemctl status tallynote.service'
log "管理员初始化命令:sudo $ADMIN_INIT_PATH"
log '如 sudo 找不到该命令,请使用上面输出的绝对路径'
}
main "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.2.3",
"version": "1.3.2",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+110 -22
View File
@@ -10,6 +10,8 @@ DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
REQUEST_FILE="$DATA_DIR/update-request.json"
CURRENT_LINK="$PREFIX/current"
STATE_FILE="$PREFIX/.update-state"
LOCK_FILE="$PREFIX/.update-runner.lock"
RUNNER_LOG="$PREFIX/.update-runner.log"
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
HOST=${TALLYNOTE_HOST:-127.0.0.1}
PORT=${TALLYNOTE_PORT:-3000}
@@ -19,13 +21,72 @@ if [[ "$HEALTH_HOST" == :: ]]; then HEALTH_HOST=::1; fi
if [[ "$HEALTH_HOST" == *:* && "$HEALTH_HOST" != \[* ]]; then HEALTH_HOST="[$HEALTH_HOST]"; fi
die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
# The runner may exit during any of the checks below. Install its EXIT cleanup
# before doing privileged preflight so a partial invocation never leaves a
# heartbeat or lock behind.
STATE_CREATED=0
heartbeat_pid=''
heartbeat_owner=$$
RUNNER_LOCK_FD=9
RUNNER_LOCK_MODE=''
stop_heartbeat() {
if [[ -n "$heartbeat_pid" ]]; then
kill "$heartbeat_pid" 2>/dev/null || true
wait "$heartbeat_pid" 2>/dev/null || true
heartbeat_pid=''
fi
}
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap
release_runner_lock() {
if [[ "$RUNNER_LOCK_MODE" == flock ]]; then
flock -u "$RUNNER_LOCK_FD" 2>/dev/null || true
eval "exec ${RUNNER_LOCK_FD}>&-" 2>/dev/null || true
elif [[ "$RUNNER_LOCK_MODE" == mkdir ]]; then
rmdir -- "$LOCK_FILE.d" 2>/dev/null || true
fi
}
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap
early_cleanup() {
local result=$?
stop_heartbeat
if (( result != 0 )); then
# A preflight failure happens before the normal phase-specific trap is
# installed. Remove only the one-shot request marker; never remove an
# existing recovery marker unless this invocation created it.
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
if (( STATE_CREATED == 1 )); then rm -f -- "$STATE_FILE" 2>/dev/null || true; fi
fi
release_runner_lock
return "$result"
}
trap early_cleanup EXIT
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
[[ -d "$PREFIX" ]] || die 'install prefix is missing'
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE" || die '无法打开更新运行锁'
flock -n "$RUNNER_LOCK_FD" || exit 0
RUNNER_LOCK_MODE=flock
else
# macOS development fixtures do not ship util-linux; retain an atomic lock
# fallback there while Linux production uses flock above.
mkdir "$LOCK_FILE.d" 2>/dev/null || exit 0
RUNNER_LOCK_MODE='mkdir'
fi
[[ -f "$REQUEST_FILE" || -f "$STATE_FILE" ]] || exit 0
[[ -L "$CURRENT_LINK" ]] || die 'current release link is missing'
old_target=$(readlink -f -- "$CURRENT_LINK")
[[ "$old_target" == "$PREFIX/releases/"* && -d "$old_target" ]] || die 'current release target is invalid'
# Capture the service state before any download/apply work. The value is
# persisted in the recovery marker so a later runner process can restore the
# operator's original state after a crash (the service is normally inactive by
# the time recovery starts).
was_active=0
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
request_operation='apply'
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
request_operation=$(sed -n 's/.*"operation"[[:space:]]*:[[:space:]]*"\(download\|apply\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
@@ -39,15 +100,11 @@ job_id=''
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
fi
STATE_CREATED=0
heartbeat_pid=''
heartbeat_owner=$$
write_recovery_state() {
local phase=$1 temporary
temporary="$PREFIX/.update-state-$$-${RANDOM}.tmp"
[[ ! -e "$temporary" && ! -L "$temporary" ]] || return 1
printf 'job_id=%s\nold_target=%s\nphase=%s\n' "$job_id" "$old_target" "$phase" > "$temporary"
printf 'job_id=%s\nold_target=%s\nphase=%s\ninitial_active=%s\n' "$job_id" "$old_target" "$phase" "$was_active" > "$temporary"
chmod 600 "$temporary"
mv -Tf -- "$temporary" "$STATE_FILE"
STATE_CREATED=1
@@ -59,14 +116,6 @@ clear_recovery_state() {
STATE_CREATED=0
}
stop_heartbeat() {
if [[ -n "$heartbeat_pid" ]]; then
kill "$heartbeat_pid" 2>/dev/null || true
wait "$heartbeat_pid" 2>/dev/null || true
heartbeat_pid=''
fi
}
heartbeat() {
# Keep the lease fresh during long downloads/backups, but stop on a hard
# runner kill so an orphaned child cannot keep the recovery marker alive.
@@ -86,9 +135,11 @@ start_heartbeat() {
# This trap covers failures before the normal apply cleanup trap is installed,
# including a missing runtime, an invalid current link, and a failed service
# stop. It deliberately does not remove a pre-existing recovery marker.
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap
preflight_cleanup() {
local result=$?
stop_heartbeat
release_runner_lock
if (( result != 0 )); then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
if (( STATE_CREATED == 1 )); then clear_recovery_state || true; fi
@@ -97,6 +148,33 @@ preflight_cleanup() {
}
trap preflight_cleanup EXIT
DOWNLOAD_TIMEOUT_SECONDS=${TALLYNOTE_UPDATE_DOWNLOAD_TIMEOUT_SECONDS:-${TALLYNOTE_UPDATE_RUNNER_DOWNLOAD_TIMEOUT_SECONDS:-1800}}
APPLY_TIMEOUT_SECONDS=${TALLYNOTE_UPDATE_APPLY_TIMEOUT_SECONDS:-${TALLYNOTE_UPDATE_RUNNER_APPLY_TIMEOUT_SECONDS:-1800}}
FINALIZE_TIMEOUT_SECONDS=${TALLYNOTE_UPDATE_FINALIZE_TIMEOUT_SECONDS:-${TALLYNOTE_UPDATE_RUNNER_FINALIZE_TIMEOUT_SECONDS:-30}}
TIMEOUT_BIN=$(command -v timeout || true)
run_update_cli() {
local node=$1 timeout_seconds=$2 label=$3 result
shift 3
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || die "${label} timeout must be a positive integer"
{
printf '\n[%s] %s (timeout=%ss)\ncommand:' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$label" "$timeout_seconds"
printf ' %q' "$node" "$CURRENT_LINK/dist/server/cli/update.js" "$@"
printf '\n'
} >>"$RUNNER_LOG"
if [[ -n "$TIMEOUT_BIN" ]]; then
"$TIMEOUT_BIN" --foreground --signal=TERM --kill-after=10s "${timeout_seconds}s" \
"$node" "$CURRENT_LINK/dist/server/cli/update.js" "$@" >>"$RUNNER_LOG" 2>&1
result=$?
elif "$node" "$CURRENT_LINK/dist/server/cli/update.js" "$@" >>"$RUNNER_LOG" 2>&1; then
result=0
else
result=$?
fi
printf '[%s] %s exited with status %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$label" "$result" >>"$RUNNER_LOG"
return "$result"
}
# Downloading is intentionally handled while the main service remains up.
# The CLI persists the validated payload under the root-owned workspace and
# leaves the job staged for a later apply request.
@@ -107,6 +185,7 @@ if [[ "$request_operation" == download ]]; then
clear_recovery_state || die '无法清理上一次下载状态'
fi
write_recovery_state download || die '无法写入更新恢复状态'
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap
cleanup_download() {
local result=$?
stop_heartbeat
@@ -116,6 +195,7 @@ if [[ "$request_operation" == download ]]; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
fi
clear_recovery_state || true
release_runner_lock
return "$result"
}
trap cleanup_download EXIT
@@ -128,7 +208,7 @@ if [[ "$request_operation" == download ]]; then
cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
set +e
"$node_bin" "$cli" --request-file "$REQUEST_FILE"
run_update_cli "$node_bin" "$DOWNLOAD_TIMEOUT_SECONDS" download --request-file "$REQUEST_FILE"
download_result=$?
set -e
if (( download_result != 0 )); then
@@ -139,7 +219,7 @@ if [[ "$request_operation" == download ]]; then
download_job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
if [[ "$download_job_id" =~ ^[0-9a-f-]{36}$ ]]; then
for _ in 1 2 3; do
if "$node_bin" "$cli" --finalize-job "$download_job_id" --finalize-status failed --message '更新下载失败' >/dev/null 2>&1; then break; fi
if run_update_cli "$node_bin" "$FINALIZE_TIMEOUT_SECONDS" finalize-download --finalize-job "$download_job_id" --finalize-status failed --message '更新下载失败'; then break; fi
sleep 1
done
fi
@@ -150,12 +230,11 @@ if [[ "$request_operation" == download ]]; then
exit 0
fi
was_active=0
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
restore_initial_service() {
local result=$?
stop_heartbeat
release_runner_lock
if (( result != 0 )); then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
if (( STATE_CREATED == 1 )); then clear_recovery_state || true; fi
@@ -175,11 +254,11 @@ finalize_state_job() {
local node=$1 status=$2 state_job=$3
[[ "$state_job" =~ ^[0-9a-f-]{36}$ && -n "$node" ]] || return 1
[[ -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 1
"$node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$state_job" --finalize-status "$status" --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
run_update_cli "$node" "$FINALIZE_TIMEOUT_SECONDS" finalize-recovery --finalize-job "$state_job" --finalize-status "$status" --message '新版本健康检查失败,已恢复上一版本'
}
recover_stale_state() {
local state_job state_old state_phase current_target recovery_node rollback_link state_mode state_uid
local state_job state_old state_phase state_initial_active current_target recovery_node rollback_link state_mode state_uid
[[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] || die 'update state file is invalid'
state_uid=$(stat -c '%u' "$STATE_FILE" 2>/dev/null || stat -f '%u' "$STATE_FILE")
state_mode=$(stat -c '%a' "$STATE_FILE" 2>/dev/null || stat -f '%Lp' "$STATE_FILE")
@@ -187,8 +266,16 @@ recover_stale_state() {
state_job=$(sed -n 's/^job_id=//p' "$STATE_FILE" | head -n 1)
state_old=$(sed -n 's/^old_target=//p' "$STATE_FILE" | head -n 1)
state_phase=$(sed -n 's/^phase=//p' "$STATE_FILE" | head -n 1)
state_initial_active=$(sed -n 's/^initial_active=//p' "$STATE_FILE" | head -n 1)
[[ "$state_job" =~ ^[0-9a-f-]{36}$ ]] || die 'update state job id is invalid'
[[ "$state_old" == "$PREFIX/releases/"* && -d "$state_old" && ! -L "$state_old" ]] || die 'update state target is invalid'
if [[ -z "$state_initial_active" ]]; then
# Markers from older releases did not persist this field. Preserve their
# historical conservative behavior instead of rejecting recovery.
state_initial_active=0
fi
[[ "$state_initial_active" == 0 || "$state_initial_active" == 1 ]] || die 'update state initial service state is invalid'
was_active=$state_initial_active
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
if [[ "$state_phase" == download && "$current_target" == "$state_old" ]]; then
# Downloading never changes the active release. If the runner was killed
@@ -312,7 +399,7 @@ finalize_failed_job() {
# Give SQLite a moment to release a transient lock before declaring the
# recovery itself failed.
for _ in 1 2 3; do
if "$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1; then
if run_update_cli "$old_node" "$FINALIZE_TIMEOUT_SECONDS" finalize-failed --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本'; then
return 0
fi
sleep 1
@@ -323,7 +410,7 @@ finalize_failed_job() {
finalize_completed_job() {
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
[[ -n "$final_node" ]] || return 1
"$final_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status completed >/dev/null 2>&1
run_update_cli "$final_node" "$FINALIZE_TIMEOUT_SECONDS" finalize-completed --finalize-job "$job_id" --finalize-status completed
}
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
@@ -347,6 +434,7 @@ cleanup_after_update() {
else
systemctl stop "$SERVICE_NAME" || true
fi
release_runner_lock
return "$result"
}
trap cleanup_after_update EXIT
@@ -358,7 +446,7 @@ cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
set +e
"$node_bin" "$cli" --request-file "$REQUEST_FILE" --defer-completion
run_update_cli "$node_bin" "$APPLY_TIMEOUT_SECONDS" apply --request-file "$REQUEST_FILE" --defer-completion
update_result=$?
set -e
if (( update_result != 0 )); then
+18 -4
View File
@@ -54,15 +54,18 @@ import {
validateNewPassword,
verifyPassword,
} from "./security.js";
import { isNewerVersion } from "./update.js";
import {
ACTIVE_UPDATE_STATUSES,
checkForUpdate,
currentReleaseVersion,
publicCheckFromCache,
publicUpdateJob,
reconcileOrphanedUpdateJobs,
readCachedRelease,
writeUpdateRequest,
cancelUpdateJob,
downloadAndStageUpdate,
type UpdateRequest,
} from "./update-service.js";
@@ -1012,6 +1015,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
const stagedJobId = input.jobId;
const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined;
if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载");
// A package may have been downloaded before the host was upgraded by
// another path. Never apply a staged archive that is no longer newer
// than the release currently serving traffic.
const effectiveCurrentVersion = currentReleaseVersion(config) ?? config.appVersion;
if (!isNewerVersion(effectiveCurrentVersion, staged.version)) {
const now = Date.now();
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=?, updated_at=? WHERE id=? AND status='staged'").run("暂存更新已过期,当前版本无需再次升级", now, now, stagedJobId);
throw new AppError(409, "UPDATE_NOT_AVAILABLE", "暂存更新已过期,请重新检查更新");
}
if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
const now = Date.now();
@@ -1175,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) => {
+49 -3
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { StringDecoder } from "node:string_decoder";
import { openDatabase, openDatabaseReadOnly } from "../db/index.js";
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js";
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword } from "../security.js";
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword, verifyPassword } from "../security.js";
import { writeAudit } from "../audit.js";
function arg(name: string): string | undefined {
@@ -79,8 +79,13 @@ async function readSecret(prompt: string): Promise<string> {
return;
} else if (character === "\u007f" || character === "\b") {
value = value.slice(0, -1);
// Keep the credential visible in the SSH terminal as requested.
// Redraw the current line so backspace behaves predictably without
// putting the value into logs or command arguments.
output.write("\r\u001b[2K" + prompt + value);
} else {
value += character;
output.write(character);
}
}
};
@@ -128,6 +133,39 @@ async function main() {
const release = acquireInstanceLock(config);
const database = openDatabase(config);
try {
const markPasswordConfigured = process.argv.includes("--mark-password-configured");
if (markPasswordConfigured) {
const username = arg("--username") ?? (await readSecret("用户名: "));
const password = await readSecret("当前密码: ");
const normalized = normalizeUsername(username);
const admin = database.sqlite.prepare(
"SELECT id, password_hash, must_change_password, version FROM admins WHERE username_norm = ?",
).get(normalized) as { id: string; password_hash: string; must_change_password: number; version: number } | undefined;
if (!admin || !(await verifyPassword(admin.password_hash, password))) {
throw new Error("用户名或当前密码不正确");
}
if (!admin.must_change_password) {
console.log("该管理员已经可以直接使用当前密码登录。");
return;
}
const now = Date.now();
database.sqlite.transaction(() => {
const result = database.sqlite.prepare(
"UPDATE admins SET must_change_password=0, auth_version=auth_version+1, version=version+1 WHERE id=? AND version=?",
).run(admin.id, admin.version);
if (result.changes !== 1) throw new Error("管理员资料已被其他操作更新,请重试");
writeAudit(database.sqlite, {
requestId: `cli:${randomUUID()}`,
actorUsername: "cli",
action: "admin.password_policy_cleared",
targetType: "admin",
targetId: admin.id,
after: { username: normalized, mustChangePassword: false, changedAt: now },
});
})();
console.log("已确认当前密码为正式密码,后续登录不再要求修改密码。");
return;
}
const existing = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
const username = arg("--username") ?? (await readSecret("用户名: "));
@@ -154,8 +192,16 @@ async function main() {
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?)
`).run(id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, now);
VALUES (?, ?, ?, ?, ?, 'active', ?, 1, 1, ?)
`).run(
id,
username.normalize("NFKC").trim(),
normalized,
normalizedDisplayName,
passwordHash,
generate ? 1 : 0,
now,
);
writeAudit(database.sqlite, {
requestId: `cli:${randomUUID()}`,
actorUsername: "cli",
+23 -4
View File
@@ -163,7 +163,10 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
operation=excluded.operation,
status=CASE WHEN update_jobs.status='cancelled' THEN update_jobs.status ELSE excluded.status END,
-- Terminal rows are immutable from the runner's ordinary progress
-- writes. In particular, a stale/replayed request must not resurrect a
-- failed job as queued/downloading/etc.
status=CASE WHEN update_jobs.status IN ('cancelled', 'failed', 'completed') THEN update_jobs.status ELSE excluded.status END,
version=excluded.version, platform=excluded.platform,
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
@@ -176,6 +179,11 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
error_message=COALESCE(excluded.error_message, update_jobs.error_message),
updated_at=excluded.updated_at,
completed_at=COALESCE(excluded.completed_at, update_jobs.completed_at)
-- Do not let a delayed runner replay overwrite any field on a terminal
-- row. The predicate is part of the same SQLite upsert, so a finalizer
-- racing this write still wins atomically instead of leaving a partially
-- mutated completed/failed/cancelled record.
WHERE update_jobs.status NOT IN ('cancelled', 'failed', 'completed')
`).run(
jobId,
values.adminId ?? null,
@@ -230,6 +238,7 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
allowedHosts: options.allowedHosts ?? [],
baseUrl: metadataUrl.toString(),
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
timeoutMs: options.timeoutMs,
publicKey: options.publicKey,
requireSignature: options.requireSignature,
});
@@ -398,19 +407,28 @@ export function finalizeUpdateJob(
status: "completed" | "failed",
message?: string,
): void {
sqlite.transaction(() => {
const row = sqlite.prepare(`
SELECT id, status, version, platform, admin_id AS adminId,
request_id AS requestId, session_hash AS sessionHash
FROM update_jobs WHERE id=?
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
if (!row) throw new Error("更新任务不存在");
// A failed finalization can be retried by the runner. Once it has been
// committed, make retries a no-op so the error and audit trail stay stable.
if (row.status === status) return;
// A completed release is terminal. A delayed recovery process must never
// be able to downgrade it to failed after the service was healthy.
if (row.status === "completed" && status === "failed") throw new Error("更新任务状态不允许完成");
const canComplete = row.status === "applying" || row.status === "completed";
const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed";
if (status === "completed" ? !canComplete : !canFail) throw new Error("更新任务状态不允许完成");
const now = Date.now();
const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null;
sqlite.transaction(() => {
sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=?").run(status, safeFailureMessage, now, now, jobId);
const safeFailureMessage = status === "failed"
? (message?.trim() ? safeErrorMessage(new Error(message)) : "新版本健康检查失败,已恢复上一版本")
: null;
const result = sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=? AND status=?").run(status, safeFailureMessage, now, now, jobId, row.status);
if (result.changes !== 1) return;
writeAudit(sqlite, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
@@ -569,6 +587,7 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive, dataBackupSource: config.dataDir } : {}),
...((arg("--backup-dir")) ? { backupDir: arg("--backup-dir") } : {}),
allowedHosts: allowedHosts.length ? allowedHosts : config.updateAllowedHosts,
timeoutMs: config.updateTimeoutMs,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
currentVersion: config.appVersion,
+1
View File
@@ -147,6 +147,7 @@ export function loadConfig() {
// as 0700 root:root; development/test callers may override --staging-dir.
updateWorkspaceDir: path.join(installPrefix, ".update-work"),
updateMaxBytes: integerEnv("TALLYNOTE_UPDATE_MAX_MB", 512) * 1024 * 1024,
updateTimeoutMs: integerEnv("TALLYNOTE_UPDATE_TIMEOUT_SECONDS", 30) * 1000,
// Update checks hit an external release endpoint. Keep a short local
// cooldown so an authenticated account cannot turn the endpoint into an
// outbound request flood; set to 0 only for controlled test environments.
+208 -12
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,
@@ -154,19 +157,19 @@ function signatureAssetFor(metadata: ReleaseMetadata, sums: ReleaseAsset): Relea
export async function attachSidecarHash(
metadata: ReleaseMetadata,
asset: ReleaseAsset,
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; publicKey?: string | undefined; requireSignature?: boolean | undefined },
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; timeoutMs?: number | undefined; publicKey?: string | undefined; requireSignature?: boolean | undefined },
): Promise<{ asset: ReleaseAsset; signatureVerified: boolean }> {
let signatureVerified = false;
if (asset.sha256 && (!options.publicKey || !options.requireSignature)) return { asset, signatureVerified };
const sums = metadata.assets.find((candidate) => /^(?:sha256sums?|checksums?)(?:\.txt)?$/i.test(path.basename(candidate.name)));
if (!sums) return { asset, signatureVerified };
try {
const content = await fetchReleaseText(sums.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: Math.min(options.maxBytes, 2 * 1024 * 1024) });
const content = await fetchReleaseText(sums.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: Math.min(options.maxBytes, 2 * 1024 * 1024), timeoutMs: options.timeoutMs });
const sha256 = sha256FromSums(content, asset.name);
if (options.publicKey) {
const signatureAsset = signatureAssetFor(metadata, sums);
if (signatureAsset) {
const signature = await fetchReleaseBytes(signatureAsset.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: 64 * 1024 });
const signature = await fetchReleaseBytes(signatureAsset.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: 64 * 1024, timeoutMs: options.timeoutMs });
signatureVerified = verifyReleaseSignature(content, signature, options.publicKey);
}
}
@@ -183,6 +186,7 @@ function policy(config: AppConfig) {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
maxRedirects: 3,
timeoutMs: config.updateTimeoutMs,
} as const;
}
@@ -222,6 +226,7 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
allowedHosts: config.updateAllowedHosts,
baseUrl: metadataUrl,
maxBytes: config.updateMaxBytes,
timeoutMs: config.updateTimeoutMs,
publicKey: config.updatePublicKey,
requireSignature: config.updateRequireSignature,
});
@@ -426,7 +431,18 @@ function requestJobId(filePath: string): string | null {
}
}
function currentReleaseVersion(config: AppConfig): string | null {
function recoveryStateJobId(filePath: string): string | null {
try {
const info = lstatSync(filePath);
if (!info.isFile() || info.isSymbolicLink()) return null;
const match = /^job_id=([0-9a-f-]{36})$/m.exec(readFileSync(filePath, "utf8"));
return match?.[1] ?? null;
} catch {
return null;
}
}
export function currentReleaseVersion(config: AppConfig): string | null {
try {
const target = realpathSync(config.currentLink);
const releases = realpathSync(config.releasesDir);
@@ -460,6 +476,12 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
const statePresent = stateMtime !== null;
const requestFresh = requestPresent && now - (requestMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
const stateFresh = statePresent && now - (stateMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
// The request marker is the hand-off contract between the web process and
// the privileged runner. A queued row with a matching, unexpired marker is
// still owned by that hand-off even when the runner has not written its
// recovery state yet (for example while systemd is starting it).
const requestMarkerJobId = requestPresent ? requestJobId(config.updateRequestPath) : null;
const stateMarkerJobId = statePresent ? recoveryStateJobId(statePath) : null;
// A staged download is normally kept for an explicit apply. The one
// exception is the hand-off window where the API has already changed the
// operation to `apply` but crashed before writing the request file. That
@@ -468,11 +490,50 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
let reconciled = 0;
const reconciledIds = new Set<string>();
for (const row of rows) {
// A fresh request/state marker means the privileged runner still owns the
// hand-off. Do not expire a staged/apply row while the runner is finishing
// a successful switch and finalization after a service restart.
const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh;
const matchingFreshState = stateMarkerJobId === row.id && stateFresh;
// A staged archive is actionable only while it is strictly newer than the
// release currently serving requests. This can become false when an
// administrator upgrades the host by another path (or another operator
// completes the same release) before returning to this page. Treat the
// archive as an expired terminal task so it cannot keep blocking the
// queue or appear as an "apply" action for the current version.
const effectiveCurrentVersion = releaseVersion ?? config.appVersion;
if (row.status === "staged" && !isNewerVersion(effectiveCurrentVersion, row.version) && !matchingFreshRequest && !matchingFreshState) {
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET status='failed', error_message=?, completed_at=?, updated_at=?
WHERE id=? AND status='staged'
`).run("暂存更新已过期,当前版本无需再次升级", now, now, row.id);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: "failure",
before: { status: row.status, operation: row.operation, version: row.version },
after: { status: "failed", version: row.version, reason: "staged_version_not_newer" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
continue;
}
// A request that never gets claimed by the root runner must not remain in
// the UI as an endless "queued" task. Once the short hand-off window has
// elapsed and no recovery marker exists, release the queue explicitly;
// a fresh state marker proves that the runner has already claimed it.
if (row.status === "queued" && typeof row.updatedAt === "number" && !stateFresh && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
if (row.status === "queued" && typeof row.updatedAt === "number" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
if (matchingFreshRequest) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
@@ -503,7 +564,7 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
// A stale request/state marker therefore no longer protects an orphaned
// row forever, while a fresh marker remains owned by the runner.
if (row.status === "staged") {
if (row.operation !== "apply" || requestFresh || stateFresh) continue;
if (row.operation !== "apply" || matchingFreshRequest || matchingFreshState) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
@@ -529,7 +590,7 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
}
continue;
}
if (requestFresh || stateFresh) continue;
if (matchingFreshRequest || matchingFreshState) continue;
const status: "completed" | "failed" = row.status === "applying" && releaseVersion === row.version ? "completed" : "failed";
const errorMessage = status === "failed" ? "更新任务超时,已释放更新队列" : null;
const changed = database.transaction(() => {
@@ -586,15 +647,15 @@ export function cancelUpdateJob(
jobId?: string,
): { cancelled: boolean; message?: string } {
const job = jobId
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get() as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=? AND admin_id=?").get(jobId, adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE admin_id=? AND status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get(adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
if (!job) return { cancelled: false, message: "当前没有处于等待调度或下载中的更新任务" };
if (job.status !== "queued" && job.status !== "downloading") return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" };
const now = Date.now();
const changed = database.transaction(() => {
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND status IN ('queued', 'downloading')").run(now, now, job.id);
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND status IN ('queued', 'downloading')").run(now, now, job.id, adminId);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId,
@@ -610,7 +671,9 @@ export function cancelUpdateJob(
})();
if (changed) {
forceRemoveRequest(config.updateRequestPath);
// The request marker is shared by the privileged runner. Never remove a
// newer/different administrator's request while cancelling this row.
if (requestJobId(config.updateRequestPath) === job.id) forceRemoveRequest(config.updateRequestPath);
if (job.downloadPath) {
const target = path.isAbsolute(job.downloadPath) ? job.downloadPath : path.join(config.stagingDir, job.downloadPath);
import("node:fs/promises").then(({ rm }) => rm(target, { recursive: true, force: true })).catch(() => {});
@@ -619,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);
}
}
+139 -65
View File
@@ -59,8 +59,22 @@ export type UrlPolicy = {
/** When allowedHosts is omitted, requests are constrained to this URL's host. */
baseUrl?: string | URL | undefined;
maxRedirects?: number | undefined;
/** Maximum time allowed for one metadata/sidecar/archive request. */
timeoutMs?: number | undefined;
};
/** Release an unread response body before following a redirect or returning
* an error. Undici keeps the underlying connection associated with a body
* until it is consumed or cancelled; leaving it open can exhaust sockets when
* an update feed repeatedly returns errors or oversized responses. */
async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// The body may already be consumed/closed. Cancellation is best effort.
}
}
function invalidVersion(): never {
throw new Error("更新版本号无效");
}
@@ -157,8 +171,37 @@ function metadataError(): Error {
}
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
/** Maximum time allowed for one update HTTP request, including its body. */
export const DEFAULT_UPDATE_TIMEOUT_MS = 30_000;
export const RELEASE_NOTES_MAX_BYTES = 64 * 1024;
type UpdateFetchOptions = {
fetchImpl?: typeof fetch | undefined;
maxBytes?: number | undefined;
timeoutMs?: number | undefined;
};
function updateTimeoutMs(options: UpdateFetchOptions): number {
if (options.timeoutMs !== undefined) {
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0) throw new Error("更新请求超时配置无效");
return options.timeoutMs;
}
const configuredSeconds = process.env.TALLYNOTE_UPDATE_TIMEOUT_SECONDS;
if (configuredSeconds !== undefined && configuredSeconds.trim() !== "") {
const seconds = Number(configuredSeconds);
if (!Number.isSafeInteger(seconds) || seconds <= 0) throw new Error("TALLYNOTE_UPDATE_TIMEOUT_SECONDS 必须是大于 0 的整数");
return seconds * 1000;
}
return DEFAULT_UPDATE_TIMEOUT_MS;
}
function beginUpdateRequest(options: UpdateFetchOptions): { signal: AbortSignal; clear: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), updateTimeoutMs(options));
timer.unref?.();
return { signal: controller.signal, clear: () => clearTimeout(timer) };
}
function releaseNotesText(value: unknown): string | undefined {
if (typeof value !== "string" || value.length === 0) return undefined;
// Gitea exposes both Markdown (body/body_html) and releaseNotes depending on
@@ -210,20 +253,29 @@ function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): st
}
/** Read a fetch body without ever buffering more than the caller's bound. */
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise<Buffer> {
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string, signal?: AbortSignal): Promise<Buffer> {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("响应大小限制无效");
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const declared = Number(contentLength);
if (Number.isFinite(declared) && declared > maxBytes) throw new Error(tooLargeMessage);
if (Number.isFinite(declared) && declared > maxBytes) {
await cancelResponseBody(response);
throw new Error(tooLargeMessage);
}
}
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
let onAbort: (() => void) | undefined;
const abort = signal ? new Promise<never>((_, reject) => {
onAbort = () => reject(new Error("更新请求超时"));
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}) : undefined;
try {
for (;;) {
const result = await reader.read();
const result = await (abort ? Promise.race([reader.read(), abort]) : reader.read());
if (result.done) break;
const chunk = Buffer.from(result.value);
if (chunk.length > maxBytes - total) {
@@ -233,7 +285,11 @@ async function readBoundedResponse(response: Response, maxBytes: number, tooLarg
total += chunk.length;
chunks.push(chunk);
}
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error;
} finally {
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
reader.releaseLock();
}
return Buffer.concat(chunks, total);
@@ -241,43 +297,35 @@ async function readBoundedResponse(response: Response, maxBytes: number, tooLarg
export async function fetchReleaseMetadata(
metadataUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
options: UrlPolicy & UpdateFetchOptions = {},
): Promise<ReleaseMetadata> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(metadataUrl, options);
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
const request = beginUpdateRequest(options);
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual", headers: { accept: "application/json" } });
response = await fetchImpl(current, { method: "GET", redirect: "manual", headers: { accept: "application/json" }, signal: request.signal });
} catch {
request.clear();
throw metadataError();
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw metadataError();
const location = response.headers.get("location");
if (!location) throw metadataError();
current = validateHttpsUrl(new URL(location, current), options.baseUrl ? options : { ...options, baseUrl: current });
}
if (response.status < 200 || response.status >= 300) throw metadataError();
let payload: unknown;
if (response.status < 300 || response.status >= 400) {
try {
if (response.status < 200 || response.status >= 300) {
await cancelResponseBody(response);
throw metadataError();
}
const maxBytes = Math.min(options.maxBytes ?? DEFAULT_METADATA_MAX_BYTES, DEFAULT_METADATA_MAX_BYTES);
const body = await readBoundedResponse(response, maxBytes, "更新发布信息过大");
payload = JSON.parse(body.toString("utf8"));
} catch { throw metadataError(); }
const body = await readBoundedResponse(response, maxBytes, "更新发布信息过大", request.signal);
const payload: unknown = JSON.parse(body.toString("utf8"));
if (!payload || typeof payload !== "object") throw metadataError();
const item = payload as Record<string, unknown>;
const rawVersion = typeof item.version === "string" ? item.version : typeof item.tag_name === "string" ? item.tag_name : typeof item.tagName === "string" ? item.tagName : undefined;
if (!rawVersion) throw metadataError();
const version = parseSemver(rawVersion);
if (typeof item.tag_name === "string") {
try {
if (compareSemver(version, item.tag_name) !== 0) throw metadataError();
} catch {
throw metadataError();
}
}
if (typeof item.tag_name === "string" && compareSemver(version, item.tag_name) !== 0) throw metadataError();
const assetsRaw = Array.isArray(item.assets) ? item.assets : [];
const assets: ReleaseAsset[] = [];
for (const raw of assetsRaw) {
@@ -298,27 +346,29 @@ export async function fetchReleaseMetadata(
const releaseName = releaseNameText(item.name ?? item.releaseName);
let releaseUrl: string | undefined;
if (typeof item.html_url === "string" || typeof item.url === "string") {
try {
const candidate = typeof item.html_url === "string" ? item.html_url : item.url as string;
releaseUrl = releaseResourceUrl(candidate, current, options);
} catch { /* omit invalid optional release page URL */ }
try { releaseUrl = releaseResourceUrl(typeof item.html_url === "string" ? item.html_url : item.url as string, current, options); } catch { /* optional */ }
}
return {
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
...(releaseName ? { releaseName } : {}),
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
...(notes ? { notes } : {}),
...(releaseUrl ? { releaseUrl } : {}),
assets,
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}), ...(releaseName ? { releaseName } : {}), ...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}), ...(notes ? { notes } : {}), ...(releaseUrl ? { releaseUrl } : {}), assets,
};
} catch { throw metadataError(); }
finally { request.clear(); }
}
await cancelResponseBody(response);
request.clear();
if (redirects >= maxRedirects) throw metadataError();
const location = response.headers.get("location");
if (!location) throw metadataError();
current = validateHttpsUrl(new URL(location, current), options.baseUrl ? options : { ...options, baseUrl: current });
}
}
/** Fetch a small text sidecar (for example SHA256SUMS) with the same
* redirect, HTTPS and host policy used for release metadata. */
export async function fetchReleaseText(
textUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
options: UrlPolicy & UpdateFetchOptions = {},
): Promise<string> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(textUrl, options);
@@ -328,27 +378,32 @@ export async function fetchReleaseText(
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
const request = beginUpdateRequest(options);
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
response = await fetchImpl(current, { method: "GET", redirect: "manual", signal: request.signal });
} catch {
request.clear();
throw new Error("更新校验文件下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (response.status < 300 || response.status >= 400) {
if (response.status < 200 || response.status >= 300) { await cancelResponseBody(response); request.clear(); throw new Error("更新校验文件下载失败"); }
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 1024 * 1024;
if (declared > maxBytes) { await cancelResponseBody(response); request.clear(); throw new Error("更新校验文件过大"); }
try {
return (await readBoundedResponse(response, maxBytes, "更新校验文件过大", request.signal)).toString("utf8");
} catch (error) {
if (error instanceof Error && error.message === "更新校验文件过大") throw error;
throw new Error("更新校验文件下载失败");
} finally { request.clear(); }
}
await cancelResponseBody(response);
request.clear();
if (redirects >= maxRedirects) throw new Error("更新校验文件下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新校验文件下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300) throw new Error("更新校验文件下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 1024 * 1024;
if (declared > maxBytes) throw new Error("更新校验文件过大");
try {
return (await readBoundedResponse(response, maxBytes, "更新校验文件过大")).toString("utf8");
} catch (error) {
if (error instanceof Error && error.message === "更新校验文件过大") throw error;
throw new Error("更新校验文件下载失败");
}
}
/** Fetch a bounded binary sidecar (for example an Ed25519 detached
@@ -356,7 +411,7 @@ export async function fetchReleaseText(
* this separate from fetchReleaseText. */
export async function fetchReleaseBytes(
bytesUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
options: UrlPolicy & UpdateFetchOptions = {},
): Promise<Buffer> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(bytesUrl, options);
@@ -366,27 +421,32 @@ export async function fetchReleaseBytes(
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
const request = beginUpdateRequest(options);
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
response = await fetchImpl(current, { method: "GET", redirect: "manual", signal: request.signal });
} catch {
request.clear();
throw new Error("更新签名下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (response.status < 300 || response.status >= 400) {
if (response.status < 200 || response.status >= 300) { await cancelResponseBody(response); request.clear(); throw new Error("更新签名下载失败"); }
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 64 * 1024;
if (declared > maxBytes) { await cancelResponseBody(response); request.clear(); throw new Error("更新签名文件过大"); }
try {
return await readBoundedResponse(response, maxBytes, "更新签名文件过大", request.signal);
} catch (error) {
if (error instanceof Error && error.message === "更新签名文件过大") throw error;
throw new Error("更新签名下载失败");
} finally { request.clear(); }
}
await cancelResponseBody(response);
request.clear();
if (redirects >= maxRedirects) throw new Error("更新签名下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新签名下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300) throw new Error("更新签名下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 64 * 1024;
if (declared > maxBytes) throw new Error("更新签名文件过大");
try {
return await readBoundedResponse(response, maxBytes, "更新签名文件过大");
} catch (error) {
if (error instanceof Error && error.message === "更新签名文件过大") throw error;
throw new Error("更新签名下载失败");
}
}
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform(), runtimeHash?: string): ReleaseAsset | undefined {
@@ -438,7 +498,7 @@ export async function verifySha256(filePath: string, expected: string): Promise<
export async function downloadReleaseAsset(
url: string | URL,
destination: string,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined; onProgress?: ((downloadedBytes: number, totalBytes: number | null) => void) | undefined } = {},
options: UrlPolicy & UpdateFetchOptions & { onProgress?: ((downloadedBytes: number, totalBytes: number | null) => void) | undefined } = {},
): Promise<{ size: number; sha256: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(url, options);
@@ -448,22 +508,32 @@ export async function downloadReleaseAsset(
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
const request = beginUpdateRequest(options);
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
response = await fetchImpl(current, { method: "GET", redirect: "manual", signal: request.signal });
} catch {
request.clear();
throw new Error("更新文件下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (response.status < 300 || response.status >= 400) { request.clear(); break; }
await cancelResponseBody(response);
request.clear();
if (redirects >= maxRedirects) throw new Error("更新文件下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新文件下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300 || !response.body) throw new Error("更新文件下载失败");
if (response.status < 200 || response.status >= 300 || !response.body) {
await cancelResponseBody(response);
throw new Error("更新文件下载失败");
}
const declared = Number(response.headers.get("content-length") ?? 0);
const totalBytes = Number.isSafeInteger(declared) && declared > 0 ? declared : null;
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
if (declared > maxBytes) throw new Error("更新文件超过大小限制");
if (declared > maxBytes) {
await cancelResponseBody(response);
throw new Error("更新文件超过大小限制");
}
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
const temporary = `${destination}.part-${randomUUID()}`;
let size = 0;
@@ -475,8 +545,10 @@ export async function downloadReleaseAsset(
hash.update(chunk);
callback(null, chunk);
} });
const request = beginUpdateRequest(options);
try {
await pipeline(Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), meter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
const source = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream, { signal: request.signal });
await pipeline(source, meter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
const fd = await open(temporary, "r");
await fd.sync();
await fd.close();
@@ -484,6 +556,8 @@ export async function downloadReleaseAsset(
} catch (error) {
await import("node:fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => undefined);
throw error instanceof Error && error.message.startsWith("更新文件") ? error : new Error("更新文件下载失败");
} finally {
request.clear();
}
return { size, sha256: hash.digest("hex") };
}
+4 -5
View File
@@ -1,8 +1,5 @@
[Unit]
Description=TallyNote privileged release updater
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
@@ -11,10 +8,12 @@ WorkingDirectory=/opt/tallynote/current
EnvironmentFile=-/etc/tallynote/tallynote.env
ExecStart=/usr/local/libexec/tallynote-update-runner
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
# Downloads, archive validation and data backups can exceed systemd's 90s
# The runner consumes queued requests immediately and applies its own bounded
# phase timeouts while keeping full CLI diagnostics in the runner log.
# Archive validation and data backups can exceed systemd's 90s
# default start timeout on a slower server. Keep one update job alive long
# enough to finish or reach its own health-check/recovery path.
TimeoutStartSec=30min
TimeoutStartSec=5min
NoNewPrivileges=true
# Keep the updater compatible with the same Node/libuv interface discovery
# path while retaining an explicit socket-family allowlist.
+1
View File
@@ -9,6 +9,7 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
TALLYNOTE_UPDATE_STRATEGY=systemd
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
TALLYNOTE_UPDATE_TIMEOUT_SECONDS=30
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
+80 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -8,6 +8,9 @@ import Database from "better-sqlite3";
const root = path.resolve(process.cwd());
const cli = path.join(root, "server", "cli", "admin-init.ts");
const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
const ptyHelper = path.join(root, "tests", "helpers", "pty-run.py");
const hasPython3 = spawnSync("python3", ["--version"]).status === 0;
const ttyTest = hasPython3 ? it : it.skip;
function runAdmin(dataDir: string, args: string[]) {
return spawnSync(process.execPath, [tsx, cli, ...args], {
@@ -24,6 +27,42 @@ function runAdmin(dataDir: string, args: string[]) {
});
}
function testEnv(dataDir: string) {
return {
...process.env,
NODE_ENV: "test",
TALLYNOTE_DATA_DIR: dataDir,
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
TALLYNOTE_COOKIE_SECURE: "false",
TALLYNOTE_UPDATE_STRATEGY: "disabled",
};
}
// The CI runner has no `expect` binary. Drive the interactive CLI through a
// real pseudo-terminal via a tiny Python pty helper (python3 ships on both
// macOS and the Linux CI image). This avoids `expect` (not installed on CI)
// and BSD `script` (injects a stray EOT byte from file input, corrupting the
// first prompt value). If python3 is unavailable the tests are skipped rather
// than failing the build.
function runAdminTTY(dataDir: string, args: string[], inputText: string) {
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-tty-"));
const inputFile = path.join(parent, "input");
const exitFile = path.join(parent, "exit-code");
writeFileSync(inputFile, inputText);
try {
const result = spawnSync("python3", [ptyHelper, process.execPath, tsx, cli, ...args], {
cwd: root,
env: { ...testEnv(dataDir), PTY_STDIN_FILE: inputFile, PTY_EXIT_FILE: exitFile },
encoding: "utf8",
timeout: 30_000,
});
const exitCode = existsSync(exitFile) ? Number(readFileSync(exitFile, "utf8")) : null;
return { exitCode, output: `${result.stdout}${result.stderr}`, spawnError: result.error };
} finally {
rmSync(parent, { recursive: true, force: true });
}
}
describe("生产管理员初始化 CLI", () => {
it("--check 是只读的,空数据目录不会被创建", () => {
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
@@ -85,6 +124,46 @@ describe("生产管理员初始化 CLI", () => {
}
}, 15_000);
ttyTest("交互式输入正式密码后不会强制首次改密", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
try {
const result = runAdminTTY(dataDir, [], "manual-admin\n手动管理员\nStrong-password-2026!\nStrong-password-2026!\n");
expect(result.spawnError).toBeUndefined();
expect(result.exitCode).toBe(0);
expect(result.output).toContain("已创建首位管理员");
expect(result.output).toContain("Strong-password-2026!");
const database = new Database(path.join(dataDir, "tallynote.db"));
const admin = database.prepare("SELECT username, must_change_password FROM admins").get() as { username: string; must_change_password: number };
expect(admin).toEqual({ username: "manual-admin", must_change_password: 0 });
database.close();
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
}, 30_000);
ttyTest("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
try {
const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]);
expect(first.status).toBe(0);
const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1];
expect(generated).toBeTruthy();
const result = runAdminTTY(dataDir, ["--mark-password-configured", "--username", "legacy-admin"], `${generated}\n`);
expect(result.spawnError).toBeUndefined();
expect(result.exitCode).toBe(0);
expect(result.output).toContain("已确认当前密码为正式密码");
const database = new Database(path.join(dataDir, "tallynote.db"));
const admin = database.prepare("SELECT must_change_password FROM admins WHERE username_norm='legacy-admin'").get() as { must_change_password: number };
expect(admin.must_change_password).toBe(0);
database.close();
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
}, 30_000);
it("密码输入不是 TTY 时明确拒绝通过管道传入", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
try {
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Minimal cross-platform pty driver for the admin-init CLI tests.
Forks a child on a real pseudo-terminal so the CLI sees a TTY and runs its
raw-mode password prompts. Forwards a prepared input file to the child's stdin
and copies child output to stdout. Writes the child's exit code to a file so
the Node test can read it deterministically.
Used instead of `expect` (not installed on CI) or BSD `script` (injects a stray
EOT byte when stdin is a regular file, corrupting the first prompt value).
"""
import os
import pty
import select
import sys
argv = sys.argv[1:]
exit_file = os.environ.get("PTY_EXIT_FILE", "")
stdin_file = os.environ.get("PTY_STDIN_FILE", "")
pid, master = pty.fork()
if pid == 0:
# Child: replace with the target command. argv[0] is an absolute node path.
os.execvp(argv[0], argv)
os._exit(127)
in_fd = os.open(stdin_file, os.O_RDONLY) if stdin_file else -1
open_stdin = in_fd >= 0
try:
while True:
fds = [master]
if open_stdin:
fds.append(in_fd)
try:
readable, _, _ = select.select(fds, [], [], 30.0)
except (OSError, ValueError):
break
if not readable:
break
if master in readable:
try:
data = os.read(master, 4096)
except OSError:
break
if not data:
break
os.write(1, data)
if open_stdin and in_fd in readable:
data = os.read(in_fd, 4096)
if data:
os.write(master, data)
else:
open_stdin = False
os.close(in_fd)
finally:
try:
_, status = os.waitpid(pid, 0)
except ChildProcessError:
status = 0
code = os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") else (status >> 8)
if exit_file:
try:
with open(exit_file, "w") as handle:
handle.write(str(code))
except OSError:
pass
+114 -31
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,54 @@ 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 () => {
const session = await login("update-staged-current");
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged-current") as { id: string };
const now = Date.now();
const stagedId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(
id, admin_id, 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,
admin.id,
config.appVersion,
detectPlatform().target,
config.updateMetadataUrl,
"current.tar.gz",
"https://updates.example/current.tar.gz",
"c".repeat(64),
"c".repeat(64),
path.join(config.dataDir, "staged-current"),
now,
now,
);
const apply = 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: config.appVersion, confirm: true },
});
expect(apply.statusCode).toBe(409);
// Reconciliation expires same-version staged jobs before the apply route
// can consume them, so the public response is the generic not-staged
// conflict while the database records the precise expiry reason.
expect(apply.json().error.code).toBe("UPDATE_NOT_STAGED");
expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(stagedId)).toEqual({
status: "failed",
errorMessage: "暂存更新已过期,当前版本无需再次升级",
});
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(status.statusCode).toBe(200);
expect(status.json().job).toBeNull();
});
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
@@ -161,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);
@@ -176,10 +225,46 @@ describe("更新 API", () => {
expect(ownDetail.json().job.errorMessage).toBe("更新失败,请查看服务器日志或重试");
});
it("取消任务按管理员隔离,并只删除匹配任务的请求文件", async () => {
const owner = await login("cancel-owner");
const other = await login("cancel-other");
const ownerId = (database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("cancel-owner") as { id: string }).id;
const otherId = (database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("cancel-other") as { id: string }).id;
const now = Date.now();
const ownerJobId = randomUUID();
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', '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);
await import("node:fs/promises").then(({ writeFile }) => writeFile(config.updateRequestPath, JSON.stringify({ jobId: otherJobId }), { encoding: "utf8", mode: 0o600 }));
const ownerCancel = await app.inject({
method: "POST", url: "/api/update/cancel",
headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf },
payload: { jobId: ownerJobId },
});
expect(ownerCancel.statusCode).toBe(200);
expect((database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(ownerJobId) as { status: string }).status).toBe("cancelled");
expect((database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(otherJobId) as { status: string }).status).toBe("queued");
expect(existsSync(config.updateRequestPath)).toBe(true);
const otherCancel = await app.inject({
method: "POST", url: "/api/update/cancel",
headers: { origin: config.publicOrigin, cookie: other.cookies, "x-csrf-token": other.csrf },
payload: {},
});
expect(otherCancel.statusCode).toBe(200);
expect((database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(otherJobId) as { status: string }).status).toBe("cancelled");
expect(existsSync(config.updateRequestPath)).toBe(false);
});
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.
@@ -204,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();
@@ -215,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}` }
@@ -227,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}`,
@@ -264,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;
@@ -291,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}` }
@@ -302,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
@@ -334,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);
+49 -28
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"),
@@ -286,8 +286,27 @@ describe("更新安全工具", () => {
expect(await readFile(path.join(config.currentLink, "dist", "marker"), "utf8")).toBe("new");
const row = database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(jobId);
expect(row).toEqual({ operation: "apply", status: "applying" });
finalizeUpdateJob(database.sqlite, jobId, "failed");
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
finalizeUpdateJob(database.sqlite, jobId, "failed", "健康检查失败(自定义)");
expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed", errorMessage: "健康检查失败(自定义)" });
finalizeUpdateJob(database.sqlite, jobId, "failed", "第二次 finalize 不应覆盖原消息");
expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed", errorMessage: "健康检查失败(自定义)" });
expect(database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE action='update.failed' AND target_id=?").get(jobId)).toEqual({ count: 1 });
const defaultJobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
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', '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 });
expect(() => finalizeUpdateJob(database.sqlite, completedJobId, "failed", "不能降级已完成任务")).toThrow("更新任务状态不允许完成");
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(completedJobId)).toEqual({ status: "completed" });
} finally {
globalThis.fetch = previousFetch;
if (database) database.sqlite.close();
@@ -323,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" });
@@ -362,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");
@@ -385,7 +404,7 @@ describe("更新安全工具", () => {
}
});
it("队列任务有新请求标记时可被重新检查,标记过期后才回收", async () => {
it("队列任务有匹配请求标记时保留到租约过期,过期后才回收", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-queued-marker-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
@@ -406,18 +425,20 @@ 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();
await utimes(config.updateRequestPath, new Date(now), new Date(now));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(1);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
await expect(stat(config.updateRequestPath)).rejects.toThrow();
// The DB row is old, but the request marker is fresh and names this
// exact job. Keep it queued while systemd has a chance to consume it.
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "queued" });
await expect(stat(config.updateRequestPath)).resolves.toBeTruthy();
const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1;
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(0);
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
await expect(stat(config.updateRequestPath)).rejects.toThrow();
} finally {
@@ -492,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 {
+3 -3
View File
@@ -32,9 +32,9 @@ function App() {
const logoutInFlight = useRef(false);
useDialogAccessibility();
const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => {
// The placement container owns the responsive right inset. Keeping the
// item offset at zero avoids pushing narrow-screen notices off canvas.
const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [24, 76] as [number, number], zIndex: 6000 };
// Keep notices in the lower-right safe area so they do not compete with
// the header controls or obscure the page title.
const options = { content: message, duration: 4200, placement: "bottom-right" as const, offset: [24, 24] as [number, number], zIndex: 6000 };
const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info;
void show(options);
}, []);
+221 -41
View File
@@ -248,29 +248,53 @@ export default function UpdatePage({
const announced = useRef<string | null>(null);
const checkInFlight = useRef(false);
const actionInFlight = useRef(false);
const cancelInFlight = useRef(false);
const loadInFlight = useRef(false);
const disconnected = useRef(false);
const recoveredNotice = useRef(false);
const info = liveInfo;
const load = async () => {
setLoading(true);
const mergeInfo = (incoming: UpdateInfo, preserveJob = false) => {
setLiveInfo((current) => {
if (!current) return incoming;
const next = {
...current,
...incoming,
// A check response describes the release, but must not erase the job
// that is already being displayed while that check is in flight.
job: preserveJob
? (current.job ?? incoming.job)
: (!Object.prototype.hasOwnProperty.call(incoming, "job") ? current.job : incoming.job),
};
// Some status/check responses are intentionally partial. Keep fields
// from the last successful response when a field is omitted.
if (!Object.prototype.hasOwnProperty.call(incoming, "latest")) next.latest = current.latest;
if (!Object.prototype.hasOwnProperty.call(incoming, "checkedAt")) next.checkedAt = current.checkedAt;
if (!Object.prototype.hasOwnProperty.call(incoming, "strategy")) next.strategy = current.strategy;
return next;
});
};
const load = async (showLoading = true): Promise<UpdateInfo | null> => {
if (loadInFlight.current) return null;
loadInFlight.current = true;
if (showLoading) setLoading(true);
setError("");
try {
const res = await api<UpdateInfo>("/api/update/status");
setLiveInfo(res);
mergeInfo(res);
updateInfoCache = res;
return res;
} catch (e) {
setError((e as Error).message);
return null;
} finally {
setLoading(false);
if (showLoading) setLoading(false);
loadInFlight.current = false;
}
};
useEffect(() => {
void load();
}, []);
// Keep terminal jobs visible so operators can understand what happened and
// recover without guessing. The polling effect below only polls active jobs.
const job = info?.job ?? null;
@@ -293,7 +317,8 @@ export default function UpdatePage({
return () => window.clearInterval(timer);
}, [restartAt]);
// Live polling effect: only poll when active job exists
// Live polling effect for an active job. Completed responses are refreshed
// once so latest/checkedAt/strategy stay current; other terminal states stay visible.
useEffect(() => {
const shouldPoll = Boolean(
job && (pollableStatuses.has(job.status) || (job.status === "staged" && job.operation === "apply"))
@@ -326,23 +351,53 @@ export default function UpdatePage({
setReloadReady(true);
notify?.("系统升级完成,请刷新页面", "success");
}
if (
if (result.job.status === "completed") {
void load(false);
} else if (
pollableStatuses.has(result.job.status) ||
(result.job.status === "staged" && result.job.operation === "apply")
) {
schedule(1500);
}
} catch {
} catch (caught) {
if (disposed) return;
const status = caught instanceof ApiError ? caught.status : 0;
const message = caught instanceof Error ? caught.message : "读取更新任务状态失败";
if (status === 404) {
setPollError("更新任务已结束,正在刷新状态。");
void load(false);
return;
}
if (status === 401) {
setPollError("登录已失效,请重新登录。");
return;
}
if (status === 403) {
setPollError("没有权限读取更新任务状态。");
return;
}
failures += 1;
disconnected.current = true;
disconnected.current = status === 0 || status === 408;
recoveredNotice.current = false;
setPollError(
`服务正在平滑重启${
restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面正在自动探活重试"
}。升级任务仍在后台安全执行。`
);
schedule(Math.min(1500 * 2 ** Math.min(failures, 3), 10_000));
if (status === 409) {
setPollError(`${message},正在刷新状态。`);
void load(false);
return;
}
if (status === 429) {
setPollError(`${message},稍后继续同步。`);
} else if (status === 408) {
setPollError("读取更新任务状态超时,正在重试。");
} else if (status === 0) {
setPollError("更新服务连接异常,正在重试。");
} else {
setPollError(`${message},正在重试。`);
}
const retryAfter = caught instanceof ApiError && caught.retryAfter
? Math.max(1000, caught.retryAfter * 1000)
: Math.min(1500 * 2 ** Math.min(failures, 3), 10_000);
schedule(retryAfter);
}
};
void poll();
@@ -352,6 +407,42 @@ export default function UpdatePage({
};
}, [job?.id, job?.status, job?.operation, notify]);
// With no visible job, make a low-frequency status request so a task created
// elsewhere can still appear without creating a request storm.
useEffect(() => {
if (job) return;
let timer: number | undefined;
let disposed = false;
const discover = () => {
if (disposed || document.visibilityState !== "visible") return;
void load(false);
};
const schedule = () => {
if (disposed) return;
if (timer !== undefined) window.clearTimeout(timer);
timer = window.setTimeout(() => {
discover();
schedule();
}, 5000);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
discover();
schedule();
} else if (timer !== undefined) {
window.clearTimeout(timer);
timer = undefined;
}
};
if (document.visibilityState === "visible") schedule();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
disposed = true;
if (timer !== undefined) window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [job?.id, job?.status, job?.operation]);
// Check update handler
const check = async () => {
if (checkInFlight.current) return;
@@ -359,7 +450,17 @@ export default function UpdatePage({
setChecking(true);
setError("");
try {
setLiveInfo(await api<UpdateInfo>("/api/update/check", { method: "POST" }));
const result = await api<UpdateInfo>("/api/update/check", { method: "POST" });
// The check endpoint returns release metadata but not task state. Read
// the status endpoint once more so reconciliation performed before the
// check is authoritative: an expired staged task must disappear from
// this page immediately instead of surviving until a full refresh.
const status = await api<UpdateInfo>("/api/update/status");
setLiveInfo((current) => ({
...(current ?? result),
...result,
job: status.job,
}));
notify?.("版本检查完成", "info");
} catch (e) {
if (e instanceof ApiError && e.status === 429) {
@@ -373,9 +474,28 @@ export default function UpdatePage({
}
};
useEffect(() => {
let disposed = false;
const bootstrap = async () => {
const snapshot = await load();
if (disposed || !snapshot) return;
// Status may be satisfied from the release cache. Refresh it on entry
// only when the cached result is absent or older than one minute; this
// keeps the page current without turning navigation into a burst of
// rate-limited checks.
const checkedAt = snapshot.checkedAt || 0;
if (!checkedAt || !snapshot.latest || Date.now() - checkedAt > 60_000) await check();
};
void bootstrap();
return () => {
disposed = true;
};
}, []);
// Cancel queued job handler
const cancelJob = async () => {
if (cancelling) return;
if (cancelInFlight.current || !job?.id) return;
cancelInFlight.current = true;
setCancelling(true);
try {
await api("/api/update/cancel", { method: "POST", body: JSON.stringify({ jobId: job?.id }) });
@@ -387,6 +507,7 @@ export default function UpdatePage({
notify?.((e as Error).message, "error");
} finally {
setCancelling(false);
cancelInFlight.current = false;
}
};
@@ -443,9 +564,20 @@ export default function UpdatePage({
const latest = info?.latest;
const notes = notesFor(latest);
const hasChecked = Boolean(info?.checkedAt);
const releaseState = checking
? "checking"
: !hasChecked
? "unverified"
: !latest
? "unavailable"
: latest.isNewer
? latest.compatible && latest.integrityReady ? "available" : "blocked"
: "up-to-date";
const canDownload = Boolean(
info?.strategy === "systemd" &&
!checking &&
latest?.isNewer &&
latest.compatible &&
latest.integrityReady &&
@@ -454,14 +586,19 @@ export default function UpdatePage({
const canApply = Boolean(
info?.strategy === "systemd" &&
!checking &&
job &&
job.status === "staged" &&
job.operation === "download"
job.operation === "download" &&
latest?.isNewer &&
latest.version === job.version &&
job.version !== info.currentVersion
);
const hasActiveJob = Boolean(
job && activeStatuses.has(job.status) && job.status !== "staged"
job && activeStatuses.has(job.status) && !(job.status === "staged" && job.operation === "download")
);
const showJobDetails = hasActiveJob || canApply || Boolean(job?.status === "staged" && job.operation === "apply");
// Compute current pipeline step index (0: check, 1: download, 2: verify/stage, 3: apply/restart)
const currentStep = useMemo(() => {
@@ -487,7 +624,7 @@ export default function UpdatePage({
if (!job) return 0;
if (job.status === "completed" || job.status === "staged") return 100;
if (job.status === "downloading") {
if (!job.sizeBytes || !job.downloadedBytes) return 10;
if (!job.sizeBytes || !job.downloadedBytes) return 0;
return Math.min(99, Math.max(1, Math.round((job.downloadedBytes / job.sizeBytes) * 100)));
}
if (job.status === "verifying") return 99;
@@ -518,7 +655,7 @@ export default function UpdatePage({
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
actions={
<div className="tn-update-page-actions">
{hasActiveJob && (
{showJobDetails && (
<Button
theme="primary"
variant="base"
@@ -589,10 +726,18 @@ export default function UpdatePage({
<span className="tn-metric-label">当前运行版本</span>
<div className="tn-metric-value">v{info.currentVersion}</div>
<div className="tn-metric-foot">
{latest?.isNewer ? (
<Tag theme="primary" size="small">可更新至 v{latest.version}</Tag>
) : (
{releaseState === "checking" ? (
<Tag theme="default" size="small">正在检查更新</Tag>
) : releaseState === "unverified" ? (
<Tag theme="default" size="small">尚未检查更新</Tag>
) : releaseState === "available" ? (
<Tag theme="primary" size="small">可更新至 v{latest?.version}</Tag>
) : releaseState === "up-to-date" ? (
<Tag theme="success" size="small">已是最新版本</Tag>
) : releaseState === "blocked" ? (
<Tag theme="warning" size="small">发现新版本,但暂不可更新</Tag>
) : (
<Tag theme="default" size="small">暂未获取发布信息</Tag>
)}
</div>
</Surface>
@@ -631,8 +776,8 @@ export default function UpdatePage({
<Surface className="tn-ascii-release-container">
<div className="tn-ascii-release-head">
<h3 className="tn-ascii-release-title">发布版本详情</h3>
<Tag theme={latest.isNewer ? "primary" : "success"} variant="light-outline">
{latest.isNewer ? "发现新版本" : "已是最新版本"}
<Tag theme={releaseState === "available" ? "primary" : releaseState === "up-to-date" ? "success" : releaseState === "blocked" ? "warning" : "default"} variant="light-outline">
{releaseState === "available" ? "发现新版本" : releaseState === "up-to-date" ? "已是最新版本" : releaseState === "blocked" ? "暂不可安全更新" : "尚未检查"}
</Tag>
</div>
@@ -684,7 +829,7 @@ export default function UpdatePage({
disabled={actionBusy}
icon={<Download size={16} />}
>
立即升级至 v{latest.version}
下载更新包
</Button>
)}
{canApply && (
@@ -695,7 +840,7 @@ export default function UpdatePage({
disabled={actionBusy}
icon={<Zap size={16} />}
>
更新包已就绪,立即应用 (v{latest.version})
立即应用并重启 v{latest.version}
</Button>
)}
{hasActiveJob && (
@@ -708,9 +853,14 @@ export default function UpdatePage({
查看当前升级进度
</Button>
)}
{!latest.isNewer && !hasActiveJob && (
{releaseState === "blocked" && !hasActiveJob && !canApply && (
<Button theme="default" variant="outline" size="large" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
重新检查
</Button>
)}
{releaseState === "up-to-date" && !hasActiveJob && !canApply && (
<Button theme="default" variant="outline" size="large" onClick={() => void check()} icon={<RefreshCw size={15} />}>
检查新版本
重新检查
</Button>
)}
</div>
@@ -718,10 +868,10 @@ export default function UpdatePage({
) : (
<Surface className="tn-empty-surface">
<div className="tn-empty-content">
<CheckCircle2 size={32} className="text-success" />
<p>暂无待更新的版本信息,当前系统已是最新状态。</p>
<Button variant="outline" onClick={() => void check()} icon={<RefreshCw size={15} />}>
检查新版本
{releaseState === "unverified" || releaseState === "checking" ? <RefreshCw size={32} className={releaseState === "checking" ? "tn-spin" : "text-secondary"} /> : <AlertCircle size={32} className="text-warning" />}
<p>{releaseState === "unverified" || releaseState === "checking" ? "尚未完成版本检查,请先获取官方发布信息。" : "暂时没有可用的发布信息,请稍后重新检查。"}</p>
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
{releaseState === "checking" ? "正在检查" : "检查新版本"}
</Button>
</div>
</Surface>
@@ -730,7 +880,7 @@ export default function UpdatePage({
{latest && notes && (
<Surface className="tn-ascii-notes-container">
<div className="tn-ascii-notes-head">
<h3 className="tn-ascii-notes-title">本次版本更新说明</h3>
<h3 className="tn-ascii-notes-title">发布说明</h3>
</div>
<div className="tn-ascii-notes-body">
<MarkdownNotes value={notes} />
@@ -755,7 +905,7 @@ export default function UpdatePage({
{/* Unified Single Upgrade Modal (800px width on desktop) */}
<Dialog
visible={showUpgradeModal}
header={`系统升级控制台 · v${latest?.version || ""}`}
header={`系统升级控制台 · v${latest?.version || job?.version || ""}`}
className="tn-dialog-large"
width="820px"
footer={null}
@@ -887,7 +1037,7 @@ export default function UpdatePage({
)}
{/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */}
{job?.status === "staged" && (
{job?.status === "staged" && job.operation === "download" && canApply && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
<CheckCircle2 size={18} />
@@ -915,6 +1065,36 @@ export default function UpdatePage({
</div>
)}
{/* A staged archive can become obsolete when the host or release
metadata changes while this page is open. Keep the state visible
but remove the apply action; the server will reconcile it on the
next status request and the operator can perform a fresh check. */}
{job?.status === "staged" && job.operation === "download" && !canApply && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title">暂存更新已失效</div>
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
这个更新包已不是当前可安全应用的版本,系统不会重复应用。请重新检查更新以获取最新发布信息。
</p>
<div className="tn-modal-actions-bar">
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking}>
重新检查
</Button>
</div>
</div>
)}
{job?.status === "staged" && job.operation === "apply" && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title">
<div style={{ marginBottom: 12 }}><BeamBar width={180} /></div>
应用请求已提交,正在等待更新服务接管
</div>
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
系统正在准备备份与重启。页面会持续同步服务状态,请不要重复提交。
</p>
</div>
)}
{/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */}
{(job?.status === "backing_up" || job?.status === "applying") && (
<div className="tn-modal-card-box">
@@ -976,7 +1156,7 @@ export default function UpdatePage({
)}
{/* Footer close button */}
{job?.status !== "staged" && job?.status !== "completed" && !confirmReadyToDownload && (
{!(job?.status === "staged" && canApply) && job?.status !== "completed" && !confirmReadyToDownload && (
<div className="tn-modal-footer-close">
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
关闭窗口(后台继续运行)
+4 -4
View File
@@ -115,14 +115,14 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
box-sizing: border-box !important;
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.t-notification__show--top-right {
top: 76px !important;
.t-notification__show--bottom-right {
bottom: 24px !important;
right: 24px !important;
z-index: 6000 !important;
}
@media (max-width: 768px) {
.t-notification__show--top-right {
top: 16px !important;
.t-notification__show--bottom-right {
bottom: 16px !important;
right: 16px !important;
left: 16px !important;
width: auto !important;
+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[]>([]);