Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
511fc5d785 | ||
|
|
32e0057bad | ||
|
|
c55ce25939 | ||
|
|
d01ad74121 | ||
|
|
9e5b2c48e2 | ||
|
|
ae5892d81c | ||
|
|
ab2d24a5c7 | ||
|
|
a070ad0434 | ||
|
|
3ab3e5e180 | ||
|
|
6c96cddd4e | ||
|
|
efbd0e0d87 | ||
|
|
ed0b492461 | ||
|
|
a080f531cd | ||
|
|
1e87f25c2b | ||
|
|
4c71861813 | ||
|
|
3a9f809f46 | ||
|
|
de45c4b20c | ||
|
|
cc9e897260 | ||
|
|
620362823b | ||
|
|
fa2fd94579 | ||
|
|
05a679c2c8 | ||
|
|
23e2f9c5e7 | ||
|
|
484881b410 | ||
|
|
32f768c8ee | ||
|
|
db37406498 | ||
|
|
2accca9a22 | ||
|
|
c791dc4915 | ||
|
|
b46a7ddc87 | ||
|
|
1ecb783d0c | ||
|
|
60c0519ac7 | ||
|
|
32a73c5b50 | ||
|
|
45de0ef759 | ||
|
|
65b5d95937 | ||
|
|
89a8edad88 | ||
|
|
283c1d77b4 | ||
|
|
f060f917a0 | ||
|
|
704740182a | ||
|
|
a61860fcb3 | ||
|
|
340d9b5245 | ||
|
|
5d02fa5769 | ||
|
|
526b2df8ea | ||
|
|
4f9629b089 | ||
|
|
36c2ed1361 | ||
|
|
755b82d2e5 | ||
|
|
0bdc812935 | ||
|
|
2de08f1358 | ||
|
|
91621df6c4 | ||
|
|
0690fe298c | ||
|
|
6a0d9e34dd | ||
|
|
77598ecc81 | ||
|
|
69a4b482ec | ||
|
|
ee89e04aae | ||
|
|
b431fe167e | ||
|
|
344985f514 | ||
|
|
c518890fc3 | ||
|
|
1925676fc9 | ||
|
|
37ffc27ff5 | ||
|
|
5dcf9d0f61 | ||
|
|
26fbec49ad | ||
|
|
3cedcb901b | ||
|
|
bac10b6fdf | ||
|
|
eeeec54d10 | ||
|
|
bcc63b8117 | ||
|
|
a268eb5fe9 | ||
|
|
4395317651 |
@@ -5,6 +5,10 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
|
||||
TALLYNOTE_TRUST_PROXY=false
|
||||
TALLYNOTE_COOKIE_SECURE=false
|
||||
# Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct
|
||||
# access. HTTP on a non-local Origin is opt-in; use HTTPS behind a proxy in
|
||||
# production.
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=false
|
||||
TALLYNOTE_SESSION_IDLE_HOURS=24
|
||||
TALLYNOTE_SESSION_ABSOLUTE_HOURS=168
|
||||
TALLYNOTE_EXPORT_TTL_MINUTES=15
|
||||
@@ -27,9 +31,12 @@ 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
|
||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,6 @@ TallyNote_报销资料_xxxxxxxx.zip
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm admin:init
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -43,7 +42,7 @@ pnpm build:next
|
||||
|
||||
`build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。
|
||||
|
||||
首次初始化会要求交互式输入管理员密码。也可以使用 `pnpm 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`。
|
||||
|
||||
@@ -51,30 +50,83 @@ pnpm build:next
|
||||
|
||||
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
|
||||
|
||||
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
|
||||
发布包必须包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、生产依赖、匹配架构的 Node runtime、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
安装命令保持简洁。首次在 SSH/终端中安装时,安装器会交互询问监听方式、端口和公开访问地址:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 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 状态和最近日志并回滚本次切换。
|
||||
|
||||
安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
|
||||
```
|
||||
|
||||
脚本会从公开仓库的 latest Release 获取当前架构归档和 `SHA256SUMS`,并在安装前始终校验 SHA-256。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。需要固定版本或预览时,仍可使用 `TALLYNOTE_VERSION`、`--version` 或 `--dry-run` 等高级选项。
|
||||
|
||||
安装过程会持续输出带统一前缀的阶段日志,不会在下载、校验或启动服务时静默等待。交互式 SSH/终端中会先显示网络配置选择,下载时还会显示 curl 进度条;非交互式运行(例如 CI)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化):
|
||||
|
||||
```text
|
||||
tallynote installer: [阶段] 检查运行环境、权限和目标架构
|
||||
tallynote installer: [完成] 运行环境可用:x64/glibc
|
||||
tallynote installer: [阶段] 从 Release API 获取最新版本
|
||||
tallynote installer: [完成] 已解析最新版本:1.1.2
|
||||
tallynote installer: [完成] Release 下载地址已准备
|
||||
tallynote installer: [阶段] 获取发布包:tallynote-1.1.2-linux-x64-glibc.tar.gz
|
||||
tallynote installer: [完成] 发布包已下载并通过大小限制
|
||||
tallynote installer: [阶段] 获取 SHA-256 校验清单
|
||||
tallynote installer: [完成] SHA-256 校验清单已准备
|
||||
tallynote installer: [阶段] 校验 SHA-256 和发布签名
|
||||
tallynote installer: [完成] 发布包校验通过
|
||||
tallynote installer: [阶段] 解包、校验包结构并原子切换到版本 1.1.2
|
||||
tallynote installer: [完成] 版本 1.1.2 已切换为当前版本
|
||||
tallynote installer: [阶段] 安装 systemd 单元、更新辅助程序和卸载器
|
||||
tallynote installer: [完成] systemd 单元、更新辅助程序和卸载器已安装
|
||||
tallynote installer: [阶段] 重新加载 systemd 并启动 TallyNote
|
||||
tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote 1.1.2
|
||||
tallynote installer: 访问地址:http://127.0.0.1:3000
|
||||
tallynote installer: 查看服务状态:systemctl status tallynote.service
|
||||
```
|
||||
|
||||
任何阶段失败都会以 `tallynote installer:` 前缀写出原因并立即停止;不会把不完整版本切换为当前版本。
|
||||
|
||||
如需额外启用签名校验,在环境中设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;不设置时不会要求公钥或 `SHA256SUMS.sig`。
|
||||
|
||||
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。
|
||||
|
||||
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`,默认仅监听 `127.0.0.1:3000`。
|
||||
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。
|
||||
|
||||
升级有两种方式:
|
||||
|
||||
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
1. 后台进入“系统更新”,点击“检查更新”后可先“下载更新包”,等待校验完成,再点击“立即更新”。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。下载阶段主服务保持运行;应用阶段才会停机、备份、切换和健康检查,页面会显示重启倒计时并自动重试连接。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。
|
||||
|
||||
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
|
||||
|
||||
### 卸载
|
||||
|
||||
安装完成后会提供 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
|
||||
安装完成后会提供 `/usr/local/sbin/tallynote-admin-init` 和 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序、管理员初始化命令和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-uninstall
|
||||
@@ -92,7 +144,9 @@ sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
|
||||
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
|
||||
|
||||
公网反代推荐使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。反代只需把域名转发到 TallyNote 端口并保留 `Host`、`X-Forwarded-Proto`;应用不会因为代理缺少或改写浏览器 `Origin` 而拦截登录。已认证写请求仍使用会话 Cookie 与 CSRF 令牌保护。
|
||||
|
||||
### 构建发布包
|
||||
|
||||
@@ -128,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
@@ -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 秒倒计时探针自动检测服务就绪,服务重启完毕自动恢复会话"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Production entry point for first-admin setup. The installer keeps the
|
||||
# EnvironmentFile root-readable only, so parse simple KEY=VALUE assignments
|
||||
# without sourcing arbitrary shell code.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
|
||||
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
|
||||
CONFIG_FILE="$CONFIG_DIR/tallynote.env"
|
||||
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}
|
||||
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
|
||||
|
||||
die() { printf 'tallynote admin-init: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
load_environment_file() {
|
||||
[[ -e "$CONFIG_FILE" ]] || return 0
|
||||
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || die '环境文件不是安全的普通文件'
|
||||
local uid mode_bits line key value
|
||||
uid=$(stat -c '%u' "$CONFIG_FILE" 2>/dev/null || stat -f '%u' "$CONFIG_FILE")
|
||||
[[ "$uid" == 0 ]] || die '环境文件必须由 root 拥有'
|
||||
mode_bits=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || stat -f '%Lp' "$CONFIG_FILE")
|
||||
[[ "$mode_bits" =~ ^[0-7]+$ ]] || die '无法读取环境文件权限'
|
||||
(( (8#$mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
[[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || die '环境文件包含无法识别的配置行'
|
||||
key=${BASH_REMATCH[1]}
|
||||
value=${BASH_REMATCH[2]}
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "环境文件中的 $key 包含控制字符"
|
||||
export "$key=$value"
|
||||
done < "$CONFIG_FILE"
|
||||
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-$PREFIX}
|
||||
}
|
||||
|
||||
resolve_release_root() {
|
||||
local root prefix_root
|
||||
[[ "$PREFIX" = /* && "$PREFIX" != *$'\n'* && "$PREFIX" != *$'\r'* ]] || die '安装目录无效'
|
||||
[[ -L "$PREFIX/current" ]] || die '当前 release 链接不存在'
|
||||
prefix_root=$(readlink -f -- "$PREFIX" 2>/dev/null || realpath "$PREFIX" 2>/dev/null || true)
|
||||
[[ -n "$prefix_root" && -d "$prefix_root" && ! -L "$prefix_root" ]] || die '安装目录不安全'
|
||||
root=$(readlink -f -- "$PREFIX/current" 2>/dev/null || realpath "$PREFIX/current" 2>/dev/null || true)
|
||||
[[ -n "$root" && "$root" == "$prefix_root/releases/"* && -d "$root" && ! -L "$root" ]] || die '当前 release 链接不安全'
|
||||
printf '%s' "$root"
|
||||
}
|
||||
|
||||
run_as_service_user() {
|
||||
local root=$1 node=$2 cli=$3
|
||||
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
|
||||
local service_uid
|
||||
service_uid=$(id -u tallynote 2>/dev/null) || die '找不到 tallynote 服务用户,拒绝以 root 身份执行管理员初始化'
|
||||
[[ "$service_uid" =~ ^[1-9][0-9]*$ ]] || die 'tallynote 服务用户 UID 无效,拒绝以 root 身份执行管理员初始化'
|
||||
command -v runuser >/dev/null 2>&1 || die '找不到 runuser,无法以 tallynote 用户初始化'
|
||||
local -a environment=(
|
||||
"NODE_ENV=production"
|
||||
"TALLYNOTE_DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}"
|
||||
"TALLYNOTE_INSTALL_PREFIX=${TALLYNOTE_INSTALL_PREFIX:-$PREFIX}"
|
||||
"TALLYNOTE_TRUST_PROXY=${TALLYNOTE_TRUST_PROXY:-false}"
|
||||
"TALLYNOTE_UPDATE_STRATEGY=${TALLYNOTE_UPDATE_STRATEGY:-systemd}"
|
||||
"TALLYNOTE_HOST=${TALLYNOTE_HOST:-127.0.0.1}"
|
||||
"TALLYNOTE_PORT=${TALLYNOTE_PORT:-3000}"
|
||||
"TALLYNOTE_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN:-http://127.0.0.1:3000}"
|
||||
"TALLYNOTE_COOKIE_SECURE=${TALLYNOTE_COOKIE_SECURE:-false}"
|
||||
"TALLYNOTE_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP:-false}"
|
||||
"TALLYNOTE_TIMEZONE=${TALLYNOTE_TIMEZONE:-Asia/Shanghai}"
|
||||
"TALLYNOTE_UPDATE_METADATA_URL=${TALLYNOTE_UPDATE_METADATA_URL:-https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest}"
|
||||
"TALLYNOTE_UPDATE_ALLOWED_HOSTS=${TALLYNOTE_UPDATE_ALLOWED_HOSTS:-git.awaioi.com}"
|
||||
"TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=${TALLYNOTE_UPDATE_REQUIRE_SIGNATURE:-false}"
|
||||
"TALLYNOTE_UPDATE_MAX_MB=${TALLYNOTE_UPDATE_MAX_MB:-512}"
|
||||
"TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS:-60}"
|
||||
"TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS:-15}"
|
||||
"TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS:-15}"
|
||||
"TALLYNOTE_MAX_FILE_MB=${TALLYNOTE_MAX_FILE_MB:-20}"
|
||||
"TALLYNOTE_MAX_FILES_PER_REQUEST=${TALLYNOTE_MAX_FILES_PER_REQUEST:-20}"
|
||||
"TALLYNOTE_MAX_RECORD_MB=${TALLYNOTE_MAX_RECORD_MB:-100}"
|
||||
"TALLYNOTE_MAX_TOTAL_MB=${TALLYNOTE_MAX_TOTAL_MB:-2048}"
|
||||
"TALLYNOTE_MAX_CONCURRENT_EXPORTS=${TALLYNOTE_MAX_CONCURRENT_EXPORTS:-2}"
|
||||
"TALLYNOTE_MAX_EXPORT_RECORDS=${TALLYNOTE_MAX_EXPORT_RECORDS:-5000}"
|
||||
"TALLYNOTE_MAX_EXPORT_MB=${TALLYNOTE_MAX_EXPORT_MB:-1024}"
|
||||
"TALLYNOTE_MAX_EXPORT_STORAGE_MB=${TALLYNOTE_MAX_EXPORT_STORAGE_MB:-2048}"
|
||||
"TALLYNOTE_SESSION_IDLE_HOURS=${TALLYNOTE_SESSION_IDLE_HOURS:-24}"
|
||||
"TALLYNOTE_SESSION_ABSOLUTE_HOURS=${TALLYNOTE_SESSION_ABSOLUTE_HOURS:-168}"
|
||||
"TALLYNOTE_EXPORT_TTL_MINUTES=${TALLYNOTE_EXPORT_TTL_MINUTES:-15}"
|
||||
)
|
||||
[[ -n "${TALLYNOTE_UPDATE_PUBLIC_KEY:-}" ]] && environment+=("TALLYNOTE_UPDATE_PUBLIC_KEY=$TALLYNOTE_UPDATE_PUBLIC_KEY")
|
||||
if [[ -n "${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}" ]]; then
|
||||
environment+=("TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$TALLYNOTE_UPDATE_PUBLIC_KEY_FILE")
|
||||
fi
|
||||
if [[ -n "${TALLYNOTE_UPDATE_HELPER_PATH:-}" ]]; then
|
||||
environment+=("TALLYNOTE_UPDATE_HELPER_PATH=$TALLYNOTE_UPDATE_HELPER_PATH")
|
||||
fi
|
||||
runuser -u tallynote -- env -i "${environment[@]}" PATH="$PATH" "$node" "$cli" "${@:4}"
|
||||
else
|
||||
"$node" "$cli" "${@:4}"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
load_environment_file
|
||||
local root node cli systemctl service_was_active=0 result check_only=0
|
||||
for argument in "$@"; do
|
||||
[[ "$argument" == "--check" ]] && check_only=1
|
||||
done
|
||||
root=$(resolve_release_root)
|
||||
node="$root/runtime/bin/node"
|
||||
[[ -x "$node" ]] || node=$(command -v node || true)
|
||||
[[ -n "$node" && -x "$node" ]] || die '找不到 Node.js runtime'
|
||||
cli="$root/dist/server/cli/admin-init.js"
|
||||
[[ -f "$cli" && ! -L "$cli" ]] || die '管理员初始化程序不存在'
|
||||
|
||||
# Validate the privilege boundary before stopping an active service. A
|
||||
# damaged installation must fail closed without causing avoidable downtime.
|
||||
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
|
||||
local service_uid
|
||||
service_uid=$(id -u tallynote 2>/dev/null) || die '找不到 tallynote 服务用户,拒绝以 root 身份执行管理员初始化'
|
||||
[[ "$service_uid" =~ ^[1-9][0-9]*$ ]] || die 'tallynote 服务用户 UID 无效,拒绝以 root 身份执行管理员初始化'
|
||||
command -v runuser >/dev/null 2>&1 || die '找不到 runuser,无法以 tallynote 用户初始化'
|
||||
fi
|
||||
|
||||
# admin-init uses the same instance lock as the web process. Pause an active
|
||||
# service for the duration, then restore exactly its previous active state.
|
||||
systemctl=$(command -v systemctl || true)
|
||||
if (( ! check_only )) && [[ "${EUID:-$(id -u)}" == 0 && -n "$systemctl" && -x "$systemctl" ]] && "$systemctl" is-active --quiet "$SERVICE_NAME"; then
|
||||
service_was_active=1
|
||||
printf 'tallynote admin-init: 暂停服务以完成管理员初始化\n' >&2
|
||||
"$systemctl" stop "$SERVICE_NAME" || die '无法暂停 TallyNote 服务'
|
||||
fi
|
||||
restore_service() {
|
||||
local exit_code=$?
|
||||
if (( service_was_active )); then
|
||||
printf 'tallynote admin-init: 恢复 TallyNote 服务\n' >&2
|
||||
"$systemctl" start "$SERVICE_NAME" || printf 'tallynote admin-init: 警告:服务恢复失败,请执行 systemctl start %s\n' "$SERVICE_NAME" >&2
|
||||
fi
|
||||
return "$exit_code"
|
||||
}
|
||||
trap restore_service EXIT
|
||||
|
||||
cd -- "$root"
|
||||
set +e
|
||||
run_as_service_user "$root" "$node" "$cli" "$@"
|
||||
result=$?
|
||||
set -e
|
||||
exit "$result"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+40
-4
@@ -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 安装
|
||||
|
||||
@@ -38,13 +40,43 @@ GITEA_TOKEN=... \
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动后,安装器会先请求本机 `/health`,只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
|
||||
```
|
||||
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。
|
||||
|
||||
安装器会在每个关键阶段输出统一格式的日志,便于在 SSH 或 systemd 安装会话中确认进度;交互式终端下载时还会显示 curl 进度条,CI 或日志重定向时则保持纯文本输出:
|
||||
|
||||
```text
|
||||
tallynote installer: [阶段] 检查运行环境、权限和目标架构
|
||||
tallynote installer: [阶段] 从 Release API 获取最新版本
|
||||
tallynote installer: [阶段] 获取发布包:tallynote-<版本>-linux-x64-glibc.tar.gz
|
||||
tallynote installer: [阶段] 获取 SHA-256 校验清单
|
||||
tallynote installer: [阶段] 校验 SHA-256 和发布签名
|
||||
tallynote installer: [阶段] 解包、校验包结构并原子切换到版本 <版本>
|
||||
tallynote installer: [完成] 版本 <版本> 已切换为当前版本
|
||||
tallynote installer: [阶段] 安装 systemd 单元、更新辅助程序和卸载器
|
||||
tallynote installer: [完成] systemd 单元、更新辅助程序和卸载器已安装
|
||||
tallynote installer: [阶段] 重新加载 systemd 并启动 TallyNote
|
||||
tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote <版本>
|
||||
tallynote installer: 访问地址:http://127.0.0.1:<端口>
|
||||
```
|
||||
|
||||
每个阶段完成时会输出 `[完成]`;错误会立即以 `tallynote installer:` 前缀输出,不会静默等待或切换半成品版本。
|
||||
|
||||
如需启用签名校验,设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;后台更新同样可通过 `TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 开启。默认关闭签名要求,方便公开自维护仓库直接更新。
|
||||
|
||||
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
|
||||
|
||||
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`。`--allow-unsigned` 作为旧版本兼容参数保留。
|
||||
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`,并提供 `/usr/local/sbin/tallynote-admin-init` 作为生产环境首次管理员初始化入口。`--allow-unsigned` 作为旧版本兼容参数保留。
|
||||
|
||||
安装布局:
|
||||
|
||||
@@ -68,17 +100,21 @@ sudo /usr/local/sbin/tallynote-uninstall
|
||||
|
||||
只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。
|
||||
|
||||
卸载过程中会输出每个 systemd 单元的检查、停止、禁用和删除阶段;systemd/dbus 调用默认 30 秒超时,避免长时间无反馈。可用 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整。
|
||||
|
||||
## 后台一键更新
|
||||
|
||||
将环境文件中的 `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 签名。
|
||||
|
||||
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata 和清单,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
|
||||
|
||||
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-update --rollback
|
||||
```
|
||||
|
||||
更新检查和应用接口带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。
|
||||
更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`;首次安装时可在交互提示中选择 `0.0.0.0` 和真实的服务器 IP/域名。直连 HTTP 会暴露未加密的会话和数据,只适合受控网络;绑定域名后必须改为 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。自动化安装可使用 `--non-interactive` 或显式网络环境变量。
|
||||
|
||||
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+684
-33
@@ -34,6 +34,22 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300}
|
||||
RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-}
|
||||
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
|
||||
UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname}
|
||||
# Service network settings are written to the systemd EnvironmentFile on a
|
||||
# fresh install. Existing values are preserved unless the corresponding
|
||||
# TALLYNOTE_* variable is explicitly supplied to the installer.
|
||||
INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1}
|
||||
INSTALL_PORT=${TALLYNOTE_PORT-3000}
|
||||
INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-}
|
||||
INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false}
|
||||
PUBLIC_IP_URL=${TALLYNOTE_PUBLIC_IP_URL-}
|
||||
NON_INTERACTIVE=0
|
||||
NETWORK_INTERACTIVE=0
|
||||
|
||||
# The production prompt uses the controlling terminal, even when the
|
||||
# installer itself is read from `curl | sudo bash`.
|
||||
PROMPT_INPUT=/dev/tty
|
||||
PROMPT_OUTPUT=/dev/tty
|
||||
PROMPT_REPLY=''
|
||||
|
||||
INSTALL_SWITCHED=0
|
||||
INSTALL_COMMITTED=0
|
||||
@@ -41,9 +57,16 @@ INSTALL_PREVIOUS_TARGET=''
|
||||
INSTALL_NEW_RELEASE=''
|
||||
INSTALL_WORK_DIR=''
|
||||
INSTALL_BACKUP_DIR=''
|
||||
INSTALL_BACKUP_COMPLETE=0
|
||||
INSTALL_WAS_ACTIVE=0
|
||||
INSTALL_PATH_WAS_ACTIVE=0
|
||||
INSTALL_UPDATE_WAS_ACTIVE=0
|
||||
INSTALL_WAS_ENABLED=0
|
||||
INSTALL_PATH_WAS_ENABLED=0
|
||||
INSTALL_UPDATE_WAS_ENABLED=0
|
||||
INSTALL_SYSTEMD_TOUCHED=0
|
||||
ADMIN_INIT_PATH=/usr/local/sbin/tallynote-admin-init
|
||||
INSTALL_FIRST_INSTALL=0
|
||||
DATA_DIR_TEMP_ROOT=0
|
||||
DATA_DIR_ORIGINAL_OWNER=''
|
||||
|
||||
@@ -58,17 +81,323 @@ Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL]
|
||||
[--signature-format ed25519|gpg]
|
||||
[--update-public-key-file FILE]
|
||||
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply]
|
||||
[--non-interactive]
|
||||
|
||||
Without arguments, the installer resolves the latest compatible release and
|
||||
installs it. SHA-256 from SHA256SUMS is always required. Detached signature
|
||||
verification is optional by default; enable it with
|
||||
TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use
|
||||
--dry-run to inspect the selected release without downloading or changing the
|
||||
host. --apply is accepted for backwards compatibility.
|
||||
host. For direct IP access, pass TALLYNOTE_HOST=0.0.0.0 and an actual
|
||||
TALLYNOTE_PUBLIC_ORIGIN such as http://203.0.113.10:3000; HTTP also requires
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener
|
||||
and public URL can be selected interactively. The installer checks that the
|
||||
selected TCP port is free, suggests a public IPv4 address when exposing
|
||||
0.0.0.0, and prints the final access URL after the service starts. Use --non-interactive (or
|
||||
TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for
|
||||
backwards compatibility.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
|
||||
log() { printf 'tallynote installer: %s\n' "$*"; }
|
||||
stage() { log "[阶段] $*"; }
|
||||
stage_done() { log "[完成] $*"; }
|
||||
|
||||
case "${TALLYNOTE_NON_INTERACTIVE:-false}" in
|
||||
true|1) NON_INTERACTIVE=1 ;;
|
||||
false|0) ;;
|
||||
*) die 'TALLYNOTE_NON_INTERACTIVE 必须是 true 或 false' ;;
|
||||
esac
|
||||
|
||||
prompt_value() {
|
||||
local label=$1 default=${2-} reply
|
||||
if [[ -n "$default" ]]; then
|
||||
printf '%s [%s]: ' "$label" "$default" > "$PROMPT_OUTPUT"
|
||||
else
|
||||
printf '%s: ' "$label" > "$PROMPT_OUTPUT"
|
||||
fi
|
||||
if ! IFS= read -r reply <&9; then
|
||||
die '无法读取终端输入;请使用 --non-interactive 或通过环境变量配置'
|
||||
fi
|
||||
PROMPT_REPLY=${reply:-$default}
|
||||
}
|
||||
|
||||
detect_public_ipv4() {
|
||||
local endpoint value octet
|
||||
local -a endpoints=()
|
||||
if [[ -n "$PUBLIC_IP_URL" ]]; then
|
||||
endpoints=("$PUBLIC_IP_URL")
|
||||
else
|
||||
# These services return the caller's address as plain text. HTTPS is
|
||||
# required, and a failure simply falls back to manual address entry.
|
||||
endpoints=(
|
||||
'https://api.ipify.org'
|
||||
'https://ifconfig.me/ip'
|
||||
'https://checkip.amazonaws.com'
|
||||
)
|
||||
fi
|
||||
command -v curl >/dev/null 2>&1 || return 1
|
||||
for endpoint in "${endpoints[@]}"; do
|
||||
[[ "$endpoint" == https://* && "$endpoint" != *[[:space:]]* && "$endpoint" != *[[:cntrl:]]* && "$endpoint" != *'@'* ]] || continue
|
||||
value=$(curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
|
||||
--connect-timeout 4 --max-time 8 --max-filesize 128 "$endpoint" 2>/dev/null \
|
||||
| tr -d '[:space:]') || continue
|
||||
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
|
||||
IFS='.' read -r -a _public_ip_octets <<< "$value"
|
||||
for octet in "${_public_ip_octets[@]}"; do
|
||||
(( 10#$octet <= 255 )) || continue 2
|
||||
done
|
||||
printf '%s' "$value"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
port_listener_state() {
|
||||
local port=$1 output status=0
|
||||
validate_listen_port "$port" >/dev/null 2>&1 || return 2
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if output=$(ss -H -ltn 2>/dev/null); then
|
||||
if awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
output=''
|
||||
status=0
|
||||
output=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null) || status=$?
|
||||
[[ -n "$output" ]] && return 1
|
||||
[[ "$status" == 1 && -z "$output" ]] && return 0
|
||||
[[ "$status" == 0 ]] && return 0
|
||||
fi
|
||||
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
if output=$(netstat -lnt 2>/dev/null); then
|
||||
if awk -v port="$port" '$6 == "LISTEN" && $4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$port" <<'PY'
|
||||
import errno
|
||||
import socket
|
||||
import sys
|
||||
|
||||
port = int(sys.argv[1])
|
||||
for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
|
||||
sock = socket.socket(family, socket.SOCK_STREAM)
|
||||
try:
|
||||
if family == socket.AF_INET6:
|
||||
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||
sock.bind((address, port))
|
||||
except OSError as error:
|
||||
if error.errno == errno.EADDRINUSE:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
sock.close()
|
||||
sys.exit(0)
|
||||
PY
|
||||
status=$?
|
||||
case "$status" in
|
||||
0) return 0 ;;
|
||||
1) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
check_requested_port() {
|
||||
local port=$1 state
|
||||
state=0
|
||||
port_listener_state "$port" || state=$?
|
||||
case "$state" in
|
||||
0) return 0 ;;
|
||||
1) die "端口 ${port} 已被占用,请选择其他端口" ;;
|
||||
*) die "无法检测端口 ${port} 是否被占用,请安装 ss、lsof、netstat 或 Python 3 后重试" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_initial_admin_wizard() {
|
||||
if (( ! INSTALL_FIRST_INSTALL )); then
|
||||
return 0
|
||||
fi
|
||||
if (( NON_INTERACTIVE )); then
|
||||
log "非交互模式:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
fi
|
||||
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || {
|
||||
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 $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
fi
|
||||
[[ "$status" == empty ]] || return 0
|
||||
|
||||
exec 9<"$PROMPT_INPUT" || die "无法打开终端输入;请稍后执行 sudo $ADMIN_INIT_PATH"
|
||||
{
|
||||
printf '\n首次安装还差一步:请创建管理员账号。\n'
|
||||
printf '管理员账号用于登录 TallyNote;这里输入的密码会直接作为正式密码。\n'
|
||||
} > "$PROMPT_OUTPUT"
|
||||
while :; do
|
||||
prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes'
|
||||
choice=$PROMPT_REPLY
|
||||
case "$choice" in
|
||||
yes|YES|Yes|y|Y) break ;;
|
||||
no|NO|No|n|N|'')
|
||||
exec 9<&-
|
||||
log "已跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
;;
|
||||
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
|
||||
esac
|
||||
done
|
||||
stage '创建首位管理员(密码不会写入安装日志)'
|
||||
if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then
|
||||
exec 9<&-
|
||||
log "管理员初始化未完成;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
fi
|
||||
exec 9<&-
|
||||
stage_done '首位管理员创建完成'
|
||||
}
|
||||
|
||||
wait_for_service_health() {
|
||||
local host=$1 port=$2 health_host health_url attempt
|
||||
health_host=$host
|
||||
case "$health_host" in
|
||||
0.0.0.0) health_host=127.0.0.1 ;;
|
||||
::) health_host=::1 ;;
|
||||
esac
|
||||
if [[ "$health_host" == *:* && "$health_host" != \[* ]]; then health_host="[$health_host]"; fi
|
||||
health_url="http://${health_host}:${port}/health"
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
if curl --proto '=http' --connect-timeout 2 --max-time 3 --fail --silent "$health_url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
(( attempt < 12 )) && sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_network_environment() {
|
||||
[[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]
|
||||
}
|
||||
|
||||
interactive_network_available() {
|
||||
(( APPLY )) || return 1
|
||||
(( NON_INTERACTIVE == 0 )) || return 1
|
||||
has_network_environment && return 1
|
||||
[[ ! -e "$CONFIG_DIR/tallynote.env" && ! -L "$CONFIG_DIR/tallynote.env" ]] || return 1
|
||||
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
configure_network_interactively() {
|
||||
interactive_network_available || return 0
|
||||
NETWORK_INTERACTIVE=1
|
||||
|
||||
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置'
|
||||
|
||||
stage '配置服务网络监听(可直接回车使用默认值)'
|
||||
{
|
||||
printf '\nTallyNote 服务监听配置\n'
|
||||
printf ' 1) 仅本机访问:127.0.0.1(更安全)\n'
|
||||
printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n'
|
||||
} > "$PROMPT_OUTPUT"
|
||||
|
||||
local choice selected_port origin answer port_state detected_ip default_origin
|
||||
while :; do
|
||||
prompt_value '请选择监听方式 1/2' '1'
|
||||
choice=$PROMPT_REPLY
|
||||
case "$choice" in
|
||||
1|2) break ;;
|
||||
*) printf '请输入 1 或 2。\n' > "$PROMPT_OUTPUT" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
while :; do
|
||||
prompt_value '监听端口' "$INSTALL_PORT"
|
||||
selected_port=$PROMPT_REPLY
|
||||
if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then
|
||||
# A real terminal can reject an occupied port immediately. The final
|
||||
# check in main() runs again after old services have been stopped.
|
||||
if [[ -t 9 ]]; then
|
||||
port_state=0
|
||||
port_listener_state "$selected_port" || port_state=$?
|
||||
case "$port_state" in
|
||||
0) break ;;
|
||||
1) printf '端口 %s 已被占用,请输入其他端口。\n' "$selected_port" > "$PROMPT_OUTPUT"; continue ;;
|
||||
*) printf '暂时无法预检端口,安装前还会再次检查。\n' > "$PROMPT_OUTPUT"; break ;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
fi
|
||||
printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT"
|
||||
done
|
||||
|
||||
if [[ "$choice" == 1 ]]; then
|
||||
INSTALL_HOST=127.0.0.1
|
||||
INSTALL_PORT=$selected_port
|
||||
INSTALL_PUBLIC_ORIGIN="http://127.0.0.1:${selected_port}"
|
||||
INSTALL_ALLOW_INSECURE_HTTP=false
|
||||
else
|
||||
INSTALL_HOST=0.0.0.0
|
||||
INSTALL_PORT=$selected_port
|
||||
detected_ip=''
|
||||
# Test fixtures replace /dev/tty with regular files; avoid making their
|
||||
# behavior depend on an external IP lookup service.
|
||||
if [[ -t 9 ]]; then
|
||||
detected_ip=$(detect_public_ipv4 || true)
|
||||
fi
|
||||
if [[ -n "$detected_ip" ]]; then
|
||||
default_origin="http://${detected_ip}:${selected_port}"
|
||||
printf '已探测公网 IPv4:%s\n' "$detected_ip" > "$PROMPT_OUTPUT"
|
||||
else
|
||||
default_origin=''
|
||||
printf '未能自动获取公网 IPv4,请手动填写访问地址。\n' > "$PROMPT_OUTPUT"
|
||||
fi
|
||||
while :; do
|
||||
prompt_value '实际访问地址(回车使用自动探测地址,也可填写域名)' "$default_origin"
|
||||
origin=$PROMPT_REPLY
|
||||
if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then
|
||||
INSTALL_PUBLIC_ORIGIN=$origin
|
||||
break
|
||||
fi
|
||||
printf '地址无效:请输入不含路径、凭据或通配监听地址的 http:// 或 https:// 地址。\n' > "$PROMPT_OUTPUT"
|
||||
done
|
||||
INSTALL_ALLOW_INSECURE_HTTP=false
|
||||
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* ]]; then
|
||||
{
|
||||
printf '\n警告:直连 HTTP 不加密,登录信息和账目数据可能被窃听。\n'
|
||||
printf '仅在受控局域网或你明确接受风险时继续。\n'
|
||||
} > "$PROMPT_OUTPUT"
|
||||
while :; do
|
||||
prompt_value '确认允许公网 HTTP?输入 yes 继续,其他内容取消' 'no'
|
||||
answer=$PROMPT_REPLY
|
||||
case "$answer" in
|
||||
yes|YES|Yes|y|Y) INSTALL_ALLOW_INSECURE_HTTP=true; break ;;
|
||||
no|NO|No|n|N|'') die '已取消:公网 HTTP 必须明确确认;请改用 HTTPS 或重新运行安装器' ;;
|
||||
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
fi
|
||||
exec 9<&-
|
||||
stage_done "网络配置已选择:${INSTALL_HOST}:${INSTALL_PORT}"
|
||||
}
|
||||
|
||||
[[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false'
|
||||
[[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false'
|
||||
@@ -104,6 +433,7 @@ while (($#)); do
|
||||
--keep-releases) KEEP_RELEASES=${2:?missing value for --keep-releases}; shift ;;
|
||||
--allow-downgrade) ALLOW_DOWNGRADE=true ;;
|
||||
--allow-unsigned) ALLOW_UNSIGNED=1; REQUIRE_SIGNATURE=false ;;
|
||||
--non-interactive) NON_INTERACTIVE=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
@@ -197,15 +527,23 @@ assert_allowed_url() {
|
||||
download() {
|
||||
local url=$1 out=$2 max_bytes=${3:-$((MAX_RELEASE_MB * 1024 * 1024))}
|
||||
local current="$url" headers status location actual origin scheme authority
|
||||
local -a curl_args=(--proto '=https' --tlsv1.2 --fail --show-error --max-redirs 0
|
||||
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes"
|
||||
--retry 2 --retry-connrefused)
|
||||
# Keep CI and journal output clean, while showing curl's standard progress
|
||||
# bar during an interactive SSH/terminal installation.
|
||||
if [[ -t 2 ]]; then
|
||||
curl_args+=(--progress-bar)
|
||||
else
|
||||
curl_args+=(--silent)
|
||||
fi
|
||||
require_https "$url"
|
||||
assert_allowed_url "$url"
|
||||
[[ ! -L "$out" && ! -e "$out" ]] || die "download destination already exists: $out"
|
||||
for _redirect in 0 1 2 3; do
|
||||
headers="${out}.headers-${RANDOM}-$$"
|
||||
status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
|
||||
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes" \
|
||||
--retry 2 --retry-connrefused --output "$out" --dump-header "$headers" \
|
||||
--write-out '%{http_code}' "$current" 2>/dev/null) || status=000
|
||||
status=$(curl "${curl_args[@]}" --output "$out" --dump-header "$headers" \
|
||||
--write-out '%{http_code}' "$current") || status=000
|
||||
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
|
||||
rm -f -- "$headers"
|
||||
break
|
||||
@@ -506,6 +844,17 @@ stop_existing_services() {
|
||||
# Stop the path trigger first so it cannot launch the privileged updater while
|
||||
# the data tree is being repaired.
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
case "$unit" in
|
||||
tallynote.service)
|
||||
if systemctl is-enabled --quiet "$unit"; then INSTALL_WAS_ENABLED=1; fi
|
||||
;;
|
||||
tallynote-update.path)
|
||||
if systemctl is-enabled --quiet "$unit"; then INSTALL_PATH_WAS_ENABLED=1; fi
|
||||
;;
|
||||
tallynote-update.service)
|
||||
if systemctl is-enabled --quiet "$unit"; then INSTALL_UPDATE_WAS_ENABLED=1; fi
|
||||
;;
|
||||
esac
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
case "$unit" in
|
||||
tallynote.service) INSTALL_WAS_ACTIVE=1 ;;
|
||||
@@ -519,6 +868,17 @@ stop_existing_services() {
|
||||
|
||||
rollback_install_if_needed() {
|
||||
local result=$? rollback_tmp
|
||||
if (( INSTALL_COMMITTED == 0 && INSTALL_SYSTEMD_TOUCHED == 1 )) && command -v systemctl >/dev/null 2>&1; then
|
||||
# The failed install may have started units that were inactive before the
|
||||
# attempt. Stop them before restoring files so systemd never keeps running
|
||||
# code from a release directory that rollback is about to remove.
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
systemctl stop "$unit" >/dev/null 2>&1 || true
|
||||
done
|
||||
if (( INSTALL_WAS_ENABLED == 0 )); then systemctl disable tallynote.service >/dev/null 2>&1 || true; fi
|
||||
if (( INSTALL_PATH_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.path >/dev/null 2>&1 || true; fi
|
||||
if (( INSTALL_UPDATE_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.service >/dev/null 2>&1 || true; fi
|
||||
fi
|
||||
if (( INSTALL_SWITCHED == 1 && INSTALL_COMMITTED == 0 )); then
|
||||
if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then
|
||||
rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
|
||||
@@ -539,13 +899,16 @@ rollback_install_if_needed() {
|
||||
chmod 700 "$DATA_DIR" 2>/dev/null || true
|
||||
DATA_DIR_TEMP_ROOT=0
|
||||
fi
|
||||
if (( INSTALL_COMMITTED == 0 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
|
||||
if (( INSTALL_COMMITTED == 0 && INSTALL_BACKUP_COMPLETE == 1 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
|
||||
local backup_name target
|
||||
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-uninstall tallynote.env update-signing-key.pub; do
|
||||
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do
|
||||
case "$backup_name" in
|
||||
tallynote.env) target="$CONFIG_DIR/tallynote.env" ;;
|
||||
update-signing-key.pub) target="$CONFIG_DIR/update-signing-key.pub" ;;
|
||||
tallynote-uninstall) target="/usr/local/sbin/tallynote-uninstall" ;;
|
||||
tallynote-admin-init) target="$ADMIN_INIT_PATH" ;;
|
||||
tallynote-update) target="/usr/local/sbin/tallynote-update" ;;
|
||||
tallynote-update-runner) target="/usr/local/libexec/tallynote-update-runner" ;;
|
||||
*) target="/etc/systemd/system/$backup_name" ;;
|
||||
esac
|
||||
[[ ! -L "$target" ]] || continue
|
||||
@@ -557,6 +920,7 @@ rollback_install_if_needed() {
|
||||
done
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
if (( INSTALL_SYSTEMD_TOUCHED == 1 )); then systemctl daemon-reload >/dev/null 2>&1 || true; fi
|
||||
if (( INSTALL_WAS_ACTIVE == 1 )); then systemctl start tallynote.service 2>/dev/null || true; fi
|
||||
if (( INSTALL_UPDATE_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.service 2>/dev/null || true; fi
|
||||
if (( INSTALL_PATH_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.path 2>/dev/null || true; fi
|
||||
@@ -571,28 +935,41 @@ backup_install_files() {
|
||||
local directory=$1 target name
|
||||
mkdir -p "$directory"
|
||||
chmod 700 "$directory"
|
||||
for name in tallynote.service tallynote-update.service tallynote-update.path; do
|
||||
target="/etc/systemd/system/$name"
|
||||
[[ ! -L "$target" ]] || die "现有 systemd 单元不能是符号链接:$target"
|
||||
# Validate every target before copying any of them. If validation fails, the
|
||||
# installer has not changed an existing file and rollback must not infer that
|
||||
# a partial backup is safe to restore from.
|
||||
for name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do
|
||||
case "$name" in
|
||||
tallynote.env) target="$CONFIG_DIR/$name" ;;
|
||||
update-signing-key.pub) target="$CONFIG_DIR/$name" ;;
|
||||
tallynote-uninstall) target="/usr/local/sbin/$name" ;;
|
||||
tallynote-admin-init) target="$ADMIN_INIT_PATH" ;;
|
||||
tallynote-update) target="/usr/local/sbin/$name" ;;
|
||||
tallynote-update-runner) target="/usr/local/libexec/$name" ;;
|
||||
*) target="/etc/systemd/system/$name" ;;
|
||||
esac
|
||||
[[ ! -L "$target" ]] || die "现有安装文件不能是符号链接:$target"
|
||||
if [[ -e "$target" ]]; then
|
||||
[[ -f "$target" ]] || die "现有 systemd 单元不是普通文件:$target"
|
||||
cp -a -- "$target" "$directory/$name"
|
||||
[[ -f "$target" ]] || die "现有安装文件不是普通文件:$target"
|
||||
fi
|
||||
done
|
||||
for name in tallynote.env update-signing-key.pub; do
|
||||
target="$CONFIG_DIR/$name"
|
||||
[[ ! -L "$target" ]] || die "现有配置不能是符号链接:$target"
|
||||
|
||||
for name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do
|
||||
case "$name" in
|
||||
tallynote.env) target="$CONFIG_DIR/$name" ;;
|
||||
update-signing-key.pub) target="$CONFIG_DIR/$name" ;;
|
||||
tallynote-uninstall) target="/usr/local/sbin/$name" ;;
|
||||
tallynote-admin-init) target="$ADMIN_INIT_PATH" ;;
|
||||
tallynote-update) target="/usr/local/sbin/$name" ;;
|
||||
tallynote-update-runner) target="/usr/local/libexec/$name" ;;
|
||||
*) target="/etc/systemd/system/$name" ;;
|
||||
esac
|
||||
if [[ -e "$target" ]]; then
|
||||
[[ -f "$target" ]] || die "现有配置不是普通文件:$target"
|
||||
cp -a -- "$target" "$directory/$name"
|
||||
cp -a -- "$target" "$directory/$name" || die "无法备份现有安装文件:$target"
|
||||
[[ -f "$directory/$name" ]] || die "现有安装文件备份不完整:$target"
|
||||
fi
|
||||
done
|
||||
target="/usr/local/sbin/tallynote-uninstall"
|
||||
[[ ! -L "$target" ]] || die "现有卸载器不能是符号链接:$target"
|
||||
if [[ -e "$target" ]]; then
|
||||
[[ -f "$target" ]] || die "现有卸载器不是普通文件:$target"
|
||||
cp -a -- "$target" "$directory/tallynote-uninstall"
|
||||
fi
|
||||
INSTALL_BACKUP_COMPLETE=1
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
@@ -611,6 +988,69 @@ validate_env_value() {
|
||||
[[ ${#value} -le 4096 ]] || die "$label 过长"
|
||||
}
|
||||
|
||||
validate_listen_host() {
|
||||
local value=$1 label=${2:-监听地址}
|
||||
validate_env_value "$value" "$label"
|
||||
if [[ "$value" == *:* ]]; then
|
||||
[[ "$value" =~ ^[0-9A-Fa-f:]+$ ]] || die "$label 必须是有效的 IPv6 地址或主机名"
|
||||
elif [[ "$value" =~ ^[0-9.]+$ ]]; then
|
||||
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
local octet
|
||||
IFS='.' read -r -a _host_octets <<< "$value"
|
||||
for octet in "${_host_octets[@]}"; do
|
||||
(( 10#$octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
done
|
||||
else
|
||||
[[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名"
|
||||
[[ "$value" != *..* && "$value" != *.-* && "$value" != *-.* ]] || die "$label 包含不受支持的主机名"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_listen_port() {
|
||||
local value=$1 label=${2:-监听端口}
|
||||
[[ "$value" =~ ^[1-9][0-9]*$ && "$value" -le 65535 ]] || die "$label 必须是 1-65535 的整数"
|
||||
}
|
||||
|
||||
validate_public_origin() {
|
||||
# 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:// 地址' ;;
|
||||
esac
|
||||
[[ "$value" != *[[:space:]]* && "$value" != *[[:cntrl:]]* && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die '公开访问地址包含不受支持的字符'
|
||||
authority=${value#*://}
|
||||
authority=${authority%%/*}
|
||||
[[ -n "$authority" ]] || die '公开访问地址缺少主机名'
|
||||
if [[ "$authority" == \[*\]* ]]; then
|
||||
host=${authority#\[}; host=${host%%\]*}
|
||||
suffix=${authority#*\]}
|
||||
if [[ -n "$suffix" ]]; then
|
||||
[[ "$suffix" =~ ^:([0-9]+)$ ]] || die '公开访问地址端口无效'
|
||||
origin_port=${BASH_REMATCH[1]}
|
||||
fi
|
||||
else
|
||||
if [[ "$authority" == *:* ]]; then
|
||||
[[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效'
|
||||
host=${BASH_REMATCH[1]}
|
||||
origin_port=${BASH_REMATCH[2]}
|
||||
else
|
||||
host=$authority
|
||||
fi
|
||||
fi
|
||||
[[ -n "$host" ]] || die '公开访问地址缺少主机名'
|
||||
[[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名'
|
||||
validate_listen_host "$host" '公开访问地址主机'
|
||||
if [[ -n "$origin_port" ]]; then
|
||||
[[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数'
|
||||
fi
|
||||
path_part=${value#*://}
|
||||
path_part=${path_part#"$authority"}
|
||||
[[ -z "$path_part" || "$path_part" == "/" ]] || die '公开访问地址不能包含路径'
|
||||
}
|
||||
|
||||
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
|
||||
@@ -630,14 +1070,14 @@ validate_install_path() {
|
||||
}
|
||||
|
||||
validate_existing_env() {
|
||||
local file=$1 value metadata_host
|
||||
local file=$1 value metadata_host host port origin allow_insecure cookie_secure
|
||||
[[ ! -L "$file" && -f "$file" ]] || die '现有环境文件不是普通文件'
|
||||
[[ "$(stat_uid "$file")" == 0 ]] || die '现有环境文件必须由 root 拥有'
|
||||
local mode_bits
|
||||
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_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
|
||||
@@ -647,6 +1087,61 @@ validate_existing_env() {
|
||||
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
|
||||
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
|
||||
if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then
|
||||
host=$(read_env_value "$file" TALLYNOTE_HOST)
|
||||
validate_listen_host "$host" '环境文件中的监听地址'
|
||||
else
|
||||
host=127.0.0.1
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_PORT) )); then
|
||||
port=$(read_env_value "$file" TALLYNOTE_PORT)
|
||||
validate_listen_port "$port" '环境文件中的监听端口'
|
||||
else
|
||||
port=3000
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) )); then
|
||||
allow_insecure=$(read_env_value "$file" TALLYNOTE_ALLOW_INSECURE_HTTP)
|
||||
[[ "$allow_insecure" == true || "$allow_insecure" == false ]] || die '环境文件中的公网 HTTP 开关必须是 true 或 false'
|
||||
else
|
||||
allow_insecure=false
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_COOKIE_SECURE) )); then
|
||||
cookie_secure=$(read_env_value "$file" TALLYNOTE_COOKIE_SECURE)
|
||||
[[ "$cookie_secure" == true || "$cookie_secure" == false ]] || die '环境文件中的安全 Cookie 配置必须是 true 或 false'
|
||||
else
|
||||
cookie_secure=''
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_PUBLIC_ORIGIN) )); then
|
||||
origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN)
|
||||
validate_env_value "$origin" '环境文件中的公开访问地址'
|
||||
else
|
||||
local origin_host=$host
|
||||
[[ "$origin_host" == *:* && "$origin_host" != \[* ]] && origin_host="[$origin_host]"
|
||||
origin="http://${origin_host}:${port}"
|
||||
fi
|
||||
validate_public_origin "$origin"
|
||||
local origin_host_for_policy=${origin#*://}
|
||||
if [[ "$origin_host_for_policy" == \[*\]* ]]; then
|
||||
origin_host_for_policy=${origin_host_for_policy#\[}
|
||||
origin_host_for_policy=${origin_host_for_policy%%\]*}
|
||||
else
|
||||
origin_host_for_policy=${origin_host_for_policy%%:*}
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then
|
||||
case "$origin_host_for_policy" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then
|
||||
case "$origin_host_for_policy" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$origin" == https://* && "$cookie_secure" == false ]]; then
|
||||
die '环境文件中的 HTTPS 公开地址必须启用安全 Cookie'
|
||||
fi
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
|
||||
if [[ -n "$value" ]]; then
|
||||
validate_env_value "$value" '环境文件更新源'
|
||||
@@ -659,14 +1154,17 @@ validate_existing_env() {
|
||||
install_release() {
|
||||
local archive=$1 version=$2 tmp release_dir current_tmp=''
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp" "$current_tmp" 2>/dev/null || true' RETURN
|
||||
# RETURN traps survive the function that installs them. Clear the trap from
|
||||
# inside its first invocation so a later function cannot evaluate the local
|
||||
# temporary path after it has gone out of scope under `set -u`.
|
||||
trap 'trap - RETURN; if [[ -n "${tmp-}" ]]; then rm -rf -- "$tmp" 2>/dev/null || true; fi; if [[ -n "${current_tmp-}" ]]; then rm -f -- "$current_tmp" 2>/dev/null || true; fi' RETURN
|
||||
safe_extract "$archive" "$tmp/unpacked"
|
||||
normalize_release_tree "$tmp/unpacked"
|
||||
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root'
|
||||
[[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote'
|
||||
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
|
||||
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/server/cli/admin-init.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
|
||||
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units'
|
||||
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" ]] || die 'release archive is missing update/uninstall support files'
|
||||
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" && -x "$tmp/unpacked/bin/tallynote-admin-init" ]] || die 'release archive is missing update/uninstall/admin-init support files'
|
||||
grep -Eq '"version"[[:space:]]*:[[:space:]]*"'"$version"'"([,}]|[[:space:]])' "$tmp/unpacked/package.json" || die 'release package version does not match requested version'
|
||||
ensure_root_directory "$PREFIX" 755
|
||||
ensure_root_directory "$PREFIX/releases" 755
|
||||
@@ -715,6 +1213,7 @@ prune_releases() {
|
||||
}
|
||||
|
||||
main() {
|
||||
stage '检查运行环境、权限和目标架构'
|
||||
# These variables are useful for isolated tests, but a root install must
|
||||
# never execute an untrusted PATH entry supplied through sudo's environment.
|
||||
if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then
|
||||
@@ -724,6 +1223,35 @@ main() {
|
||||
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
|
||||
fi
|
||||
detect_platform
|
||||
configure_network_interactively
|
||||
validate_listen_host "$INSTALL_HOST"
|
||||
validate_listen_port "$INSTALL_PORT"
|
||||
if (( APPLY && NETWORK_INTERACTIVE )); then
|
||||
check_requested_port "$INSTALL_PORT"
|
||||
fi
|
||||
if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin'
|
||||
fi
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true || "$INSTALL_ALLOW_INSECURE_HTTP" == false ]] || die 'TALLYNOTE_ALLOW_INSECURE_HTTP 必须是 true 或 false'
|
||||
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
validate_env_value "$INSTALL_PUBLIC_ORIGIN" '公开访问地址'
|
||||
validate_public_origin "$INSTALL_PUBLIC_ORIGIN"
|
||||
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* && "$INSTALL_ALLOW_INSECURE_HTTP" != true ]]; then
|
||||
public_host=${INSTALL_PUBLIC_ORIGIN#http://}
|
||||
if [[ "$public_host" == \[*\]* ]]; then
|
||||
public_host=${public_host#\[}
|
||||
public_host=${public_host%%\]*}
|
||||
else
|
||||
public_host=${public_host%%:*}
|
||||
fi
|
||||
case "$public_host" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
|
||||
esac
|
||||
fi
|
||||
elif [[ "$INSTALL_HOST" != 127.0.0.1 && "$INSTALL_HOST" != localhost && "$INSTALL_HOST" != ::1 ]]; then
|
||||
die '监听非本机地址时必须提供 TALLYNOTE_PUBLIC_ORIGIN(例如 http://服务器IP:3000)'
|
||||
fi
|
||||
[[ "$KEEP_RELEASES" =~ ^[1-9][0-9]*$ ]] || die '--keep-releases must be a positive integer'
|
||||
validate_install_path "$PREFIX" '安装目录'
|
||||
validate_install_path "$DATA_DIR" '数据目录'
|
||||
@@ -737,14 +1265,21 @@ main() {
|
||||
# TALLYNOTE_RELEASE_ALLOWED_HOSTS when the operator has reviewed it.
|
||||
append_allowed_host "$(url_host "$RELEASE_API_URL")"
|
||||
append_allowed_host "$(url_host "$REPOSITORY_URL")"
|
||||
stage_done "运行环境可用:${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}"
|
||||
if [[ "$VERSION" == "latest" ]]; then
|
||||
if (( ! APPLY )); then
|
||||
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
|
||||
stage '预览最新版本解析(dry-run 不访问 Release)'
|
||||
log 'version: latest (release lookup skipped in dry-run)'
|
||||
log 'dry-run: pass --version VERSION to preview an exact artifact'
|
||||
stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd'
|
||||
return 0
|
||||
fi
|
||||
stage '从 Release API 获取最新版本'
|
||||
resolve_latest_version
|
||||
stage_done "已解析最新版本:${VERSION#v}"
|
||||
else
|
||||
stage "使用指定版本:${VERSION#v}"
|
||||
fi
|
||||
validate_semver "$VERSION" || die 'version must be a semantic version (for example 1.2.3)'
|
||||
VERSION=${VERSION#v}
|
||||
@@ -755,15 +1290,21 @@ main() {
|
||||
die "拒绝安装不高于当前版本的 release:当前 $current_version,候选 $VERSION(如确需降级请使用 --allow-downgrade)"
|
||||
fi
|
||||
fi
|
||||
stage '准备 Release 下载地址和发布包'
|
||||
release_urls
|
||||
local artifact archive checksum signature artifact_url work release_dir
|
||||
artifact=${RELEASE_FILE:+$(basename -- "$RELEASE_FILE")}
|
||||
artifact=${artifact:-tallynote-${VERSION}-linux-${TALLYNOTE_ARCH}-${TALLYNOTE_LIBC}.tar.gz}
|
||||
[[ "$artifact" =~ ^[A-Za-z0-9][A-Za-z0-9._+\-]*\.(tar\.gz|tgz|tar)$ ]] || die 'release 文件名无效'
|
||||
artifact_url="$RELEASE_BASE_URL/$artifact"
|
||||
stage_done 'Release 下载地址已准备'
|
||||
log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}"
|
||||
log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR"
|
||||
if (( ! APPLY )); then log 'dry-run: no download, extraction, or systemd changes'; return 0; fi
|
||||
if (( ! APPLY )); then
|
||||
log 'dry-run: no download, extraction, or systemd changes'
|
||||
stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd'
|
||||
return 0
|
||||
fi
|
||||
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行'
|
||||
[[ $EUID -eq 0 ]] || die '安装必须以 root 运行'
|
||||
for command_name in curl sha256sum tar install sed awk find systemctl; do
|
||||
@@ -777,6 +1318,7 @@ main() {
|
||||
INSTALL_BACKUP_DIR="$work/original"
|
||||
trap rollback_install_if_needed EXIT
|
||||
archive="$work/$artifact"
|
||||
stage "获取发布包:$artifact"
|
||||
if [[ -n "$RELEASE_FILE" && -f "$RELEASE_FILE" && ! -L "$RELEASE_FILE" ]]; then
|
||||
cp -- "$RELEASE_FILE" "$archive"
|
||||
chmod 600 "$archive"
|
||||
@@ -785,8 +1327,10 @@ main() {
|
||||
[[ -z "$RELEASE_FILE" ]] || die '本地 release 文件不存在或是符号链接'
|
||||
download "$artifact_url" "$archive"
|
||||
fi
|
||||
stage_done '发布包已下载并通过大小限制'
|
||||
checksum="$work/SHA256SUMS"
|
||||
SHA256_URL=${SHA256_URL:-$RELEASE_BASE_URL/SHA256SUMS}
|
||||
stage '获取 SHA-256 校验清单'
|
||||
if [[ -n "$SHA256_FILE" && -f "$SHA256_FILE" && ! -L "$SHA256_FILE" ]]; then
|
||||
cp -- "$SHA256_FILE" "$checksum"
|
||||
chmod 600 "$checksum"
|
||||
@@ -795,9 +1339,11 @@ main() {
|
||||
[[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接'
|
||||
download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024))
|
||||
fi
|
||||
stage_done 'SHA-256 校验清单已准备'
|
||||
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
|
||||
signature=''
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" ]]; then
|
||||
stage '获取发布签名'
|
||||
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
|
||||
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc}
|
||||
signature="$work/$artifact.asc"
|
||||
@@ -806,10 +1352,19 @@ main() {
|
||||
signature="$work/SHA256SUMS.sig"
|
||||
fi
|
||||
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
||||
stage_done '发布签名已准备'
|
||||
fi
|
||||
stage '校验 SHA-256 和发布签名'
|
||||
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
|
||||
stage_done '发布包校验通过'
|
||||
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
|
||||
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
|
||||
if [[ -L "$PREFIX/current" || -e "$PREFIX/current" ]]; then
|
||||
INSTALL_FIRST_INSTALL=0
|
||||
else
|
||||
INSTALL_FIRST_INSTALL=1
|
||||
fi
|
||||
stage '停止旧服务并准备安装、配置和数据目录'
|
||||
id tallynote >/dev/null 2>&1 || useradd --system --user-group --home-dir "$DATA_DIR" --shell /usr/sbin/nologin tallynote
|
||||
backup_install_files "$INSTALL_BACKUP_DIR"
|
||||
stop_existing_services
|
||||
@@ -821,28 +1376,45 @@ main() {
|
||||
if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
validate_existing_env "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
# During upgrades, the existing environment remains authoritative unless a
|
||||
# new port was explicitly supplied. Check the effective listener port after
|
||||
# stopping the old service so an unrelated process cannot claim it.
|
||||
local effective_port=$INSTALL_PORT
|
||||
if [[ -z "${TALLYNOTE_PORT+x}" && -f "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
effective_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
effective_port=${effective_port:-3000}
|
||||
fi
|
||||
check_requested_port "$effective_port"
|
||||
stage_done '目录、权限和旧服务状态已准备'
|
||||
stage "解包、校验包结构并原子切换到版本 ${VERSION#v}"
|
||||
install_release "$archive" "$VERSION"
|
||||
stage_done "版本 ${VERSION#v} 已切换为当前版本"
|
||||
release_dir="$PREFIX/releases/$VERSION"
|
||||
[[ -f "$release_dir/systemd/tallynote.service" && -f "$release_dir/systemd/tallynote-update.service" && -f "$release_dir/systemd/tallynote-update.path" ]] || die 'release package is missing systemd unit files'
|
||||
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" ]] || die 'release package is missing update/uninstall support files'
|
||||
install -d -m 755 /usr/local/libexec /etc/systemd/system
|
||||
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" && -x "$release_dir/bin/tallynote-admin-init" && -f "$release_dir/dist/server/cli/admin-init.js" ]] || die 'release package is missing update/uninstall/admin-init support files'
|
||||
stage '安装 systemd 单元、更新辅助程序和卸载器'
|
||||
install -d -m 755 /usr/local/sbin /usr/local/libexec /etc/systemd/system
|
||||
local unit_tmp
|
||||
unit_tmp=$(mktemp -d)
|
||||
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service"
|
||||
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service"
|
||||
sed "s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
|
||||
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
|
||||
sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$unit_tmp/tallynote-admin-init"
|
||||
install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service
|
||||
install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service
|
||||
install -o root -g root -m 644 "$unit_tmp/tallynote-update.path" /etc/systemd/system/tallynote-update.path
|
||||
install -o root -g root -m 755 "$unit_tmp/tallynote-admin-init" "$ADMIN_INIT_PATH"
|
||||
rm -rf "$unit_tmp"
|
||||
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update
|
||||
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
|
||||
install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall
|
||||
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
|
||||
local env_created=0
|
||||
if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env"
|
||||
chown root:root "$CONFIG_DIR/tallynote.env"
|
||||
chmod 640 "$CONFIG_DIR/tallynote.env"
|
||||
env_created=1
|
||||
fi
|
||||
ensure_env_key() {
|
||||
local key=$1 value=$2
|
||||
@@ -855,6 +1427,40 @@ main() {
|
||||
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
}
|
||||
set_env_key() {
|
||||
local key=$1 value=$2 escaped
|
||||
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效'
|
||||
validate_env_value "$value" "$key"
|
||||
escaped=${value//\\/\\\\}
|
||||
escaped=${escaped//&/\\&}
|
||||
escaped=${escaped//|/\\|}
|
||||
if grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then
|
||||
sed -i "s|^${key}=.*|${key}=${escaped}|" "$CONFIG_DIR/tallynote.env"
|
||||
else
|
||||
if [[ -s "$CONFIG_DIR/tallynote.env" && "$(tail -c 1 "$CONFIG_DIR/tallynote.env")" != $'\n' ]]; then
|
||||
printf '\n' >> "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
}
|
||||
# A fresh install gets the requested network settings. On upgrades, only
|
||||
# explicitly supplied values change the existing administrator config.
|
||||
if (( env_created )) || [[ -n "${TALLYNOTE_HOST+x}" ]]; then set_env_key TALLYNOTE_HOST "$INSTALL_HOST"; fi
|
||||
if (( env_created )) || [[ -n "${TALLYNOTE_PORT+x}" ]]; then set_env_key TALLYNOTE_PORT "$INSTALL_PORT"; fi
|
||||
if (( env_created )); then
|
||||
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
|
||||
elif [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" ]]; then
|
||||
local generated_origin_host=$INSTALL_HOST
|
||||
[[ "$generated_origin_host" == *:* && "$generated_origin_host" != \[* ]] && generated_origin_host="[$generated_origin_host]"
|
||||
set_env_key TALLYNOTE_PUBLIC_ORIGIN "http://${generated_origin_host}:${INSTALL_PORT}"
|
||||
fi
|
||||
if [[ "$INSTALL_PUBLIC_ORIGIN" == https://* ]]; then set_env_key TALLYNOTE_COOKIE_SECURE true; fi
|
||||
set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"
|
||||
elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then
|
||||
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
|
||||
fi
|
||||
if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi
|
||||
ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX"
|
||||
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR"
|
||||
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
|
||||
@@ -881,13 +1487,58 @@ main() {
|
||||
fi
|
||||
chown root:root "$CONFIG_DIR/tallynote.env"
|
||||
chmod 640 "$CONFIG_DIR/tallynote.env"
|
||||
stage_done 'systemd 单元、更新辅助程序和卸载器已安装'
|
||||
stage '重新加载 systemd 并启动 TallyNote'
|
||||
systemctl daemon-reload
|
||||
INSTALL_SYSTEMD_TOUCHED=1
|
||||
systemctl enable --now tallynote.service tallynote-update.path
|
||||
local health_host health_port
|
||||
health_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
|
||||
health_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
health_host=${health_host:-$INSTALL_HOST}
|
||||
health_port=${health_port:-$INSTALL_PORT}
|
||||
stage "检查本机健康接口(${health_host}:${health_port})"
|
||||
if ! wait_for_service_health "$health_host" "$health_port"; then
|
||||
log "本机健康检查失败:http://${health_host}:${health_port}/health"
|
||||
systemctl status tallynote.service --no-pager -l || true
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
journalctl -u tallynote.service -n 30 --no-pager || true
|
||||
fi
|
||||
die 'TallyNote 服务未通过健康检查;安装未完成,请根据上面的 systemd 日志修复后重试'
|
||||
fi
|
||||
stage_done '本机健康检查通过,服务正在监听'
|
||||
if [[ "$health_host" == 127.0.0.1 || "$health_host" == localhost || "$health_host" == ::1 ]]; then
|
||||
log '当前监听仅限本机;公网或其他设备无法直接访问,请重新安装并选择 0.0.0.0,或配置 HTTPS 反向代理'
|
||||
else
|
||||
log '当前监听已绑定非本机地址;若外部仍无法连接,请检查云安全组、主机防火墙和公网 IP/NAT'
|
||||
fi
|
||||
stage_done 'TallyNote 服务已启用并启动'
|
||||
stage '清理旧版本并完成安装'
|
||||
prune_releases
|
||||
stage_done '旧版本清理完成'
|
||||
INSTALL_COMMITTED=1
|
||||
trap - EXIT
|
||||
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
|
||||
INSTALL_WORK_DIR=''
|
||||
log 'installed; inspect with systemctl status tallynote.service'
|
||||
stage_done "安装完成:TallyNote ${VERSION#v}"
|
||||
run_initial_admin_wizard
|
||||
local access_url access_host access_port configured_host configured_port
|
||||
access_url=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PUBLIC_ORIGIN 2>/dev/null || true)
|
||||
if [[ -z "$access_url" ]]; then
|
||||
configured_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
|
||||
configured_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
|
||||
access_host=${configured_host:-$INSTALL_HOST}
|
||||
access_port=${configured_port:-$INSTALL_PORT}
|
||||
if [[ "$access_host" == 0.0.0.0 ]]; then
|
||||
access_host=$(detect_public_ipv4 || true)
|
||||
fi
|
||||
[[ -n "$access_host" ]] || access_host=$INSTALL_HOST
|
||||
[[ "$access_host" == *:* && "$access_host" != \[* ]] && access_host="[$access_host]"
|
||||
access_url="http://${access_host}:${access_port}"
|
||||
fi
|
||||
log "访问地址:$access_url"
|
||||
log '查看服务状态:systemctl status tallynote.service'
|
||||
log "管理员初始化命令:sudo $ADMIN_INIT_PATH"
|
||||
log '如 sudo 找不到该命令,请使用上面输出的绝对路径'
|
||||
}
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE update_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'apply' CHECK(operation IN ('download','apply'));
|
||||
CREATE INDEX IF NOT EXISTS update_jobs_operation_idx ON update_jobs(operation, status, created_at);
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -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/"
|
||||
@@ -36,7 +42,7 @@ cp uninstall.sh "$stage/uninstall.sh"
|
||||
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
|
||||
node_path=$(command -v node)
|
||||
cp -L "$node_path" "$stage/runtime/bin/node"
|
||||
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
|
||||
chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
|
||||
|
||||
# pnpm's default linker creates symlinks. A release archive is deliberately
|
||||
# symlink-free so the installer can reject traversal links deterministically.
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -10,61 +10,255 @@ 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}
|
||||
HEALTH_HOST=$HOST
|
||||
if [[ "$HEALTH_HOST" == 0.0.0.0 ]]; then HEALTH_HOST=127.0.0.1; fi
|
||||
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
|
||||
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
|
||||
restore_initial_service() {
|
||||
local result=$?
|
||||
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
|
||||
return "$result"
|
||||
}
|
||||
trap restore_initial_service EXIT
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
|
||||
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
|
||||
old_node="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
|
||||
switched=0
|
||||
handled=0
|
||||
|
||||
write_update_state() {
|
||||
write_recovery_state() {
|
||||
local phase=$1 temporary
|
||||
temporary="$PREFIX/.update-state-$$-${RANDOM}.tmp"
|
||||
[[ ! -e "$temporary" && ! -L "$temporary" ]] || return 1
|
||||
printf 'job_id=%s\nold_target=%s\nphase=%s\n' "$job_id" "$old_target" "$phase" > "$temporary"
|
||||
printf 'job_id=%s\nold_target=%s\nphase=%s\ninitial_active=%s\n' "$job_id" "$old_target" "$phase" "$was_active" > "$temporary"
|
||||
chmod 600 "$temporary"
|
||||
mv -Tf -- "$temporary" "$STATE_FILE"
|
||||
STATE_CREATED=1
|
||||
}
|
||||
|
||||
clear_update_state() {
|
||||
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
|
||||
run_update_cli "$node_bin" "$DOWNLOAD_TIMEOUT_SECONDS" download --request-file "$REQUEST_FILE"
|
||||
download_result=$?
|
||||
set -e
|
||||
if (( download_result != 0 )); then
|
||||
# The CLI normally records failed itself. Retry the explicit finalization
|
||||
# for failures that happen before its catch handler can persist the row,
|
||||
# then remove the one-shot request so a failed download cannot keep the
|
||||
# path unit in a permanently triggered state.
|
||||
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 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
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
exit "$download_result"
|
||||
fi
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
exit 0
|
||||
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
|
||||
old_node="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
|
||||
handled=0
|
||||
|
||||
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")
|
||||
@@ -72,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)
|
||||
@@ -88,6 +299,27 @@ recover_stale_state() {
|
||||
done
|
||||
return 1
|
||||
fi
|
||||
if [[ "$current_target" == "$state_old" ]]; then
|
||||
# The process may have restored the old release before it was killed. In
|
||||
# that case the old link is already safe to serve, but the database row
|
||||
# can still be `applying`; finish it as failed before clearing recovery
|
||||
# markers so the UI does not poll forever.
|
||||
recovery_node="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
|
||||
if finalize_state_job "$recovery_node" failed "$state_job"; then
|
||||
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
|
||||
clear_update_state || true
|
||||
return 11
|
||||
fi
|
||||
# A crash before the CLI created its job row is safe to retry. Preserve
|
||||
# the request while dropping only the stale state marker.
|
||||
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
|
||||
clear_update_state || true
|
||||
return 0
|
||||
fi
|
||||
clear_update_state || true
|
||||
return 0
|
||||
fi
|
||||
if [[ "$current_target" != "$state_old" ]]; then
|
||||
rollback_link="$PREFIX/.current-recovery-$$-${RANDOM}.tmp"
|
||||
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
|
||||
@@ -132,10 +364,26 @@ 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)
|
||||
[[ "$current_target" == "$old_target" ]] && return 0
|
||||
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.
|
||||
return 0
|
||||
fi
|
||||
rollback_link="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
|
||||
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
|
||||
ln -s -- "$old_target" "$rollback_link" || return 1
|
||||
@@ -143,27 +391,38 @@ rollback_current() {
|
||||
rm -f -- "$rollback_link" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
switched=0
|
||||
}
|
||||
|
||||
finalize_failed_job() {
|
||||
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
|
||||
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 0
|
||||
"$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
|
||||
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 1
|
||||
# Give SQLite a moment to release a transient lock before declaring the
|
||||
# recovery itself failed.
|
||||
for _ in 1 2 3; do
|
||||
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
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
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
|
||||
if (( rollback_ok == 1 && switched == 0 )); then
|
||||
# Once the old release is active again, always try to close the job. The
|
||||
# previous marker could remain set when an earlier branch had already
|
||||
# rolled back before entering this EXIT trap, leaving `applying` forever.
|
||||
if (( rollback_ok == 1 )); then
|
||||
if finalize_failed_job; then
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
clear_update_state || true
|
||||
@@ -175,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'
|
||||
@@ -187,22 +446,19 @@ 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"
|
||||
healthy=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
|
||||
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HEALTH_HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
||||
@@ -2,14 +2,48 @@
|
||||
set -Eeuo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
|
||||
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service"
|
||||
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service"
|
||||
grep -Eq '^PathExists=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
|
||||
grep -Eq '^PathChanged=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
|
||||
grep -Eq '^PathChanged=/opt/tallynote$' "$root/systemd/tallynote-update.path"
|
||||
if grep -Eq '^ConditionPathExists=' "$root/systemd/tallynote-update.service"; then
|
||||
echo 'update service must not require only the request file' >&2
|
||||
exit 1
|
||||
fi
|
||||
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'dry-run' <<<"$output"
|
||||
grep -q '\[阶段\] 检查运行环境' <<<"$output"
|
||||
grep -q '\[完成\] dry-run 预览完成' <<<"$output"
|
||||
output=$(bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
grep -q '\[阶段\] 使用指定版本:1.2.3' <<<"$output"
|
||||
if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected non-HTTPS URL to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
if TALLYNOTE_HOST=0.0.0.0 bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected non-local listener without public origin to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
output=$(TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=true \
|
||||
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
output=$(TALLYNOTE_HOST=::1 TALLYNOTE_PORT=3443 \
|
||||
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=65536 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 TALLYNOTE_ALLOW_INSECURE_HTTP=true \
|
||||
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected invalid listener port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
|
||||
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP without explicit opt-in to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
tmp=$(mktemp -d)
|
||||
cleanup_tmp() {
|
||||
if [[ -d "$tmp" ]]; then
|
||||
@@ -54,12 +88,295 @@ 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
|
||||
fi
|
||||
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
|
||||
|
||||
# The port probe must distinguish a listening TCP port from a free one.
|
||||
port_tools="$tmp/port-tools"
|
||||
mkdir -p "$port_tools"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "LISTEN 0 128 127.0.0.1:3443 0.0.0.0:*"' > "$port_tools/ss"
|
||||
chmod 755 "$port_tools/ss"
|
||||
bash -c '
|
||||
script=$1
|
||||
tools=$2
|
||||
set --
|
||||
source "$script"
|
||||
PATH="$tools:$PATH"
|
||||
state=0
|
||||
port_listener_state 3443 || state=$?
|
||||
[[ "$state" == 1 ]]
|
||||
state=0
|
||||
port_listener_state 3444 || state=$?
|
||||
[[ "$state" == 0 ]]
|
||||
' _ "$installer_lib" "$port_tools"
|
||||
|
||||
# The lsof fallback must treat its normal "no matches" exit status as a free
|
||||
# port, while still reporting a listener when it returns a PID.
|
||||
lsof_tools="$tmp/lsof-tools"
|
||||
mkdir -p "$lsof_tools"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exit 127' > "$lsof_tools/ss"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'case "$*" in *TCP:3443*) printf "%s\\n" 4242; exit 0 ;; *) exit 1 ;; esac' > "$lsof_tools/lsof"
|
||||
chmod 755 "$lsof_tools/ss" "$lsof_tools/lsof"
|
||||
bash -c '
|
||||
script=$1
|
||||
tools=$2
|
||||
set --
|
||||
source "$script"
|
||||
PATH="$tools:$PATH"
|
||||
state=0
|
||||
port_listener_state 3443 || state=$?
|
||||
[[ "$state" == 1 ]]
|
||||
state=0
|
||||
port_listener_state 3444 || state=$?
|
||||
[[ "$state" == 0 ]]
|
||||
' _ "$installer_lib" "$lsof_tools"
|
||||
|
||||
# Public-IP discovery accepts a valid IPv4 response and rejects malformed
|
||||
# values without making the test depend on an external service.
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
PUBLIC_IP_URL=https://ip.example.test
|
||||
curl() { printf "%s\\n" "198.51.100.7"; }
|
||||
[[ "$(detect_public_ipv4)" == "198.51.100.7" ]]
|
||||
curl() { printf "%s\\n" "999.1.1.1"; }
|
||||
if detect_public_ipv4 >/dev/null 2>&1; then
|
||||
echo "expected invalid public IPv4 response to fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib"
|
||||
|
||||
# The service health probe maps wildcard listeners to loopback and must return
|
||||
# promptly when the local endpoint is healthy.
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
curl() { [[ "$*" == *"http://127.0.0.1:3011/health"* ]] || return 1; }
|
||||
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"
|
||||
mkdir -p "$release_fixture/dist/server/cli" "$release_fixture/dist/web" "$release_fixture/bin" \
|
||||
"$release_fixture/scripts" "$release_fixture/runtime/bin" "$release_fixture/systemd"
|
||||
printf '%s\n' '{"version":"1.0.0"}' > "$release_fixture/package.json"
|
||||
printf '%s\n' server > "$release_fixture/dist/server/index.js"
|
||||
printf '%s\n' cli > "$release_fixture/dist/server/cli/admin-init.js"
|
||||
printf '%s\n' web > "$release_fixture/dist/web/index.html"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/bin/tallynote"
|
||||
cp "$root/bin/tallynote-admin-init" "$release_fixture/bin/tallynote-admin-init"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update-runner.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/uninstall.sh"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote.service"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.service"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.path"
|
||||
printf '%s\n' 'TALLYNOTE_HOST=127.0.0.1' > "$release_fixture/systemd/tallynote.env.example"
|
||||
chmod 755 "$release_fixture/bin/tallynote" "$release_fixture/bin/tallynote-admin-init" "$release_fixture/scripts"/*.sh "$release_fixture/uninstall.sh"
|
||||
release_archive="$tmp/release-fixture.tar.gz"
|
||||
tar -C "$release_fixture" -czf "$release_archive" .
|
||||
bash -c '
|
||||
script=$1
|
||||
archive=$2
|
||||
destination=$3
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX="$destination/prefix"
|
||||
ensure_root_directory() { mkdir -p "$1"; }
|
||||
chown() { :; }
|
||||
mv() {
|
||||
if [[ "${1:-}" == -Tf ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi
|
||||
}
|
||||
install_release "$archive" 1.0.0
|
||||
[[ -x "$PREFIX/releases/1.0.0/bin/tallynote-admin-init" ]]
|
||||
set_env_key() { local key=$1 value=$2 escaped; :; }
|
||||
set_env_key test value
|
||||
' _ "$installer_lib" "$release_archive" "$tmp/install-release"
|
||||
|
||||
# The installed path unit must watch both the data-directory request and the
|
||||
# release-prefix recovery marker after custom paths are substituted.
|
||||
rendered_path="$tmp/rendered-update.path"
|
||||
sed "s#/opt/tallynote#$tmp/custom-prefix#g; s#/var/lib/tallynote#$tmp/custom-data#g" \
|
||||
"$root/systemd/tallynote-update.path" > "$rendered_path"
|
||||
grep -Fxq "PathExists=$tmp/custom-data/update-request.json" "$rendered_path"
|
||||
grep -Fxq "PathChanged=$tmp/custom-data/update-request.json" "$rendered_path"
|
||||
grep -Fxq "PathExists=$tmp/custom-prefix/.update-state" "$rendered_path"
|
||||
grep -Fxq "PathChanged=$tmp/custom-prefix/.update-state" "$rendered_path"
|
||||
grep -Fxq "PathChanged=$tmp/custom-prefix" "$rendered_path"
|
||||
|
||||
# The production admin wrapper must load a release-relative runtime, change to
|
||||
# the release root, and forward CLI arguments without requiring pnpm.
|
||||
wrapper_prefix="$tmp/wrapper-prefix"
|
||||
mkdir -p "$wrapper_prefix/releases/1.0.0/runtime/bin" "$wrapper_prefix/releases/1.0.0/dist/server/cli"
|
||||
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"
|
||||
# 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"
|
||||
grep -Fxq -- '--generate' "$tmp/wrapper.log"
|
||||
|
||||
# The first-install prompt is optional and must support an explicit later
|
||||
# initialization path without blocking the rest of the install.
|
||||
admin_wizard_dir="$tmp/admin-wizard"
|
||||
mkdir -p "$admin_wizard_dir"
|
||||
printf '%s\n' yes remaining-input > "$admin_wizard_dir/input"
|
||||
: > "$admin_wizard_dir/output"
|
||||
cat > "$admin_wizard_dir/admin-init" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${1:-}" == --check ]]; then
|
||||
printf '%s\n' empty
|
||||
else
|
||||
printf '%s\n' initialized > "$TALLYNOTE_ADMIN_WIZARD_RESULT"
|
||||
fi
|
||||
EOF
|
||||
chmod 755 "$admin_wizard_dir/admin-init"
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
INSTALL_FIRST_INSTALL=1
|
||||
NON_INTERACTIVE=0
|
||||
PROMPT_INPUT="$dir/input"
|
||||
PROMPT_OUTPUT="$dir/output"
|
||||
ADMIN_INIT_PATH="$dir/admin-init"
|
||||
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/result"
|
||||
export TALLYNOTE_ADMIN_WIZARD_RESULT
|
||||
run_initial_admin_wizard
|
||||
[[ -f "$dir/result" ]]
|
||||
' _ "$installer_lib" "$admin_wizard_dir"
|
||||
|
||||
# An upgrade must never reopen the first-admin wizard, even if a damaged or
|
||||
# deliberately empty database would otherwise report an uninitialized state.
|
||||
printf '%s\n' yes > "$admin_wizard_dir/upgrade-input"
|
||||
rm -f "$admin_wizard_dir/upgrade-result"
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
INSTALL_FIRST_INSTALL=0
|
||||
NON_INTERACTIVE=0
|
||||
PROMPT_INPUT="$dir/upgrade-input"
|
||||
PROMPT_OUTPUT="$dir/upgrade-output"
|
||||
ADMIN_INIT_PATH="$dir/admin-init"
|
||||
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/upgrade-result"
|
||||
export TALLYNOTE_ADMIN_WIZARD_RESULT
|
||||
run_initial_admin_wizard
|
||||
[[ ! -e "$dir/upgrade-result" ]]
|
||||
' _ "$installer_lib" "$admin_wizard_dir"
|
||||
|
||||
# Validation or password errors after the base service is committed must leave
|
||||
# the installation usable and point the operator at the standalone command.
|
||||
failed_wizard_dir="$tmp/failed-admin-wizard"
|
||||
mkdir -p "$failed_wizard_dir"
|
||||
printf '%s\n' yes > "$failed_wizard_dir/input"
|
||||
: > "$failed_wizard_dir/output"
|
||||
cat >"$failed_wizard_dir/admin-init" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${1:-}" == --check ]]; then
|
||||
printf '%s\n' empty
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
EOF
|
||||
chmod 755 "$failed_wizard_dir/admin-init"
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
INSTALL_FIRST_INSTALL=1
|
||||
NON_INTERACTIVE=0
|
||||
PROMPT_INPUT="$dir/input"
|
||||
PROMPT_OUTPUT="$dir/output"
|
||||
ADMIN_INIT_PATH="$dir/admin-init"
|
||||
run_initial_admin_wizard
|
||||
[[ -x "$dir/admin-init" ]]
|
||||
' _ "$installer_lib" "$failed_wizard_dir"
|
||||
|
||||
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
|
||||
# when the first value looks valid (systemd uses the later value).
|
||||
duplicate_env="$tmp/duplicate.env"
|
||||
@@ -77,6 +394,199 @@ bash -c '
|
||||
fi
|
||||
' _ "$installer_lib" "$duplicate_env"
|
||||
|
||||
# Existing installations must validate the network settings they preserve on
|
||||
# upgrade, including the direct-IP HTTP combination used by the documented
|
||||
# installer command.
|
||||
network_env="$tmp/network.env"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
|
||||
'TALLYNOTE_ALLOW_INSECURE_HTTP=true' > "$network_env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$network_env"
|
||||
if sed 's/^TALLYNOTE_PORT=.*/TALLYNOTE_PORT=65536/' "$network_env" > "$tmp/invalid-port.env"; then
|
||||
if bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/invalid-port.env" >/dev/null 2>&1; then
|
||||
echo 'expected invalid existing listener port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
|
||||
'TALLYNOTE_ALLOW_INSECURE_HTTP=false' > "$tmp/public-http-without-opt-in.env"
|
||||
if bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/public-http-without-opt-in.env" >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP without opt-in in existing env to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
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' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=https://tallynote.example.com' \
|
||||
'TALLYNOTE_COOKIE_SECURE=true' > "$tmp/public-https.env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/public-https.env"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=::1' \
|
||||
'TALLYNOTE_PORT=3443' > "$tmp/ipv6-default-origin.env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/ipv6-default-origin.env"
|
||||
if bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
validate_public_origin "http://example.test:65536"
|
||||
' _ "$installer_lib" >/dev/null 2>&1; then
|
||||
echo 'expected invalid public origin port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A fresh interactive install reads from the controlling terminal even when
|
||||
# the installer script itself is piped from curl. The test substitutes files
|
||||
# for that terminal and verifies both listener choices without touching the
|
||||
# host filesystem.
|
||||
interactive_dir="$tmp/interactive"
|
||||
mkdir -p "$interactive_dir/config"
|
||||
printf '\n\n' > "$interactive_dir/local-input"
|
||||
: > "$interactive_dir/local-output"
|
||||
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/local-input"
|
||||
PROMPT_OUTPUT="$dir/local-output"
|
||||
configure_network_interactively
|
||||
[[ "$INSTALL_HOST" == 127.0.0.1 ]]
|
||||
[[ "$INSTALL_PORT" == 3000 ]]
|
||||
[[ "$INSTALL_PUBLIC_ORIGIN" == http://127.0.0.1:3000 ]]
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == false ]]
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
printf '2\n3443\nhttp://203.0.113.10:3443\nyes\n' > "$interactive_dir/public-input"
|
||||
: > "$interactive_dir/public-output"
|
||||
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/public-input"
|
||||
PROMPT_OUTPUT="$dir/public-output"
|
||||
configure_network_interactively
|
||||
[[ "$INSTALL_HOST" == 0.0.0.0 ]]
|
||||
[[ "$INSTALL_PORT" == 3443 ]]
|
||||
[[ "$INSTALL_PUBLIC_ORIGIN" == http://203.0.113.10:3443 ]]
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true ]]
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
printf '2\n3000\nhttp://203.0.113.10:3000\nno\n' > "$interactive_dir/refuse-input"
|
||||
: > "$interactive_dir/refuse-output"
|
||||
if env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/refuse-input"
|
||||
PROMPT_OUTPUT="$dir/refuse-output"
|
||||
configure_network_interactively
|
||||
' _ "$installer_lib" "$interactive_dir" >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP confirmation refusal to stop configuration' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Explicit environment variables take precedence over the prompt, including
|
||||
# when a terminal is available.
|
||||
env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/local-input"
|
||||
PROMPT_OUTPUT="$dir/local-output"
|
||||
if interactive_network_available; then
|
||||
echo "expected explicit network environment to skip prompt" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
# A release archive is extracted under umask 077, then explicitly normalized
|
||||
# so the tallynote system user can traverse and execute the shipped tree.
|
||||
source_tmp="$tmp/source"
|
||||
|
||||
@@ -34,8 +34,9 @@ make_fixture() {
|
||||
done
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exec /opt/tallynote/current/runtime/bin/node /opt/tallynote/current/dist/server/cli/admin-init.js' > "$fixture/usr/local/sbin/tallynote-admin-init"
|
||||
cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall"
|
||||
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-uninstall"
|
||||
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-admin-init" "$fixture/usr/local/sbin/tallynote-uninstall"
|
||||
printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db"
|
||||
printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db"
|
||||
}
|
||||
@@ -73,6 +74,7 @@ run_uninstall "$fixture"
|
||||
[[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]]
|
||||
[[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]]
|
||||
[[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]]
|
||||
[[ ! -e "$fixture/usr/local/sbin/tallynote-admin-init" ]]
|
||||
grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null
|
||||
grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null
|
||||
path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
|
||||
@@ -98,6 +100,20 @@ run_uninstall "$fixture" --purge-data --yes --purge-config
|
||||
[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]]
|
||||
[[ ! -e "$fixture/etc/tallynote" ]]
|
||||
|
||||
# In production the service user owns the data directory. The target itself
|
||||
# must be accepted while its parent directories remain root-owned. This test
|
||||
# is meaningful only when the suite runs as root on a host with that account;
|
||||
# ordinary developer runs continue with the portable fixture coverage above.
|
||||
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
|
||||
if [[ "$EUID" == 0 && -n "$tallynote_uid" && "$tallynote_uid" != "$(id -u)" ]]; then
|
||||
fixture="$tmp/service-user-data"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
chown -R "$tallynote_uid" "$fixture/var/lib/tallynote"
|
||||
TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID="$tallynote_uid" run_uninstall "$fixture" --purge-data --yes
|
||||
[[ ! -e "$fixture/var/lib/tallynote" ]]
|
||||
fi
|
||||
|
||||
# A custom data path must not overlap the release prefix; otherwise removing
|
||||
# releases could destroy data that the default uninstall promises to keep.
|
||||
fixture="$tmp/overlap"
|
||||
|
||||
+145
-32
@@ -23,6 +23,7 @@ import {
|
||||
permanentDeleteSchema,
|
||||
statusUpdateSchema,
|
||||
updateApplySchema,
|
||||
updateDownloadSchema,
|
||||
versionSchema,
|
||||
type AttachmentKind,
|
||||
type ExpenseStatus,
|
||||
@@ -53,13 +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";
|
||||
|
||||
@@ -94,7 +100,7 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const sessionCookie = "tally_session";
|
||||
const csrfCookie = "tally_csrf";
|
||||
|
||||
type UpdateRateState = { checkedAt: number; appliedAt: number };
|
||||
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
|
||||
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
|
||||
|
||||
function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState {
|
||||
@@ -105,7 +111,7 @@ function updateRateState(database: DatabaseContext["sqlite"], adminId: string):
|
||||
}
|
||||
let state = states.get(adminId);
|
||||
if (!state) {
|
||||
state = { checkedAt: 0, appliedAt: 0 };
|
||||
state = { checkedAt: 0, downloadedAt: 0, appliedAt: 0 };
|
||||
states.set(adminId, state);
|
||||
}
|
||||
return state;
|
||||
@@ -115,13 +121,13 @@ function enforceUpdateCooldown(
|
||||
database: DatabaseContext["sqlite"],
|
||||
config: AppConfig,
|
||||
adminId: string,
|
||||
operation: "check" | "apply",
|
||||
operation: "check" | "download" | "apply",
|
||||
reply: FastifyReply,
|
||||
): void {
|
||||
): number {
|
||||
const state = updateRateState(database, adminId);
|
||||
const now = Date.now();
|
||||
const previous = operation === "check" ? state.checkedAt : state.appliedAt;
|
||||
const cooldown = operation === "check" ? config.updateCheckCooldownMs : config.updateApplyCooldownMs;
|
||||
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
|
||||
const cooldown = operation === "check" ? config.updateCheckCooldownMs : operation === "download" ? config.updateDownloadCooldownMs : config.updateApplyCooldownMs;
|
||||
if (cooldown > 0 && previous > 0 && now - previous < cooldown) {
|
||||
const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000));
|
||||
reply.header("Retry-After", retryAfter);
|
||||
@@ -130,7 +136,9 @@ 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 {
|
||||
@@ -588,6 +596,13 @@ function conflict(database: DatabaseContext, id: string): never {
|
||||
}
|
||||
|
||||
export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
// Helmet's defaults include `upgrade-insecure-requests`, HSTS, COOP and
|
||||
// Origin-Agent-Cluster. Those headers are appropriate for HTTPS, but an
|
||||
// explicitly opted-in HTTP deployment must remain HTTP all the way through
|
||||
// the asset graph; otherwise browsers upgrade `/assets/*` to HTTPS and the
|
||||
// plain HTTP listener appears as a blank page. Keep the transport-sensitive
|
||||
// headers protocol-aware while retaining the other hardening headers.
|
||||
const secureOrigin = config.publicOrigin.startsWith("https:");
|
||||
const app = Fastify({
|
||||
logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false,
|
||||
// Fastify's runtime accepts a numeric hop count, while its v5 typings do
|
||||
@@ -602,10 +617,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(helmet, {
|
||||
...(config.isLocalOrigin ? { hsts: false } : {}),
|
||||
...(!secureOrigin || config.isLocalOrigin ? { hsts: false } : {}),
|
||||
frameguard: { action: "deny" },
|
||||
referrerPolicy: { policy: "no-referrer" },
|
||||
crossOriginOpenerPolicy: { policy: "same-origin" },
|
||||
...(secureOrigin ? { crossOriginOpenerPolicy: { policy: "same-origin" }, originAgentCluster: true } : { crossOriginOpenerPolicy: false, originAgentCluster: false }),
|
||||
crossOriginResourcePolicy: { policy: "same-origin" },
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
@@ -616,7 +631,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
"frame-ancestors": ["'none'"],
|
||||
"base-uri": ["'none'"],
|
||||
"form-action": ["'self'"],
|
||||
...(config.isLocalOrigin ? { "upgrade-insecure-requests": null } : {}),
|
||||
...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -637,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) {
|
||||
@@ -930,13 +932,25 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
// Release metadata and task state should never be stored by an upstream
|
||||
// proxy or a shared browser cache.
|
||||
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, status, version, platform, asset_name AS assetName,
|
||||
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
|
||||
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
|
||||
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
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=? 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,
|
||||
@@ -945,11 +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, {
|
||||
@@ -968,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,
|
||||
@@ -985,9 +1007,50 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
let applyAuditRecorded = false;
|
||||
let applyAuditTarget: string | undefined;
|
||||
try {
|
||||
reconcileOrphanedUpdateJobs(database.sqlite, config);
|
||||
if (config.updateStrategy !== "systemd") {
|
||||
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
|
||||
}
|
||||
if (input.jobId) {
|
||||
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();
|
||||
const active = database.sqlite.transaction(() => {
|
||||
const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined;
|
||||
if (conflictRow) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
const changed = database.sqlite.prepare("UPDATE update_jobs SET operation='apply', error_message=NULL, requested_at=?, request_id=?, updated_at=? WHERE id=? AND status='staged' AND operation='download'").run(now, request.id, now, stagedJobId);
|
||||
if (changed.changes !== 1) throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: stagedJobId, after: { version: staged.version, staged: true } });
|
||||
return { id: stagedJobId, now };
|
||||
}).immediate();
|
||||
applyAuditTarget = stagedJobId;
|
||||
if (!staged.expectedSha256 || !/^[a-f0-9]{64}$/i.test(staged.expectedSha256)) {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("暂存更新缺少有效校验值", Date.now(), active.id);
|
||||
throw new AppError(409, "UPDATE_NOT_VERIFIED", "暂存更新缺少有效校验值,请重新下载");
|
||||
}
|
||||
try {
|
||||
await writeUpdateRequest(config, { jobId: active.id, operation: "apply", version: staged.version, metadataUrl: config.updateMetadataUrl, assetUrl: staged.assetUrl, assetName: staged.assetName ?? "staged", expectedSha256: staged.expectedSha256, requestedAt: active.now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
|
||||
} catch {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("无法创建系统更新请求", Date.now(), active.id);
|
||||
applyAuditRecorded = true;
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: active.id, outcome: "failure" });
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id: active.id, status: "staged", version: staged.version, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
|
||||
}
|
||||
// Preserve the actionable in-progress response for duplicate clicks before
|
||||
// applying the per-admin cooldown.
|
||||
const activeBeforeCheck = database.sqlite.prepare(`
|
||||
@@ -1055,8 +1118,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
return { id, now };
|
||||
}).immediate();
|
||||
applyAuditTarget = active.id;
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
operation: "apply",
|
||||
version: requestedVersion,
|
||||
metadataUrl: cached.metadataUrl,
|
||||
assetUrl: releaseAsset.url,
|
||||
@@ -1084,7 +1148,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion } });
|
||||
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
|
||||
} catch (error) {
|
||||
if (!applyAuditRecorded) {
|
||||
writeAudit(database.sqlite, {
|
||||
@@ -1101,12 +1165,61 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
|
||||
const input = updateDownloadSchema.parse(request.body);
|
||||
reconcileOrphanedUpdateJobs(database.sqlite, config);
|
||||
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
|
||||
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
|
||||
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply);
|
||||
const checked = await checkForUpdate(database.sqlite, config);
|
||||
const version = input.version.replace(/^v/i, "");
|
||||
if (!checked.latest || checked.latest.version !== version || !checked.latest.isNewer || !checked.latest.compatible || !checked.latest.integrityReady) throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新");
|
||||
const cached = readCachedRelease(database.sqlite, config);
|
||||
const cachedAsset = cached?.asset;
|
||||
if (!cached || !cachedAsset?.sha256 || cached.version !== version) throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新");
|
||||
const now = Date.now();
|
||||
const id = randomUUID();
|
||||
database.sqlite.transaction(() => {
|
||||
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
|
||||
if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
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, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now);
|
||||
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 {
|
||||
// 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_DOWNLOAD_FAILED", "无法启动下载,请稍后重试");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
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) => {
|
||||
const id = z.string().uuid().parse((request.params as { id: string }).id);
|
||||
reconcileOrphanedUpdateJobs(database.sqlite, config);
|
||||
const row = database.sqlite.prepare(`
|
||||
SELECT id, status, version, platform, asset_name AS assetName,
|
||||
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
|
||||
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;
|
||||
if (!row) notFound("更新任务不存在");
|
||||
|
||||
+149
-22
@@ -1,9 +1,9 @@
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { openDatabase } from "../db/index.js";
|
||||
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 {
|
||||
@@ -11,45 +11,161 @@ function arg(name: string): string | undefined {
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
// A terminal paste can contain more than one line. Keep the unread tail for
|
||||
// the next prompt instead of silently discarding credentials after the first
|
||||
// newline.
|
||||
let pendingInput = "";
|
||||
let pendingSkipLf = false;
|
||||
|
||||
async function readSecret(prompt: string): Promise<string> {
|
||||
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
|
||||
output.write(prompt);
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let value = "";
|
||||
let escapeSequence = false;
|
||||
let cleaned = false;
|
||||
const decoder = new StringDecoder("utf8");
|
||||
const wasRaw = Boolean(input.isRaw);
|
||||
const onData = (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8");
|
||||
if (text === "\u0003") {
|
||||
cleanup();
|
||||
reject(new Error("已取消"));
|
||||
} else if (text === "\r" || text === "\n") {
|
||||
cleanup();
|
||||
output.write("\n");
|
||||
resolve(value);
|
||||
} else if (text === "\u007f") {
|
||||
value = value.slice(0, -1);
|
||||
} else if (!text.includes("\u001b")) {
|
||||
value += text;
|
||||
}
|
||||
};
|
||||
const initialInput = pendingInput;
|
||||
pendingInput = "";
|
||||
let onData: (chunk: Buffer | string) => void;
|
||||
let onSignal: () => void;
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
input.off("data", onData);
|
||||
input.off("error", onInputError);
|
||||
process.off("SIGINT", onSignal);
|
||||
process.off("SIGTERM", onSignal);
|
||||
input.setRawMode?.(wasRaw);
|
||||
input.pause();
|
||||
};
|
||||
const finish = (error?: Error) => {
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else {
|
||||
output.write("\n");
|
||||
resolve(value);
|
||||
}
|
||||
};
|
||||
const onInputError = (error: Error) => finish(error);
|
||||
onSignal = () => finish(new Error("已取消"));
|
||||
const consume = (text: string) => {
|
||||
let offset = 0;
|
||||
for (const character of text) {
|
||||
offset += character.length;
|
||||
if (pendingSkipLf) {
|
||||
if (character === "\n") {
|
||||
pendingSkipLf = false;
|
||||
continue;
|
||||
}
|
||||
pendingSkipLf = false;
|
||||
}
|
||||
if (character === "\u0003") {
|
||||
finish(new Error("已取消"));
|
||||
return;
|
||||
}
|
||||
if (escapeSequence) {
|
||||
if (/[A-Za-z~]/.test(character)) escapeSequence = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\u001b") {
|
||||
escapeSequence = true;
|
||||
} else if (character === "\r" || character === "\n") {
|
||||
const tail = text.slice(offset);
|
||||
pendingInput = tail.startsWith("\n") && character === "\r" ? tail.slice(1) : tail;
|
||||
pendingSkipLf = character === "\r" && !tail.startsWith("\n");
|
||||
finish();
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
onData = (chunk) => {
|
||||
consume(typeof chunk === "string" ? chunk : decoder.write(chunk));
|
||||
};
|
||||
input.resume();
|
||||
input.setRawMode?.(true);
|
||||
process.once("SIGINT", onSignal);
|
||||
process.once("SIGTERM", onSignal);
|
||||
input.once("error", onInputError);
|
||||
input.on("data", onData);
|
||||
if (initialInput) consume(initialInput);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
if (checkOnly) {
|
||||
let database;
|
||||
try {
|
||||
database = openDatabaseReadOnly(config);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
console.log("empty");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const hasAdminsTable = database.sqlite
|
||||
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'admins'")
|
||||
.get();
|
||||
const existing = hasAdminsTable
|
||||
? database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number }
|
||||
: { count: 0 };
|
||||
console.log(existing.count > 0 ? "initialized" : "empty");
|
||||
} finally {
|
||||
database.sqlite.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
prepareDataDirectories(config);
|
||||
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
||||
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("用户名: "));
|
||||
@@ -64,6 +180,9 @@ async function main() {
|
||||
if (policyError) throw new Error(policyError);
|
||||
const normalized = normalizeUsername(username);
|
||||
if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符");
|
||||
if ([...normalized].length > 64) throw new Error("用户名最多 64 个字符");
|
||||
const normalizedDisplayName = displayName.normalize("NFKC").trim();
|
||||
if ([...normalizedDisplayName].length < 1 || [...normalizedDisplayName].length > 80) throw new Error("显示名称必须为 1-80 个字符");
|
||||
const passwordHash = await hashPassword(password);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
@@ -73,15 +192,23 @@ 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, displayName.trim(), 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",
|
||||
action: "admin.initialized",
|
||||
targetType: "admin",
|
||||
targetId: id,
|
||||
after: { username: normalized, displayName: displayName.trim(), status: "active" },
|
||||
after: { username: normalized, displayName: normalizedDisplayName, status: "active" },
|
||||
});
|
||||
})();
|
||||
console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。");
|
||||
|
||||
+220
-25
@@ -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,
|
||||
@@ -26,11 +28,12 @@ import {
|
||||
type ReleaseMetadata,
|
||||
type UrlPolicy,
|
||||
} from "../update.js";
|
||||
import { attachSidecarHash } from "../update-service.js";
|
||||
import { ACTIVE_UPDATE_STATUSES, attachSidecarHash } from "../update-service.js";
|
||||
import type { UpdateJobStatus } from "../../shared/contracts.js";
|
||||
|
||||
const updateRequestFileSchema = z.object({
|
||||
jobId: z.string().uuid(),
|
||||
operation: z.enum(["download", "apply"]).default("apply"),
|
||||
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
|
||||
metadataUrl: z.string().url(),
|
||||
assetUrl: z.string().url(),
|
||||
@@ -96,6 +99,8 @@ export type UpdateRunOptions = UrlPolicy & {
|
||||
jobId?: string | undefined;
|
||||
publicKey?: string | undefined;
|
||||
requireSignature?: boolean | undefined;
|
||||
operation?: "download" | "apply" | undefined;
|
||||
stagedPath?: string | undefined;
|
||||
};
|
||||
|
||||
export type UpdateRunResult = {
|
||||
@@ -140,22 +145,29 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
requestId?: string | undefined;
|
||||
requestedAt?: number | undefined;
|
||||
startedAt?: number | undefined;
|
||||
operation?: "download" | "apply" | undefined;
|
||||
}): void {
|
||||
if (!sqlite) return;
|
||||
const now = Date.now();
|
||||
const effectiveOperation = values.operation ?? (sqlite.prepare("SELECT operation FROM update_jobs WHERE id=?").get(jobId) as { operation?: "download" | "apply" } | undefined)?.operation ?? "apply";
|
||||
sqlite.prepare(`
|
||||
INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at,
|
||||
status, version, platform, release_url, asset_name, asset_url,
|
||||
operation, status, version, platform, release_url, asset_name, asset_url,
|
||||
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
|
||||
created_at, updated_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
|
||||
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
|
||||
request_id=COALESCE(excluded.request_id, update_jobs.request_id),
|
||||
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
|
||||
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
|
||||
status=excluded.status, version=excluded.version, platform=excluded.platform,
|
||||
operation=excluded.operation,
|
||||
-- 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,
|
||||
@@ -167,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,
|
||||
@@ -174,6 +191,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
values.requestId ?? null,
|
||||
values.requestedAt ?? null,
|
||||
values.startedAt ?? null,
|
||||
effectiveOperation,
|
||||
values.status,
|
||||
values.version,
|
||||
values.platform,
|
||||
@@ -205,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,
|
||||
});
|
||||
@@ -238,9 +264,29 @@ async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Validate a queued staged directory before a root process consumes it. */
|
||||
async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise<string> {
|
||||
const rootResolved = path.resolve(workspaceRoot);
|
||||
const rootInfo = await lstat(rootResolved).catch(() => null);
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) {
|
||||
throw new Error("更新工作目录权限无效");
|
||||
}
|
||||
const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); });
|
||||
const resolved = path.resolve(candidate);
|
||||
if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
const info = await lstat(resolved).catch(() => null);
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效");
|
||||
const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); });
|
||||
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
return real;
|
||||
}
|
||||
|
||||
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
|
||||
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);
|
||||
@@ -250,27 +296,74 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
|
||||
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
|
||||
writeJob(options.sqlite, jobId, {
|
||||
status: "queued", version: resolved.version, platform: platform.target,
|
||||
operation, status: "queued", version: resolved.version, platform: platform.target,
|
||||
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
|
||||
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
|
||||
requestId: options.requestId, requestedAt: Date.now(),
|
||||
});
|
||||
|
||||
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
|
||||
const workspace = await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
||||
let keepWorkspace = false;
|
||||
const workspace = operation === "download"
|
||||
? path.join(path.resolve(options.stagingDir), `update-${jobId}`)
|
||||
: await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
||||
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, { 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, { 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) });
|
||||
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, { 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: archivePath });
|
||||
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;
|
||||
return { jobId, version: resolved.version, asset: resolved.asset, archivePath };
|
||||
}
|
||||
|
||||
let backupArchivePath: string | undefined;
|
||||
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
||||
@@ -295,8 +388,10 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
updateJob(options.sqlite, jobId, { status: options.deferCompletion ? "applying" : "completed", 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), backupPath: switchedBackup ?? backupArchivePath, ...(options.deferCompletion ? {} : { completedAt }) });
|
||||
return { jobId, version: resolved.version, asset: resolved.asset, archivePath, ...(backupArchivePath ? { backupArchivePath } : {}), ...(switchedBackup ? { backupDir: switchedBackup } : {}) };
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
clearTransientJobPath(options.sqlite, jobId);
|
||||
if (!keepWorkspace) {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
clearTransientJobPath(options.sqlite, jobId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackVersion = resolved?.version ?? options.version ?? "0.0.0";
|
||||
@@ -312,17 +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("更新任务不存在");
|
||||
if (row.status !== "applying" && row.status !== "completed" && row.status !== "failed") 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,
|
||||
@@ -335,6 +441,59 @@ export function finalizeUpdateJob(
|
||||
})();
|
||||
}
|
||||
|
||||
export async function applyStagedUpdate(options: {
|
||||
sqlite: Database.Database;
|
||||
jobId: string;
|
||||
version: string;
|
||||
stagedPath: string;
|
||||
currentDir: string;
|
||||
currentLink: string;
|
||||
releasesDir: string;
|
||||
backupArchivePath?: string;
|
||||
dataBackupArchivePath?: string;
|
||||
dataBackupSource?: string;
|
||||
maxBytes?: number;
|
||||
dataBackupMaxBytes?: number;
|
||||
workspaceRoot?: string;
|
||||
}): Promise<void> {
|
||||
const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record<string, unknown> | undefined;
|
||||
if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态");
|
||||
if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致");
|
||||
const stagedPath = options.workspaceRoot
|
||||
? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot)
|
||||
: options.stagedPath;
|
||||
const payload = path.join(stagedPath, "payload");
|
||||
const payloadInfo = await lstat(payload).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
|
||||
await normalizeReleasePermissions(payload);
|
||||
let switchedBackup: string | undefined;
|
||||
let committed = false;
|
||||
try {
|
||||
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.dataBackupArchivePath });
|
||||
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, { maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024 });
|
||||
}
|
||||
if (options.backupArchivePath) {
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath });
|
||||
const source = await realpath(options.currentDir).catch(() => options.currentDir);
|
||||
await createSafeArchive(source, options.backupArchivePath, { maxBytes: options.maxBytes ?? 512 * 1024 * 1024 });
|
||||
}
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath, startedAt: Date.now() });
|
||||
switchedBackup = (await atomicSwitchRelease(payload, options.currentLink, options.releasesDir, options.version)).previousTarget;
|
||||
committed = true;
|
||||
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (!committed) {
|
||||
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "failed", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), errorMessage: safeErrorMessage(error) });
|
||||
clearTransientJobPath(options.sqlite, options.jobId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, backupPath: switchedBackup ?? options.backupArchivePath });
|
||||
clearTransientJobPath(options.sqlite, options.jobId);
|
||||
}
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
@@ -379,9 +538,43 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
prepareDataDirectories(config);
|
||||
if (request) await ensurePrivilegedWorkspace(stagingDir);
|
||||
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
|
||||
const release = acquireInstanceLock(config);
|
||||
// The download phase intentionally runs beside the live app so users keep
|
||||
// access while the archive is fetched and staged. SQLite WAL plus the
|
||||
// configured busy timeout serializes writes; the exclusive process lock is
|
||||
// reserved for apply/rollback, when the service is stopped by systemd.
|
||||
const release = request?.operation === "download" ? () => undefined : acquireInstanceLock(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
if (request?.operation === "apply") {
|
||||
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined;
|
||||
if (staged?.status === "staged" && staged.operation === "apply") {
|
||||
if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
|
||||
const root = path.resolve(config.updateWorkspaceDir);
|
||||
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
|
||||
await applyStagedUpdate({
|
||||
sqlite: database.sqlite,
|
||||
jobId: request.jobId,
|
||||
version: request.version,
|
||||
stagedPath: candidate,
|
||||
currentDir,
|
||||
currentLink: request.currentLink,
|
||||
releasesDir: request.releasesDir,
|
||||
workspaceRoot: root,
|
||||
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
||||
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
|
||||
dataBackupSource: config.dataDir,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
});
|
||||
console.log(`更新已切换:${request.version}`);
|
||||
return;
|
||||
}
|
||||
if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效");
|
||||
// A direct one-click request starts in queued/apply. Older clients do
|
||||
// not have a separate download step, so fall through to runUpdate,
|
||||
// which downloads, verifies, backs up, and switches the release in one
|
||||
// transaction. A staged request still takes the branch above.
|
||||
}
|
||||
const result = await runUpdate({
|
||||
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
|
||||
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
|
||||
@@ -394,10 +587,12 @@ 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,
|
||||
...(deferCompletion ? { deferCompletion: true } : {}),
|
||||
...(request?.operation === "download" ? { operation: "download" as const } : {}),
|
||||
...(request ? { jobId: request.jobId } : {}),
|
||||
publicKey: config.updatePublicKey,
|
||||
requireSignature: config.updateRequireSignature,
|
||||
|
||||
+19
-2
@@ -69,7 +69,8 @@ export function loadConfig() {
|
||||
const installPrefix = path.resolve(process.env.TALLYNOTE_INSTALL_PREFIX ?? (updateStrategyRaw === "systemd" ? path.dirname(projectRoot) : projectRoot));
|
||||
const host = process.env.TALLYNOTE_HOST ?? "127.0.0.1";
|
||||
const port = integerEnv("TALLYNOTE_PORT", 3000, 1);
|
||||
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${host}:${port}`;
|
||||
const originHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${originHost}:${port}`;
|
||||
let parsedOrigin: URL;
|
||||
try {
|
||||
parsedOrigin = new URL(publicOrigin);
|
||||
@@ -88,7 +89,14 @@ export function loadConfig() {
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production" || process.env.TALLYNOTE_ENV === "production";
|
||||
const cookieSecure = booleanEnv("TALLYNOTE_COOKIE_SECURE", parsedOrigin.protocol === "https:");
|
||||
// Direct IP access is useful during a first deployment, but it is not
|
||||
// encrypted. Keep this explicitly opt-in so a public install cannot
|
||||
// accidentally expose session cookies over HTTP.
|
||||
const allowInsecureHttp = booleanEnv("TALLYNOTE_ALLOW_INSECURE_HTTP", false);
|
||||
const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (["0.0.0.0", "::"].includes(publicHost)) {
|
||||
throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名");
|
||||
}
|
||||
const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost);
|
||||
const appVersion = (() => {
|
||||
try {
|
||||
@@ -122,6 +130,7 @@ export function loadConfig() {
|
||||
timezone,
|
||||
trustProxy: trustProxyEnv(),
|
||||
cookieSecure,
|
||||
allowInsecureHttp,
|
||||
appVersion,
|
||||
updateMetadataUrl,
|
||||
updateAllowedHosts,
|
||||
@@ -138,10 +147,12 @@ 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.
|
||||
updateCheckCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS", 60) * 1000,
|
||||
updateDownloadCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS", 15) * 1000,
|
||||
updateApplyCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS", 15) * 1000,
|
||||
isLocalOrigin: localOrigin,
|
||||
dataDir,
|
||||
@@ -165,7 +176,13 @@ export function loadConfig() {
|
||||
isProduction,
|
||||
};
|
||||
|
||||
if (!localOrigin && (parsedOrigin.protocol !== "https:" || !cookieSecure)) {
|
||||
if (!localOrigin && parsedOrigin.protocol !== "https:" && !allowInsecureHttp) {
|
||||
throw new Error("公网 HTTP 访问必须显式启用 TALLYNOTE_ALLOW_INSECURE_HTTP=true;生产环境建议使用 HTTPS");
|
||||
}
|
||||
if (!localOrigin && parsedOrigin.protocol !== "https:" && cookieSecure) {
|
||||
throw new Error("HTTP public origin 不能启用安全 Cookie");
|
||||
}
|
||||
if (!localOrigin && parsedOrigin.protocol === "https:" && !cookieSecure) {
|
||||
throw new Error("公网部署必须使用 HTTPS 并启用安全 Cookie");
|
||||
}
|
||||
if (parsedOrigin.protocol === "https:" && !cookieSecure) {
|
||||
|
||||
+22
-1
@@ -1,6 +1,6 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AppConfig } from "../config.js";
|
||||
@@ -44,3 +44,24 @@ export function openDatabase(config: AppConfig): DatabaseContext {
|
||||
if (foreignKeys !== 1) throw new Error("SQLite 外键未启用");
|
||||
return { sqlite, db: drizzle(sqlite, { schema }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an existing database without creating directories, changing journal
|
||||
* mode, running migrations, or changing file permissions. This is used by
|
||||
* administrative status checks that must be side-effect free.
|
||||
*/
|
||||
export function openDatabaseReadOnly(config: AppConfig): DatabaseContext {
|
||||
const info = lstatSync(config.dbPath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`数据库文件不是安全的普通文件:${config.dbPath}`);
|
||||
const sqlite = new Database(config.dbPath, { readonly: true, fileMustExist: true });
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
sqlite.pragma("busy_timeout = 5000");
|
||||
sqlite.pragma("temp_store = MEMORY");
|
||||
sqlite.pragma("query_only = ON");
|
||||
const foreignKeys = sqlite.pragma("foreign_keys", { simple: true });
|
||||
if (foreignKeys !== 1) {
|
||||
sqlite.close();
|
||||
throw new Error("SQLite 外键未启用");
|
||||
}
|
||||
return { sqlite, db: drizzle(sqlite, { schema }) };
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ export const updateJobs = sqliteTable("update_jobs", {
|
||||
adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }),
|
||||
sessionHash: text("session_hash"),
|
||||
requestId: text("request_id"),
|
||||
operation: text("operation", { enum: ["download", "apply"] }).notNull().default("apply"),
|
||||
status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(),
|
||||
version: text("version").notNull(),
|
||||
platform: text("platform").notNull(),
|
||||
@@ -147,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,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);
|
||||
|
||||
+499
-9
@@ -1,24 +1,32 @@
|
||||
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";
|
||||
import { writeAudit } from "./audit.js";
|
||||
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,
|
||||
RELEASE_NOTES_MAX_BYTES,
|
||||
type ReleaseAsset,
|
||||
type ReleaseMetadata,
|
||||
} 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",
|
||||
@@ -29,12 +37,22 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
|
||||
"applying",
|
||||
];
|
||||
|
||||
// A queued job normally starts within seconds and an applying job completes
|
||||
// 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;
|
||||
metadataUrl: string;
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
platform: string;
|
||||
signatureVerified?: boolean;
|
||||
asset?: {
|
||||
@@ -53,7 +71,10 @@ export type UpdateCheckResult = {
|
||||
latest: {
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
compatible: boolean;
|
||||
integrityReady: boolean;
|
||||
signatureReady: boolean;
|
||||
@@ -65,6 +86,7 @@ export type UpdateCheckResult = {
|
||||
|
||||
export type UpdateRequest = {
|
||||
jobId: string;
|
||||
operation?: "download" | "apply";
|
||||
version: string;
|
||||
metadataUrl: string;
|
||||
assetUrl: string;
|
||||
@@ -76,6 +98,7 @@ export type UpdateRequest = {
|
||||
currentLink: string;
|
||||
releasesDir: string;
|
||||
dataDir: string;
|
||||
stagedPath?: string;
|
||||
};
|
||||
|
||||
function setting(database: Database.Database, key: string): string | undefined {
|
||||
@@ -134,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);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +186,7 @@ function policy(config: AppConfig) {
|
||||
allowedHosts: config.updateAllowedHosts,
|
||||
baseUrl: config.updateMetadataUrl,
|
||||
maxRedirects: 3,
|
||||
timeoutMs: config.updateTimeoutMs,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -187,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,
|
||||
});
|
||||
@@ -206,7 +239,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
metadataUrl,
|
||||
version: safeVersion,
|
||||
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
|
||||
...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}),
|
||||
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
|
||||
...(metadata.notes ? { notes: metadata.notes } : {}),
|
||||
...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}),
|
||||
platform: platform.target,
|
||||
signatureVerified,
|
||||
...(asset ? {
|
||||
@@ -227,7 +263,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
latest: {
|
||||
version: safeVersion,
|
||||
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
|
||||
...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}),
|
||||
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
|
||||
...(metadata.notes ? { notes: metadata.notes } : {}),
|
||||
...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}),
|
||||
compatible: Boolean(asset),
|
||||
integrityReady: Boolean(asset?.sha256 && (!config.updateRequireSignature || signatureVerified)),
|
||||
signatureReady: !config.updateRequireSignature || signatureVerified,
|
||||
@@ -245,6 +284,9 @@ export function readCachedRelease(database: Database.Database, config: AppConfig
|
||||
if (!value || typeof value !== "object" || typeof value.version !== "string" || typeof value.metadataUrl !== "string" || typeof value.platform !== "string") return null;
|
||||
parseSemver(value.version);
|
||||
const metadataUrl = validateHttpsUrl(value.metadataUrl, policy(config)).toString();
|
||||
if (value.releaseName !== undefined && (typeof value.releaseName !== "string" || value.releaseName.length > 200 || /[\u0000-\u001f\u007f]/.test(value.releaseName))) return null;
|
||||
if (value.notes !== undefined && (typeof value.notes !== "string" || Buffer.byteLength(value.notes, "utf8") > RELEASE_NOTES_MAX_BYTES)) return null;
|
||||
if (value.releaseUrl !== undefined) validateHttpsUrl(value.releaseUrl, policy(config));
|
||||
if (value.signatureVerified !== undefined && typeof value.signatureVerified !== "boolean") return null;
|
||||
if (value.asset) {
|
||||
if (typeof value.asset.name !== "string" || typeof value.asset.url !== "string") return null;
|
||||
@@ -266,7 +308,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon
|
||||
return { configured: config.updateStrategy !== "disabled", currentVersion: config.appVersion, platform, checkedAt: cached?.checkedAt ?? 0, latest: cached ? {
|
||||
version: cached.version,
|
||||
...(cached.tagName ? { tagName: cached.tagName } : {}),
|
||||
...(cached.releaseName ? { releaseName: cached.releaseName } : {}),
|
||||
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
|
||||
...(cached.notes ? { notes: cached.notes } : {}),
|
||||
...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}),
|
||||
compatible,
|
||||
integrityReady: compatible && Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
|
||||
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
|
||||
@@ -282,7 +327,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon
|
||||
latest: {
|
||||
version: cached.version,
|
||||
...(cached.tagName ? { tagName: cached.tagName } : {}),
|
||||
...(cached.releaseName ? { releaseName: cached.releaseName } : {}),
|
||||
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
|
||||
...(cached.notes ? { notes: cached.notes } : {}),
|
||||
...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}),
|
||||
compatible: Boolean(cached.asset),
|
||||
integrityReady: Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
|
||||
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
|
||||
@@ -306,22 +354,464 @@ 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;
|
||||
const updatedAt = typeof row.updatedAt === "number" ? row.updatedAt : null;
|
||||
const expectedRecoveryAt = row.status === "applying" && updatedAt !== null ? updatedAt + 30_000 : null;
|
||||
return {
|
||||
id: row.id,
|
||||
operation: row.operation ?? "apply",
|
||||
status: row.status,
|
||||
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,
|
||||
...(row.applyQueuedAt ? { applyQueuedAt: row.applyQueuedAt } : {}),
|
||||
...(expectedRecoveryAt ? { expectedRecoveryAt } : {}),
|
||||
...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markerMtime(filePath: string): number | null {
|
||||
try {
|
||||
const info = lstatSync(filePath);
|
||||
return info.isFile() ? info.mtimeMs : null;
|
||||
} catch {
|
||||
return 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);
|
||||
if (!target.startsWith(`${releases}${path.sep}`)) return null;
|
||||
return path.basename(target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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; operation: "download" | "apply"; version: string; adminId: string | null; requestId: string | null; updatedAt: number | null }>;
|
||||
if (rows.length === 0) 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) {
|
||||
// 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(() => {
|
||||
const result = database.prepare(`
|
||||
UPDATE update_jobs
|
||||
SET status=?, error_message=?, completed_at=?, updated_at=?
|
||||
WHERE id=? AND status=? AND updated_at=?
|
||||
`).run(status, errorMessage, now, now, row.id, row.status, 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: status === "completed" ? "success" : "failure",
|
||||
before: { status: row.status, version: row.version },
|
||||
after: { status, version: row.version, reason: "orphaned_timeout" },
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+239
-81
@@ -35,18 +35,46 @@ export type ReleaseAsset = {
|
||||
export type ReleaseMetadata = {
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
/** Plain-text release notes, bounded to keep API/cache payloads small. */
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
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("更新版本号无效");
|
||||
}
|
||||
@@ -143,22 +171,111 @@ 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
|
||||
// endpoint/version. Keep the browser contract text-only and bounded.
|
||||
const text = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p\s*>/gi, "\n\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim();
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
if (bytes.length <= RELEASE_NOTES_MAX_BYTES) return text;
|
||||
return bytes.subarray(0, RELEASE_NOTES_MAX_BYTES).toString("utf8").replace(/\uFFFD$/u, "") + "\n[内容已截断]";
|
||||
}
|
||||
|
||||
function releaseNameText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
||||
return text.length > 0 ? text.slice(0, 200) : undefined;
|
||||
}
|
||||
|
||||
/** Gitea installations behind a reverse proxy sometimes emit internal HTTP
|
||||
* asset URLs. Rebind those URLs to the already trusted HTTPS release origin,
|
||||
* while continuing to reject arbitrary HTTPS hosts and credentials. */
|
||||
function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): string {
|
||||
let candidate: URL;
|
||||
try {
|
||||
candidate = new URL(value, current);
|
||||
} catch {
|
||||
throw new Error("更新地址无效");
|
||||
}
|
||||
if (candidate.username || candidate.password) throw new Error("更新地址不允许携带凭据");
|
||||
try {
|
||||
return validateHttpsUrl(candidate, { ...options, baseUrl: current }).toString();
|
||||
} catch {
|
||||
if (candidate.protocol !== "http:") throw new Error("更新地址必须使用 HTTPS");
|
||||
const rebound = new URL(current);
|
||||
rebound.pathname = candidate.pathname;
|
||||
rebound.search = candidate.search;
|
||||
rebound.hash = "";
|
||||
return validateHttpsUrl(rebound, { ...options, baseUrl: current }).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -168,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);
|
||||
@@ -176,72 +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: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
||||
}
|
||||
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 } : {}),
|
||||
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
|
||||
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);
|
||||
@@ -251,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
|
||||
@@ -279,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);
|
||||
@@ -289,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()));
|
||||
@@ -331,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 {
|
||||
@@ -356,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);
|
||||
@@ -366,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();
|
||||
@@ -400,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") };
|
||||
}
|
||||
|
||||
@@ -93,12 +93,21 @@ export const updateJobStatusSchema = z.enum([
|
||||
]);
|
||||
export type UpdateJobStatus = z.infer<typeof updateJobStatusSchema>;
|
||||
|
||||
export const updateOperationSchema = z.enum(["download", "apply"]);
|
||||
export type UpdateOperation = z.infer<typeof updateOperationSchema>;
|
||||
|
||||
/** The browser never supplies release URLs or filesystem paths. */
|
||||
export const updateApplySchema = z.object({
|
||||
// Keep the browser contract aligned with server/update.ts' SemVer parser,
|
||||
// including optional prerelease and build metadata segments.
|
||||
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),
|
||||
jobId: z.string().uuid().optional(),
|
||||
}).strict();
|
||||
|
||||
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.boolean().default(true).optional(),
|
||||
}).strict();
|
||||
|
||||
export type ApiError = {
|
||||
|
||||
@@ -4,6 +4,15 @@ Description=Watch for TallyNote release update requests
|
||||
[Path]
|
||||
PathExists=/var/lib/tallynote/update-request.json
|
||||
PathChanged=/var/lib/tallynote/update-request.json
|
||||
# The recovery marker lives beside the release link. Watching it as well
|
||||
# allows systemd to resume reconciliation when the runner is interrupted
|
||||
# after consuming the request but before clearing its state file.
|
||||
PathExists=/opt/tallynote/.update-state
|
||||
PathChanged=/opt/tallynote/.update-state
|
||||
# Keep a directory-level fallback because some systemd/inotify versions skip
|
||||
# dotfiles when watching an individual path. State writes are atomic renames,
|
||||
# so the containing directory changes even when the marker itself is hidden.
|
||||
PathChanged=/opt/tallynote
|
||||
Unit=tallynote-update.service
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
[Unit]
|
||||
Description=TallyNote privileged release updater
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/var/lib/tallynote/update-request.json
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
@@ -12,10 +8,16 @@ WorkingDirectory=/opt/tallynote/current
|
||||
EnvironmentFile=-/etc/tallynote/tallynote.env
|
||||
ExecStart=/usr/local/libexec/tallynote-update-runner
|
||||
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
# The runner consumes queued requests immediately and applies its own bounded
|
||||
# phase timeouts while keeping full CLI diagnostics in the runner log.
|
||||
# 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=5min
|
||||
NoNewPrivileges=true
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
# 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
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
@@ -23,7 +25,6 @@ ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
|
||||
@@ -4,12 +4,15 @@ TALLYNOTE_DATA_DIR=/var/lib/tallynote
|
||||
TALLYNOTE_INSTALL_PREFIX=/opt/tallynote
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
|
||||
TALLYNOTE_COOKIE_SECURE=false
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=false
|
||||
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
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
# Optional: configure a root-managed Ed25519 public key and set
|
||||
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
|
||||
|
||||
@@ -14,13 +14,17 @@ Environment=PATH=/opt/tallynote/current/runtime/bin:/usr/sbin:/usr/bin:/sbin:/bi
|
||||
ExecStart=/opt/tallynote/current/bin/tallynote
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
# Do not let a wedged Node process hold an update stop forever.
|
||||
TimeoutStopSec=30s
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
InaccessiblePaths=/opt/tallynote/.update-work
|
||||
ProtectHome=true
|
||||
PrivateDevices=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
# Fastify logs the addresses of wildcard listeners. Node's libuv uses the
|
||||
# Linux netlink family while enumerating interfaces for that log message.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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";
|
||||
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], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...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",
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
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-"));
|
||||
const dataDir = path.join(parent, "data");
|
||||
try {
|
||||
const result = runAdmin(dataDir, ["--check"]);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("empty");
|
||||
expect(existsSync(dataDir)).toBe(false);
|
||||
expect(existsSync(path.join(dataDir, "tallynote.db"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("--check 不会执行迁移或创建 schema_migrations", () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
|
||||
const database = new Database(path.join(dataDir, "tallynote.db"));
|
||||
database.exec("CREATE TABLE admins (id TEXT PRIMARY KEY)");
|
||||
database.close();
|
||||
try {
|
||||
const result = runAdmin(dataDir, ["--check"]);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("empty");
|
||||
const verify = new Database(path.join(dataDir, "tallynote.db"), { readonly: true });
|
||||
const schemaMigrations = verify
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'")
|
||||
.get();
|
||||
expect(schemaMigrations).toBeUndefined();
|
||||
verify.close();
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("只允许初始化首位管理员,并写入一次性密码和审计记录", () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
||||
try {
|
||||
const first = runAdmin(dataDir, ["--username", "admin", "--display-name", "管理员", "--generate"]);
|
||||
expect(first.status).toBe(0);
|
||||
expect(first.stdout).toMatch(/已创建首位管理员。一次性密码:\S+/);
|
||||
|
||||
const database = new Database(path.join(dataDir, "tallynote.db"));
|
||||
const admin = database.prepare("SELECT username, display_name, must_change_password FROM admins").get() as { username: string; display_name: string; must_change_password: number };
|
||||
const audit = database.prepare("SELECT action, actor_username FROM audit_events ORDER BY occurred_at DESC LIMIT 1").get() as { action: string; actor_username: string };
|
||||
expect(admin).toEqual({ username: "admin", display_name: "管理员", must_change_password: 1 });
|
||||
expect(audit).toEqual({ action: "admin.initialized", actor_username: "cli" });
|
||||
database.close();
|
||||
|
||||
const check = runAdmin(dataDir, ["--check"]);
|
||||
expect(check.status).toBe(0);
|
||||
expect(check.stdout.trim()).toBe("initialized");
|
||||
|
||||
const second = runAdmin(dataDir, ["--username", "other", "--display-name", "其他", "--generate"]);
|
||||
expect(second.status).not.toBe(0);
|
||||
expect(`${second.stdout}${second.stderr}`).toContain("INITIAL_ADMIN_EXISTS");
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 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 {
|
||||
const result = spawnSync(process.execPath, [tsx, cli], {
|
||||
cwd: root,
|
||||
input: "admin\n管理员\npassword-password\npassword-password\n",
|
||||
env: {
|
||||
...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",
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(`${result.stdout}${result.stderr}`).toContain("交互式 TTY");
|
||||
expect(readFileSync(path.join(dataDir, "tallynote.db"))).toBeTruthy();
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+45
-3
@@ -87,10 +87,52 @@ describe("TallyNote API", () => {
|
||||
expect(missing.json().error.requestId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("拒绝没有 Origin 的写请求", async () => {
|
||||
it("显式允许的公网 HTTP 不会把静态资源升级到 HTTPS", async () => {
|
||||
const publicHttpConfig = {
|
||||
...config,
|
||||
publicOrigin: "http://192.0.2.10:3999",
|
||||
isLocalOrigin: false,
|
||||
allowInsecureHttp: true,
|
||||
cookieSecure: false,
|
||||
};
|
||||
const publicHttpApp = await buildApp(database, publicHttpConfig);
|
||||
try {
|
||||
const response = await publicHttpApp.inject({ method: "GET", url: "/health" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-security-policy"]).not.toContain("upgrade-insecure-requests");
|
||||
expect(response.headers["strict-transport-security"]).toBeUndefined();
|
||||
expect(response.headers["cross-origin-opener-policy"]).toBeUndefined();
|
||||
expect(response.headers["origin-agent-cluster"]).toBeUndefined();
|
||||
} finally {
|
||||
await publicHttpApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("HTTPS 仍保留传输安全响应头", async () => {
|
||||
const secureConfig = {
|
||||
...config,
|
||||
publicOrigin: "https://example.test:3999",
|
||||
isLocalOrigin: false,
|
||||
allowInsecureHttp: false,
|
||||
cookieSecure: true,
|
||||
};
|
||||
const secureApp = await buildApp(database, secureConfig);
|
||||
try {
|
||||
const response = await secureApp.inject({ method: "GET", url: "/health" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-security-policy"]).toContain("upgrade-insecure-requests");
|
||||
expect(response.headers["strict-transport-security"]).toContain("max-age=");
|
||||
expect(response.headers["cross-origin-opener-policy"]).toBe("same-origin");
|
||||
expect(response.headers["origin-agent-cluster"]).toBe("?1");
|
||||
} finally {
|
||||
await secureApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
Executable
+66
@@ -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
|
||||
@@ -41,9 +41,11 @@ describe("数据库迁移", () => {
|
||||
{ name: "0001_invoice_missing_reason.sql" },
|
||||
{ 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"]));
|
||||
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);
|
||||
|
||||
+23
-1
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { loadConfig, prepareDataDirectories } from "../server/config.js";
|
||||
|
||||
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
|
||||
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
|
||||
|
||||
afterEach(() => { for (const key of keys) delete process.env[key]; });
|
||||
|
||||
@@ -13,11 +13,32 @@ describe("部署安全配置", () => {
|
||||
it("公网 HTTP 或 HTTPS 非安全 Cookie 一律拒绝", () => {
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://example.test";
|
||||
expect(() => loadConfig()).toThrow(/HTTPS/);
|
||||
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
|
||||
expect(loadConfig().allowInsecureHttp).toBe(true);
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
||||
expect(() => loadConfig()).toThrow(/安全 Cookie/);
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "false";
|
||||
expect(() => loadConfig()).toThrow(/安全 Cookie/);
|
||||
});
|
||||
|
||||
it("允许显式配置服务器 IP 的直连 HTTP,并拒绝通配 Origin", () => {
|
||||
process.env.TALLYNOTE_HOST = "0.0.0.0";
|
||||
process.env.TALLYNOTE_PORT = "3000";
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://192.0.2.10:3000";
|
||||
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "false";
|
||||
expect(loadConfig()).toMatchObject({ host: "0.0.0.0", port: 3000, publicOrigin: "http://192.0.2.10:3000", allowInsecureHttp: true });
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://0.0.0.0:3000";
|
||||
expect(() => loadConfig()).toThrow(/通配监听地址/);
|
||||
});
|
||||
|
||||
it("为 IPv6 监听地址生成合法的默认 Origin", () => {
|
||||
process.env.TALLYNOTE_HOST = "::1";
|
||||
process.env.TALLYNOTE_PORT = "3000";
|
||||
expect(loadConfig().publicOrigin).toBe("http://[::1]:3000");
|
||||
});
|
||||
|
||||
it("生产环境不接受任意 trust proxy", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.TALLYNOTE_TRUST_PROXY = "true";
|
||||
@@ -27,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";
|
||||
|
||||
+309
-10
@@ -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.1.3-${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.1.3", 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,45 +70,147 @@ 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.1.3", 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.1.3", 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.1.3", 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.1.3", 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"]));
|
||||
});
|
||||
|
||||
it("先下载并暂存更新包,再由同一管理员认领应用", async () => {
|
||||
const session = await login("update-staged");
|
||||
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: "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: 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, "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: "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.1.3" } });
|
||||
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.1.3", 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);
|
||||
@@ -123,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.1.3", 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();
|
||||
});
|
||||
});
|
||||
|
||||
+254
-14
@@ -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, sign } from "node:crypto";
|
||||
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
||||
import {
|
||||
atomicSwitchRelease,
|
||||
applicationUpdateRuntimeHash,
|
||||
createSafeArchive,
|
||||
detectPlatform,
|
||||
downloadReleaseAsset,
|
||||
@@ -16,15 +17,16 @@ import {
|
||||
normalizeReleasePermissions,
|
||||
sanitizeAssetName,
|
||||
selectReleaseAsset,
|
||||
runtimeHashFromLockfile,
|
||||
validateHttpsUrl,
|
||||
} from "../server/update.js";
|
||||
import { runUpdate } from "../server/cli/update.js";
|
||||
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
|
||||
import { validateUpdateRequest } from "../server/cli/update.js";
|
||||
import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js";
|
||||
import { checkForUpdate, ORPHANED_UPDATE_TIMEOUT_MS, reconcileOrphanedUpdateJobs, verifyReleaseSignature } from "../server/update-service.js";
|
||||
import { loadConfig, prepareDataDirectories } from "../server/config.js";
|
||||
import { openDatabase } from "../server/db/index.js";
|
||||
|
||||
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
|
||||
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_INSTALL_PREFIX", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -207,6 +220,233 @@ describe("更新安全工具", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("更新器支持旧客户端创建的 queued/apply 直接更新请求", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-direct-"));
|
||||
const previousFetch = globalThis.fetch;
|
||||
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(config.releasesDir, { recursive: true, mode: 0o755 });
|
||||
const oldRelease = path.join(config.releasesDir, config.appVersion);
|
||||
await mkdir(path.join(oldRelease, "dist"), { recursive: true, mode: 0o755 });
|
||||
await writeFile(path.join(oldRelease, "dist", "marker"), "old");
|
||||
await symlink(oldRelease, config.currentLink);
|
||||
|
||||
const source = path.join(root, "source");
|
||||
await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o755 });
|
||||
await writeFile(path.join(source, "dist", "marker"), "new");
|
||||
const archive = path.join(root, "release.tar.gz");
|
||||
await createSafeArchive(source, archive);
|
||||
const bytes = await readFile(archive);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
const jobId = randomUUID();
|
||||
database = openDatabase(config);
|
||||
const now = Date.now();
|
||||
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', '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: "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: "9.9.9",
|
||||
currentVersion: config.appVersion,
|
||||
currentDir: config.currentLink,
|
||||
stagingDir: path.join(root, "staging"),
|
||||
currentLink: config.currentLink,
|
||||
releasesDir: config.releasesDir,
|
||||
allowedHosts: config.updateAllowedHosts,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
deferCompletion: true,
|
||||
operation: "apply",
|
||||
jobId,
|
||||
sqlite: database.sqlite,
|
||||
fetchImpl: globalThis.fetch,
|
||||
});
|
||||
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, 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();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("在请求和恢复标记丢失后收敛孤儿任务,但保留 staged 下载", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-reconcile-"));
|
||||
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 insert = database.sqlite.prepare(`
|
||||
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const queuedId = randomUUID();
|
||||
const applyingId = randomUUID();
|
||||
const stagedId = randomUUID();
|
||||
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", "9.9.9", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
|
||||
insert.run(stagedApplyId, "apply", "staged", "9.9.9", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
|
||||
const now = Date.now();
|
||||
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3);
|
||||
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });
|
||||
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(applyingId)).toEqual({ status: "completed" });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-"));
|
||||
try {
|
||||
@@ -273,17 +513,17 @@ describe("更新元数据缓存", () => {
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
const digest = "b".repeat(64);
|
||||
const platformAsset = `tallynote-1.1.3-${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.1.3", 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.1.3", 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 {
|
||||
|
||||
+30
-6
@@ -10,6 +10,7 @@ umask 077
|
||||
|
||||
TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false}
|
||||
TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-}
|
||||
TEST_DATA_OWNER_UID=${TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID:-}
|
||||
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
|
||||
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
|
||||
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
|
||||
@@ -18,6 +19,7 @@ SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
|
||||
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
|
||||
SYSTEMCTL_BIN=systemctl
|
||||
SYSTEMCTL_AVAILABLE=0
|
||||
SYSTEMCTL_TIMEOUT_SECONDS=${TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS:-30}
|
||||
PURGE_DATA=0
|
||||
PURGE_CONFIG=0
|
||||
YES=0
|
||||
@@ -51,6 +53,7 @@ if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TE
|
||||
fi
|
||||
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
|
||||
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
|
||||
[[ "$SYSTEMCTL_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || die 'TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS must be a positive integer'
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
@@ -103,7 +106,7 @@ allowed_data_owner() {
|
||||
local path=$1 uid tallynote_uid
|
||||
uid=$(stat_uid "$path")
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 || ( -n "$TEST_DATA_OWNER_UID" && "$uid" == "$TEST_DATA_OWNER_UID" ) ]]
|
||||
return
|
||||
fi
|
||||
[[ "$uid" == 0 ]] && return 0
|
||||
@@ -130,7 +133,13 @@ validate_parent_chain() {
|
||||
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
|
||||
if [[ -e "$current" ]]; then
|
||||
[[ -d "$current" ]] || die "路径不是目录:$current"
|
||||
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
|
||||
# The target itself is checked by validate_target with its path-specific
|
||||
# owner policy (data may belong to the tallynote service user). Keep all
|
||||
# ancestor directories root-owned, but do not apply that policy twice to
|
||||
# the final target.
|
||||
if [[ "$current" != "$target" ]]; then
|
||||
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
|
||||
fi
|
||||
local mode_bits
|
||||
mode_bits=$(stat_mode_bits "$current")
|
||||
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
|
||||
@@ -236,14 +245,14 @@ assert_test_scope() {
|
||||
managed_file() {
|
||||
local target=$1 label=$2
|
||||
case "$label" in
|
||||
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|uninstaller)
|
||||
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|admin\ initializer|uninstaller)
|
||||
grep -Eiq 'tallynote|TallyNote' "$target" || return 1
|
||||
if [[ "$label" == 'path unit' ]]; then
|
||||
grep -Fq "$DATA_DIR" "$target" || return 1
|
||||
elif [[ "$label" == *unit ]]; then
|
||||
grep -Fq "$PREFIX" "$target" || return 1
|
||||
else
|
||||
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote' "$target" || return 1
|
||||
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote|admin-init.js' "$target" || return 1
|
||||
fi
|
||||
;;
|
||||
environment\ file)
|
||||
@@ -289,7 +298,13 @@ run_systemctl() {
|
||||
else
|
||||
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
|
||||
fi
|
||||
"$SYSTEMCTL_BIN" "$@"
|
||||
# A stuck systemd/dbus call must not leave the uninstaller looking frozen.
|
||||
# Test fixtures intentionally bypass the external timeout command.
|
||||
if [[ "$TEST_MODE" != true ]] && command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$SYSTEMCTL_TIMEOUT_SECONDS" "$SYSTEMCTL_BIN" "$@"
|
||||
else
|
||||
"$SYSTEMCTL_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_services() {
|
||||
@@ -304,8 +319,10 @@ stop_services() {
|
||||
done
|
||||
return 0
|
||||
fi
|
||||
log "正在停止 TallyNote 服务(systemd 操作超时 ${SYSTEMCTL_TIMEOUT_SECONDS} 秒)"
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
active=0
|
||||
log "检查服务:$unit"
|
||||
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
|
||||
active=1
|
||||
else
|
||||
@@ -316,13 +333,17 @@ stop_services() {
|
||||
esac
|
||||
fi
|
||||
if (( active )); then
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit"
|
||||
log "停止服务:$unit"
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit(如果 systemd 正在等待进程退出,请稍后重试)"
|
||||
log "已停止服务:$unit"
|
||||
fi
|
||||
if [[ -e "$UNIT_DIR/$unit" ]]; then
|
||||
log "禁用服务:$unit"
|
||||
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
|
||||
fi
|
||||
done
|
||||
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
|
||||
log 'systemd 服务已停止并禁用'
|
||||
}
|
||||
|
||||
validate_systemctl() {
|
||||
@@ -457,13 +478,16 @@ main() {
|
||||
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
|
||||
fi
|
||||
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
|
||||
log '开始移除 TallyNote 文件和服务配置'
|
||||
stop_services
|
||||
remove_prefix
|
||||
log '发布文件和更新组件已移除'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper'
|
||||
remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-admin-init" 'admin initializer'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller'
|
||||
remove_config
|
||||
remove_data
|
||||
|
||||
@@ -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
@@ -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 : "尚未初始化管理员,请先在服务器执行 pnpm 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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</>;
|
||||
|
||||
@@ -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>>) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+1166
-10
File diff suppressed because it is too large
Load Diff
+40
-7
@@ -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[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user