Compare commits

...
15 Commits
Author SHA1 Message Date
Qiufeng b431fe167e fix: center navigation wordmark
TallyNote release / linux-x64 (push) Successful in 6m40s
2026-09-03 00:43:38 +08:00
Qiufeng 344985f514 release: 1.1.12
TallyNote release / linux-x64 (push) Successful in 6m26s
2026-09-02 20:23:46 +08:00
Qiufeng c518890fc3 fix: keep opted-in HTTP assets on HTTP
TallyNote release / linux-x64 (push) Successful in 6m7s
2026-09-02 15:11:48 +08:00
Qiufeng 1925676fc9 fix: allow netlink for wildcard listener startup
TallyNote release / linux-x64 (push) Successful in 6m4s
2026-09-02 14:06:49 +08:00
Qiufeng 37ffc27ff5 fix: verify service health after installation
TallyNote release / linux-x64 (push) Successful in 7m7s
2026-09-02 12:14:18 +08:00
Qiufeng 5dcf9d0f61 fix: make installer and uninstaller completion reliable
TallyNote release / linux-x64 (push) Successful in 6m23s
2026-09-02 07:59:41 +08:00
Qiufeng 26fbec49ad feat: improve installer network setup
TallyNote release / linux-x64 (push) Successful in 5m48s
2026-09-02 07:38:38 +08:00
Qiufeng 3cedcb901b fix: allow service-owned data directory during uninstall
TallyNote release / linux-x64 (push) Successful in 5m55s
2026-09-02 06:54:36 +08:00
Qiufeng bac10b6fdf feat: add interactive installer network setup
TallyNote release / linux-x64 (push) Successful in 5m54s
2026-09-02 06:29:29 +08:00
Qiufeng eeeec54d10 feat: support direct IP service access
TallyNote release / linux-x64 (push) Successful in 7m17s
2026-09-01 20:57:56 +08:00
Qiufeng bcc63b8117 test: keep update fixtures ahead of current release
TallyNote release / linux-x64 (push) Successful in 6m9s
2026-09-01 15:04:31 +08:00
Qiufeng a268eb5fe9 feat: add staged release updates
TallyNote release / linux-x64 (push) Failing after 2m51s
2026-09-01 14:46:32 +08:00
Qiufeng 4395317651 feat: add friendly installer progress logs 2026-09-01 11:42:25 +08:00
Qiufeng 4b9c80cc3a feat: add safe one-click uninstall
TallyNote release / linux-x64 (push) Successful in 6m16s
2026-09-01 06:57:52 +08:00
Qiufeng 4434acf697 release: simplify unsigned installation
TallyNote release / linux-x64 (push) Successful in 6m24s
2026-09-01 00:10:35 +08:00
35 changed files with 2988 additions and 243 deletions
+8 -3
View File
@@ -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,10 @@ 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
# One-click/systemd updates require an Ed25519 signature over SHA256SUMS.
# Keep this file root-readable and point to a root-managed public key.
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
# 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
+1 -2
View File
@@ -32,10 +32,9 @@ jobs:
pnpm test
- name: Build Linux release
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
- name: Create and publish signed Gitea Release
- name: Create and publish Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TALLYNOTE_RELEASE_SIGNING_KEY: ${{ secrets.TALLYNOTE_RELEASE_SIGNING_KEY }}
run: ./scripts/publish-gitea-release.sh "$GITHUB_REF_NAME" ./release
# Linux x86 (i386/i686) is intentionally not published: Node.js 24 and the
+83 -17
View File
@@ -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 tallynote-admin-init` 即可。也可以使用 `sudo tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。
默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。
@@ -51,29 +50,96 @@ 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 单元,以及 `SHA256SUMS` 和 `SHA256SUMS.sig`。安装器默认 dry-run,只有显式 `--apply` 才会下载或写盘;正式安装必须提供独立核对过的 Ed25519 公钥:
发布包必须包含 `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 -s -- --apply --version 1.1.0 \
--signing-key /root/tallynote-update.pub \
--update-public-key-file /root/tallynote-update.pub
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
指定版本时,脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 获取归档、`SHA256SUMS` 和签名。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。`--allow-unsigned` 仅供隔离开发机测试,不能用于公网或真实财务数据。
安装命令保持简洁。首次在 SSH/终端中安装时,安装器会交互询问监听方式、端口和公开访问地址:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入;选择稍后创建也不会阻塞服务启动,之后执行 `sudo tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。
监听方式有两个选项:`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-admin-init` 和 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序、管理员初始化命令和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
```bash
sudo /usr/local/sbin/tallynote-uninstall
```
如果确认不再需要数据库、附件、暂存、导出和更新备份,必须显式同时提供 `--purge-data --yes`:
```bash
sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
```
卸载检测到未完成的更新状态时会停止并要求人工确认;确认更新已停止后再加 `--force`。也可以直接从公开仓库获取同一脚本执行普通卸载:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
```
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
### 构建发布包
@@ -82,17 +148,17 @@ curl --proto '=https' --tlsv1.2 -fsSL \
```bash
pnpm install --frozen-lockfile
pnpm release:build 1.1.0 ./release
pnpm release:build 1.1.2 ./release
```
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS` 与 `SHA256SUMS.sig`;当前仓库还没有首个 tag/release 时,后台会明确显示不可用,不会下载未验证文件。CI 需要 `GITEA_TOKEN` 和 `TALLYNOTE_RELEASE_SIGNING_KEY` secrets。
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS`;如果 CI 提供签名私钥,还会额外上传 `SHA256SUMS.sig`。CI 只需要 `GITEA_TOKEN`;签名私钥属于可选增强。
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.0` 与 `v1.1.0`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.2` 与 `v1.1.2`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
```bash
git add .
git commit -m "release: 1.1.0"
git tag -a v1.1.0 -m "TallyNote 1.1.0"
git commit -m "release: 1.1.2"
git tag -a v1.1.2 -m "TallyNote 1.1.2"
git push origin main --follow-tags
```
@@ -109,4 +175,4 @@ 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 反代访问。
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256 的归档、路径穿越、特殊文件和符号链接;启用签名要求时也会拒绝无有效签名的归档。附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
+149
View File
@@ -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 "$@"
+59 -18
View File
@@ -6,48 +6,75 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
## 自动发布
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.0`)会触发 `.gitea/workflows/release.yml`:
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.2`)会触发 `.gitea/workflows/release.yml`:
1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
3. 用 Ed25519 私钥生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和签名。
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
在仓库的 Actions secrets 配置:
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
- `TALLYNOTE_RELEASE_SIGNING_KEY`:Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
- `TALLYNOTE_RELEASE_SIGNING_KEY`:可选的 Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
也可以在 Linux 发布机上手动执行:
```bash
pnpm install --frozen-lockfile
pnpm check && pnpm test
pnpm release:build 1.1.0 ./release
pnpm release:build 1.1.2 ./release
GITHUB_REPOSITORY=awaioi/TallyNote \
GITEA_TOKEN=... \
TALLYNOTE_RELEASE_SIGNING_KEY_FILE=/root/secrets/tallynote-release.key \
./scripts/publish-gitea-release.sh v1.1.0 ./release
./scripts/publish-gitea-release.sh v1.1.2 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.1.0-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
## curl 安装
安装器默认只做 dry-run;只有显式 `--apply` 才会下载或写盘。正式安装必须同时提供 Ed25519 公钥和 `SHA256SUMS.sig`,公钥应通过独立的受信渠道核对指纹。下面示例假设公钥已安全放在服务器 `/root/tallynote-update.pub`:
安装器默认直接获取并安装 latest Release。它始终校验 `SHA256SUMS` 中的 SHA-256,不要求公钥或签名文件:
```bash
curl --proto '=https' --tlsv1.2 -fsSL \
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
| sudo bash -s -- --apply --version 1.1.0 \
--signing-key /root/tallynote-update.pub \
--update-public-key-file /root/tallynote-update.pub
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档、`SHA256SUMS` 和 `SHA256SUMS.sig`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。
首次在交互式 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`)时才允许回退版本。
`--allow-unsigned` 只用于隔离的开发/测试主机,不能用于公网或保存真实财务数据的服务器。安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`,并提供 `/usr/local/sbin/tallynote-admin-init` 作为生产环境首次管理员初始化入口。`--allow-unsigned` 作为旧版本兼容参数保留。
安装布局:
@@ -61,17 +88,31 @@ curl --proto '=https' --tlsv1.2 -fsSL \
/etc/tallynote/tallynote.env
```
## 卸载与数据保留
默认卸载只移除发布代码、systemd 单元、更新辅助程序和已知配置,数据目录与更新备份不会删除:
```bash
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` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且同时通过 SHA-256 与 Ed25519 签名验证的资产;缺少任一项时“更新”按钮保持禁用。
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata、清单和签名,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;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` 或显式网络环境变量。
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
+711 -52
View File
File diff suppressed because it is too large Load Diff
@@ -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);
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.0",
"version": "1.1.13",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
@@ -20,6 +20,7 @@
"check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web-next.json --noEmit",
"check:next": "tsc -p tsconfig.web-next.json --noEmit",
"test": "vitest run",
"test:installer": "bash scripts/test-installer.sh && bash scripts/test-uninstaller.sh",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
+2 -1
View File
@@ -32,10 +32,11 @@ cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
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"
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.
+22 -11
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Publish one immutable, signed release to a Gitea-compatible API. The script
# is intentionally separate from the workflow so operators can dry-run the
# exact same asset selection locally without ever exposing a signing key.
# Publish one immutable release to a Gitea-compatible API. SHA256SUMS is
# always generated; an Ed25519 detached signature is added when a signing key
# is supplied. The script remains separate from the workflow so operators can
# dry-run the exact same asset selection locally without exposing a key.
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
export PATH
umask 077
@@ -23,6 +24,7 @@ DRY_RUN=0
AUTH_CONFIG=''
SUMS_TMP=''
SIG_TMP=''
SIGNATURE_GENERATED=0
usage() {
cat <<'EOF'
@@ -30,8 +32,12 @@ Usage: publish-gitea-release.sh TAG [ASSET_DIR] [--dry-run]
Required in publish mode:
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
Optional:
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
or TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
Without a signing key, the release is published with SHA256SUMS only.
EOF
}
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
@@ -116,7 +122,9 @@ API_ROOT=${API_ROOT%/}
validate_api_root "$API_ROOT"
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
if [[ -n "$SIGNING_KEY_FILE" || -n "$SIGNING_KEY_VALUE" ]]; then
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signing a release'
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'
@@ -164,8 +172,6 @@ elif [[ -n "$SIGNING_KEY_VALUE" ]]; then
chmod 600 "$temporary_key"
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
unset SIGNING_KEY_VALUE
else
[[ "$DRY_RUN" -eq 1 ]] || die 'TALLYNOTE_RELEASE_SIGNING_KEY_FILE or TALLYNOTE_RELEASE_SIGNING_KEY is required'
fi
if [[ -n "$temporary_key" ]]; then
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
@@ -174,16 +180,18 @@ if [[ -n "$temporary_key" ]]; then
chmod 600 "$SIG_TMP"
mv -f -- "$SIG_TMP" "$SIG_FILE"
SIG_TMP=''
SIGNATURE_GENERATED=1
fi
log "tag: $TAG"
log "assets: ${#assets[@]} archive(s), SHA256SUMS${temporary_key:+, SHA256SUMS.sig}"
asset_summary="assets: ${#assets[@]} archive(s), SHA256SUMS"
if (( SIGNATURE_GENERATED )); then asset_summary+=", SHA256SUMS.sig"; fi
log "$asset_summary"
if (( DRY_RUN )); then
log 'dry-run: no API request was sent'
exit 0
fi
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
[[ -s "$SIG_FILE" ]] || die 'signature was not generated'
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
@@ -230,7 +238,8 @@ assets_endpoint="$API_ROOT/repos/$repo_path/releases/$release_id/assets"
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
while IFS=$'\t' read -r existing_id existing_name; do
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
for candidate in "${assets[@]}" "$SUMS_FILE" "$SIG_FILE"; do
candidates=("${assets[@]}" "$SUMS_FILE" "$SIG_FILE")
for candidate in "${candidates[@]}"; do
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
done
@@ -245,5 +254,7 @@ upload_asset() {
}
for file in "${assets[@]}"; do upload_asset "$file"; done
upload_asset "$SUMS_FILE"
upload_asset "$SIG_FILE"
if (( SIGNATURE_GENERATED )); then
upload_asset "$SIG_FILE"
fi
log "published $TAG to $REPOSITORY"
+25 -1
View File
@@ -13,6 +13,10 @@ STATE_FILE="$PREFIX/.update-state"
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; }
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
@@ -22,6 +26,26 @@ die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
old_target=$(readlink -f -- "$CURRENT_LINK")
[[ "$old_target" == "$PREFIX/releases/"* && -d "$old_target" ]] || die 'current release target is invalid'
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
# 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
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'
"$node_bin" "$cli" --request-file "$REQUEST_FILE" || exit $?
rm -f -- "$REQUEST_FILE"
exit 0
fi
was_active=0
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
@@ -202,7 +226,7 @@ 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
+440
View File
@@ -2,14 +2,41 @@
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"
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
@@ -37,6 +64,13 @@ fi
# main invocation lets this subprocess source the exact production code.
installer_lib="$tmp/install-lib.sh"
sed '$d' "$root/install.sh" > "$installer_lib"
bash -c '
script=$1
set --
source "$script"
[[ "$APPLY" -eq 1 ]]
[[ "$REQUIRE_SIGNATURE" == false ]]
' _ "$installer_lib"
bash -c '
script=$1
mode_dir=$2
@@ -53,6 +87,205 @@ bash -c '
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"
# 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 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"
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"
@@ -70,15 +303,207 @@ 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"
' _ "$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"
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh"
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/uninstall.sh"
archive_tmp="$tmp/release.tar.gz"
tar -C "$source_tmp" -czf "$archive_tmp" .
bash -c '
@@ -92,8 +517,23 @@ bash -c '
[[ "$(stat_mode "$destination/dist")" == 755 ]]
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
[[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]]
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
# A normal public-release install only needs the detached SHA-256 manifest;
# absence of a signature and public key must not block archive verification.
checksum_tmp="$tmp/SHA256SUMS"
(cd "$(dirname -- "$archive_tmp")" && sha256sum "$(basename -- "$archive_tmp")") > "$checksum_tmp"
bash -c '
script=$1
archive=$2
checksum=$3
set --
source "$script"
REQUIRE_SIGNATURE=false
verify_archive "$archive" "$checksum" "" ""
' _ "$installer_lib" "$archive_tmp" "$checksum_tmp"
# Newline/control characters in release configuration must never become extra
# systemd EnvironmentFile assignments.
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
set -Eeuo pipefail
root=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
bash -n "$root/uninstall.sh"
tmp=$(cd "$(mktemp -d)" && pwd -P)
cleanup() { rm -rf -- "$tmp" 2>/dev/null || true; }
trap cleanup EXIT
make_fixture() {
local fixture=$1
mkdir -p "$fixture/opt/tallynote/releases/1.1.1/dist" \
"$fixture/opt/tallynote/.update-work" \
"$fixture/var/lib/tallynote/files" \
"$fixture/var/lib/tallynote/staging" \
"$fixture/var/lib/tallynote/exports" \
"$fixture/var/lib/tallynote-backups" \
"$fixture/etc/tallynote" \
"$fixture/etc/systemd/system" \
"$fixture/usr/local/sbin" \
"$fixture/usr/local/libexec"
printf '%s\n' 'release' > "$fixture/opt/tallynote/releases/1.1.1/dist/index.js"
ln -s "$fixture/opt/tallynote/releases/1.1.1" "$fixture/opt/tallynote/current"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
"TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$fixture/etc/tallynote/update-signing-key.pub" \
> "$fixture/etc/tallynote/tallynote.env"
chmod 600 "$fixture/etc/tallynote/tallynote.env"
printf '%s\n' 'fake public key' > "$fixture/etc/tallynote/update-signing-key.pub"
for unit in tallynote.service tallynote-update.service tallynote-update.path; do
printf '%s\n' "Description=TallyNote $unit" "WorkingDirectory=$fixture/opt/tallynote/current" "PathExists=$fixture/var/lib/tallynote/update-request.json" > "$fixture/etc/systemd/system/$unit"
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-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"
}
make_systemctl() {
local fixture=$1
cat > "$fixture/systemctl" <<'EOF'
#!/usr/bin/env bash
set -u
printf '%s\n' "$*" >> "$TALLYNOTE_TEST_SYSTEMCTL_LOG"
case "${1:-}" in
is-active) exit 0 ;;
stop|disable|daemon-reload) exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod 755 "$fixture/systemctl"
}
run_uninstall() {
local fixture=$1
TALLYNOTE_UNINSTALL_TEST_MODE=true \
TALLYNOTE_UNINSTALL_ROOT="$fixture" \
TALLYNOTE_SYSTEMCTL_BIN="$fixture/systemctl" \
TALLYNOTE_TEST_SYSTEMCTL_LOG="$fixture/systemctl.log" \
bash "$root/uninstall.sh" "${@:2}"
}
fixture="$tmp/normal"
make_fixture "$fixture"
make_systemctl "$fixture"
run_uninstall "$fixture"
[[ -d "$fixture/var/lib/tallynote" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
[[ -d "$fixture/var/lib/tallynote-backups" && -f "$fixture/var/lib/tallynote-backups/backup.db" ]]
[[ ! -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)
update_stop=$(grep -n '^stop tallynote-update.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
main_stop=$(grep -n '^stop tallynote.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
(( path_stop < update_stop && update_stop < main_stop ))
# Re-running after the first uninstall is harmless and does not touch data.
run_uninstall "$fixture"
[[ -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Purge requires the explicit acknowledgement flag and must fail before any
# application files are removed.
fixture="$tmp/purge"
make_fixture "$fixture"
make_systemctl "$fixture"
if run_uninstall "$fixture" --purge-data >/dev/null 2>&1; then
echo 'expected --purge-data without --yes to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
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"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
"TALLYNOTE_DATA_DIR=$fixture/opt/tallynote/releases/data" \
> "$fixture/etc/tallynote/tallynote.env"
mkdir -p "$fixture/opt/tallynote/releases/data"
printf '%s\n' protected > "$fixture/opt/tallynote/releases/data/keep.db"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected overlapping data path to fail' >&2
exit 1
fi
[[ -f "$fixture/opt/tallynote/releases/data/keep.db" ]]
# Trailing-slash aliases are rejected before the lexical overlap guard can be
# bypassed.
fixture="$tmp/trailing"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/" \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
> "$fixture/etc/tallynote/tallynote.env"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected trailing slash path to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Dot-component aliases are rejected as well; textual paths must be canonical
# before the managed-directory containment checks run.
fixture="$tmp/dot"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/." \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
> "$fixture/etc/tallynote/tallynote.env"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected dot path component to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Pending update state blocks destructive work until an operator overrides it.
fixture="$tmp/pending"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' pending > "$fixture/opt/tallynote/.update-state"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected pending update state to block uninstall' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# A current link escaping the release tree is rejected without deleting data.
fixture="$tmp/link"
make_fixture "$fixture"
make_systemctl "$fixture"
rm -f "$fixture/opt/tallynote/current"
ln -s "$fixture/outside" "$fixture/opt/tallynote/current"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected unsafe current symlink to fail' >&2
exit 1
fi
[[ -L "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# dry-run must not call systemctl or remove files.
fixture="$tmp/dry-run"
make_fixture "$fixture"
make_systemctl "$fixture"
run_uninstall "$fixture" --dry-run >/dev/null
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
[[ ! -e "$fixture/systemctl.log" ]]
printf '%s\n' 'uninstaller shell tests passed'
+88 -15
View File
@@ -23,6 +23,7 @@ import {
permanentDeleteSchema,
statusUpdateSchema,
updateApplySchema,
updateDownloadSchema,
versionSchema,
type AttachmentKind,
type ExpenseStatus,
@@ -94,7 +95,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 +106,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 +116,13 @@ function enforceUpdateCooldown(
database: DatabaseContext["sqlite"],
config: AppConfig,
adminId: string,
operation: "check" | "apply",
operation: "check" | "download" | "apply",
reply: FastifyReply,
): void {
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,6 +131,7 @@ function enforceUpdateCooldown(
: "更新操作过于频繁,请稍后再试");
}
if (operation === "check") state.checkedAt = now;
else if (operation === "download") state.downloadedAt = now;
else state.appliedAt = now;
}
@@ -588,6 +590,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 +611,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 +625,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 } : {}),
},
},
});
@@ -932,9 +941,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
const cached = publicCheckFromCache(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,
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,
requested_at AS applyQueuedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
return {
@@ -988,6 +998,37 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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", "更新任务尚未完成下载");
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 +1096,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 +1126,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 +1143,43 @@ 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);
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 {
await writeUpdateRequest(config, { jobId: id, operation: "download", version, metadataUrl: cached.metadataUrl, assetUrl: cachedAsset.url, assetName: cachedAsset.name, expectedSha256: cachedAsset.sha256, requestedAt: now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
} catch {
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=?").run("无法创建系统更新请求", Date.now(), id);
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } });
});
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);
const row = database.sqlite.prepare(`
SELECT id, status, version, platform, asset_name AS assetName,
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
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("更新任务不存在");
+101 -20
View File
@@ -1,7 +1,7 @@
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 { writeAudit } from "../audit.js";
@@ -11,42 +11,120 @@ 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);
} else {
value += 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 {
@@ -64,6 +142,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();
@@ -74,14 +155,14 @@ async function main() {
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);
`).run(id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, 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}` : "已创建首位管理员。");
+129 -11
View File
@@ -31,6 +31,7 @@ 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 +97,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,21 +143,24 @@ 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),
operation=excluded.operation,
status=excluded.status, 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),
@@ -174,6 +180,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,
@@ -238,9 +245,28 @@ 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";
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
try {
resolved = await resolveRelease(options, platform);
@@ -250,27 +276,36 @@ 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() });
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
if (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 });
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 });
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 +330,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";
@@ -335,6 +372,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 +469,36 @@ 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 download_path AS downloadPath, version FROM update_jobs WHERE id=? AND status='staged' AND operation='apply'").get(request.jobId) as { downloadPath: string | null; version: string } | undefined;
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;
}
const result = await runUpdate({
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
@@ -398,9 +515,10 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
dataBackupMaxBytes: config.maxTotalBytes,
currentVersion: config.appVersion,
...(deferCompletion ? { deferCompletion: true } : {}),
...(request?.operation === "download" ? { operation: "download" as const } : {}),
...(request ? { jobId: request.jobId } : {}),
publicKey: config.updatePublicKey,
requireSignature: request ? true : config.updateRequireSignature,
requireSignature: config.updateRequireSignature,
sqlite: database.sqlite,
});
console.log(`更新完成:${result.version}`);
+22 -3
View File
@@ -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 {
@@ -104,7 +112,10 @@ export function loadConfig() {
|| "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest";
const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS");
const updatePublicKey = updatePublicKeyEnv();
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", updateStrategyRaw === "systemd");
// Public releases always require HTTPS, host allowlisting, and SHA-256.
// Detached signatures remain an opt-in hardening layer so a self-hosted
// public repository can use one-click updates without provisioning a key.
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", false);
if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) {
throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd");
}
@@ -119,6 +130,7 @@ export function loadConfig() {
timezone,
trustProxy: trustProxyEnv(),
cookieSecure,
allowInsecureHttp,
appVersion,
updateMetadataUrl,
updateAllowedHosts,
@@ -139,6 +151,7 @@ export function loadConfig() {
// 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,
@@ -162,7 +175,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
View File
@@ -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 }) };
}
+1
View File
@@ -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(),
+30
View File
@@ -14,6 +14,7 @@ import {
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
RELEASE_NOTES_MAX_BYTES,
type ReleaseAsset,
type ReleaseMetadata,
} from "./update.js";
@@ -34,7 +35,10 @@ export type CachedRelease = {
metadataUrl: string;
version: string;
tagName?: string;
releaseName?: string;
publishedAt?: string;
notes?: string;
releaseUrl?: string;
platform: string;
signatureVerified?: boolean;
asset?: {
@@ -53,7 +57,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 +72,7 @@ export type UpdateCheckResult = {
export type UpdateRequest = {
jobId: string;
operation?: "download" | "apply";
version: string;
metadataUrl: string;
assetUrl: string;
@@ -76,6 +84,7 @@ export type UpdateRequest = {
currentLink: string;
releasesDir: string;
dataDir: string;
stagedPath?: string;
};
function setting(database: Database.Database, key: string): string | undefined {
@@ -206,7 +215,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 +239,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 +260,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 +284,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 +303,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,
@@ -309,8 +333,11 @@ export async function writeUpdateRequest(config: AppConfig, request: UpdateReque
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,
@@ -323,5 +350,8 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
createdAt: row.createdAt,
updatedAt: row.updatedAt,
completedAt: row.completedAt ?? null,
...(row.applyQueuedAt ? { applyQueuedAt: row.applyQueuedAt } : {}),
...(expectedRecoveryAt ? { expectedRecoveryAt } : {}),
...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}),
};
}
+68 -1
View File
@@ -35,7 +35,11 @@ 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[];
};
@@ -143,6 +147,57 @@ function metadataError(): Error {
}
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
export const RELEASE_NOTES_MAX_BYTES = 64 * 1024;
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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/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> {
@@ -227,12 +282,24 @@ export async function fetchReleaseMetadata(
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 } : {}) });
assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
}
const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html);
const releaseName = releaseNameText(item.name ?? item.releaseName);
let releaseUrl: string | undefined;
if (typeof item.html_url === "string" || typeof item.url === "string") {
try {
const candidate = typeof item.html_url === "string" ? item.html_url : item.url as string;
releaseUrl = releaseResourceUrl(candidate, current, options);
} catch { /* omit invalid optional release page URL */ }
}
return {
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
...(releaseName ? { releaseName } : {}),
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
...(notes ? { notes } : {}),
...(releaseUrl ? { releaseUrl } : {}),
assets,
};
}
+9
View File
@@ -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.literal(true),
}).strict();
export type ApiError = {
+3 -1
View File
@@ -15,7 +15,9 @@ Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
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
+5 -2
View File
@@ -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_REQUIRE_SIGNATURE=true
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
# Configure a root-managed Ed25519 public key before enabling one-click updates.
# Optional: configure a root-managed Ed25519 public key and set
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
+3 -1
View File
@@ -20,7 +20,9 @@ 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
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } 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");
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",
});
}
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);
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 });
}
});
});
+42
View File
@@ -87,6 +87,48 @@ describe("TallyNote API", () => {
expect(missing.json().error.requestId).toBeTruthy();
});
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);
+2 -1
View File
@@ -41,9 +41,10 @@ describe("数据库迁移", () => {
{ name: "0001_invoice_missing_reason.sql" },
{ name: "0002_update_jobs.sql" },
{ name: "0003_update_job_ownership.sql" },
{ name: "0004_update_download_apply.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"]));
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);
+26 -3
View File
@@ -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,14 +48,16 @@ describe("部署安全配置", () => {
expect(loadConfig().trustProxy).toBe(1);
});
it("systemd 更新必须绑定主机白名单并默认要求签名", () => {
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
process.env.TALLYNOTE_COOKIE_SECURE = "true";
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
const config = loadConfig();
expect(config.updateRequireSignature).toBe(true);
expect(config.updateRequireSignature).toBe(false);
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
expect(loadConfig().updateRequireSignature).toBe(true);
});
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
+38 -9
View File
@@ -59,10 +59,10 @@ describe("更新 API", () => {
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
const asset = `tallynote-1.2.0-${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.1", 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: "v1.2.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
}
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
@@ -70,21 +70,21 @@ 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.1", compatible: true, integrityReady: true, isNewer: true });
expect(checked.json().latest).toMatchObject({ version: "1.2.0", 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.1", confirm: true } });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", 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.1", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
expect(request).toMatchObject({ jobId, version: "1.2.0", 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.1", 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: "1.2.0", 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 } });
@@ -93,9 +93,38 @@ describe("更新 API", () => {
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: "1.2.0", confirm: true } });
expect(downloaded.statusCode).toBe(202);
const downloadJobId = downloaded.json().job.id as string;
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.2.0" });
const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string };
expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" });
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" });
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message='test', updated_at=? WHERE id=?").run(Date.now(), downloadJobId);
const stagedId = randomUUID();
const now = Date.now();
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.2.0", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.2.0", confirm: true } });
expect(applied.statusCode).toBe(202);
expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" });
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" });
const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string };
expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) });
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.2.0", confirm: true } });
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.1" } });
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0" } });
expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig();
@@ -108,7 +137,7 @@ describe("更新 API", () => {
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.1", 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: "1.2.0", 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);
@@ -126,7 +155,7 @@ describe("更新 API", () => {
it("应用前重新校验失败时写入失败审计", async () => {
const session = await login("update-audit");
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", 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: "1.2.0", confirm: true } });
expect(response.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");
+3 -3
View File
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
const platformAsset = `tallynote-1.2.0-${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.1", 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: "v1.2.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
try {
const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
expect(result.latest).toMatchObject({ version: "1.2.0", 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 {
Executable
+497
View File
@@ -0,0 +1,497 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# TallyNote native uninstaller. The default operation removes only the
# application and service integration; the database and attachments stay in
# place until --purge-data --yes is explicitly requested.
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
export PATH
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}
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}
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
DRY_RUN=0
FORCE=0
EXPLICIT_PREFIX=0
EXPLICIT_DATA=0
EXPLICIT_CONFIG=0
die() { printf 'tallynote uninstaller: %s\n' "$*" >&2; exit 1; }
log() { printf 'tallynote uninstaller: %s\n' "$*"; }
usage() {
cat <<'EOF'
Usage: tallynote-uninstall [--yes] [--purge-data] [--purge-config]
[--dry-run] [--force]
[--prefix PATH] [--data-dir PATH] [--config-dir PATH]
By default, remove the TallyNote release tree, systemd units, update helpers,
and known configuration files. The database, attachments, staging, exports,
update queue, and update backups are preserved. Data removal requires both
--purge-data and --yes. --force is only for an operator who has verified that
no update is in progress; it overrides the pending-update guard.
EOF
}
is_true() { [[ "$1" == true || "$1" == 1 ]]; }
if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TEST_MODE" != 0 ]]; then
die 'TALLYNOTE_UNINSTALL_TEST_MODE must be true or false'
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
--yes) YES=1 ;;
--purge-data) PURGE_DATA=1 ;;
--purge-config) PURGE_CONFIG=1 ;;
--dry-run) DRY_RUN=1 ;;
--force) FORCE=1 ;;
--prefix) PREFIX=${2:?missing value for --prefix}; EXPLICIT_PREFIX=1; shift ;;
--data-dir) DATA_DIR=${2:?missing value for --data-dir}; EXPLICIT_DATA=1; shift ;;
--config-dir) CONFIG_DIR=${2:?missing value for --config-dir}; EXPLICIT_CONFIG=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
shift
done
if [[ "$TEST_MODE" == true ]]; then
[[ -n "$TEST_ROOT" ]] || die 'test mode requires TALLYNOTE_UNINSTALL_ROOT'
[[ "$TEST_ROOT" = /* && "$TEST_ROOT" != *'..'* && "$TEST_ROOT" != *'//'* && "$TEST_ROOT" != *$'\n'* && "$TEST_ROOT" != *$'\r'* ]] || die 'test root is invalid'
(( EXPLICIT_PREFIX )) || PREFIX=${TALLYNOTE_PREFIX:-$TEST_ROOT/opt/tallynote}
(( EXPLICIT_DATA )) || DATA_DIR=${TALLYNOTE_DATA_DIR:-$TEST_ROOT/var/lib/tallynote}
(( EXPLICIT_CONFIG )) || CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-$TEST_ROOT/etc/tallynote}
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-$TEST_ROOT/etc/systemd/system}
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-$TEST_ROOT/usr/local/sbin}
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-$TEST_ROOT/usr/local/libexec}
SYSTEMCTL_BIN=${TALLYNOTE_SYSTEMCTL_BIN:-systemctl}
fi
stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; }
stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; }
stat_mode_bits() {
local mode
mode=$(stat_mode "$1")
[[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1"
printf '%d' "$((8#$mode))"
}
allowed_owner() {
local path=$1 uid
uid=$(stat_uid "$path")
if [[ "$TEST_MODE" == true ]]; then
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
else
[[ "$uid" == 0 ]]
fi
}
allowed_data_owner() {
local path=$1 uid tallynote_uid
uid=$(stat_uid "$path")
if [[ "$TEST_MODE" == true ]]; then
[[ "$uid" == "$(id -u)" || "$uid" == 0 || ( -n "$TEST_DATA_OWNER_UID" && "$uid" == "$TEST_DATA_OWNER_UID" ) ]]
return
fi
[[ "$uid" == 0 ]] && return 0
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
[[ -n "$tallynote_uid" && "$uid" == "$tallynote_uid" ]]
}
validate_path_value() {
local value=$1 label=$2
[[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径"
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"//"* && "$value" != *"/../"* && "$value" != */.. && "$value" != *"/./"* && "$value" != */. && "$value" != / && "$value" != */ ]] || die "$label 包含不受支持的路径字符"
case "$value" in
/opt|/var|/etc|/usr|/usr/local|/bin|/sbin|/home|/root|/tmp) die "$label 不能指向系统顶层目录" ;;
esac
}
validate_parent_chain() {
local target=$1 current=/ component relative
relative=${target#/}
IFS='/' read -r -a _parts <<< "$relative"
for component in "${_parts[@]}"; do
[[ -n "$component" ]] || continue
current="${current%/}/$component"
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
if [[ -e "$current" ]]; then
[[ -d "$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"
fi
done
}
validate_target() {
local target=$1 label=$2 owner_check=allowed_owner
[[ "${3:-}" == data ]] && owner_check=allowed_data_owner
validate_path_value "$target" "$label"
validate_parent_chain "$target"
if [[ -e "$target" || -L "$target" ]]; then
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
fi
}
read_env_value() {
local file=$1 key=$2
sed -n "s/^${key}=//p" "$file" | head -n 1
}
env_key_count() {
local file=$1 key=$2
awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file"
}
load_config() {
local env_file=$CONFIG_DIR/tallynote.env value key count
[[ -e "$env_file" || -L "$env_file" ]] || return 0
[[ -f "$env_file" && ! -L "$env_file" ]] || die '环境文件不是普通文件'
allowed_owner "$env_file" || die '环境文件的所有者不受信任'
local mode_bits
mode_bits=$(stat_mode_bits "$env_file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR; do
count=$(env_key_count "$env_file" "$key")
[[ "$count" == 0 || "$count" == 1 ]] || die "环境文件包含重复配置:$key"
done
if (( ! EXPLICIT_PREFIX )); then
value=$(read_env_value "$env_file" TALLYNOTE_INSTALL_PREFIX)
[[ -z "$value" ]] || PREFIX=$value
fi
if (( ! EXPLICIT_DATA )); then
value=$(read_env_value "$env_file" TALLYNOTE_DATA_DIR)
[[ -z "$value" ]] || DATA_DIR=$value
fi
}
path_inside() {
local child=$1 parent=$2
[[ "$child" == "$parent"/* ]]
}
assert_disjoint_paths() {
local left left_label right right_label
local -a labels=(prefix data config unit sbin libexec)
for left_label in "${labels[@]}"; do
case "$left_label" in
prefix) left=$PREFIX ;;
data) left=$DATA_DIR ;;
config) left=$CONFIG_DIR ;;
unit) left=$UNIT_DIR ;;
sbin) left=$SBIN_DIR ;;
libexec) left=$LIBEXEC_DIR ;;
esac
for right_label in "${labels[@]}"; do
[[ "$left_label" == "$right_label" ]] && continue
case "$right_label" in
prefix) right=$PREFIX ;;
data) right=$DATA_DIR ;;
config) right=$CONFIG_DIR ;;
unit) right=$UNIT_DIR ;;
sbin) right=$SBIN_DIR ;;
libexec) right=$LIBEXEC_DIR ;;
esac
if [[ "$left" == "$right" ]] || path_inside "$left" "$right" || path_inside "$right" "$left"; then
die "卸载目录不能互相嵌套:$left 与 $right"
fi
done
done
}
assert_test_scope() {
[[ "$TEST_MODE" == true ]] || return 0
[[ -d "$TEST_ROOT" && ! -L "$TEST_ROOT" ]] || die 'test root must be an existing directory'
validate_parent_chain "$TEST_ROOT"
allowed_owner "$TEST_ROOT" || die 'test root owner is not trusted'
local value label
for label in PREFIX DATA_DIR CONFIG_DIR UNIT_DIR SBIN_DIR LIBEXEC_DIR; do
case "$label" in
PREFIX) value=$PREFIX ;;
DATA_DIR) value=$DATA_DIR ;;
CONFIG_DIR) value=$CONFIG_DIR ;;
UNIT_DIR) value=$UNIT_DIR ;;
SBIN_DIR) value=$SBIN_DIR ;;
LIBEXEC_DIR) value=$LIBEXEC_DIR ;;
esac
[[ "$value" == "$TEST_ROOT"/* ]] || die "test mode path escapes TALLYNOTE_UNINSTALL_ROOT: $value"
done
}
managed_file() {
local target=$1 label=$2
case "$label" in
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|admin-init.js' "$target" || return 1
fi
;;
environment\ file)
grep -q '^TALLYNOTE_INSTALL_PREFIX=' "$target" || return 1
grep -q '^TALLYNOTE_DATA_DIR=' "$target" || return 1
[[ "$(read_env_value "$target" TALLYNOTE_INSTALL_PREFIX)" == "$PREFIX" ]] || return 1
[[ "$(read_env_value "$target" TALLYNOTE_DATA_DIR)" == "$DATA_DIR" ]] || return 1
;;
update\ public\ key)
[[ -f "$CONFIG_DIR/tallynote.env" ]] || return 1
[[ "$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_UPDATE_PUBLIC_KEY_FILE)" == "$target" ]] || return 1
;;
*) return 0 ;;
esac
}
validate_release_tree() {
local tree=$1 owner_check=${2:-allowed_owner}
[[ -d "$tree" && ! -L "$tree" ]] || die "发布目录无效:$tree"
"$owner_check" "$tree" || die "发布目录的所有者不受信任:$tree"
if find "$tree" -type l -print -quit | grep -q .; then
die "发布目录包含符号链接:$tree"
fi
if find "$tree" ! -type d ! -type f -print -quit | grep -q .; then
die "发布目录包含不支持的文件类型:$tree"
fi
local node mode_bits
while IFS= read -r node; do
"$owner_check" "$node" || die "发布目录节点的所有者不受信任:$node"
mode_bits=$(stat_mode_bits "$node")
(( (mode_bits & 18) == 0 )) || die "发布目录节点权限过宽:$node"
done < <(find "$tree" -print)
}
pending_update() {
[[ -e "$PREFIX/.update-state" || -L "$PREFIX/.update-state" || -e "$DATA_DIR/update-request.json" || -L "$DATA_DIR/update-request.json" ]]
}
run_systemctl() {
(( DRY_RUN )) && return 0
if [[ "$SYSTEMCTL_BIN" == */* ]]; then
[[ -x "$SYSTEMCTL_BIN" ]] || return 0
else
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
fi
# 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() {
local unit active status
if (( DRY_RUN )); then
log 'dry-run: would stop/disable systemd units in path -> updater -> app order'
return 0
fi
if (( ! SYSTEMCTL_AVAILABLE )); then
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
[[ ! -e "$UNIT_DIR/$unit" ]] || die 'systemctl 不可用,无法安全停止已安装服务'
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
status=$?
case "$status" in
3|4) ;;
*) die "无法读取服务状态:$unit" ;;
esac
fi
if (( active )); then
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() {
local resolved uid mode_bits
if [[ "$TEST_MODE" == true ]]; then
if [[ "$SYSTEMCTL_BIN" == */* && -x "$SYSTEMCTL_BIN" ]]; then
SYSTEMCTL_AVAILABLE=1
fi
return 0
fi
resolved=$(command -v systemctl 2>/dev/null || true)
if [[ -z "$resolved" ]]; then
SYSTEMCTL_AVAILABLE=0
return 0
fi
[[ -x "$resolved" && ! -L "$resolved" ]] || die 'systemctl 必须是可信的普通可执行文件'
uid=$(stat_uid "$resolved")
mode_bits=$(stat_mode_bits "$resolved")
[[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die 'systemctl 必须由 root 拥有且不可被其他用户写入'
SYSTEMCTL_BIN=$resolved
SYSTEMCTL_AVAILABLE=1
}
remove_file_if_owned() {
local target=$1 label=$2
[[ -e "$target" || -L "$target" ]] || return 0
if [[ -L "$target" || ! -f "$target" ]]; then
log "warning: 保留非普通文件:$target"
return 0
fi
if ! allowed_owner "$target"; then
log "warning: 保留非本安装创建的文件:$target"
return 0
fi
if ! managed_file "$target" "$label"; then
log "warning: 保留内容不匹配的文件:$target"
return 0
fi
if (( DRY_RUN )); then
log "dry-run: remove $label $target"
else
rm -f -- "$target"
fi
}
remove_tree() {
local target=$1 label=$2 owner_check=${3:-allowed_owner}
[[ -e "$target" || -L "$target" ]] || return 0
[[ -d "$target" && ! -L "$target" ]] || die "$label 不是安全目录:$target"
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
validate_release_tree "$target" "$owner_check"
if (( DRY_RUN )); then
log "dry-run: remove $label $target"
else
rm -rf -- "$target"
fi
}
remove_prefix() {
local current=$PREFIX/current current_target releases=$PREFIX/releases
if [[ -L "$current" ]]; then
current_target=$(readlink "$current")
[[ "$current_target" = "$PREFIX/releases/"* && "$current_target" != *'..'* ]] || die 'current 符号链接指向安装目录之外'
[[ -d "$current_target" && ! -L "$current_target" ]] || die 'current 目标不是安全目录'
if (( DRY_RUN )); then
log "dry-run: remove current link $current"
else
rm -f -- "$current"
fi
elif [[ -e "$current" ]]; then
log "warning: 保留非符号链接 current:$current"
fi
remove_tree "$releases" 'releases'
remove_tree "$PREFIX/.update-work" 'update work'
remove_file_if_owned "$PREFIX/.update-state" 'update state'
if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then
allowed_owner "$PREFIX" || die "安装目录的所有者不受信任:$PREFIX"
if (( DRY_RUN )); then
log "dry-run: remove empty install directory if empty: $PREFIX"
else
rmdir -- "$PREFIX" 2>/dev/null || true
fi
fi
}
remove_config() {
remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key'
remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file'
if (( PURGE_CONFIG )) && [[ -d "$CONFIG_DIR" && ! -L "$CONFIG_DIR" ]]; then
allowed_owner "$CONFIG_DIR" || die '配置目录的所有者不受信任'
if (( DRY_RUN )); then log "dry-run: remove config directory if safe: $CONFIG_DIR"; else rmdir -- "$CONFIG_DIR" 2>/dev/null || true; fi
fi
}
remove_data() {
local backup_dir
backup_dir=$(dirname -- "$DATA_DIR")/tallynote-backups
if (( PURGE_DATA )); then
(( YES )) || die '--purge-data 必须同时提供 --yes'
remove_tree "$DATA_DIR" 'data' allowed_data_owner
remove_tree "$backup_dir" 'backup data'
else
log "保留数据目录:$DATA_DIR"
if [[ -d "$backup_dir" ]]; then
log "保留备份目录:$backup_dir"
fi
fi
return 0
}
main() {
if [[ "$TEST_MODE" != true ]]; then
[[ $EUID -eq 0 ]] || die '卸载必须以 root 运行(请使用 sudo)'
fi
if (( PURGE_DATA && ! YES )); then
die '--purge-data 必须同时提供 --yes'
fi
validate_path_value "$CONFIG_DIR" '配置目录'
validate_target "$CONFIG_DIR" '配置目录'
assert_test_scope
load_config
validate_target "$PREFIX" '安装目录'
validate_target "$DATA_DIR" '数据目录' data
validate_target "$CONFIG_DIR" '配置目录'
validate_target "$UNIT_DIR" 'systemd 单元目录'
validate_target "$SBIN_DIR" 'sbin 目录'
validate_target "$LIBEXEC_DIR" 'libexec 目录'
assert_test_scope
assert_disjoint_paths
validate_systemctl
if (( ! FORCE )) && pending_update; then
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
log 'uninstall complete'
}
main "$@"
+1 -1
View File
@@ -90,7 +90,7 @@ function App() {
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 (!session.admin) return <LoginPage
notice={session.initialized ? undefined : "尚未初始化管理员,请先在服务器执行 pnpm admin:init。"}
notice={session.initialized ? undefined : "首次安装还差一步:请在服务器执行 sudo tallynote-admin-init 创建管理员账号。"}
onSuccess={() => setPasswordOpen(false)}
/>;
if (session.admin.mustChangePassword) return <ChangePasswordPage admin={session.admin} firstLogin onSuccess={() => notify("密码已更新", "success")} />;
+73 -64
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal } from "lucide-react";
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal, Zap } from "lucide-react";
import { Button, Dialog, Tag } from "tdesign-react";
import { ApiError, api } from "../../services/api";
import { dateText } from "../expenses/date";
@@ -7,10 +7,23 @@ import { ErrorBanner, Page, Surface } from "../common";
import type { Notify } from "../expenses/types";
type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
type UpdateJob = { id: string; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt: number; updatedAt: number; completedAt?: number | null };
type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: { version: string; tagName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number } | null; job: UpdateJob | null };
type UpdateJob = { id: string; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null };
type LatestRelease = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number; notes?: string | null; releaseNotes?: string | null; body?: string | null; htmlUrl?: string | null };
type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: LatestRelease | null; job: UpdateJob | null };
const active = new Set<JobStatus>(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]);
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "准备完成", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
const pollable = new Set<JobStatus>(["queued", "downloading", "verifying", "backing_up", "applying"]);
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "下载完成,等待应用", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
function timestamp(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === "") return null;
const n = typeof value === "number" ? value : Date.parse(value);
if (!Number.isFinite(n)) return null;
return n < 10_000_000_000 ? n * 1000 : n;
}
function notesFor(latest: LatestRelease): string | null {
const value = latest.notes ?? latest.releaseNotes ?? latest.body;
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
@@ -19,95 +32,91 @@ export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { tim
const [error, setError] = useState("");
const [pollError, setPollError] = useState("");
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [confirmAction, setConfirmAction] = useState<"download" | "apply">("download");
const [actionBusy, setActionBusy] = useState(false);
const [reloadReady, setReloadReady] = useState(false);
const [now, setNow] = useState(() => Date.now());
const announced = useRef<string | null>(null);
const checkInFlight = useRef(false);
const applyInFlight = useRef(false);
const actionInFlight = useRef(false);
const disconnected = useRef(false);
const recoveredNotice = useRef(false);
const load = async () => { setLoading(true); setError(""); try { setInfo(await api<UpdateInfo>("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } };
useEffect(() => { void load(); }, []);
const job = info?.job;
const applyQueuedAt = timestamp(job?.applyQueuedAt);
const restartAt = timestamp(job?.restartDeadline)
?? timestamp(job?.restartAt)
?? timestamp(job?.expectedRecoveryAt)
?? (job?.operation === "apply" && applyQueuedAt ? applyQueuedAt + (job.restartWindowSeconds ?? 30) * 1000 : null)
?? (job?.status === "applying" && job.updatedAt ? timestamp(job.updatedAt)! + 30_000 : null);
const restartSeconds = restartAt ? Math.max(0, Math.ceil((restartAt - now) / 1000)) : null;
useEffect(() => { if (!restartAt) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [restartAt]);
useEffect(() => {
const job = info?.job;
if (!job) { setPollError(""); return; }
const announceCompletion = (completedJob: UpdateJob) => {
if (completedJob.status === "completed" && announced.current !== completedJob.id) {
announced.current = completedJob.id;
setReloadReady(true);
notify?.("更新完成,请重新加载页面", "success");
}
};
if (job.status === "completed") {
setPollError("");
announceCompletion(job);
return;
}
if (!active.has(job.status)) { setPollError(""); return; }
let disposed = false;
let timer: number | undefined;
let failureCount = 0;
const shouldPoll = Boolean(job && (pollable.has(job.status) || (job.status === "staged" && job.operation === "apply")));
if (!shouldPoll || !job) { setPollError(""); return; }
let disposed = false; let timer: number | undefined; let failures = 0;
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
const poll = async () => {
try {
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
if (disposed) return;
failureCount = 0;
setPollError("");
failures = 0;
if (disconnected.current && !recoveredNotice.current) { recoveredNotice.current = true; notify?.("服务已恢复,更新状态已刷新", "success"); }
disconnected.current = false; setPollError("");
setInfo(current => current ? { ...current, job: result.job } : current);
announceCompletion(result.job);
if (active.has(result.job.status)) schedule(1500);
} catch (caught) {
if (result.job.status === "completed" && announced.current !== result.job.id) { announced.current = result.job.id; setReloadReady(true); notify?.("更新完成,请重新加载页面", "success"); }
if (pollable.has(result.job.status) || (result.job.status === "staged" && result.job.operation === "apply")) schedule(1500);
} catch {
if (disposed) return;
failureCount += 1;
setPollError(`${(caught as Error).message}。更新任务仍在后台运行,页面会自动重试。`);
schedule(Math.min(1500 * (2 ** Math.min(failureCount, 3)), 12_000));
failures += 1; disconnected.current = true; recoveredNotice.current = false;
setPollError(`服务暂时不可用${restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面会自动重试"}。更新任务仍在后台运行。`);
schedule(Math.min(1500 * (2 ** Math.min(failures, 3)), 12_000));
}
};
void poll();
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
}, [info?.job?.id, info?.job?.status, notify]);
}, [job?.id, job?.status, job?.operation, notify]);
const check = async () => {
if (checkInFlight.current) return;
checkInFlight.current = true;
setChecking(true);
setError("");
checkInFlight.current = true; setChecking(true); setError("");
try {
const result = await api<Omit<UpdateInfo, "job"> & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" });
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null }));
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null })); notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
} catch (caught) {
// A configured release source is intentionally rate-limited. A repeated
// click should still be useful: show the cached status instead of a
// blocking error, while preserving the server-side flood protection.
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
try {
await load();
const seconds = caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查";
notify?.(`已显示最近一次检查结果${seconds}`, "info");
return;
} catch {
// Fall through to the normal error surface if the status read fails.
}
}
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") { try { await load(); notify?.(`已显示最近一次检查结果${caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查"}`, "info"); return; } catch { /* fall through */ } }
setError((caught as Error).message);
} finally {
checkInFlight.current = false;
setChecking(false);
}
} finally { checkInFlight.current = false; setChecking(false); }
};
const submitAction = async () => {
if (!confirmVersion || actionInFlight.current) return;
actionInFlight.current = true; setActionBusy(true); setError("");
try {
const endpoint = confirmAction === "download" ? "/api/update/download" : "/api/update/apply";
const body = confirmAction === "download" ? { version: confirmVersion, confirm: true } : { jobId: job?.id, version: confirmVersion, confirm: true };
const result = await api<{ job: UpdateJob }>(endpoint, { method: "POST", body: JSON.stringify(body) });
setConfirmVersion(null); setReloadReady(false); setInfo(current => current ? { ...current, job: result.job } : current); notify?.(confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启", "info");
} catch (caught) { setError((caught as Error).message); } finally { actionInFlight.current = false; setActionBusy(false); }
};
const apply = async () => { if (!confirmVersion || applyInFlight.current) return; applyInFlight.current = true; setApplying(true); setError(""); try { const result = await api<{ job: UpdateJob }>("/api/update/apply", { method: "POST", body: JSON.stringify({ version: confirmVersion, confirm: true }) }); setConfirmVersion(null); setInfo(current => current ? { ...current, job: result.job } : current); notify?.("更新请求已提交,服务会短暂重启", "info"); } catch (e) { setError((e as Error).message); } finally { applyInFlight.current = false; setApplying(false); } };
const latest = info?.latest; const job = info?.job; const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !job || info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job && !active.has(job.status));
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
{error && <ErrorBanner message={error} onRetry={() => void load()} />}
{pollError && <ErrorBanner message={pollError} />}
const latest = info?.latest; const hasActiveJob = Boolean(job && active.has(job.status));
const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version);
const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && !sameCompleted && (!job || job.version !== latest.version || job.status === "failed" || job.status === "cancelled"));
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job?.operation === "download" && job.status === "staged" && job.version === latest.version);
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
const notes = latest ? notesFor(latest) : null;
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading || hasActiveJob} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
{error && <ErrorBanner message={error} onRetry={() => void load()} />}{pollError && <ErrorBanner message={pollError} />}
{loading ? <div className="tn-empty" role="status" aria-live="polite">正在读取版本信息…</div> : info && <>
<div className="tn-update-grid"><Surface className="tn-update-block"><Server size={20} /><span className="tn-eyebrow">当前版本</span><strong className="tn-update-value">v{info.currentVersion}</strong><small>运行平台:{info.platform.target}</small></Surface><Surface className="tn-update-block"><ShieldCheck size={20} /><span className="tn-eyebrow">更新方式</span><strong>{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}</strong><small>{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}</small></Surface></div>
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div><div className="tn-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 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canApply && <Button theme="primary" onClick={() => setConfirmVersion(latest.version)} disabled={applying} icon={<Download size={16} />}>更新到 v{latest.version}</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.releaseName || latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div>{notes && <div className="tn-release-notes"><span className="tn-eyebrow">Release notes</span><div>{notes}</div></div>}<div className="tn-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 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canDownload && <Button theme="primary" onClick={() => { setConfirmAction("download"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "download"} icon={<Download size={16} />}>下载更新包</Button>}{canApply && <Button theme="primary" onClick={() => { setConfirmAction("apply"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "apply"} icon={<Zap size={16} />}>立即更新</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
{!info.configured && <div className="tn-update-explainer"><Terminal size={17} /><div><strong>当前为手动更新模式</strong><p>源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。</p></div></div>}
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>更新服务正在后台运行,页面会自动刷新状态。</small></>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>{job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}</small>{restartSeconds !== null && (job.status === "applying" || disconnected.current) && <div className="tn-restart-countdown" role="status">服务正在重启,预计 {restartSeconds} 秒后恢复</div>}</>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
</>}
<Dialog visible={Boolean(confirmVersion)} header="确认系统更新" confirmBtn={{ content: "确认更新", theme: "primary", loading: applying, disabled: applying }} cancelBtn="取消" onClose={() => { if (!applying) setConfirmVersion(null); }} onConfirm={() => void apply()} onCancel={() => { if (!applying) setConfirmVersion(null); }}>将更新到 v{confirmVersion}。服务会短暂重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。</Dialog>
<Dialog visible={Boolean(confirmVersion)} header={confirmAction === "download" ? "下载更新包" : "确认立即更新"} confirmBtn={{ content: confirmAction === "download" ? "开始下载" : "立即更新", theme: "primary", loading: actionBusy, disabled: actionBusy }} cancelBtn="取消" onClose={() => { if (!actionBusy) setConfirmVersion(null); }} onConfirm={() => void submitAction()} onCancel={() => { if (!actionBusy) setConfirmVersion(null); }}>{confirmAction === "download" ? `将下载并校验 v${confirmVersion},完成后可选择立即更新。` : `将应用已下载的 v${confirmVersion}。服务会短暂重启,更新前会备份数据目录。`}</Dialog>
</Page>;
}
+15 -1
View File
@@ -134,7 +134,18 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
.tn-user-dropdown-identity { display: grid; gap: 3px; margin: 2px 4px 6px; padding: 8px 9px 10px; border-bottom: 1px solid var(--tn-border-subtle); }
.tn-user-dropdown-identity strong { color: var(--tn-text); font-size: 13px; }
.tn-user-dropdown-identity span { overflow: hidden; color: var(--tn-text-secondary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.tn-menu-logo { display: flex; width: 100%; height: 64px; align-items: center; justify-content: center; color: var(--tn-navy-900); }
.tn-menu-logo {
/* TDesign adds a left margin to every direct logo child. The logo owns
its full-width centering, so that default inset makes the wordmark look
visibly shifted to the right in both expanded and collapsed menus. */
display: flex;
width: 100%;
height: 64px;
margin-left: 0 !important;
align-items: center;
justify-content: center;
color: var(--tn-navy-900);
}
.tn-logo-full { font-size: 18px; font-weight: 750; letter-spacing: .01em; }
.tn-logo-short { color: var(--tn-blue-700); font-size: 16px; font-weight: 800; letter-spacing: .04em; }
.tn-menu-icon { flex: 0 0 20px; width: 20px; height: 20px; margin-right: 10px; }
@@ -278,6 +289,8 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
.tn-update-release .t-card__body { padding: 20px; }
.tn-update-release-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
.tn-update-release h2 { margin: 3px 0 5px; color: var(--tn-text); font-size: 21px; }
.tn-release-notes { margin: 16px 0; padding: 12px 14px; border-left: 3px solid var(--td-brand-color-3); background: #f7f9fc; color: var(--tn-text-secondary); font-size: 13px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.tn-release-notes .tn-eyebrow { display: block; margin-bottom: 4px; color: var(--tn-navy-900); }
.tn-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0; padding: 14px 0; border-top: 1px solid var(--tn-border-subtle); border-bottom: 1px solid var(--tn-border-subtle); }
.tn-facts span { display: block; margin-bottom: 4px; color: var(--tn-text-secondary); font-size: 12px; }
.tn-facts strong { overflow-wrap: anywhere; color: #344054; font-size: 14px; }
@@ -344,6 +357,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
.tn-inline-error, .tn-inline-info { display: flex; align-items: center; gap: 7px; margin-top: 10px; padding: 9px 11px; border-radius: 3px; font-size: 13px; }
.tn-inline-error { color: #a33a3a; background: #fff0f0; }
.tn-inline-info { color: #246044; background: #eaf7ef; }
.tn-restart-countdown { margin-top: 10px; color: var(--tn-warning); font-size: 12px; font-variant-numeric: tabular-nums; }
.tn-update-explainer { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 14px; padding: 13px 15px; border: 1px solid var(--td-brand-color-2); border-radius: 4px; color: var(--tn-navy-900); background: var(--td-brand-color-1); }
.tn-update-explainer > svg { flex: 0 0 auto; margin-top: 1px; color: var(--td-brand-color); }
.tn-update-explainer strong { display: block; font-size: 13px; }