Compare commits

...
51 Commits
Author SHA1 Message Date
Qiufeng 511fc5d785 release: 1.3.3
TallyNote release / linux-x64 (push) Successful in 6m40s
2026-09-11 08:21:34 +08:00
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
Qiufeng a080f531cd release: 1.2.3
TallyNote release / linux-x64 (push) Successful in 6m47s
2026-09-05 10:31:29 +08:00
Qiufeng 1e87f25c2b fix: remove update page mock controls 2026-09-05 10:22:25 +08:00
Qiufeng 4c71861813 fix: prevent duplicate update submissions
TallyNote release / linux-x64 (push) Successful in 6m47s
2026-09-05 10:08:49 +08:00
Qiufeng 3a9f809f46 fix: show update rate limits as toast
TallyNote release / linux-x64 (push) Successful in 6m42s
2026-09-05 09:39:10 +08:00
Qiufeng de45c4b20c fix: keep release workflow runner-compatible
TallyNote release / linux-x64 (push) Successful in 6m38s
2026-09-05 09:04:09 +08:00
Qiufeng cc9e897260 fix: correct release workflow version gate 2026-09-05 09:02:48 +08:00
Qiufeng 620362823b release: 1.2.0
TallyNote release / linux-x64 (push) Failing after 13s
2026-09-05 08:42:47 +08:00
Qiufeng fa2fd94579 feat: 全量切换为完整安装包流式下载、彻底废除增量差分包、全流程实时进度可见
TallyNote release / linux-x64 (push) Successful in 7m51s
2026-09-04 22:58:24 +08:00
Qiufeng 05a679c2c8 fix: 补全第三步校验与准备场景卡片消除空白、优化增量文件就绪内核加速
TallyNote release / linux-x64 (push) Successful in 7m21s
2026-09-04 22:27:33 +08:00
Qiufeng 23e2f9c5e7 refactor: 移除安装包下载直链板块与代码块裸露链接、回归纯净专业看板布局
TallyNote release / linux-x64 (push) Successful in 7m14s
2026-09-04 21:29:11 +08:00
Qiufeng 484881b410 fix: 修复更新下载请求缺少确认参数导致报错、增加弹窗内嵌显式错误条、统一全站通知弹窗右上角对齐
TallyNote release / linux-x64 (push) Successful in 7m23s
2026-09-04 20:27:17 +08:00
Qiufeng 32f768c8ee perf: 页面切换零延迟渲染、消除动态 chunk 加载白屏与二次 loading 闪烁、重构微滑淡入过渡
TallyNote release / linux-x64 (push) Failing after 9m14s
2026-09-04 17:29:52 +08:00
Qiufeng db37406498 fix: 修复极光光流条未声明变量导致透明静止、重构流光动效为显式光晕与平滑位移
TallyNote release / linux-x64 (push) Successful in 20m31s
2026-09-04 17:14:40 +08:00
Qiufeng 2accca9a22 chore(release): 1.1.36 - 应用内流式直连下载、透明化直链与排队卡死彻底修复
TallyNote release / linux-x64 (push) Successful in 6m21s
2026-09-04 16:51:43 +08:00
Qiufeng c791dc4915 chore(release): 1.1.35 - 系统更新看板化重构、消除弹窗抖动与排队卡死
TallyNote release / linux-x64 (push) Successful in 6m27s
2026-09-04 15:18:39 +08:00
Qiufeng b46a7ddc87 fix: 优化电脑端弹窗尺寸并彻底修复系统更新排队调度卡死问题
TallyNote release / linux-x64 (push) Successful in 6m46s
2026-09-04 14:24:54 +08:00
Qiufeng 1ecb783d0c chore(release): 1.1.33 - 全面重塑系统商务与专业化文案
TallyNote release / linux-x64 (push) Successful in 7m1s
2026-09-04 14:01:58 +08:00
Qiufeng 60c0519ac7 fix: run release tests in one thread
TallyNote release / linux-x64 (push) Successful in 7m50s
2026-09-04 13:19:34 +08:00
Qiufeng 32a73c5b50 release: 1.1.31 with detailed release notes
TallyNote release / linux-x64 (push) Failing after 9m7s
2026-09-04 13:06:44 +08:00
Qiufeng 45de0ef759 fix: use stable vitest thread pool in releases
TallyNote release / linux-x64 (push) Failing after 8m51s
2026-09-04 12:55:17 +08:00
Qiufeng 65b5d95937 fix: omit empty release note sections
TallyNote release / linux-x64 (push) Failing after 8m51s
2026-09-04 12:42:45 +08:00
Qiufeng 89a8edad88 fix: stabilize release test workers
TallyNote release / linux-x64 (push) Successful in 8m25s
2026-09-04 12:15:29 +08:00
Qiufeng 283c1d77b4 fix: generate detailed markdown release notes
TallyNote release / linux-x64 (push) Failing after 8m37s
2026-09-04 10:43:45 +08:00
Qiufeng f060f917a0 release: 1.1.26
TallyNote release / linux-x64 (push) Successful in 6m41s
2026-09-04 01:13:42 +08:00
Qiufeng 704740182a fix: hide stale update failures on status load 2026-09-04 01:08:46 +08:00
Qiufeng a61860fcb3 refactor: move update pipeline into dialog 2026-09-04 01:02:12 +08:00
Qiufeng 340d9b5245 feat: add lightweight application updates
TallyNote release / linux-x64 (push) Failing after 8m43s
2026-09-03 23:32:16 +08:00
Qiufeng 5d02fa5769 fix: simplify update metrics
TallyNote release / linux-x64 (push) Successful in 7m2s
2026-09-03 21:58:37 +08:00
Qiufeng 526b2df8ea feat: refine update center and release notes
TallyNote release / linux-x64 (push) Successful in 6m55s
2026-09-03 21:31:39 +08:00
Qiufeng 4f9629b089 fix: allow reverse proxy login
TallyNote release / linux-x64 (push) Successful in 6m56s
2026-09-03 15:27:10 +08:00
Qiufeng 36c2ed1361 fix: show completed download progress
TallyNote release / linux-x64 (push) Successful in 7m0s
2026-09-03 15:02:44 +08:00
Qiufeng 755b82d2e5 chore: align package version with v1.1.21
TallyNote release / linux-x64 (push) Successful in 7m27s
2026-09-03 14:55:38 +08:00
Qiufeng 0bdc812935 fix: support proxied origins and update progress
TallyNote release / linux-x64 (push) Failing after 11s
2026-09-03 14:54:30 +08:00
Qiufeng 2de08f1358 chore: bump release to 1.1.20
TallyNote release / linux-x64 (push) Successful in 6m45s
2026-09-03 13:42:57 +08:00
Qiufeng 91621df6c4 fix: recover stuck update queue
TallyNote release / linux-x64 (push) Successful in 6m34s
2026-09-03 12:39:44 +08:00
Qiufeng 0690fe298c fix: initialize optional origin port
TallyNote release / linux-x64 (push) Successful in 6m49s
2026-09-03 08:22:46 +08:00
Qiufeng 6a0d9e34dd fix: make admin wrapper fixture portable in CI
TallyNote release / linux-x64 (push) Failing after 3m6s
2026-09-03 08:10:52 +08:00
Qiufeng 77598ecc81 fix: make installer gate portable in root CI
TallyNote release / linux-x64 (push) Failing after 3m5s
2026-09-03 08:02:48 +08:00
Qiufeng 69a4b482ec fix: prevent stuck background updates
TallyNote release / linux-x64 (push) Failing after 2m51s
2026-09-03 07:51:35 +08:00
49 changed files with 19527 additions and 419 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
+12 -2
View File
@@ -17,6 +17,10 @@ jobs:
steps:
- name: Checkout tag
uses: actions/checkout@v4
with:
# Release notes are derived from the previous version tag. A shallow
# checkout would leave only the synthetic release commit available.
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -26,10 +30,16 @@ jobs:
- name: Verify tag and test gate
run: |
set -euo pipefail
test "$(node -p 'require("./package.json").version')" = "${GITHUB_REF_NAME#v}"
target_version="${GITHUB_REF_NAME#v}"
package_version="$(node -p 'require("./package.json").version')"
test "$package_version" = "$target_version"
pnpm install --frozen-lockfile
pnpm check
pnpm test
# better-sqlite3 is a native addon; a single Vitest worker avoids a
# Node cleanup race observed on the hosted runner while preserving
# the complete test suite.
pnpm test -- --pool=threads --poolOptions.threads.singleThread=true
pnpm test:installer
- name: Build Linux release
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
- name: Create and publish Gitea Release
+11 -3
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 状态和最近日志并回滚本次切换。
@@ -140,7 +146,7 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
公网反代推荐使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。反代只需把域名转发到 TallyNote 端口并保留 `Host`、`X-Forwarded-Proto`;应用不会因为代理缺少或改写浏览器 `Origin` 而拦截登录。已认证写请求仍使用会话 Cookie 与 CSRF 令牌保护。
### 构建发布包
@@ -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 -->
File diff suppressed because one or more lines are too long
+256
View File
@@ -0,0 +1,256 @@
{
"schema_version": 1,
"diagram_type": "workflow",
"meta": {
"title": "TallyNote 平滑更新与应用内直连下载流程",
"subtitle": "告别外部守护等待 · 应用进程直连流式下载 · 原子热切换",
"output": "artifacts/tallynote-update-workflow.html",
"animation": "trace",
"quality_profile": "showcase",
"views": [
{
"id": "stream-download",
"label": "应用内流式下载",
"focus": [
"ui_render",
"stream_worker",
"verify_sha"
],
"note": "Web 进程 0 延时直连 Gitea 流式拉取并比对哈希,彻底废除外部 systemd.path 调度等待。"
},
{
"id": "atomic-switch",
"label": "原子切换与秒级恢复",
"focus": [
"ui_ready",
"atomic_switch",
"health_probe",
"ui_refreshed"
],
"note": "包就绪后秒级原子切换 current 软链接,30s 倒计时探活自动无缝恢复。"
}
]
},
"lanes": [
{
"id": "ui",
"label": "管理控制台 (前端 UI)"
},
{
"id": "app",
"label": "Web 应用后端 (Node.js)"
},
{
"id": "system",
"label": "系统底层与运行时 (Linux / systemd)"
},
{
"id": "git",
"label": "Gitea 官方源 (HTTPS)"
}
],
"phases": [
{
"id": "phase_check",
"label": "版本发现",
"fromCol": 0,
"toCol": 1
},
{
"id": "phase_download",
"label": "直连下载与校验",
"fromCol": 2,
"toCol": 3,
"variant": "emphasis"
},
{
"id": "phase_apply",
"label": "原子切换与自愈",
"fromCol": 4,
"toCol": 5,
"variant": "dashed"
}
],
"groups": [
{
"id": "grp_stream",
"label": "应用内直接流式拉取 (无外部阻塞)",
"lane": "app",
"fromCol": 2,
"toCol": 3,
"variant": "emphasis"
}
],
"mainPath": [
"ui_check",
"api_check",
"git_source",
"ui_render",
"stream_worker",
"verify_sha",
"ui_ready",
"atomic_switch",
"health_probe",
"ui_refreshed"
],
"nodes": [
{
"id": "ui_check",
"lane": "ui",
"col": 0,
"type": "frontend",
"label": "检查更新",
"sublabel": "点击查询新版"
},
{
"id": "api_check",
"lane": "app",
"col": 0,
"type": "backend",
"label": "查询 Release",
"sublabel": "只读接口校验",
"tag": "只读"
},
{
"id": "git_source",
"lane": "git",
"col": 1,
"type": "external",
"label": "Gitea 官方源",
"sublabel": "返回最新元数据",
"tag": "HTTPS"
},
{
"id": "ui_render",
"lane": "ui",
"col": 1,
"type": "frontend",
"label": "版本看板呈现",
"sublabel": "日志与升级入口"
},
{
"id": "stream_worker",
"lane": "app",
"col": 2,
"type": "backend",
"label": "流式拉取",
"sublabel": "应用直连下载",
"tag": "实时进度"
},
{
"id": "verify_sha",
"lane": "app",
"col": 3,
"type": "security",
"label": "SHA-256 校验",
"sublabel": "比对并解压",
"tag": "完整性"
},
{
"id": "ui_ready",
"lane": "ui",
"col": 3,
"type": "frontend",
"label": "确认重启",
"sublabel": "更新包已就绪"
},
{
"id": "atomic_switch",
"lane": "system",
"col": 4,
"type": "cloud",
"label": "原子切换",
"sublabel": "切换软链接重载"
},
{
"id": "health_probe",
"lane": "app",
"col": 5,
"type": "backend",
"label": "健康探测探针",
"sublabel": "轮询探活至 200"
},
{
"id": "ui_refreshed",
"lane": "ui",
"col": 5,
"type": "frontend",
"label": "平滑上线刷新",
"sublabel": "自动进入新版本"
}
],
"edges": [
{
"id": "e1",
"from": "ui_check",
"to": "api_check"
},
{
"id": "e2",
"from": "api_check",
"to": "git_source"
},
{
"id": "e3",
"from": "git_source",
"to": "ui_render",
"channelX": 250
},
{
"id": "e4",
"from": "ui_render",
"to": "stream_worker"
},
{
"id": "e5",
"from": "stream_worker",
"to": "verify_sha"
},
{
"id": "e6",
"from": "verify_sha",
"to": "ui_ready"
},
{
"id": "e7",
"from": "ui_ready",
"to": "atomic_switch"
},
{
"id": "e8",
"from": "atomic_switch",
"to": "health_probe"
},
{
"id": "e9",
"from": "health_probe",
"to": "ui_refreshed"
}
],
"cards": [
{
"dot": "emerald",
"title": "核心升级点:消除外部调度依赖",
"items": [
"传统模式:Web 写入 JSON 队列,傻等外部 root 守护进程监听唤醒,导致常态化卡死在等待系统调度",
"新模式:Web 后端进程直接建立 HTTPS 流式管道下载,0 秒立即响应,进度条真实可见"
]
},
{
"dot": "cyan",
"title": "透明化监控与错误拦截",
"items": [
"网络层直抓:DNS 失败、超时或 404 当场捕获,前端弹窗直接展示错误详情与重试按钮",
"进度实时计算:每 500ms 计算下载字节与传输速率(MB/s),无感后台拉取"
]
},
{
"dot": "violet",
"title": "平滑原子切换与自愈",
"items": [
"文件完整校验后再切换软链接,绝不损坏现有运行中的实例",
"前端 30 秒倒计时探针自动检测服务就绪,服务重启完毕自动恢复会话"
]
}
]
}
+4 -2
View File
@@ -12,6 +12,8 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
发布脚本会根据当前 tag 与上一个版本 tag 之间的真实 Git 提交自动生成 Release 正文,按“新增功能、问题修复、优化与重构、文档与测试”分类,并以 Markdown 写入 Gitea。Gitea 页面会渲染这些标题和列表;更新中心读取同一份正文后再进行安全的 Markdown 子集渲染,不会显示 Markdown 源代码。旧版本曾使用单行占位正文 `TallyNote <版本>`,新版本发布时不会再使用该占位内容。
在仓库的 Actions secrets 配置:
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
@@ -28,7 +30,7 @@ GITEA_TOKEN=... \
./scripts/publish-gitea-release.sh v1.1.2 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。构建脚本会同时生成完整安装包和轻量更新包:`tallynote-1.1.2-linux-x64-glibc.tar.gz` 用于首次安装,`tallynote-1.1.2-linux-x64-glibc.update-<锁文件 SHA256>.tar.gz` 仅用于复用现有运行时的后台更新。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
## curl 安装
@@ -104,7 +106,7 @@ sudo /usr/local/sbin/tallynote-uninstall
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
+303
View File
@@ -0,0 +1,303 @@
# 在线更新重构方案
## 一、问题背景
当前在线更新使用 4 次进程交接链路:
```
web 进程 → 写 update-request.json → tallynote-update.path 触发
→ tallynote-update.service → tallynote-update-runner.sh (root)
→ 下载 + 校验 + 暂存 + 停服 + 备份 + 切换 + 重启 + 健康检查
```
下载在 root runner 中执行,前端只能轮询 DB 状态,看不到实时进度。
多次出现"等待系统调度"卡死,根因是链路中任一环节出错都会断链。
## 二、目标
将下载移入 web 进程同步执行,root runner 只负责特权应用(停服/备份/切换/重启)。
链路从 4 次交接缩减为 1 次。
## 三、当前架构(需改动的文件清单)
| 文件 | 行数 | 职责 | 改动级别 |
|---|---|---|---|
| server/update-service.ts | ~420 | checkForUpdate, writeUpdateRequest, reconcileOrphanedUpdateJobs, cancelUpdateJob, publicUpdateJob | 大改 |
| server/update.ts | ~300 | fetchReleaseMetadata, fetchReleaseBytes, selectReleaseAsset, validateHttpsUrl | 小改 |
| server/app.ts (930-1230) | ~300 | 6 个 API 路由 | 大改 |
| scripts/tallynote-update-runner.sh | ~200 | root runner: flock+心跳+恢复+下载+校验+暂存+应用 | 大改 |
| scripts/tallynote-update.sh | ~100 | 手动更新/回滚入口 | 小改 |
| systemd/tallynote-update.service | ~30 | oneshot root 服务 | 小改 |
| systemd/tallynote-update.path | ~20 | 监听请求文件触发 | 不变 |
| web/src/main.tsx (697-790) | ~90 | UpdateCenter 组件 | 大改 |
| shared/contracts.ts (85-100) | ~15 | UpdateJobStatus 枚举 | 小改 |
| server/db/schema.ts (134-163) | ~30 | update_jobs 表 | 不变 |
| server/config.ts | ~100 | TALLYNOTE_UPDATE_* 配置 | 小改 |
| tests/update-api.test.ts | ~450 | 更新 API 测试 | 大改 |
## 四、改动后的架构
```
用户点"下载更新包"
↓
web 进程 (tallynote 用户, 非 root)
├── 创建 job 行 (status=downloading)
├── HTTPS 流式下载归档到 /var/lib/tallynote/staging/update-<jobId>.tar.gz
├── 边下载边更新 DB: downloadedBytes, downloadSpeedBps
├── 下载完成 → SHA-256 校验 → status=staged
└── 写 update-request.json (operation=apply, 含暂存路径)
↓
tallynote-update.path 触发 → tallynote-update.service (root)
├── 读请求文件
├── 停服 → 备份 → 原子切换 → 重启 → 健康检查
└── 更新 DB: status=completed/failed
```
## 五、详细代码修改
### 5.1 server/update-service.ts
**新增函数:**
```ts
// 同步下载归档,流式写入暂存目录,实时更新 DB 进度
export async function downloadReleaseAsset(
database: Database.Database,
config: AppConfig,
jobId: string,
assetUrl: string,
expectedSha256: string,
assetName: string,
): Promise<{ actualSha256: string; sizeBytes: number; downloadPath: string }>;
```
逻辑:
- 用 fetchReleaseBytes (已存在于 update.ts) 发起 HTTPS 请求
- 创建可写流到 config.dataDir/staging/update-<jobId>.tar.gz (tallynote 用户可写)
- pipeline(response.body → createHash('sha256') → fileStream),边算 hash 边写盘
- 每秒更新 DB: downloadedBytes, downloadSpeedBps, status=downloading
- 完成后比对 expectedSha256 vs actualSha256,不匹配 → status=failed
- 匹配 → status=staged, 写 downloadPath 到 DB
- 然后写 update-request.json (operation=apply)
**修改函数:**
- `reconcileOrphanedUpdateJobs`: 保留,但 queued 状态不再出现(下载在 web 进程内)
- `publicUpdateJob`: 保留,已支持 downloadedBytes/downloadSpeedBps 字段
- `cancelUpdateJob`: 增加 abort 下载流的能力
- `writeUpdateRequest`: 增加 stagedPath 字段传递暂存文件路径
**删除/简化:**
- QUEUED_UPDATE_TIMEOUT_MS 逻辑不再需要(下载不在 systemd 队列中等待)
### 5.2 server/app.ts — API 路由修改
**POST /api/update/download → 改为同步下载**
当前:创建 job → 写请求文件 → 返回 202
改为:
1. 创建 job (status=downloading)
2. 在请求处理函数内同步执行 downloadReleaseAsset
3. 下载完成后写 apply 请求文件
4. 返回 { job: { status: "staged", ... } }
5. 如果下载中客户端断开,设置 AbortController 取消下载
注意:Fastify 请求超时需配置为足够长(115MB / 最低网速)。设置路由级
bodyLimit=0 (不读 body) 并配置 reply 的 connectionTimeout。
**新增 SSE 端点:GET /api/update/progress**
返回 Server-Sent Events 流,推送实时下载进度:
```
event: progress
data: {"downloadedBytes": 12345678, "speedBps": 5242880, "sizeBytes": 120586240}
```
前端用 EventSource 监听。下载完成后关闭 SSE。
**POST /api/update/apply — 不变**
仍然读请求文件触发 root runner。
**GET /api/update/status — 不变**
仍然返回 job 状态。
### 5.3 scripts/tallynote-update-runner.sh
**删除:**
- 下载逻辑 (约 80 行)
- 校验 SHA-256 逻辑 (约 30 行)
- 暂存逻辑
- 心跳 (heartbeat) — 下载不再在 root 中,apply 很快不需要心跳
- QUEUED 状态处理
**保留:**
- flock 锁
- 恢复状态 (.update-state) — apply 阶段仍需要
- 停服 → 备份 → 原子切换 → 重启 → 健康检查
- 回滚逻辑
**简化后:** runner 只做 apply:读暂存路径 → 停服 → 备份 → 切换 → 启动 → 健康检查
约从 200 行缩减到 80 行。
### 5.4 scripts/tallynote-update.sh
手动入口不变,但 runner 已不下载,所以手动入口也跳过下载阶段。
`--rollback` 逻辑完全不变。
### 5.5 systemd/tallynote-update.service
```ini
# 简化:不再需要 32 分钟超时(无下载阶段)
TimeoutStartSec=5min
# 其余安全约束不变
```
### 5.6 systemd/tallynote-update.path
不变。仍然监听 update-request.json 触发 runner。
但请求文件的 operation 现在只有 "apply"。
### 5.7 web/src/main.tsx — UpdateCenter 组件
**当前流程(前端):**
1. 进入页面 → GET /api/update/status
2. 点"检查更新" → POST /api/update/check
3. 点"更新到 vX.X.X" → POST /api/update/download → 轮询 /api/update/jobs/:id
4. staged 后 → POST /api/update/apply → 轮询
5. completed → 显示"重新加载"
**改为:**
1. 进入页面 → GET /api/update/status(自动检查最新版本)
2. 点"检查更新" → POST /api/update/check
3. 点"下载更新包" → POST /api/update/download(同步)
- 同时打开 EventSource(/api/update/progress) 监听实时进度
- 显示:下载进度条 + 已下载/总量 + 网速 + 剩余时间
- 下载完成 → 自动切换到"立即更新"按钮
4. 点"立即更新" → POST /api/update/apply
- 弹窗显示:正在应用更新 → 倒计时 → 自动重连
5. 重连成功 → 显示"更新完成" + 版本号变化
**UI 状态机:**
```
idle → checking → hasUpdate
→ downloading (实时进度, 可取消)
→ verifying (校验中, 短暂)
→ staged (显示"立即更新"按钮)
→ applying (倒计时弹窗)
→ completed (显示"重新加载")
→ failed (显示错误 + 重试)
```
**取消下载:** 下载中显示"取消"按钮 → POST /api/update/cancel → abort 流
### 5.8 shared/contracts.ts
UpdateJobStatus 不变(仍包含所有状态)。
新增 downloadProgress 的事件类型定义。
### 5.9 server/config.ts
新增:
- `stagingDir`: path.join(dataDir, "staging") — 暂存目录
- `updateDownloadTimeoutMs`: 下载超时 (默认 10 分钟)
### 5.10 tests/update-api.test.ts
重写下载测试:
- mock HTTPS 响应,验证流式下载 + SHA-256 校验
- 验证下载进度写入 DB
- 验证下载完成后写 apply 请求文件
- 验证取消下载清理暂存文件
- apply 测试不变
## 六、不修改的部分
- 后端 API 契约语义不变(check/apply/cancel/status 接口签名不变)
- update_jobs 表结构不变
- 数据目录布局不变
- 安装/卸载逻辑不变
- 权限语义不变(web 非 root, runner root)
- SHA-256 强制校验不变
- 原子切换 + 自动回滚不变
- 版本号比较逻辑不变
- Release 元数据获取逻辑不变
## 七、向后兼容
- 旧版本安装(v1.2.9 及之前)升级到新版本后:
- 已有的 systemd 单元仍能工作
- 如果有遗留的 queued 状态 job,reconcileOrphanedUpdateJobs 会清理
- runner 简化后仍能处理 apply 请求
- 数据库迁移:不需要(表结构不变)
- 请求文件格式:增加 stagedPath 字段,旧 runner 忽略未知字段
## 八、验收标准
### 功能验收
1. 进入更新页面 → 自动检查最新版本 → 显示 Release 信息
2. 点"下载更新包" → 实时显示进度条、已下载字节数、网速
3. 下载完成 → 自动校验 SHA-256 → 显示"立即更新"
4. 点"立即更新" → 弹窗倒计时 → 服务重启 → 自动重连 → 显示新版本号
5. 更新失败 → 显示错误 → 可重试
6. 下载中可取消 → 暂存文件清理干净
7. 不出现"等待系统调度"状态
8. 不显示直链下载地址
9. 更新日志 markdown 正确渲染
10. 通知弹窗在右下角,使用柔和语义双层卡片样式
11. 无 emoji,使用 Lucide 图标
### 安全验收
12. 下载必须 HTTPS
13. SHA-256 校验不匹配时拒绝应用
14. web 进程不执行 systemctl
15. root runner 仍用 flock 防并发
16. 路径穿越、符号链接仍被拒绝
### 回滚验收
17. 应用失败 → 自动回滚到上一版本
18. 数据目录不被替换
19. 手动回滚 `sudo /usr/local/sbin/tallynote-update --rollback` 仍可用
### 测试验收
20. pnpm check 通过
21. pnpm test 全量通过
22. pnpm test:installer 通过
23. pnpm run build 通过
24. CI 构建通过(python3 pty 测试不依赖 expect)
### 前端验收
25. 页面切换过渡丝滑,无延迟感
26. 下载进度条垂直水平居中
27. 弹窗内图标与文字水平对齐
28. 响应式:窄屏不溢出、不遮挡
29. 键盘可操作核心流程
30. prefers-reduced-motion 下功能完整
## 九、实施顺序
1. 后端:server/update-service.ts 新增 downloadReleaseAsset
2. 后端:server/app.ts 改 download 路由 + 新增 progress SSE
3. 后端:server/config.ts 新增 stagingDir
4. 脚本:scripts/tallynote-update-runner.sh 简化(删下载/心跳)
5. systemd:tallynote-update.service 调整超时
6. 前端:web/src/main.tsx UpdateCenter 组件重写
7. 测试:tests/update-api.test.ts 重写下载测试
8. 全量验证:check + test + test:installer + build
9. 发布新版本
## 十、风险评估
| 风险 | 级别 | 缓解 |
|---|---|---|
| 长时间 HTTP 请求占用 Fastify 连接 | 中 | 路由级超时 + SSE 独立连接 |
| 下载中途 web 进程崩溃 | 低 | job 行标记 failed,暂存文件下次清理 |
| 并发下载 | 低 | DB 级活跃 job 检查 + 文件锁 |
| 暂存目录磁盘空间不足 | 低 | 下载前检查可用空间 |
| 旧版本残留的 queued job | 低 | reconcileOrphanedUpdateJobs 清理 |
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f5f7f5" />
<title>TallyNote · 采购报销记录</title>
<title>TallyNote · 采购报销协同管理平台</title>
</head>
<body>
<div id="root"></div>
+14 -9
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<&-
@@ -1012,7 +1012,10 @@ validate_listen_port() {
}
validate_public_origin() {
local value=$1 authority host path_part origin_port suffix
# Keep the optional origin port defined under `set -u`. Origins without an
# explicit port (for example https://example.test) are valid and should
# proceed to the default-port handling below.
local value=$1 authority host path_part origin_port='' suffix
case "$value" in
http://*|https://*) ;;
*) die '公开访问地址必须是 http:// 或 https:// 地址' ;;
@@ -1074,7 +1077,7 @@ validate_existing_env() {
mode_bits=$(stat_mode_bits "$file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
local key key_count
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
key_count=$(env_key_count "$file" "$key")
[[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key"
done
@@ -1535,5 +1538,7 @@ main() {
fi
log "访问地址:$access_url"
log '查看服务状态:systemctl status tallynote.service'
log "管理员初始化命令:sudo $ADMIN_INIT_PATH"
log '如 sudo 找不到该命令,请使用上面输出的绝对路径'
}
main "$@"
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE update_jobs ADD COLUMN downloaded_bytes INTEGER;
ALTER TABLE update_jobs ADD COLUMN download_started_at INTEGER;
ALTER TABLE update_jobs ADD COLUMN download_speed_bps INTEGER;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.14",
"version": "1.3.3",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+10 -1
View File
@@ -13,6 +13,8 @@ if [[ -z "$VERSION" ]]; then
fi
VERSION=${VERSION#v}
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || { printf 'invalid version: %s\n' "$VERSION" >&2; exit 2; }
PACKAGE_VERSION=$(node -p 'require("./package.json").version')
[[ "$VERSION" == "$PACKAGE_VERSION" ]] || { printf 'version mismatch: release %s does not match package.json %s\n' "$VERSION" "$PACKAGE_VERSION" >&2; exit 2; }
case "$(uname -m)" in
x86_64|amd64) ARCH=x64 ;;
aarch64|arm64) ARCH=arm64 ;;
@@ -27,7 +29,11 @@ pnpm build
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
cp -a dist/. "$stage/dist/"
# Copy only the production build outputs. In particular, do not carry a
# stale dist/web-next directory from a previous local preview build.
cp -a dist/server "$stage/dist/"
cp -a dist/shared "$stage/dist/"
cp -a dist/web "$stage/dist/"
cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
@@ -46,6 +52,9 @@ find "$stage" -type l -delete
mkdir -p "$OUT_DIR"
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
# Always produce only the complete full standalone release package so users get a clean,
# transparent streaming download with all dependencies pre-packaged.
# Keep the sidecar useful when a caller builds more than one architecture into
# the same directory. The publishing script recomputes this list immediately
# before signing, so stale or hand-edited entries can never reach a Release.
+104 -3
View File
@@ -24,6 +24,7 @@ DRY_RUN=0
AUTH_CONFIG=''
SUMS_TMP=''
SIG_TMP=''
RELEASE_NOTES_TMP=''
SIGNATURE_GENERATED=0
usage() {
@@ -43,6 +44,81 @@ EOF
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
log() { printf 'release publisher: %s\n' "$*"; }
generate_release_notes() {
local current=${TAG#v} previous='' subject kind line count=0
local -a commits
commits=()
# A workflow checks out the tag with history. Prefer an explicitly supplied
# notes file for mirrors, then derive notes from the immutable tag range.
if [[ -n "${TALLYNOTE_RELEASE_NOTES_FILE:-}" && -f "$TALLYNOTE_RELEASE_NOTES_FILE" ]]; then
# Read at most the API's bounded notes size without a pipe that can turn a
# deliberately truncated input into a SIGPIPE failure under pipefail.
LC_ALL=C awk 'BEGIN { remaining = 65536 } { if (remaining <= 0) exit; line=$0; gsub(/[[:cntrl:]]/, "", line); bytes=length(line)+1; if (bytes > remaining) { print substr(line, 1, remaining); exit } print line; remaining-=bytes }' "$TALLYNOTE_RELEASE_NOTES_FILE"
return
fi
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "$line" == "v${current}" ]] && continue
previous="$line"
break
done < <(git tag --sort=-version:refname --list 'v*')
if [[ -n "$previous" && "$previous" != "v${current}" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log --format='%s' "${previous}..${TAG}")
else
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log -n 30 --format='%s' "$TAG")
fi
fi
printf '# TallyNote %s\n\n' "$current"
if [[ -n "$previous" ]]; then
printf '> 从 `%s` 到 `%s` 的变更\n\n' "$previous" "v${current}"
else
printf '> 本版本变更\n\n'
fi
local -a features fixes improvements docs other
features=(); fixes=(); improvements=(); docs=(); other=()
for subject in "${commits[@]-}"; do
# Do not expose merge noise or the synthetic release commit in user notes.
[[ "$subject" != Merge\ * && "$subject" != release:* ]] || continue
kind=${subject%%:*}
if [[ "$subject" == *:* ]]; then subject=${subject#*: }; fi
subject=${subject# }
[[ -n "$subject" ]] || continue
case "$kind" in
feat|feature) features+=("$subject") ;;
fix|bugfix) fixes+=("$subject") ;;
refactor|perf|style|improvement) improvements+=("$subject") ;;
docs|doc|test|tests) docs+=("$subject") ;;
*) other+=("$subject") ;;
esac
done
print_group() {
local title=$1; shift
local item
(($# > 0)) || return 0
printf '## %s\n\n' "$title"
for item in "$@"; do printf -- '- %s\n' "$item"; done
printf '\n'
}
((${#features[@]})) && print_group '新增功能' "${features[@]}"
((${#fixes[@]})) && print_group '问题修复' "${fixes[@]}"
((${#improvements[@]})) && print_group '优化与重构' "${improvements[@]}"
((${#docs[@]})) && print_group '文档与测试' "${docs[@]}"
((${#other[@]})) && print_group '其他变更' "${other[@]}"
if (( ${#features[@]} + ${#fixes[@]} + ${#improvements[@]} + ${#docs[@]} + ${#other[@]} == 0 )); then
printf '本版本包含内部维护更新。\n'
fi
}
validate_semver() {
local value=$1 prerelease part
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
@@ -128,7 +204,8 @@ fi
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
assets=()
full_assets=()
update_assets=()
for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file")
@@ -136,9 +213,16 @@ for file in "$ASSET_DIR"/*.tar.gz; do
asset_version=${name#tallynote-}
asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
assets+=("$file")
if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then
update_assets+=("$file")
else
full_assets+=("$file")
fi
done
assets=("${full_assets[@]}")
if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
@@ -161,6 +245,7 @@ cleanup() {
if [[ -n "$AUTH_CONFIG" ]]; then rm -f -- "$AUTH_CONFIG"; fi
if [[ -n "$SUMS_TMP" ]]; then rm -f -- "$SUMS_TMP"; fi
if [[ -n "$SIG_TMP" ]]; then rm -f -- "$SIG_TMP"; fi
if [[ -n "$RELEASE_NOTES_TMP" ]]; then rm -f -- "$RELEASE_NOTES_TMP"; fi
}
trap cleanup EXIT
if [[ -n "$SIGNING_KEY_FILE" ]]; then
@@ -196,6 +281,13 @@ command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
# Keep the release body deterministic and human-readable. Gitea renders this
# Markdown in the Release page; the update API later exposes the same body as
# text for the safe client-side Markdown renderer.
RELEASE_NOTES_TMP=$(mktemp)
generate_release_notes > "$RELEASE_NOTES_TMP"
release_notes=$(<"$RELEASE_NOTES_TMP")
api_curl() {
"$CURL_BIN" --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
@@ -213,8 +305,17 @@ release_json=$(mktemp)
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取 Gitea Release'
if [[ "$status" == 200 ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
existing_body=$(jq -r '.body // ""' "$release_json")
# Older releases used a one-line placeholder. Upgrade that placeholder when
# a tag is republished, while leaving deliberately authored release notes
# untouched.
if [[ "$existing_body" == "TallyNote $TAG" || -z "$existing_body" ]]; then
patch_body=$(jq -cn --arg body "$release_notes" '{body:$body}')
patch_status=$(api_curl_status -X PATCH -H 'Content-Type: application/json' -d "$patch_body" -o /dev/null -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/$release_id") || die '无法更新 Gitea Release 日志'
[[ "$patch_status" == 2* ]] || die "无法更新 Gitea Release 日志(HTTP $patch_status)"
fi
elif [[ "$status" == 404 ]]; then
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "TallyNote $TAG" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "$release_notes" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
create_status=$(api_curl_status -H 'Content-Type: application/json' -d "$body" -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases") || die '无法创建 Gitea Release'
if [[ "$create_status" == 2* ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
+212 -35
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,30 +21,194 @@ 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)
[[ "$request_operation" == download || "$request_operation" == apply ]] || request_operation='apply'
fi
# Capture the request id before any privileged preflight can fail. The
# request file is an application-owned one-shot marker; removing it on an
# early runner failure lets the server-side lease reaper release the DB row.
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
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\ninitial_active=%s\n' "$job_id" "$old_target" "$phase" "$was_active" > "$temporary"
chmod 600 "$temporary"
mv -Tf -- "$temporary" "$STATE_FILE"
STATE_CREATED=1
}
clear_recovery_state() {
[[ ! -L "$STATE_FILE" ]] || return 1
rm -f -- "$STATE_FILE"
STATE_CREATED=0
}
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.
while kill -0 "$heartbeat_owner" 2>/dev/null; do
sleep 10 || exit 0
[[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] || exit 0
touch "$STATE_FILE" 2>/dev/null || exit 0
done
}
start_heartbeat() {
stop_heartbeat
heartbeat &
heartbeat_pid=$!
}
# 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
fi
return "$result"
}
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.
if [[ "$request_operation" == download ]]; then
# A previous download runner may have been interrupted after creating its
# marker. Clear only that download marker and retry the idempotent request.
if [[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] && grep -q '^phase=download$' "$STATE_FILE"; 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
if (( result != 0 )); then
# The CLI normally records failed itself. If it died before opening the
# database, the expired marker/request will be reconciled by the app.
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
fi
clear_recovery_state || true
release_runner_lock
return "$result"
}
trap cleanup_download EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
start_heartbeat
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
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
@@ -53,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
@@ -64,49 +230,35 @@ 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
fi
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
return "$result"
}
trap restore_initial_service EXIT
systemctl stop "$SERVICE_NAME"
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
old_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
switched=0
handled=0
write_update_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"
chmod 600 "$temporary"
mv -Tf -- "$temporary" "$STATE_FILE"
}
clear_update_state() {
[[ ! -L "$STATE_FILE" ]] || return 1
rm -f -- "$STATE_FILE"
}
write_update_state() { write_recovery_state "$1"; }
clear_update_state() { clear_recovery_state; }
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")
@@ -114,9 +266,26 @@ 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
# after the CLI staged its payload but before it removed the recovery
# marker, keep the request available for an idempotent retry. Treating
# every stale download marker as a failed apply would discard a usable
# staged payload and leave the browser showing a misleading failure.
clear_update_state || true
return 0
fi
if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
@@ -195,13 +364,24 @@ fi
[[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]] || exit 0
# Only create the marker for this invocation after any marker from a previous
# interrupted run has been reconciled. Otherwise the freshly-created `running`
# marker is indistinguishable from stale recovery state and the runner can
# finalize its own queued job as failed before the update CLI starts.
if [[ ! -e "$STATE_FILE" ]]; then
write_recovery_state running || die '无法写入更新恢复状态'
fi
start_heartbeat
if ! systemctl stop "$SERVICE_NAME"; then
die '无法停止 TallyNote 服务'
fi
rollback_current() {
local current_target rollback_link
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
if [[ "$current_target" == "$old_target" ]]; then
# An earlier failure branch may already have restored the link. Keep the
# marker truthful so the EXIT trap can still finalize the job.
switched=0
return 0
fi
rollback_link="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
@@ -211,7 +391,6 @@ rollback_current() {
rm -f -- "$rollback_link" 2>/dev/null || true
return 1
fi
switched=0
}
finalize_failed_job() {
@@ -220,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
@@ -231,12 +410,13 @@ 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
cleanup_after_update() {
local result=$? rollback_ok=1
stop_heartbeat
if (( result != 0 && handled == 0 )); then
if ! rollback_current; then rollback_ok=0; fi
# Once the old release is active again, always try to close the job. The
@@ -254,11 +434,11 @@ cleanup_after_update() {
else
systemctl stop "$SERVICE_NAME" || true
fi
release_runner_lock
return "$result"
}
trap cleanup_after_update EXIT
write_update_state running || exit 1
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
@@ -266,16 +446,13 @@ 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
exit "$update_result"
fi
if [[ "$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)" != "$old_target" ]]; then
switched=1
fi
write_update_state health-check || exit 1
systemctl start "$SERVICE_NAME"
+77 -1
View File
@@ -88,6 +88,12 @@ bash -c '
chmod 700 "$mode_dir"
[[ "$(stat_mode_bits "$mode_dir")" == 448 ]]
mkdir -p "$owner_parent"
# CI runs this shell suite as root. Make the parent genuinely non-root in
# that environment so the assertion exercises the ownership guard instead
# of accidentally passing because root-owned parents are allowed.
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
chown 65534:65534 "$owner_parent"
fi
if (assert_path_chain "$owner_parent/child") >/dev/null 2>&1; then
echo "expected non-root path parent to fail" >&2
exit 1
@@ -160,6 +166,69 @@ bash -c '
wait_for_service_health 0.0.0.0 3011
' _ "$installer_lib"
# Exercise the privileged runner's normal apply hand-off with portable command
# shims. In particular, the freshly-created running marker must not be treated
# as stale state before the update CLI gets a chance to process the request.
runner_root="$tmp/runner"
runner_prefix="$runner_root/prefix"
runner_data="$runner_root/data"
runner_tools="$runner_root/tools"
mkdir -p "$runner_prefix/releases/1.0.0/runtime/bin" "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools"
ln -s "$runner_prefix/releases/1.0.0" "$runner_prefix/current"
printf '%s\n' '{"jobId":"00000000-0000-4000-8000-000000000001","operation":"apply"}' > "$runner_data/update-request.json"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "$*" >> "$TALLYNOTE_NODE_TRACE"' 'exit 0' > "$runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$runner_tools/readlink"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-Tf" ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi' > "$runner_tools/mv"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$runner_tools/curl"
chmod 755 "$runner_prefix/releases/1.0.0/runtime/bin/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl"
runner_script="$runner_root/runner.sh"
runner_path="$runner_tools:/usr/sbin:/usr/bin:/sbin:/bin"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script"
chmod 755 "$runner_script"
runner_prefix_physical=$(cd "$runner_prefix" && pwd -P)
runner_data_physical=$(cd "$runner_data" && pwd -P)
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script"
grep -q -- '--request-file' "$runner_root/node.log"
grep -q -- '--finalize-job' "$runner_root/node.log"
[[ ! -e "$runner_data/update-request.json" ]]
[[ ! -e "$runner_prefix/.update-state" ]]
# A stale download marker must be recoverable without finalizing the staged
# download as a failed apply. The next runner invocation should retry the
# request and let the CLI preserve/refresh its staged workspace.
download_runner_root="$tmp/download-runner"
download_runner_prefix="$download_runner_root/prefix"
download_runner_data="$download_runner_root/data"
download_runner_tools="$download_runner_root/tools"
mkdir -p "$download_runner_prefix/releases/1.0.0/runtime/bin" "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools"
ln -s "$download_runner_prefix/releases/1.0.0" "$download_runner_prefix/current"
# The request has already been consumed; only the stale download marker is
# left, which is the narrow recovery window covered by this fixture.
download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P)
download_runner_data_physical=$(cd "$download_runner_data" && pwd -P)
printf '%s\n' 'job_id=00000000-0000-4000-8000-000000000002' "old_target=$download_runner_prefix_physical/releases/1.0.0" 'phase=download' > "$download_runner_prefix/.update-state"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"' 'exit 0' > "$download_runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$download_runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$download_runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$download_runner_tools/readlink"
cat >"$download_runner_tools/stat" <<'EOF'
#!/usr/bin/env bash
case "$*" in
*"-c %u"*|*"-f %u"*) printf '0\n' ;;
*"-c %a"*|*"-f %Lp"*) printf '600\n' ;;
*) /usr/bin/stat "$@" ;;
esac
EOF
chmod 755 "$download_runner_prefix/releases/1.0.0/runtime/bin/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat"
download_runner_script="$download_runner_root/runner.sh"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$download_runner_tools:/usr/sbin:/usr/bin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script"
chmod 755 "$download_runner_script"
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script"
[[ ! -e "$download_runner_root/node.log" ]]
[[ ! -e "$download_runner_prefix/.update-state" ]]
# A RETURN trap installed by install_release must be cleared while its local
# temporary variables still exist; otherwise set -u fails at the end of main.
release_fixture="$tmp/release-fixture"
@@ -218,7 +287,11 @@ ln -s "$wrapper_prefix/releases/1.0.0" "$wrapper_prefix/current"
printf '%s\n' '#!/usr/bin/env bash' 'pwd -P > "$TALLYNOTE_WRAPPER_LOG"' 'printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"' > "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
chmod 755 "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js"
TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
# This fixture verifies release-relative execution and argument forwarding.
# Force the wrapper's non-root branch so the root CI runner does not need a
# real `tallynote` service account or a privileged runuser hand-off; that
# privilege boundary is validated by the production checks themselves.
env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
bash "$root/bin/tallynote-admin-init" --generate
wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P)
grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log"
@@ -385,6 +458,9 @@ bash -c '
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_public_origin "http://[2001:db8::10]:3000"
# A standard HTTPS origin may omit its default port; this must remain valid
# under the installer strict unset-variable mode.
validate_public_origin "https://example.test"
' _ "$installer_lib"
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
+55 -21
View File
@@ -54,14 +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";
@@ -119,7 +123,7 @@ function enforceUpdateCooldown(
adminId: string,
operation: "check" | "download" | "apply",
reply: FastifyReply,
): void {
): number {
const state = updateRateState(database, adminId);
const now = Date.now();
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
@@ -134,6 +138,7 @@ function enforceUpdateCooldown(
if (operation === "check") state.checkedAt = now;
else if (operation === "download") state.downloadedAt = now;
else state.appliedAt = now;
return now;
}
function adminSelect(alias = ""): string {
@@ -647,19 +652,6 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return payload;
});
app.addHook("onRequest", async (request) => {
if (!unsafeMethods.has(request.method) || !request.url.startsWith("/api/")) return;
const origin = request.headers.origin;
const allowed = new Set([config.publicOrigin]);
if (!config.isProduction) {
allowed.add("http://127.0.0.1:5173");
allowed.add("http://localhost:5173");
}
if (typeof origin !== "string" || !allowed.has(origin)) {
throw new AppError(403, "ORIGIN_FORBIDDEN", "请求来源不受信任");
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error));
if (error instanceof ZodError) {
@@ -942,13 +934,23 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
reconcileOrphanedUpdateJobs(database.sqlite, config);
const cached = publicCheckFromCache(database.sqlite, config);
// Status is a live control surface, not an update history endpoint.
// Terminal failures/cancellations from a previous attempt must not be
// replayed as if the operator had just started an update. They remain in
// the database/audit log, while this endpoint exposes only an actionable
// task (or the latest successful completion for confirmation).
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
asset_url AS assetUrl, release_url AS releaseUrl,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
FROM update_jobs
WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")})
ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record<string, unknown> | undefined;
return {
...cached,
strategy: config.updateStrategy,
@@ -957,12 +959,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
});
app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const rateState = updateRateState(database.sqlite, request.auth!.admin.id);
const previousCheckedAt = rateState.checkedAt;
let reservedCheckedAt: number | null = null;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
// Disabled/dev installs do not contact a release endpoint, so repeated
// checks are local status reads and should remain immediately usable.
if (config.updateStrategy !== "disabled") {
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
reservedCheckedAt = enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
}
const result = await checkForUpdate(database.sqlite, config);
writeAudit(database.sqlite, {
@@ -981,6 +986,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
return { ...result, strategy: config.updateStrategy };
} catch (error) {
// A failed upstream request is not a successful check. Release the
// reservation only when this request still owns it, so a concurrent
// successful check cannot have its cooldown overwritten.
if (reservedCheckedAt !== null && rateState.checkedAt === reservedCheckedAt) rateState.checkedAt = previousCheckedAt;
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
@@ -1006,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();
@@ -1169,13 +1187,26 @@ 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) => {
reconcileOrphanedUpdateJobs(database.sqlite, config);
const body = (request.body && typeof request.body === "object" ? request.body : {}) as { jobId?: string };
const result = cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId);
if (!result.cancelled) {
throw new AppError(409, "CANNOT_CANCEL", result.message || "无法取消当前更新任务");
}
reply.header("Cache-Control", "no-store");
return reply.send({ success: true, message: "已取消更新任务" });
});
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
@@ -1183,8 +1214,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reconcileOrphanedUpdateJobs(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
asset_url AS assetUrl, release_url AS releaseUrl,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE id=? AND admin_id=?
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
+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",
+86 -18
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3";
@@ -10,6 +10,7 @@ import { writeAudit } from "../audit.js";
import {
atomicSwitchDirectory,
atomicSwitchRelease,
applicationUpdateRuntimeHash,
compareSemver,
createSafeArchive,
detectPlatform,
@@ -19,6 +20,7 @@ import {
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
selectReleaseAsset,
sanitizeAssetName,
validateHttpsUrl,
@@ -161,7 +163,11 @@ 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=excluded.status, version=excluded.version, platform=excluded.platform,
-- 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),
asset_url=excluded.asset_url,
@@ -173,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,
@@ -212,14 +223,22 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(metadataUrl, options);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
} catch {
// Fall back to the full archive when the current installation predates
// runtime fingerprints or is missing deployment provenance.
}
let asset = options.assetUrl && !options.requireSignature
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
: selectReleaseAsset(release, platform);
: selectReleaseAsset(release, platform, runtimeHash);
if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [],
baseUrl: metadataUrl.toString(),
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
timeoutMs: options.timeoutMs,
publicKey: options.publicKey,
requireSignature: options.requireSignature,
});
@@ -267,6 +286,7 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
const platform = options.platform ?? detectPlatform();
const jobId = options.jobId ?? randomUUID();
const operation = options.operation ?? "apply";
const sqlite = options.sqlite;
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
try {
resolved = await resolveRelease(options, platform);
@@ -290,17 +310,55 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
if (operation === "download") await mkdir(workspace, { recursive: false, mode: 0o700 });
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
try {
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
if (sqlite) {
const claim = sqlite.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) throw new Error("更新任务已取消或已被其他进程接管");
} else {
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
}
const progressStartedAt = Date.now();
let lastProgressWrite = 0;
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, {
...options,
onProgress: (downloadedBytes, totalBytes) => {
const now = Date.now();
if (!options.sqlite || now - lastProgressWrite < 250) return;
lastProgressWrite = now;
const elapsed = Math.max(1, now - progressStartedAt);
const speedBps = Math.round(downloadedBytes * 1000 / elapsed);
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloadedBytes, totalBytes, progressStartedAt, speedBps, now, jobId);
},
});
if (options.sqlite) {
const finishedAt = Date.now();
const elapsed = Math.max(1, finishedAt - progressStartedAt);
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloaded.size, downloaded.size, progressStartedAt, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId);
}
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
updateJob(options.sqlite, jobId, { operation, status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
const currentInfo = await lstat(currentRelease).catch(() => null);
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry);
const sourceInfo = await lstat(source).catch(() => null);
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
}
}
await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
updateJob(options.sqlite, jobId, { operation, status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: workspace });
if (sqlite) {
const staged = sqlite.prepare("UPDATE update_jobs SET status='staged', 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) throw new Error("更新任务已取消,已停止继续处理");
} else {
updateJob(options.sqlite, jobId, { operation, status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: workspace });
}
if (operation === "download") {
keepWorkspace = true;
@@ -349,19 +407,28 @@ export function finalizeUpdateJob(
status: "completed" | "failed",
message?: string,
): void {
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("更新任务不存在");
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 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"
? (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,
@@ -520,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.
+3
View File
@@ -148,6 +148,9 @@ export const updateJobs = sqliteTable("update_jobs", {
downloadPath: text("download_path"),
backupPath: text("backup_path"),
sizeBytes: integer("size_bytes"),
downloadedBytes: integer("downloaded_bytes"),
downloadStartedAt: integer("download_started_at"),
downloadSpeedBps: integer("download_speed_bps"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
requestedAt: integer("requested_at"),
+3
View File
@@ -3,6 +3,7 @@ import { loadConfig, prepareDataDirectories, acquireInstanceLock } from "./confi
import { openDatabase } from "./db/index.js";
import { buildApp } from "./app.js";
import { cleanupOrphanedExports, expireExports, resumeExports } from "./exporter.js";
import { reconcileOrphanedUpdateJobs } from "./update-service.js";
const config = loadConfig();
prepareDataDirectories(config);
@@ -18,6 +19,7 @@ async function start() {
await expireExports(database.sqlite, config);
await cleanupOrphanedExports(database.sqlite, config);
await resumeExports(database.sqlite, config);
reconcileOrphanedUpdateJobs(database.sqlite, config);
const app = await buildApp(database, config);
const janitor = setInterval(() => {
void cleanupStaging(config);
@@ -27,6 +29,7 @@ async function start() {
void processFileDeletions(database.sqlite, config);
void expireExports(database.sqlite, config);
void cleanupOrphanedExports(database.sqlite, config);
reconcileOrphanedUpdateJobs(database.sqlite, config);
}, 60_000);
const shutdown = async () => {
clearInterval(janitor);
+411 -28
View File
@@ -1,5 +1,5 @@
import { lstatSync, realpathSync } from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
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,11 +8,15 @@ import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
detectPlatform,
downloadReleaseAsset,
extractSafeArchive,
fetchReleaseBytes,
fetchReleaseMetadata,
fetchReleaseText,
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
@@ -22,6 +26,7 @@ import {
} from "./update.js";
import type { UpdateJobStatus } from "../shared/contracts.js";
export const UPDATE_CACHE_KEY = "update.release.v1";
export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
"queued",
@@ -33,10 +38,11 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
];
// A queued job normally starts within seconds and an applying job completes
// after the service health check. This grace period only applies when both
// hand-off markers are gone, so an active runner is never reclaimed midway
// through a download, backup, or switch.
// after the service health check. The runner refreshes its recovery marker as
// a lease while doing long downloads/backups; only an expired lease permits
// the server to reclaim an active row.
export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000;
export const QUEUED_UPDATE_TIMEOUT_MS = 25 * 1000;
export type CachedRelease = {
checkedAt: number;
@@ -151,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);
}
}
@@ -180,6 +186,7 @@ function policy(config: AppConfig) {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
maxRedirects: 3,
timeoutMs: config.updateTimeoutMs,
} as const;
}
@@ -204,13 +211,22 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
} catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
}
let asset = selectReleaseAsset(metadata, platform);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml")));
} catch {
// Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime.
}
// Force choosing the full standalone archive so users always get a real, visible streaming download
let asset = selectReleaseAsset(metadata, platform, undefined);
let signatureVerified = false;
if (asset) {
const integrity = await attachSidecarHash(metadata, asset, {
allowedHosts: config.updateAllowedHosts,
baseUrl: metadataUrl,
maxBytes: config.updateMaxBytes,
timeoutMs: config.updateTimeoutMs,
publicKey: config.updatePublicKey,
requireSignature: config.updateRequireSignature,
});
@@ -338,6 +354,15 @@ export async function writeUpdateRequest(config: AppConfig, request: UpdateReque
}
}
function safePublicErrorMessage(msg: unknown): string {
if (typeof msg !== "string" || !msg.trim()) return "更新失败,请查看服务器日志或重试";
if (msg.includes("/var/lib") || msg.includes("/opt/") || msg.includes("/etc/") || msg.includes("secret") || msg.includes("command-output")) {
return "更新失败,请查看服务器日志或重试";
}
return msg.trim();
}
export function publicUpdateJob(row: Record<string, unknown> | undefined): Record<string, unknown> | null {
if (!row) return null;
const hasError = typeof row.errorMessage === "string" && row.errorMessage.length > 0;
@@ -350,11 +375,13 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
version: row.version,
platform: row.platform,
assetName: row.assetName ?? null,
assetUrl: row.assetUrl ?? null,
releaseUrl: row.releaseUrl ?? null,
sizeBytes: row.sizeBytes ?? null,
// Do not expose filesystem paths, command output, or upstream response
// text through the authenticated status endpoint. Detailed diagnostics
// remain in the server journal for operators.
errorMessage: hasError ? "更新失败,请查看服务器日志或重试" : null,
downloadedBytes: row.downloadedBytes ?? null,
downloadStartedAt: row.downloadStartedAt ?? null,
downloadSpeedBps: row.downloadSpeedBps ?? null,
errorMessage: hasError ? safePublicErrorMessage(row.errorMessage) : null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
completedAt: row.completedAt ?? null,
@@ -364,16 +391,58 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
};
}
function markerExists(filePath: string): boolean {
function markerMtime(filePath: string): number | null {
try {
const info = lstatSync(filePath);
return info.isFile() || info.isSymbolicLink();
return info.isFile() ? info.mtimeMs : null;
} catch {
return false;
return null;
}
}
function currentReleaseVersion(config: AppConfig): string | null {
function forceRemoveRequest(filePath: string): void {
try {
const info = lstatSync(filePath);
if (!info.isFile() && !info.isSymbolicLink()) return;
unlinkSync(filePath);
} catch {}
}
function removeExpiredRequest(filePath: string, now: number): void {
try {
const info = lstatSync(filePath);
if (!info.isFile() && !info.isSymbolicLink()) return;
if (now - info.mtimeMs < ORPHANED_UPDATE_TIMEOUT_MS) return;
unlinkSync(filePath);
} catch {
// The root runner may own the marker during a recovery race. The DB
// transition below is still enough to release the browser queue.
}
}
function requestJobId(filePath: string): string | null {
try {
const info = lstatSync(filePath);
if (!info.isFile() || info.isSymbolicLink()) return null;
const value = JSON.parse(readFileSync(filePath, "utf8")) as { jobId?: unknown };
return typeof value.jobId === "string" && /^[0-9a-f-]{36}$/.test(value.jobId) ? value.jobId : null;
} catch {
return 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);
@@ -385,28 +454,143 @@ function currentReleaseVersion(config: AppConfig): string | null {
}
/**
* Release an update row left behind after both privileged hand-off markers
* disappeared. This is deliberately conservative: staged downloads remain
* available for an explicit apply, and any visible marker means the runner
* still owns recovery.
* Release an update row left behind after its privileged runner lease expired.
* This is deliberately conservative: staged downloads remain available for an
* explicit apply, and a fresh request/state marker means the runner still owns
* recovery.
*/
export function reconcileOrphanedUpdateJobs(database: Database.Database, config: AppConfig, now = Date.now()): number {
const placeholders = ACTIVE_UPDATE_STATUSES.map(() => "?").join(",");
const rows = database.prepare(`
SELECT id, status, version, admin_id AS adminId, request_id AS requestId,
SELECT id, status, operation, version, admin_id AS adminId, request_id AS requestId,
updated_at AS updatedAt
FROM update_jobs
WHERE status IN (${placeholders})
ORDER BY updated_at ASC
`).all(...ACTIVE_UPDATE_STATUSES) as Array<{ id: string; status: UpdateJobStatus; version: string; adminId: string | null; requestId: string | null; updatedAt: number | null }>;
`).all(...ACTIVE_UPDATE_STATUSES) as Array<{ id: string; status: UpdateJobStatus; operation: "download" | "apply"; version: string; adminId: string | null; requestId: string | null; updatedAt: number | null }>;
if (rows.length === 0) return 0;
const requestPresent = markerExists(config.updateRequestPath);
const statePresent = markerExists(path.join(config.installPrefix, ".update-state"));
if (requestPresent || statePresent) return 0;
const statePath = path.join(config.installPrefix, ".update-state");
const requestMtime = markerMtime(config.updateRequestPath);
const stateMtime = markerMtime(statePath);
const requestPresent = requestMtime !== null;
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
// row is still safe to retry and must not block the queue forever.
const releaseVersion = currentReleaseVersion(config);
let reconciled = 0;
const reconciledIds = new Set<string>();
for (const row of rows) {
if (row.status === "staged" || typeof row.updatedAt !== "number" || now - row.updatedAt < ORPHANED_UPDATE_TIMEOUT_MS) continue;
// 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" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
if (matchingFreshRequest) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET status='failed', error_message=?, completed_at=?, updated_at=?
WHERE id=? AND status='queued' AND updated_at=?
`).run("更新服务未在规定时间内接管任务", now, now, row.id, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: "failure",
before: { status: row.status, version: row.version },
after: { status: "failed", version: row.version, reason: "runner_claim_timeout" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
continue;
}
if (typeof row.updatedAt !== "number" || now - row.updatedAt < ORPHANED_UPDATE_TIMEOUT_MS) continue;
// The runner refreshes the state marker while a download is in flight.
// 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" || matchingFreshRequest || matchingFreshState) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET operation='download', error_message=NULL, updated_at=?
WHERE id=? AND status='staged' AND operation='apply' AND updated_at=?
`).run(now, row.id, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: "success",
before: { status: row.status, operation: row.operation, version: row.version },
after: { status: "staged", operation: "download", version: row.version, reason: "apply_request_missing" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
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(() => {
@@ -428,7 +612,206 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
});
return true;
})();
if (changed) reconciled += 1;
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
}
// Prevent a stale request from being replayed after its DB row has been
// marked failed. The path is fixed by the server configuration and the
// operation is safe even when a root runner is racing with this call.
// A download runner refreshes the state marker while it is still using the
// request. Keep the request until that lease also expires; otherwise a
// long download can lose its job id and fail to finalize its row.
const queuedRequestId = requestPresent ? requestJobId(config.updateRequestPath) : null;
const queuedRequest = queuedRequestId ? rows.find((row) => row.id === queuedRequestId) : undefined;
const requestStillNeeded = Boolean(
queuedRequest
&& ACTIVE_UPDATE_STATUSES.includes(queuedRequest.status)
&& !reconciledIds.has(queuedRequest.id)
&& !(queuedRequest.status === "staged" && queuedRequest.operation === "download"),
);
if (!stateFresh && !requestStillNeeded) {
forceRemoveRequest(config.updateRequestPath);
} else if (!stateFresh && (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS))) {
removeExpiredRequest(config.updateRequestPath, now);
}
return reconciled;
}
export function cancelUpdateJob(
database: Database.Database,
config: AppConfig,
adminId: string,
requestId: string,
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=? 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 admin_id=? AND status IN ('queued', 'downloading')").run(now, now, job.id, adminId);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId,
actorAdminId: adminId,
action: "update.cancelled",
targetType: "update",
targetId: job.id,
outcome: "success",
before: { status: job.status, operation: job.operation, version: job.version },
after: { status: "cancelled", version: job.version },
});
return true;
})();
if (changed) {
// 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(() => {});
}
return { cancelled: true };
}
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);
}
}
+184 -93
View File
@@ -43,14 +43,38 @@ export type ReleaseMetadata = {
assets: ReleaseAsset[];
};
const APPLICATION_UPDATE_ASSET = /\.update-([a-f0-9]{64})\.tar\.gz$/i;
export function applicationUpdateRuntimeHash(assetName: string): string | undefined {
return APPLICATION_UPDATE_ASSET.exec(assetName)?.[1]?.toLowerCase();
}
export function runtimeHashFromLockfile(lockfile: string | Buffer): string {
return createHash("sha256").update(lockfile).digest("hex");
}
export type UrlPolicy = {
/** Host names or HTTPS URLs which are allowed for requests. */
allowedHosts?: readonly string[] | undefined;
/** 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("更新版本号无效");
}
@@ -147,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
@@ -200,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) {
@@ -223,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);
@@ -231,84 +297,78 @@ 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 (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, "更新发布信息过大", 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" && compareSemver(version, item.tag_name) !== 0) throw metadataError();
const assetsRaw = Array.isArray(item.assets) ? item.assets : [];
const assets: ReleaseAsset[] = [];
for (const raw of assetsRaw) {
if (!raw || typeof raw !== "object") continue;
const asset = raw as Record<string, unknown>;
const name = typeof asset.name === "string" ? asset.name : undefined;
const url = typeof asset.url === "string" ? asset.url : typeof asset.browser_download_url === "string" ? asset.browser_download_url : undefined;
if (!name || !url) continue;
let sha256: string | undefined;
const digest = typeof asset.sha256 === "string" ? asset.sha256 : typeof asset.digest === "string" ? asset.digest : undefined;
if (digest) {
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
}
assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
}
const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html);
const releaseName = releaseNameText(item.name ?? item.releaseName);
let releaseUrl: string | undefined;
if (typeof item.html_url === "string" || typeof item.url === "string") {
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,
};
} 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 });
}
if (response.status < 200 || response.status >= 300) throw metadataError();
let payload: unknown;
try {
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(); }
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();
}
}
const assetsRaw = Array.isArray(item.assets) ? item.assets : [];
const assets: ReleaseAsset[] = [];
for (const raw of assetsRaw) {
if (!raw || typeof raw !== "object") continue;
const asset = raw as Record<string, unknown>;
const name = typeof asset.name === "string" ? asset.name : undefined;
const url = typeof asset.url === "string" ? asset.url : typeof asset.browser_download_url === "string" ? asset.browser_download_url : undefined;
if (!name || !url) continue;
let sha256: string | undefined;
const digest = typeof asset.sha256 === "string" ? asset.sha256 : typeof asset.digest === "string" ? asset.digest : undefined;
if (digest) {
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
}
assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
}
const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html);
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 */ }
}
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,
};
}
/** 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);
@@ -318,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
@@ -346,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);
@@ -356,30 +421,35 @@ 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()): ReleaseAsset | undefined {
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform(), runtimeHash?: string): ReleaseAsset | undefined {
const platformCandidates = release.assets.filter((asset) => {
const name = asset.name.toLowerCase();
return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase()));
@@ -398,7 +468,12 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl
const target = platform.target.toLowerCase();
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
});
return candidates[0];
const normalizedRuntimeHash = runtimeHash?.trim().toLowerCase();
if (normalizedRuntimeHash && /^[a-f0-9]{64}$/.test(normalizedRuntimeHash)) {
const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash);
if (applicationUpdate) return applicationUpdate;
}
return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name));
}
export function sanitizeAssetName(value: string): string {
@@ -423,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 } = {},
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);
@@ -433,33 +508,47 @@ 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;
const hash = createHash("sha256");
const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) {
size += chunk.length;
options.onProgress?.(size, totalBytes);
if (size > maxBytes) return callback(new Error("更新文件超过大小限制"));
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();
@@ -467,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") };
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const updateApplySchema = z.object({
export const updateDownloadSchema = z.object({
version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
confirm: z.literal(true),
confirm: z.boolean().default(true).optional(),
}).strict();
export type ApiError = {
+4 -8
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,13 +8,13 @@ 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
CapabilityBoundingSet=
AmbientCapabilities=
# Keep the updater compatible with the same Node/libuv interface discovery
# path while retaining an explicit socket-family allowlist.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
@@ -28,7 +25,6 @@ ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
LockPersonality=true
RestrictRealtime=true
+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 {
+3 -3
View File
@@ -129,10 +129,10 @@ describe("TallyNote API", () => {
}
});
it("拒绝没有 Origin 的写请求", async () => {
it("反向代理缺少 Origin 时仍允许登录请求进入认证流程", async () => {
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe("ORIGIN_FORBIDDEN");
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("INVALID_CREDENTIALS");
});
it("将非法 JSON、伪造请求 ID 处理为结构化 400", async () => {
+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
+2 -1
View File
@@ -42,9 +42,10 @@ describe("数据库迁移", () => {
{ name: "0002_update_jobs.sql" },
{ name: "0003_update_job_ownership.sql" },
{ name: "0004_update_download_apply.sql" },
{ name: "0005_update_progress.sql" },
]);
const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>;
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation"]));
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation", "downloaded_bytes", "download_started_at", "download_speed_bps"]));
expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null });
migrated.sqlite.close();
migrated = openDatabase(config);
+1
View File
@@ -48,6 +48,7 @@ describe("部署安全配置", () => {
expect(loadConfig().trustProxy).toBe(1);
});
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
+289 -19
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chmodSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
@@ -59,10 +59,10 @@ describe("更新 API", () => {
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.2.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.2.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,25 +70,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);
expect(checked.json().latest).toMatchObject({ version: "1.2.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);
expect(tooSoon.headers["retry-after"]).toBeDefined();
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.2.0", confirm: true } });
// Cooldown is scoped to the authenticated administrator, not the whole
// database or release endpoint.
const otherSession = await login("update-admin-other");
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: "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.2.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.2.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"]));
});
@@ -98,46 +106,111 @@ 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.2.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.2.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.2.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.2.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.2.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.2.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();
expect(disabledConfig.updateStrategy).toBe("disabled");
});
it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => {
const session = await login("update-history");
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string };
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at)
VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?)
`).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000);
const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(initial.statusCode).toBe(200);
expect(initial.json().job).toBeNull();
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "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 () => {
const owner = await login("update-owner");
const other = await login("update-other");
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.2.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);
@@ -152,12 +225,209 @@ 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.2.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.
const check = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(check.statusCode).toBe(502);
const retry = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(retry.statusCode).toBe(502);
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
expect(audit?.outcome).toBe("failure");
});
it("下载请求交由 systemd runner 接管,并保留可查询的排队状态", async () => {
const { createSafeArchive } = await import("../server/update.js");
const { createHash } = await import("node:crypto");
const { mkdirSync, writeFileSync } = await import("node:fs");
const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-test-payload-"));
mkdirSync(path.join(payloadSource, "dist"), { recursive: true });
writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);");
const archivePath = path.join(tmpdir(), `tallynote-archive-${randomUUID()}.tar.gz`);
await createSafeArchive(payloadSource, archivePath);
const archiveBytes = readFileSync(archivePath);
const digest = createHash("sha256").update(archiveBytes).digest("hex");
const assetName = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("SHA256SUMS")) {
return new Response(`${digest} ${assetName}\n`, { status: 200 });
}
if (url.endsWith(assetName)) {
return new Response(archiveBytes, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
}
return new Response(JSON.stringify({
tag_name: "v9.9.9",
assets: [
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
]
}), { status: 200 });
}) as typeof fetch;
const session = await login("update-inprocess");
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: "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(["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: expect.any(String),
operation: "download",
assetName,
assetUrl: `https://updates.example/${assetName}`,
});
rmSync(payloadSource, { recursive: true, force: true });
rmSync(archivePath, { force: true });
});
it("管理员可取消 systemd 下载任务并清理请求文件", async () => {
const { createSafeArchive } = await import("../server/update.js");
const { createHash } = await import("node:crypto");
const { mkdirSync, writeFileSync } = await import("node:fs");
const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-cancel-payload-"));
mkdirSync(path.join(payloadSource, "dist"), { recursive: true });
writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);");
const archivePath = path.join(tmpdir(), `tallynote-cancel-${randomUUID()}.tar.gz`);
await createSafeArchive(payloadSource, archivePath);
const archiveBytes = readFileSync(archivePath);
const digest = createHash("sha256").update(archiveBytes).digest("hex");
const assetName = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
// Mock a slow stream
let fetchAborted = false;
globalThis.fetch = (async (input: string | URL, init?: any) => {
const url = input.toString();
if (url.endsWith("SHA256SUMS")) {
return new Response(`${digest} ${assetName}\n`, { status: 200 });
}
if (url.endsWith(assetName)) {
init?.signal?.addEventListener("abort", () => {
fetchAborted = true;
});
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(archiveBytes.slice(0, 50));
// Simulate hanging network until aborted
await new Promise((resolve) => {
if (init?.signal?.aborted) return resolve(undefined);
init?.signal?.addEventListener("abort", () => resolve(undefined));
});
controller.close();
}
});
return new Response(stream, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
}
return new Response(JSON.stringify({
tag_name: "v9.9.9",
assets: [
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
]
}), { status: 200 });
}) as typeof fetch;
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: "9.9.9", confirm: true } });
const downloadJobId = downloaded.json().job.id as string;
// Wait until status becomes downloading
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 30));
const row = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any;
if (row?.status === "downloading") break;
}
const cancelRes = await app.inject({
method: "POST",
url: "/api/update/cancel",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { jobId: downloadJobId }
});
expect(cancelRes.statusCode).toBe(200);
expect(cancelRes.json().success).toBe(true);
const cancelledRow = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any;
expect(cancelledRow?.status).toBe("cancelled");
expect(fetchAborted).toBe(false);
rmSync(payloadSource, { recursive: true, force: true });
rmSync(archivePath, { force: true });
});
it("管理员可主动取消排队中的更新任务并清理请求文件", async () => {
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: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
expect(existsSync(config.updateRequestPath)).toBe(true);
const cancelRes = await app.inject({ method: "POST", url: "/api/update/cancel", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(cancelRes.statusCode).toBe(200);
expect(cancelRes.json().success).toBe(true);
expect(existsSync(config.updateRequestPath)).toBe(false);
const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(statusRes.statusCode).toBe(200);
expect(statusRes.json().job).toBeNull();
});
});
+144 -20
View File
@@ -1,11 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "node:fs/promises";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir, utimes } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
import {
atomicSwitchRelease,
applicationUpdateRuntimeHash,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
@@ -16,6 +17,7 @@ import {
normalizeReleasePermissions,
sanitizeAssetName,
selectReleaseAsset,
runtimeHashFromLockfile,
validateHttpsUrl,
} from "../server/update.js";
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
@@ -38,18 +40,29 @@ 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.2.0",
version: "9.9.9",
assets: [
{ name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
{ name: "tallynote-1.2.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.2.0", assets: [{ name: "tallynote-1.2.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-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);
expect(applicationUpdateRuntimeHash(full.name)).toBeUndefined();
});
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
@@ -89,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.2.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.2.0");
expect(metadata.version).toBe("9.9.9");
expect((await fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"] })).trim()).toContain(digest);
});
@@ -240,22 +253,22 @@ describe("更新安全工具", () => {
const jobId = randomUUID();
database = openDatabase(config);
const now = Date.now();
const assetName = `tallynote-1.2.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.2.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.2.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.2.0",
version: "9.9.9",
currentVersion: config.appVersion,
currentDir: config.currentLink,
stagingDir: path.join(root, "staging"),
@@ -273,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();
@@ -309,14 +341,106 @@ describe("更新安全工具", () => {
const queuedId = randomUUID();
const applyingId = randomUUID();
const stagedId = randomUUID();
insert.run(queuedId, "apply", "queued", "1.2.0", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt);
const stagedApplyId = randomUUID();
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.2.0", "linux-x64", "https://updates.example/staged.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(2);
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(applyingId)).toEqual({ status: "completed" });
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "staged" });
expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "staged", operation: "download" });
expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedApplyId)).toEqual({ status: "staged", operation: "download" });
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("下载心跳有效时不回收任务或删除仍在使用的请求文件", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-heartbeat-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true });
await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
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");
await writeFile(statePath, `job_id=${jobId}\nold_target=${path.join(config.releasesDir, config.appVersion)}\nphase=download\n`);
const now = Date.now();
await utimes(config.updateRequestPath, new Date(staleAt), new Date(staleAt));
await utimes(statePath, new Date(now), new Date(now));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "downloading" });
expect(await stat(config.updateRequestPath)).toBeTruthy();
const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1;
await utimes(statePath, new Date(staleAt), new Date(staleAt));
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 {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("队列任务有匹配请求标记时保留到租约过期,过期后才回收", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-queued-marker-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
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));
// 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(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 {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
@@ -389,17 +513,17 @@ describe("更新元数据缓存", () => {
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.2.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.2.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.2.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
expect(result.latest).toMatchObject({ version: "9.9.9", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
} finally {
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
export function BeamBar({
className = "",
width = 140,
height = 4,
}: {
className?: string;
width?: number | string;
height?: number;
}) {
return (
<div
className={`tn-beam-bar ${className}`}
style={{ width, height }}
role="progressbar"
aria-label="加载中"
/>
);
}
export function BeamLoading({
text,
className = "",
width,
}: {
text?: ReactNode;
className?: string;
width?: number | string;
}) {
return (
<div className={`tn-beam-loading ${className}`} role="status" aria-live="polite">
<BeamBar width={width} />
{text && <span className="tn-beam-text">{text}</span>}
</div>
);
}
export default BeamLoading;
+14 -13
View File
@@ -1,3 +1,4 @@
import { BeamLoading } from "./components/BeamLoading";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import "tdesign-react/es/_util/react-19-adapter";
@@ -10,12 +11,12 @@ import { setAppTimezone } from "./utils/date";
import { AppLayout } from "./layouts";
import { DEFAULT_ROUTE_ID, isRouteId, routeIdFromPath, routePath, routeTitle, type RouteId } from "./router";
import { LoginPage, ChangePasswordPage } from "./pages/auth";
const ExpensesPage = lazy(() => import("./pages/expenses"));
const DashboardPage = lazy(() => import("./pages/dashboard"));
const TrashPage = lazy(() => import("./pages/trash").then(module => ({ default: module.TrashPage })));
const AdminsPage = lazy(() => import("./pages/admins").then(module => ({ default: module.AdminsPage })));
const AuditPage = lazy(() => import("./pages/audit").then(module => ({ default: module.AuditPage })));
const UpdatePage = lazy(() => import("./pages/update").then(module => ({ default: module.UpdatePage })));
import ExpensesPage from "./pages/expenses";
import DashboardPage from "./pages/dashboard";
import { TrashPage } from "./pages/trash";
import { AdminsPage } from "./pages/admins";
import { AuditPage } from "./pages/audit";
import { UpdatePage } from "./pages/update";
import { UnsavedChangesProvider, useUnsavedActions } from "./contexts/UnsavedChanges";
import { useDialogAccessibility } from "./hooks/useDialogAccessibility";
import "./styles/theme.css";
@@ -31,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: [0, 76] as [number, number], zIndex: 5000 };
// 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);
}, []);
@@ -87,10 +88,10 @@ function App() {
// Keep the login form mounted for those requests so the user sees the
// button's busy state instead of losing the entire form to a bootstrap
// spinner. `bootstrapRequestId` is only set by the initial session check.
if (isSessionBootstrapping(session)) return <main className="tn-auth-shell" role="status" aria-live="polite"><Loading text="正在连接本地账本…" /></main>;
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接 TallyNote</h1><p className="tn-page-subtitle">{session.error || "请确认本地服务正在运行。"}</p><Button theme="primary" onClick={() => void dispatch(bootstrapSession())}>重新连接</Button></div></main>;
if (isSessionBootstrapping(session)) return <main className="tn-auth-shell" role="status" aria-live="polite"><BeamLoading text="正在进入系统…" /></main>;
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接服务</h1><p className="tn-page-subtitle">{session.error || "服务暂时无法连接,请稍后重试或检查网络状态。"}</p><Button theme="primary" onClick={() => void dispatch(bootstrapSession())}>重新连接</Button></div></main>;
if (!session.admin) return <LoginPage
notice={session.initialized ? undefined : "首次安装还差一步:请在服务器执行 sudo tallynote-admin-init 创建管理员账号。"}
notice={session.initialized ? undefined : "系统尚未初始化管理员账号,请联系系统管理员完成初始配置后登录。"}
onSuccess={() => setPasswordOpen(false)}
/>;
if (session.admin.mustChangePassword) return <ChangePasswordPage admin={session.admin} firstLogin onSuccess={() => notify("密码已更新", "success")} />;
@@ -104,7 +105,7 @@ function App() {
: page === "audit" ? <AuditPage timezone={session.timezone} />
: <UpdatePage timezone={session.timezone} notify={notify} />;
return <AppLayout activeId={page} adminName={session.admin.displayName} adminUsername={session.admin.username} onNavigate={onNavigate} onLogout={onLogout} onOpenPassword={() => { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}><Suspense fallback={<main className="tn-page-loading" role="status" aria-live="polite"><Loading text="正在打开页面…" /></main>}><div key={passwordOpen ? "password" : page} className="tn-page-transition">{content}</div></Suspense></AppLayout>;
return <AppLayout activeId={page} adminName={session.admin.displayName} adminUsername={session.admin.username} onNavigate={onNavigate} onLogout={onLogout} onOpenPassword={() => { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}><Suspense fallback={<main className="tn-page-loading" role="status" aria-live="polite"><BeamLoading text="页面加载中…" /></main>}><div key={passwordOpen ? "password" : page} className="tn-page-transition">{content}</div></Suspense></AppLayout>;
}
function RouteErrorPage() {
+5 -5
View File
@@ -77,18 +77,18 @@ export default function AdminsPage({ currentAdmin, timezone = "Asia/Shanghai", n
{ colKey: "status", title: "状态", cell: ({ row }: any) => <StatusTag status={row.status} /> },
{ colKey: "lastLoginAt", title: "最近登录", cell: ({ row }: any) => row.lastLoginAt ? dateText(row.lastLoginAt, timezone) : "从未登录" },
{ colKey: "version", title: "版本", cell: ({ row }: any) => <span className="tn-code">v{row.version}</span> },
{ colKey: "actions", title: "操作", width: 250, cell: ({ row }: any) => <Space className="tn-action-group"><Tooltip content={row.id === currentAdmin.id ? "当前账号请使用右上角修改密码" : "生成一次性临时密码"}><Button variant="outline" onClick={() => setAction({ kind: "reset", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={<KeyRound size={15} />}>重置密码</Button></Tooltip><Button variant="outline" theme={row.status === "active" ? "danger" : "primary"} onClick={() => setAction({ kind: "toggle", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={row.status === "active" ? <UserRoundX size={15} /> : <UserRoundCheck size={15} />}>{row.status === "active" ? "停用" : "启用"}</Button></Space> },
{ colKey: "actions", title: "操作", width: 250, cell: ({ row }: any) => <Space className="tn-action-group"><Tooltip content={row.id === currentAdmin.id ? "当前账号请在个人菜单中修改密码" : "重置并生成临时登录密码"}><Button variant="outline" onClick={() => setAction({ kind: "reset", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={<KeyRound size={15} />}>重置密码</Button></Tooltip><Button variant="outline" theme={row.status === "active" ? "danger" : "primary"} onClick={() => setAction({ kind: "toggle", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={row.status === "active" ? <UserRoundX size={15} /> : <UserRoundCheck size={15} />}>{row.status === "active" ? "停用" : "启用"}</Button></Space> },
];
return <Page title="管理员" subtitle="多个等权管理员共享同一本地账目,停用会立即撤销该账号的现有会话。" actions={<Space><Button variant="outline" onClick={() => void load()} disabled={loading} icon={<RotateCcw size={15} />}>刷新</Button><Button theme="primary" onClick={() => { setForm({ username: "", displayName: "" }); setFormError(""); setFormFields({}); setShowCreate(true); }} icon={<Plus size={16} />}>新增管理员</Button></Space>}>
return <Page title="管理员" subtitle="管理员协同维护团队账单与报销凭据,停用后将立即限制该账号访问并注销其登录会话。" actions={<Space><Button variant="outline" onClick={() => void load()} disabled={loading} icon={<RotateCcw size={15} />}>刷新</Button><Button theme="primary" onClick={() => { setForm({ username: "", displayName: "" }); setFormError(""); setFormFields({}); setShowCreate(true); }} icon={<Plus size={16} />}>新增管理员</Button></Space>}>
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><UserRound size={30} /><p>暂无管理员</p></div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap" role="region" aria-label="管理员列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div></AsyncState>
<Drawer className="tn-content-drawer tn-admin-drawer" visible={showCreate} destroyOnClose placement="right" size="440px" header="新增管理员" onClose={() => { if (!busy) requestDiscard(() => setShowCreate(false)); }} footer={<Space><Button variant="outline" onClick={() => requestDiscard(() => setShowCreate(false))} disabled={busy}>取消</Button><Button theme="primary" type="button" onClick={() => void create()} disabled={busy} icon={busy ? <BusyIcon /> : <ShieldCheck size={15} />}>创建并生成临时密码</Button></Space>}>
<Form id="admin-create-form" layout="vertical" onSubmit={() => { void create(); }}><Form.FormItem label="用户名" help={formFields.username || "至少 3 个字符"} status={formFields.username ? "error" : undefined} rules={[{ required: true, min: 3, max: 64, message: "用户名至少需要 3 个字符" }]}><AccessibleInput disabled={busy} inputAriaLabel="用户名" inputAriaInvalid={Boolean(formFields.username)} value={form.username} onChange={value => { setForm({ ...form, username: value }); setFormFields(current => ({ ...current, username: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={64} autocomplete="off" /></Form.FormItem><Form.FormItem label="显示名" help={formFields.displayName} status={formFields.displayName ? "error" : undefined} rules={[{ required: true, max: 80, message: "请输入显示名" }]}><AccessibleInput disabled={busy} inputAriaLabel="显示名" inputAriaInvalid={Boolean(formFields.displayName)} value={form.displayName} onChange={value => { setForm({ ...form, displayName: value }); setFormFields(current => ({ ...current, displayName: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={80} /></Form.FormItem>{formError && <div className="tn-inline-error" role="alert">{formError}</div>}</Form>
</Drawer>
<Dialog visible={Boolean(action)} header={action?.kind === "reset" ? "重置管理员密码?" : action?.admin.status === "active" ? "停用管理员?" : "启用管理员?"} confirmBtn={{ content: action?.kind === "reset" ? "重置密码" : action?.admin.status === "active" ? "停用" : "启用", theme: action?.kind === "toggle" && action.admin.status === "active" ? "danger" : "primary", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void (action?.kind === "reset" ? reset() : toggle())} onCancel={() => { if (!busy) setAction(null); }}>
{action?.kind === "reset" ? <>将生成一次性临时密码,并立即使“{action.admin.displayName}”的现有会话失效。</> : action?.admin.status === "active" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"}
<Dialog width="540px" visible={Boolean(action)} header={action?.kind === "reset" ? "重置管理员密码?" : action?.admin.status === "active" ? "停用管理员?" : "启用管理员?"} confirmBtn={{ content: action?.kind === "reset" ? "重置密码" : action?.admin.status === "active" ? "停用" : "启用", theme: action?.kind === "toggle" && action.admin.status === "active" ? "danger" : "primary", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void (action?.kind === "reset" ? reset() : toggle())} onCancel={() => { if (!busy) setAction(null); }}>
{action?.kind === "reset" ? <>将生成一次性临时密码,同时让管理员“{action.admin.displayName}”已登录的会话安全退出。</> : action?.admin.status === "active" ? "停用后该管理员将无法访问系统,已登录的会话会立即注销。" : "启用后该管理员可恢复系统访问并正常登录。"}
</Dialog>
<Dialog visible={Boolean(secret)} header="临时密码已生成" confirmBtn="关闭" cancelBtn={null} onClose={() => setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}><div className="tn-secret"><code>{secret}</code><Button variant="outline" icon={<Copy size={15} />} onClick={() => { void copySecret(secret); }}>复制</Button></div><p className="tn-dialog-note">请通过安全渠道交给管理员。首次登录必须修改密码。</p></Dialog>
<Dialog width="540px" visible={Boolean(secret)} header="临时密码已生成" confirmBtn="关闭" cancelBtn={null} onClose={() => setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}><div className="tn-secret"><code>{secret}</code><Button variant="outline" icon={<Copy size={15} />} onClick={() => { void copySecret(secret); }}>复制</Button></div><p className="tn-dialog-note">请妥善保管并将临时密码交付给管理员,该密码在首次登录时会被强制更新。</p></Dialog>
</Page>;
async function copySecret(value: string) {
+3 -3
View File
@@ -23,7 +23,7 @@ const ACTION_LABELS: Record<string, string> = {
"auth.login_failed": "登录失败",
"expense.created": "创建账目",
"expense.updated": "更新账目",
"expense.status_changed": "切换报销状态",
"expense.status_changed": "更新报销状态",
"expense.trashed": "移入回收站",
"expense.restored": "恢复账目",
"expense.purged": "永久删除账目",
@@ -133,8 +133,8 @@ export default function AuditPage({ timezone = "Asia/Shanghai" }: { timezone?: s
{ colKey: "outcome", title: "结果", cell: ({ row }: any) => <Tag theme={row.outcome === "success" || !row.outcome ? "success" : row.outcome === "denied" ? "warning" : "danger"}>{outcomeLabel(row.outcome)}</Tag> },
];
return <Page title="审计日志" subtitle="记录登录、账目、附件、导出、管理员和更新操作。日志只读,永久删除也不会清除它。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || loadingMore} icon={<RotateCcw size={15} />}>刷新</Button>}>
<form className="tn-toolbar tn-audit-toolbar" onSubmit={e => { e.preventDefault(); applyFilters(); }}><AccessibleInput inputAriaLabel="动作筛选" value={actionDraft} onChange={setActionDraft} maxlength={100} placeholder="动作,例如 expense.created" prefixIcon={<Search size={16} />} /><Select aria-label="目标类型" value={targetDraft} onChange={v => setTargetDraft(String(v))} options={[{ label: "全部目标", value: "" }, { label: "账目", value: "expense" }, { label: "管理员", value: "admin" }, { label: "导出", value: "export" }, { label: "会话", value: "session" }, { label: "更新任务", value: "update" }, { label: "系统检查", value: "system" }]} /><Button theme="primary" type="submit" icon={<Search size={15} />}>筛选</Button></form>
return <Page title="审计日志" subtitle="全量记录系统鉴权、账目变更、凭证管理、数据导出与维护行为,审计日志严格只读留存,确保财务追溯合规。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || loadingMore} icon={<RotateCcw size={15} />}>刷新</Button>}>
<form className="tn-toolbar tn-audit-toolbar" onSubmit={e => { e.preventDefault(); applyFilters(); }}><AccessibleInput inputAriaLabel="动作筛选" value={actionDraft} onChange={setActionDraft} maxlength={100} placeholder="输入操作行为筛选" prefixIcon={<Search size={16} />} /><Select aria-label="目标类型" value={targetDraft} onChange={v => setTargetDraft(String(v))} options={[{ label: "全部目标", value: "" }, { label: "账目", value: "expense" }, { label: "管理员", value: "admin" }, { label: "导出", value: "export" }, { label: "会话", value: "session" }, { label: "更新任务", value: "update" }, { label: "系统检查", value: "system" }]} /><Button theme="primary" type="submit" icon={<Search size={15} />}>筛选</Button></form>
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Archive size={30} /><p>{action || targetType ? "没有符合当前筛选条件的审计记录" : "暂无审计记录"}</p>{(action || targetType) && <Button variant="outline" onClick={() => setSearchParams(new URLSearchParams())}>清除筛选</Button>}</div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap tn-audit-table" role="region" aria-label="审计日志列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>{hasMore && <div className="tn-table-more"><Button variant="outline" onClick={() => void load(true)} disabled={loadingMore} icon={<RotateCcw size={15} />}>{loadingMore ? "加载中…" : "加载更早记录"}</Button></div>}</AsyncState>
</Page>;
}
@@ -108,14 +108,14 @@ export default function ChangePasswordPage({ admin, onSuccess, onCancel, returnL
</section>;
if (!isFirstLogin) {
return <Page title="修改密码" subtitle="更新当前管理员的登录凭据。" actions={onCancel ? <Button variant="text" type="button" onClick={onCancel} icon={<ArrowLeft size={16} />}>{returnLabel}</Button> : undefined} className="tn-password-page">{panel}</Page>;
return <Page title="修改密码" subtitle="定期更新管理员账户登录密码,保障财务数据访问安全。" actions={onCancel ? <Button variant="text" type="button" onClick={onCancel} icon={<ArrowLeft size={16} />}>{returnLabel}</Button> : undefined} className="tn-password-page">{panel}</Page>;
}
return <main className="tn-login-page" data-page="change-password">
<section className="tn-login-container tn-password-container">
<div className="tn-login-heading">
<h1 id="password-title" className="tn-login-title">首次登录保护</h1>
<p className="tn-login-subtitle">管理员 {displayName} 需要先设置新密码。</p>
<h1 id="password-title" className="tn-login-title">初始安全设置</h1>
<p className="tn-login-subtitle">欢迎使用系统,管理员 {displayName},为保障账户安全,首次登录请先设置新密码。</p>
</div>
{panel}
</section>
+1 -1
View File
@@ -76,7 +76,7 @@ export default function LoginPage({ notice, onSuccess }: LoginPageProps) {
<main className="tn-login-page" data-page="login">
<section className="tn-login-container" aria-labelledby="login-title">
<div className="tn-login-heading">
<h1 id="login-title" className="tn-login-title">登录到 <span className="tn-login-title-brand">TallyNote</span></h1>
<h1 id="login-title" className="tn-login-title">登录 <span className="tn-login-title-brand">TallyNote</span> 工作台</h1>
</div>
<Form
+3 -2
View File
@@ -1,3 +1,4 @@
import { BeamLoading, BeamBar } from "../components/BeamLoading";
import type { ReactNode } from "react";
import { AlertCircle, Loader2 } from "lucide-react";
import { Alert, Button, Card, Loading, Space, Tag } from "tdesign-react";
@@ -26,10 +27,10 @@ export function AsyncState({ loading, error, empty, onRetry, children }: {
onRetry?: () => void;
children: ReactNode;
}) {
if (loading && empty) return <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div>;
if (loading && empty) return <div className="tn-empty" role="status" aria-live="polite"><BeamLoading text="数据加载中…" /></div>;
if (error && empty) return <div className="tn-empty" role="alert"><AlertCircle size={28} /><p>{error}</p>{onRetry && <Button variant="outline" onClick={onRetry}>重试</Button>}</div>;
return <>
{loading && <div className="tn-inline-loading" role="status"><Loader2 size={15} className="tn-spin" aria-hidden="true" />正在更新…</div>}
{loading && <div className="tn-inline-loading" role="status"><BeamBar className="tn-beam-bar-sm" /> 正在同步…</div>}
{error && <ErrorBanner message={error} onRetry={onRetry} />}
{empty || children}
</>;
+10 -5
View File
@@ -1,3 +1,4 @@
import { BeamLoading } from "../../components/BeamLoading";
import { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
import { Alert, Button, Card, DatePicker, Empty, Loading, Space, Statistic, Table, Tag, Tooltip } from "tdesign-react";
@@ -17,6 +18,8 @@ echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, Canvas
type Props = { timezone?: string; onNavigate?: (id: RouteId, search?: string) => void };
type ExpenseResult = { items: Expense[]; summary: { count: number; amountCents: number } };
let dashboardCache: { month: string; unreimbursed: ExpenseResult; reimbursed: ExpenseResult } | null = null;
function prefersReducedMotion(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
@@ -28,11 +31,12 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }
const rawMonthParam = searchParams.get("month");
const defaultMonth = monthNow(timezone);
const month = rawMonthParam && /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonthParam) ? rawMonthParam : defaultMonth;
const [unreimbursed, setUnreimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
const [reimbursed, setReimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
const [loading, setLoading] = useState(true);
const isCached = dashboardCache?.month === month;
const [unreimbursed, setUnreimbursed] = useState<ExpenseResult>(() => (isCached ? dashboardCache!.unreimbursed : { items: [], summary: { count: 0, amountCents: 0 } }));
const [reimbursed, setReimbursed] = useState<ExpenseResult>(() => (isCached ? dashboardCache!.reimbursed : { items: [], summary: { count: 0, amountCents: 0 } }));
const [loading, setLoading] = useState(() => !isCached);
const [error, setError] = useState("");
const [loadedMonth, setLoadedMonth] = useState<string | null>(null);
const [loadedMonth, setLoadedMonth] = useState<string | null>(() => (isCached ? month : null));
const requestSequence = useRef(0);
const [reducedMotion, setReducedMotion] = useState(prefersReducedMotion);
@@ -74,6 +78,7 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }
setUnreimbursed(pending);
setReimbursed(done);
setLoadedMonth(month);
dashboardCache = { month, unreimbursed: pending, reimbursed: done };
} catch (caught) {
if (sequence === requestSequence.current) setError((caught as Error).message);
} finally {
@@ -140,7 +145,7 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }
<div className="tn-page-actions"><div className="tn-dashboard-actions"><div className="tn-dashboard-month-control"><Tooltip content="上个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={16} />} /></Tooltip><DatePicker className="tn-dashboard-month" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) setDashboardMonth(next); }} inputProps={{ "aria-label": "仪表盘月份" } as any} /><Tooltip content="下个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={16} />} /></Tooltip></div><div className="tn-dashboard-secondary-actions"><Button className="tn-dashboard-refresh" variant="outline" onClick={() => void load()} disabled={loading} icon={<RefreshCw size={15} />}>刷新</Button><Button className="tn-dashboard-view" theme="primary" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看账目</Button></div></div></div>
</div>
{error && hasCurrentSnapshot && <Alert theme="error" icon={<AlertCircle size={16} />} message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />}
{loading ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载仪表盘…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
{loading && !hasCurrentSnapshot ? <div className="tn-empty" role="status" aria-live="polite"><BeamLoading text="正在汇总本月数据…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
<div className="tn-dashboard-stats">
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-total"><Statistic title="本月总额" value={totalCents / 100} prefix="¥" decimalPlaces={2} /></Card>
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-count"><Statistic title="账目笔数" value={totalCount} suffix="笔" /></Card>
@@ -1,3 +1,4 @@
import { BeamLoading } from "../../components/BeamLoading";
import { useCallback, useEffect, useRef, useState } from "react";
import { AlertCircle, ArrowDownToLine, FileText, Image as ImageIcon, Loader2, Search, Settings, Trash2, X } from "lucide-react";
import { Button, Dialog, Drawer, Loading, Space, Tag, Textarea, Tooltip } from "tdesign-react";
@@ -10,7 +11,7 @@ type Props = { expense: Expense; timezone?: string; onClose: () => void; onUpdat
const TIMELINE_LABELS: Record<string, string> = {
"expense.created": "创建账目",
"expense.updated": "更新账目",
"expense.status_changed": "切换报销状态",
"expense.status_changed": "更新报销状态",
"expense.trashed": "移入回收站",
"expense.restored": "从回收站恢复",
"attachment.added": "添加附件",
@@ -49,7 +50,7 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC
const current = (caught.details as { current?: Expense } | undefined)?.current;
if (!current) return false;
setDetail(current);
setMessage("这笔账目刚被其他管理员修改,已加载最新版本,请确认后重试。");
setMessage("此笔账目已被其他管理员更新,已为您自动同步最新记录,请确认后重试。");
return true;
};
const updateStatus = async () => { setBusy(true); try { const next = detail.status === "reimbursed" ? "unreimbursed" : "reimbursed"; const result = await api<{ expense: Expense }>(`/api/expenses/${detail.id}/status`, { method: "POST", body: JSON.stringify({ status: next, version: detail.version }) }); setDetail(result.expense); setAction(null); notify?.(next === "reimbursed" ? "已标记为已报销" : "已改回未报销", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
@@ -57,16 +58,16 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC
const remove = async () => { if (!removeTarget) return; const requires = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim(); if (requires && !removeReason.trim()) { setRemoveError("请填写无发票原因"); return; } setBusy(true); setRemoveError(""); try { const body: { version: number; invoiceMissingReason?: string } = { version: detail.version }; if (requires) body.invoiceMissingReason = removeReason.trim(); const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(body) }); setDetail(result.expense); setRemoveTarget(null); notify?.("附件已删除", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setRemoveError)) setRemoveError((caught as Error).message); } finally { setBusy(false); } };
return <>
<Drawer className="tn-content-drawer tn-detail-drawer" placement="right" size="520px" visible destroyOnClose header="账目详情" onClose={onClose} footer={<Space><Button onClick={() => setAction("status")} disabled={busy}>{detail.status === "reimbursed" ? "标记未报销" : "标记已报销"}</Button><Button theme="danger" onClick={() => setAction("trash")} disabled={busy}><Trash2 size={15} />移入回收站</Button></Space>}>
{loading ? <div role="status" aria-live="polite"><Loading text="加载详情…" /></div> : error ? <div role="alert" className="expense-error"><AlertCircle size={16} />{error}<Button variant="text" onClick={load}>重试</Button></div> : <div className="expense-detail">
{loading ? <div className="tn-drawer-loading-wrap" role="status" aria-live="polite"><BeamLoading text="正在加载账目详情…" /></div> : error ? <div role="alert" className="expense-error"><AlertCircle size={16} />{error}<Button variant="text" onClick={load}>重试</Button></div> : <div className="expense-detail">
<div className="expense-detail-head"><strong>{money(detail.amountCents)}</strong><Button variant="outline" onClick={() => onRequestEdit(detail)}><Settings size={15} />编辑</Button></div>
<dl><div><dt>支付时间</dt><dd>{dateText(detail.paidAt, timezone)}</dd></div><div><dt>发票</dt><dd>{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <><Tag theme="warning">无发票</Tag>:{detail.invoiceMissingReason}</> : <Tag theme="danger">未说明</Tag>}</dd></div><div><dt>状态</dt><dd><Tag theme={detail.status === "reimbursed" ? "success" : "warning"}>{detail.status === "reimbursed" ? "已报销" : "未报销"}</Tag></dd></div><div><dt>备注</dt><dd>{detail.note || "无"}</dd></div></dl>
<h3>附件 <small>{detail.attachments?.length || 0}</small></h3><div className="expense-attachments">{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return <div className="expense-attachment" key={a.id}><span title={a.originalName}>{a.mimeType.startsWith("image/") ? <ImageIcon size={16} /> : <FileText size={16} />} {a.originalName}<small>{formatBytes(a.sizeBytes)}</small></span><Space>{a.previewable && <Tooltip content="预览附件" placement="left"><Button variant="text" shape="circle" onClick={() => setPreview(a)} aria-label={`预览 ${a.originalName}`}><Search size={15} /></Button></Tooltip>}<Tooltip content="下载附件" placement="left"><Button variant="text" shape="circle" onClick={() => { window.location.href = `/api/attachments/${a.id}/content?download=1`; }} aria-label={`下载 ${a.originalName}`}><ArrowDownToLine size={15} /></Button></Tooltip><Tooltip content={protectsLastProof ? "至少保留一张付款凭证" : "删除附件"} placement="left"><Button variant="text" shape="circle" theme="danger" disabled={protectsLastProof} onClick={() => { setRemoveReason(""); setRemoveError(""); setRemoveTarget(a); }} aria-label={protectsLastProof ? `不可删除最后一张付款凭证 ${a.originalName}` : `删除 ${a.originalName}`}><Trash2 size={14} /></Button></Tooltip></Space></div>; })}</div>
{timeline.length > 0 && <><h3>操作记录</h3><div className="expense-timeline">{timeline.slice(0, 12).map(t => <div key={t.id}><span>{dateText(t.occurredAt, timezone)}</span><strong title={t.action}>{TIMELINE_LABELS[t.action] || t.action}</strong><small>{t.actorUsername || "系统"}</small></div>)}</div></>}
</div>}
</Drawer>
<Dialog visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"}</Dialog>
<Dialog visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。</Dialog>
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert"><AlertCircle size={16} />{removeError}</div>}</>}</Dialog>
<Dialog visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
<Dialog width="540px" visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "确认将该笔账目恢复为未报销状态?" : "确认该笔账目已完成报销审批与结算?"}</Dialog>
<Dialog width="540px" visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>移入回收站后将不在正常列表中展示,关联附件会完整保留,可随时前往回收站恢复。</Dialog>
<Dialog width="560px" visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "每笔账目至少需要保留一张有效的付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "当前为该账目唯一的发票附件,删除后请补充说明无发票原因。" : "确认删除该发票附件?"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert">{removeError}</div>}</>}</Dialog>
<Dialog width="880px" className="tn-dialog-xlarge" visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
</>;
}
@@ -143,7 +143,7 @@ export default function ExpenseDrawer({ expense, timezone = "Asia/Shanghai", onC
{error && <div role="alert" className="expense-error"><AlertCircle size={16} />{error}</div>}
</form>
</Drawer>
<Dialog visible={Boolean(conflict)} header="记录已被更新" confirmBtn="保留当前内容" cancelBtn="加载服务器版本" onClose={() => { setConflict(null); setError("冲突提示已关闭,请再次保存以重新确认最新版本。"); }} onConfirm={() => { if (conflict) { setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); setError("当前内容已保留,请再次保存以覆盖服务器版本。"); } }} onCancel={() => { if (conflict) { setAmount((conflict.amountCents / 100).toFixed(2)); setNote(conflict.note); setPaidAt(dateInputValue(new Date(conflict.paidAt), timezone)); setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); } }}>另一位管理员刚刚修改了这笔账目。请选择如何处理,系统不会静默覆盖。</Dialog>
<Dialog width="560px" visible={Boolean(conflict)} header="记录已被更新" confirmBtn="保留当前内容" cancelBtn="加载服务器版本" onClose={() => { setConflict(null); setError("已取消冲突提示,再次点击保存将重新确认最新数据。"); }} onConfirm={() => { if (conflict) { setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); setError("已保留您当前编辑的内容,再次点击保存将更新此账目。"); } }} onCancel={() => { if (conflict) { setAmount((conflict.amountCents / 100).toFixed(2)); setNote(conflict.note); setPaidAt(dateInputValue(new Date(conflict.paidAt), timezone)); setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); } }}>此笔账目已被其他管理员更新。为保障财务数据准确,请选择保留您当前的编辑并覆盖,或同步加载最新版本。</Dialog>
</>;
}
function focusExpenseField(errors: Partial<Record<ExpenseField, string>>) {
+17 -9
View File
@@ -1,3 +1,4 @@
import { BeamLoading } from "../../components/BeamLoading";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronLeft, ChevronRight, ClipboardList, FileDown, FileText, Pencil, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
import { Button, Checkbox, DatePicker, Dialog, Loading, Radio, Space, Table, Tag, Tooltip } from "tdesign-react";
@@ -9,6 +10,8 @@ import AccessibleInput from "../../components/AccessibleInput";
import { dateText, money, monthNow } from "./date";
import type { Expense, Notify } from "./types";
let expensesCache: { key: string; items: Expense[]; summary: { count: number; amountCents: number } } | null = null;
type Props = { timezone?: string; notify?: Notify; sessionKey?: string };
const EXPORT_JOB_STORAGE_KEY = "tallynote.exportJobId";
@@ -29,9 +32,14 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
const query = rawQuery.slice(0, 200);
const rawMissingInvoice = searchParams.get("missingInvoice");
const missingInvoice = searchParams.get("missingInvoice") === "true";
const [queryDraft, setQueryDraft] = useState(query);
const [items, setItems] = useState<Expense[]>([]); const [summary, setSummary] = useState({ count: 0, amountCents: 0 }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [loadedFilterKey, setLoadedFilterKey] = useState<string | null>(null); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState<Expense | null>(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState<string | null>(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState<Expense | null>(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false);
const filterKey = `${month}|${status}|${query}|${missingInvoice ? "1" : "0"}`;
const isCached = expensesCache?.key === filterKey;
const [queryDraft, setQueryDraft] = useState(query);
const [items, setItems] = useState<Expense[]>(() => (isCached ? expensesCache!.items : []));
const [summary, setSummary] = useState(() => (isCached ? expensesCache!.summary : { count: 0, amountCents: 0 }));
const [loading, setLoading] = useState(() => !isCached);
const [error, setError] = useState("");
const [loadedFilterKey, setLoadedFilterKey] = useState<string | null>(() => (isCached ? filterKey : null)); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState<Expense | null>(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState<string | null>(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState<Expense | null>(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false);
useEffect(() => {
const params = new URLSearchParams(searchParams);
let changed = false;
@@ -54,8 +62,8 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
if (next.missingInvoice ?? missingInvoice) params.set("missingInvoice", "true"); else params.delete("missingInvoice");
setSearchParams(params);
};
const load = async () => { const s = ++sequence.current; const requestedKey = filterKey; setLoading(true); setError(""); try { const result = await api<{ items: Expense[]; summary: typeof summary }>(`/api/expenses?month=${month}&status=${status}&query=${encodeURIComponent(query)}&missingInvoice=${missingInvoice}`); if (s === sequence.current) { setItems(result.items); setSummary(result.summary); setLoadedFilterKey(requestedKey); setSelectedKeys(keys => keys.filter(k => result.items.some(x => x.id === k))); } } catch (e) { if (s === sequence.current) { setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); setError((e as Error).message); } } finally { if (s === sequence.current) setLoading(false); } };
useEffect(() => { setSelectedKeys([]); setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); void load(); }, [filterKey]);
const load = async () => { const s = ++sequence.current; const requestedKey = filterKey; setLoading(true); setError(""); try { const result = await api<{ items: Expense[]; summary: typeof summary }>(`/api/expenses?month=${month}&status=${status}&query=${encodeURIComponent(query)}&missingInvoice=${missingInvoice}`); if (s === sequence.current) { setItems(result.items); setSummary(result.summary); setLoadedFilterKey(requestedKey); setSelectedKeys(keys => keys.filter(k => result.items.some(x => x.id === k))); expensesCache = { key: requestedKey, items: result.items, summary: result.summary }; } } catch (e) { if (s === sequence.current) { setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); setError((e as Error).message); } } finally { if (s === sequence.current) setLoading(false); } };
useEffect(() => { setSelectedKeys([]); if (expensesCache?.key !== filterKey) { setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); } void load(); }, [filterKey]);
const selectedTotal = useMemo(() => items.filter(i => selectedKeys.includes(i.id)).reduce((sum, i) => sum + i.amountCents, 0), [items, selectedKeys]);
const shiftMonth = (delta: number) => { const [rawYear, rawMonthNumber] = month.split("-").map(Number); const y = rawYear || new Date().getFullYear(); const m = rawMonthNumber || 1; const d = new Date(y, m - 1 + delta, 1); updateFilters({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` }); };
const rememberExportJob = (jobId: string | null) => {
@@ -92,14 +100,14 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
} catch {
if (disposed) return;
failureCount += 1;
setExportIssue("网络连接不稳定,导出仍在后台进行,正在重新查询状态…");
setExportIssue("网络响应稍慢,数据导出仍在后台处理中,正在自动同步进度…");
schedule(Math.min(1000 * (2 ** Math.min(failureCount, 3)), 8000));
}
};
void poll();
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
}, [exporting, notify]);
const moveToTrash = async () => { if (!trashTarget) return; setTrashing(true); try { await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) }); notify?.("账目已移入回收站,可在回收站恢复", "success"); setTrashTarget(null); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setTrashing(false); } };
const moveToTrash = async () => { if (!trashTarget) return; setTrashing(true); try { await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) }); notify?.("账目已移入回收站,可随时在回收站恢复", "success"); setTrashTarget(null); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setTrashing(false); } };
const columns = [
{ colKey: "row-select", type: "multiple" },
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
@@ -122,10 +130,10 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
return <div className="tn-page expenses-page"><div className="tn-page-head expenses-head"><div><h1 className="tn-page-title">账目列表</h1></div><div className="tn-page-actions"><Checkbox checked={includeManifest} onChange={setIncludeManifest}>包含 manifest.json</Checkbox><Button variant="outline" onClick={() => void exportAll()} disabled={Boolean(exporting) || (!selectedKeys.length && (!items.length || !dataReady))} icon={<FileDown size={16} />}>{exporting ? "导出中…" : selectedKeys.length ? `导出所选(${selectedKeys.length})` : "导出筛选结果"}</Button><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={16} />}>新增账目</Button></div></div>
<div className="tn-toolbar expenses-toolbar"><div className="tn-expense-month-controls"><Tooltip content="上个月"><Button variant="text" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={18} />} /></Tooltip><DatePicker className="tn-month-picker" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) updateFilters({ month: next }); }} placeholder="选择月份" inputProps={{ "aria-label": "账目月份" } as any} /><Tooltip content="下个月"><Button variant="text" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={18} />} /></Tooltip></div><Radio.Group className="tn-segmented" theme="button" variant="primary-filled" value={status} onChange={(value: any) => updateFilters({ status: value as "unreimbursed" | "reimbursed" })} aria-label="报销状态"><Radio.Button value="unreimbursed">未报销</Radio.Button><Radio.Button value="reimbursed">已报销</Radio.Button></Radio.Group><div className="tn-expense-search-controls"><AccessibleInput inputAriaLabel="搜索备注" className="tn-expense-search" value={queryDraft} onChange={setQueryDraft} onEnter={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} maxlength={200} placeholder="搜索备注" prefixIcon={<Search size={16} />} suffix={queryDraft ? <Tooltip content="清除搜索"><Button variant="text" shape="square" onClick={() => { setQueryDraft(""); updateFilters({ query: "" }); }} aria-label="清除搜索" icon={<X size={14} />} /></Tooltip> : undefined} /><Button variant="outline" onClick={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} disabled={loading} icon={<Search size={15} />}>搜索</Button></div><div className="tn-expense-filter-actions"><Checkbox checked={missingInvoice} onChange={checked => updateFilters({ missingInvoice: checked })}>缺发票</Checkbox><Tooltip content="刷新当前结果"><Button variant="outline" shape="square" onClick={() => void load()} disabled={loading} aria-label="刷新当前结果" icon={<RefreshCw size={15} />} /></Tooltip></div></div>
<div className="tn-summary expenses-summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong>{selectedKeys.length > 0 && <><span>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}</span><Button variant="text" onClick={() => setSelectedKeys([])}>清除选择</Button></>}</div>
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>停止等待</Button></div>}
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>关闭提示</Button></div>}
{error && <div className="expense-error" role="alert"><AlertCircle size={16} />{error}<Button variant="text" onClick={() => void load()}>重试</Button></div>}
{error ? null : !dataReady || (loading && !items.length) ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
{error ? null : (!items.length && (loading || !dataReady)) ? <div className="tn-empty" role="status" aria-live="polite"><BeamLoading text="正在加载账目列表…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
{drawer === "new" && <ExpenseDrawer timezone={timezone} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />}
{trashTarget && <Dialog visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站。它会从普通列表和导出结果中隐藏,附件会保留,可随时恢复。</Dialog>}
{trashTarget && <Dialog width="540px" visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>确认将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站?移入后将不在正常列表中展示,关联附件将完整保留,可随时恢复。</Dialog>}
</div>;
}
+3 -3
View File
@@ -53,12 +53,12 @@ export default function TrashPage({ timezone = "Asia/Shanghai", notify }: { time
{ colKey: "actions", title: "操作", width: 190, cell: ({ row }: any) => <Space className="tn-action-group"><Button variant="outline" onClick={() => void restore(row)} disabled={busy} icon={busy ? <BusyIcon /> : <RotateCcw size={15} />}>恢复</Button><Button theme="danger" variant="outline" onClick={() => { setPurgeTarget(row); setPassword(""); setPurgeError(""); }} disabled={busy} icon={<Trash2 size={15} />}>永久删除</Button></Space> },
];
return <Page title="回收站" subtitle="已删除的账目会保留附件,可恢复或经过密码确认后永久删除。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || busy} icon={<RotateCcw size={15} />}>刷新</Button>}>
return <Page title="回收站" subtitle="已标记删除的账目暂存于此,支持一键恢复或经安全验证后彻底清除。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || busy} icon={<RotateCcw size={15} />}>刷新</Button>}>
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Trash2 size={30} /><p>回收站为空</p></div> : undefined} onRetry={() => void load()}>
<div className="tn-table-wrap" role="region" aria-label="回收站账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>
</AsyncState>
<Dialog visible={Boolean(purgeTarget)} header="永久删除账目" confirmBtn={{ content: "永久删除", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }} onConfirm={() => void purge()} onCancel={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }}>
<p>此操作会移除账目和附件字节,完整审计内容仍会保留,且无法恢复。</p>
<Dialog width="540px" visible={Boolean(purgeTarget)} header="永久删除账目" confirmBtn={{ content: "永久删除", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }} onConfirm={() => void purge()} onCancel={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }}>
<p>此操作将永久清除该笔账目及其关联的所有凭证与发票附件,审计日志将予以留存,清除后不可恢复。</p>
<p className="tn-dialog-note">请输入当前管理员密码确认。</p>
<AccessibleInput inputAriaLabel="当前管理员密码" inputAriaInvalid={Boolean(purgeError)} inputAriaDescribedby={purgeError ? "trash-purge-error" : undefined} type="password" value={password} onChange={value => { setPassword(value); setPurgeError(""); }} placeholder="当前管理员密码" autocomplete="current-password" />
{purgeError && <div id="trash-purge-error" className="tn-inline-error" role="alert">{purgeError}</div>}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -96,7 +96,7 @@ export async function api<T = unknown>(url: string, init: ApiRequestInit = {}):
}
throw new ApiError(
response.status,
error?.message || `请求失败(${response.status})`,
error?.message || (response.status >= 500 ? "服务器暂时繁忙,请稍后重试" : "操作未能完成,请稍后重试"),
error?.code,
error?.details,
error?.requestId,
@@ -108,7 +108,7 @@ export async function api<T = unknown>(url: string, init: ApiRequestInit = {}):
if (caught instanceof ApiError) throw caught;
if (timedOut) throw new ApiError(408, "请求超时,请稍后重试", "REQUEST_TIMEOUT");
if (externalSignal?.aborted) throw new ApiError(0, "请求已取消", "REQUEST_CANCELED");
throw new ApiError(0, "网络连接失败,请确认服务仍在运行");
throw new ApiError(0, "网络连接异常,请检查网络后重试");
} finally {
clearTimeout(timeout);
externalSignal?.removeEventListener("abort", abortFromCaller);
File diff suppressed because it is too large Load Diff
+40 -7
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,25 +798,28 @@ 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 || hasActiveJob}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
<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>
{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}
{loading ? <div className="update-loading"><Loader2 className="spin" size={22} />正在读取版本信息</div> : info && <>
<div className="update-overview">
<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[]>([]);