Compare commits

...
33 Commits
Author SHA1 Message Date
Qiufeng 65b5d95937 fix: omit empty release note sections
TallyNote release / linux-x64 (push) Failing after 8m51s
2026-09-04 12:42:45 +08:00
Qiufeng 89a8edad88 fix: stabilize release test workers
TallyNote release / linux-x64 (push) Successful in 8m25s
2026-09-04 12:15:29 +08:00
Qiufeng 283c1d77b4 fix: generate detailed markdown release notes
TallyNote release / linux-x64 (push) Failing after 8m37s
2026-09-04 10:43:45 +08:00
Qiufeng f060f917a0 release: 1.1.26
TallyNote release / linux-x64 (push) Successful in 6m41s
2026-09-04 01:13:42 +08:00
Qiufeng 704740182a fix: hide stale update failures on status load 2026-09-04 01:08:46 +08:00
Qiufeng a61860fcb3 refactor: move update pipeline into dialog 2026-09-04 01:02:12 +08:00
Qiufeng 340d9b5245 feat: add lightweight application updates
TallyNote release / linux-x64 (push) Failing after 8m43s
2026-09-03 23:32:16 +08:00
Qiufeng 5d02fa5769 fix: simplify update metrics
TallyNote release / linux-x64 (push) Successful in 7m2s
2026-09-03 21:58:37 +08:00
Qiufeng 526b2df8ea feat: refine update center and release notes
TallyNote release / linux-x64 (push) Successful in 6m55s
2026-09-03 21:31:39 +08:00
Qiufeng 4f9629b089 fix: allow reverse proxy login
TallyNote release / linux-x64 (push) Successful in 6m56s
2026-09-03 15:27:10 +08:00
Qiufeng 36c2ed1361 fix: show completed download progress
TallyNote release / linux-x64 (push) Successful in 7m0s
2026-09-03 15:02:44 +08:00
Qiufeng 755b82d2e5 chore: align package version with v1.1.21
TallyNote release / linux-x64 (push) Successful in 7m27s
2026-09-03 14:55:38 +08:00
Qiufeng 0bdc812935 fix: support proxied origins and update progress
TallyNote release / linux-x64 (push) Failing after 11s
2026-09-03 14:54:30 +08:00
Qiufeng 2de08f1358 chore: bump release to 1.1.20
TallyNote release / linux-x64 (push) Successful in 6m45s
2026-09-03 13:42:57 +08:00
Qiufeng 91621df6c4 fix: recover stuck update queue
TallyNote release / linux-x64 (push) Successful in 6m34s
2026-09-03 12:39:44 +08:00
Qiufeng 0690fe298c fix: initialize optional origin port
TallyNote release / linux-x64 (push) Successful in 6m49s
2026-09-03 08:22:46 +08:00
Qiufeng 6a0d9e34dd fix: make admin wrapper fixture portable in CI
TallyNote release / linux-x64 (push) Failing after 3m6s
2026-09-03 08:10:52 +08:00
Qiufeng 77598ecc81 fix: make installer gate portable in root CI
TallyNote release / linux-x64 (push) Failing after 3m5s
2026-09-03 08:02:48 +08:00
Qiufeng 69a4b482ec fix: prevent stuck background updates
TallyNote release / linux-x64 (push) Failing after 2m51s
2026-09-03 07:51:35 +08:00
Qiufeng ee89e04aae fix: recover stuck background updates
TallyNote release / linux-x64 (push) Successful in 6m3s
2026-09-03 06:49:48 +08:00
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
41 changed files with 4952 additions and 247 deletions
+5
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
@@ -32,4 +36,5 @@ TALLYNOTE_UPDATE_MAX_MB=512
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
+9 -1
View File
@@ -17,6 +17,10 @@ jobs:
steps:
- name: Checkout tag
uses: actions/checkout@v4
with:
# Release notes are derived from the previous version tag. A shallow
# checkout would leave only the synthetic release commit available.
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -29,7 +33,11 @@ jobs:
test "$(node -p 'require("./package.json").version')" = "${GITHUB_REF_NAME#v}"
pnpm install --frozen-lockfile
pnpm check
pnpm test
# better-sqlite3 is a native addon; a single Vitest worker avoids a
# Node cleanup race observed on the hosted runner while preserving
# the complete test suite.
pnpm test -- --maxWorkers=1 --minWorkers=1
pnpm test:installer
- name: Build Linux release
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
- name: Create and publish Gitea Release
+55 -7
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,30 +50,77 @@ pnpm build:next
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
发布包必须包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、生产依赖、匹配架构的 Node runtime、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
安装命令保持简洁。首次在 SSH/终端中安装时,安装器会交互询问监听方式、端口和公开访问地址:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入;选择稍后创建也不会阻塞服务启动,之后执行 `sudo 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-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
安装完成后会提供 `/usr/local/sbin/tallynote-admin-init` 和 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序、管理员初始化命令和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
```bash
sudo /usr/local/sbin/tallynote-uninstall
@@ -92,7 +138,9 @@ sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
```
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
公网反代推荐使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。反代只需把域名转发到 TallyNote 端口并保留 `Host`、`X-Forwarded-Proto`;应用不会因为代理缺少或改写浏览器 `Origin` 而拦截登录。已认证写请求仍使用会话 Cookie 与 CSRF 令牌保护。
### 构建发布包
+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 "$@"
+40 -4
View File
@@ -12,6 +12,8 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
发布脚本会根据当前 tag 与上一个版本 tag 之间的真实 Git 提交自动生成 Release 正文,按“新增功能、问题修复、优化与重构、文档与测试”分类,并以 Markdown 写入 Gitea。Gitea 页面会渲染这些标题和列表;更新中心读取同一份正文后再进行安全的 Markdown 子集渲染,不会显示 Markdown 源代码。旧版本曾使用单行占位正文 `TallyNote <版本>`,新版本发布时不会再使用该占位内容。
在仓库的 Actions secrets 配置:
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
@@ -28,7 +30,7 @@ GITEA_TOKEN=... \
./scripts/publish-gitea-release.sh v1.1.2 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。构建脚本会同时生成完整安装包和轻量更新包:`tallynote-1.1.2-linux-x64-glibc.tar.gz` 用于首次安装,`tallynote-1.1.2-linux-x64-glibc.update-<锁文件 SHA256>.tar.gz` 仅用于复用现有运行时的后台更新。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
## curl 安装
@@ -38,13 +40,43 @@ GITEA_TOKEN=... \
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动后,安装器会先请求本机 `/health`,只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。
非交互安装命令:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
```
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。
安装器会在每个关键阶段输出统一格式的日志,便于在 SSH 或 systemd 安装会话中确认进度;交互式终端下载时还会显示 curl 进度条,CI 或日志重定向时则保持纯文本输出:
```text
tallynote installer: [阶段] 检查运行环境、权限和目标架构
tallynote installer: [阶段] 从 Release API 获取最新版本
tallynote installer: [阶段] 获取发布包:tallynote-<版本>-linux-x64-glibc.tar.gz
tallynote installer: [阶段] 获取 SHA-256 校验清单
tallynote installer: [阶段] 校验 SHA-256 和发布签名
tallynote installer: [阶段] 解包、校验包结构并原子切换到版本 <版本>
tallynote installer: [完成] 版本 <版本> 已切换为当前版本
tallynote installer: [阶段] 安装 systemd 单元、更新辅助程序和卸载器
tallynote installer: [完成] systemd 单元、更新辅助程序和卸载器已安装
tallynote installer: [阶段] 重新加载 systemd 并启动 TallyNote
tallynote installer: [完成] TallyNote 服务已启用并启动
tallynote installer: [阶段] 清理旧版本并完成安装
tallynote installer: [完成] 旧版本清理完成
tallynote installer: [完成] 安装完成:TallyNote <版本>
tallynote installer: 访问地址:http://127.0.0.1:<端口>
```
每个阶段完成时会输出 `[完成]`;错误会立即以 `tallynote installer:` 前缀输出,不会静默等待或切换半成品版本。
如需启用签名校验,设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;后台更新同样可通过 `TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 开启。默认关闭签名要求,方便公开自维护仓库直接更新。
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`。`--allow-unsigned` 作为旧版本兼容参数保留。
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`,并提供 `/usr/local/sbin/tallynote-admin-init` 作为生产环境首次管理员初始化入口。`--allow-unsigned` 作为旧版本兼容参数保留。
安装布局:
@@ -68,17 +100,21 @@ sudo /usr/local/sbin/tallynote-uninstall
只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。
卸载过程中会输出每个 systemd 单元的检查、停止、禁用和删除阶段;systemd/dbus 调用默认 30 秒超时,避免长时间无反馈。可用 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整。
## 后台一键更新
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata 和清单,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
```bash
sudo /usr/local/sbin/tallynote-update --rollback
```
更新检查和应用接口带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。
更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`;首次安装时可在交互提示中选择 `0.0.0.0` 和真实的服务器 IP/域名。直连 HTTP 会暴露未加密的会话和数据,只适合受控网络;绑定域名后必须改为 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。自动化安装可使用 `--non-interactive` 或显式网络环境变量。
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
+682 -33
View File
@@ -34,6 +34,22 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300}
RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-}
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname}
# Service network settings are written to the systemd EnvironmentFile on a
# fresh install. Existing values are preserved unless the corresponding
# TALLYNOTE_* variable is explicitly supplied to the installer.
INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1}
INSTALL_PORT=${TALLYNOTE_PORT-3000}
INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-}
INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false}
PUBLIC_IP_URL=${TALLYNOTE_PUBLIC_IP_URL-}
NON_INTERACTIVE=0
NETWORK_INTERACTIVE=0
# The production prompt uses the controlling terminal, even when the
# installer itself is read from `curl | sudo bash`.
PROMPT_INPUT=/dev/tty
PROMPT_OUTPUT=/dev/tty
PROMPT_REPLY=''
INSTALL_SWITCHED=0
INSTALL_COMMITTED=0
@@ -41,9 +57,16 @@ INSTALL_PREVIOUS_TARGET=''
INSTALL_NEW_RELEASE=''
INSTALL_WORK_DIR=''
INSTALL_BACKUP_DIR=''
INSTALL_BACKUP_COMPLETE=0
INSTALL_WAS_ACTIVE=0
INSTALL_PATH_WAS_ACTIVE=0
INSTALL_UPDATE_WAS_ACTIVE=0
INSTALL_WAS_ENABLED=0
INSTALL_PATH_WAS_ENABLED=0
INSTALL_UPDATE_WAS_ENABLED=0
INSTALL_SYSTEMD_TOUCHED=0
ADMIN_INIT_PATH=/usr/local/sbin/tallynote-admin-init
INSTALL_FIRST_INSTALL=0
DATA_DIR_TEMP_ROOT=0
DATA_DIR_ORIGINAL_OWNER=''
@@ -58,17 +81,323 @@ Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL]
[--signature-format ed25519|gpg]
[--update-public-key-file FILE]
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply]
[--non-interactive]
Without arguments, the installer resolves the latest compatible release and
installs it. SHA-256 from SHA256SUMS is always required. Detached signature
verification is optional by default; enable it with
TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use
--dry-run to inspect the selected release without downloading or changing the
host. --apply is accepted for backwards compatibility.
host. For direct IP access, pass TALLYNOTE_HOST=0.0.0.0 and an actual
TALLYNOTE_PUBLIC_ORIGIN such as http://203.0.113.10:3000; HTTP also requires
TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener
and public URL can be selected interactively. The installer checks that the
selected TCP port is free, suggests a public IPv4 address when exposing
0.0.0.0, and prints the final access URL after the service starts. Use --non-interactive (or
TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for
backwards compatibility.
EOF
}
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
log() { printf 'tallynote installer: %s\n' "$*"; }
stage() { log "[阶段] $*"; }
stage_done() { log "[完成] $*"; }
case "${TALLYNOTE_NON_INTERACTIVE:-false}" in
true|1) NON_INTERACTIVE=1 ;;
false|0) ;;
*) die 'TALLYNOTE_NON_INTERACTIVE 必须是 true 或 false' ;;
esac
prompt_value() {
local label=$1 default=${2-} reply
if [[ -n "$default" ]]; then
printf '%s [%s]: ' "$label" "$default" > "$PROMPT_OUTPUT"
else
printf '%s: ' "$label" > "$PROMPT_OUTPUT"
fi
if ! IFS= read -r reply <&9; then
die '无法读取终端输入;请使用 --non-interactive 或通过环境变量配置'
fi
PROMPT_REPLY=${reply:-$default}
}
detect_public_ipv4() {
local endpoint value octet
local -a endpoints=()
if [[ -n "$PUBLIC_IP_URL" ]]; then
endpoints=("$PUBLIC_IP_URL")
else
# These services return the caller's address as plain text. HTTPS is
# required, and a failure simply falls back to manual address entry.
endpoints=(
'https://api.ipify.org'
'https://ifconfig.me/ip'
'https://checkip.amazonaws.com'
)
fi
command -v curl >/dev/null 2>&1 || return 1
for endpoint in "${endpoints[@]}"; do
[[ "$endpoint" == https://* && "$endpoint" != *[[:space:]]* && "$endpoint" != *[[:cntrl:]]* && "$endpoint" != *'@'* ]] || continue
value=$(curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
--connect-timeout 4 --max-time 8 --max-filesize 128 "$endpoint" 2>/dev/null \
| tr -d '[:space:]') || continue
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
IFS='.' read -r -a _public_ip_octets <<< "$value"
for octet in "${_public_ip_octets[@]}"; do
(( 10#$octet <= 255 )) || continue 2
done
printf '%s' "$value"
return 0
done
return 1
}
port_listener_state() {
local port=$1 output status=0
validate_listen_port "$port" >/dev/null 2>&1 || return 2
if command -v ss >/dev/null 2>&1; then
if output=$(ss -H -ltn 2>/dev/null); then
if awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
return 1
fi
return 0
fi
fi
if command -v lsof >/dev/null 2>&1; then
output=''
status=0
output=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null) || status=$?
[[ -n "$output" ]] && return 1
[[ "$status" == 1 && -z "$output" ]] && return 0
[[ "$status" == 0 ]] && return 0
fi
if command -v netstat >/dev/null 2>&1; then
if output=$(netstat -lnt 2>/dev/null); then
if awk -v port="$port" '$6 == "LISTEN" && $4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
return 1
fi
return 0
fi
fi
if command -v python3 >/dev/null 2>&1; then
python3 - "$port" <<'PY'
import errno
import socket
import sys
port = int(sys.argv[1])
for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
sock = socket.socket(family, socket.SOCK_STREAM)
try:
if family == socket.AF_INET6:
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
sock.bind((address, port))
except OSError as error:
if error.errno == errno.EADDRINUSE:
sys.exit(1)
finally:
sock.close()
sys.exit(0)
PY
status=$?
case "$status" in
0) return 0 ;;
1) return 1 ;;
esac
fi
return 2
}
check_requested_port() {
local port=$1 state
state=0
port_listener_state "$port" || state=$?
case "$state" in
0) return 0 ;;
1) die "端口 ${port} 已被占用,请选择其他端口" ;;
*) die "无法检测端口 ${port} 是否被占用,请安装 ss、lsof、netstat 或 Python 3 后重试" ;;
esac
}
run_initial_admin_wizard() {
if (( ! INSTALL_FIRST_INSTALL )); then
return 0
fi
if (( NON_INTERACTIVE )); then
log '非交互模式:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
return 0
fi
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || {
log '未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
return 0
}
[[ -x "$ADMIN_INIT_PATH" ]] || die '管理员初始化命令未安装'
local status choice
if ! status=$("$ADMIN_INIT_PATH" --check 2>/dev/null); then
log '无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo tallynote-admin-init'
return 0
fi
[[ "$status" == empty ]] || return 0
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请稍后执行 sudo tallynote-admin-init'
{
printf '\n首次安装还差一步:请创建管理员账号。\n'
printf '管理员账号用于登录 TallyNote,首次登录后需要设置正式密码。\n'
} > "$PROMPT_OUTPUT"
while :; do
prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes'
choice=$PROMPT_REPLY
case "$choice" in
yes|YES|Yes|y|Y) break ;;
no|NO|No|n|N|'')
exec 9<&-
log '已跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
return 0
;;
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
esac
done
stage '创建首位管理员(密码不会写入安装日志)'
if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then
exec 9<&-
log '管理员初始化未完成;基础安装已完成,稍后可执行 sudo tallynote-admin-init'
return 0
fi
exec 9<&-
stage_done '首位管理员创建完成'
}
wait_for_service_health() {
local host=$1 port=$2 health_host health_url attempt
health_host=$host
case "$health_host" in
0.0.0.0) health_host=127.0.0.1 ;;
::) health_host=::1 ;;
esac
if [[ "$health_host" == *:* && "$health_host" != \[* ]]; then health_host="[$health_host]"; fi
health_url="http://${health_host}:${port}/health"
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
if curl --proto '=http' --connect-timeout 2 --max-time 3 --fail --silent "$health_url" >/dev/null 2>&1; then
return 0
fi
(( attempt < 12 )) && sleep 1
done
return 1
}
has_network_environment() {
[[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]
}
interactive_network_available() {
(( APPLY )) || return 1
(( NON_INTERACTIVE == 0 )) || return 1
has_network_environment && return 1
[[ ! -e "$CONFIG_DIR/tallynote.env" && ! -L "$CONFIG_DIR/tallynote.env" ]] || return 1
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || return 1
return 0
}
configure_network_interactively() {
interactive_network_available || return 0
NETWORK_INTERACTIVE=1
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置'
stage '配置服务网络监听(可直接回车使用默认值)'
{
printf '\nTallyNote 服务监听配置\n'
printf ' 1) 仅本机访问:127.0.0.1(更安全)\n'
printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n'
} > "$PROMPT_OUTPUT"
local choice selected_port origin answer port_state detected_ip default_origin
while :; do
prompt_value '请选择监听方式 1/2' '1'
choice=$PROMPT_REPLY
case "$choice" in
1|2) break ;;
*) printf '请输入 1 或 2。\n' > "$PROMPT_OUTPUT" ;;
esac
done
while :; do
prompt_value '监听端口' "$INSTALL_PORT"
selected_port=$PROMPT_REPLY
if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then
# A real terminal can reject an occupied port immediately. The final
# check in main() runs again after old services have been stopped.
if [[ -t 9 ]]; then
port_state=0
port_listener_state "$selected_port" || port_state=$?
case "$port_state" in
0) break ;;
1) printf '端口 %s 已被占用,请输入其他端口。\n' "$selected_port" > "$PROMPT_OUTPUT"; continue ;;
*) printf '暂时无法预检端口,安装前还会再次检查。\n' > "$PROMPT_OUTPUT"; break ;;
esac
fi
break
fi
printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT"
done
if [[ "$choice" == 1 ]]; then
INSTALL_HOST=127.0.0.1
INSTALL_PORT=$selected_port
INSTALL_PUBLIC_ORIGIN="http://127.0.0.1:${selected_port}"
INSTALL_ALLOW_INSECURE_HTTP=false
else
INSTALL_HOST=0.0.0.0
INSTALL_PORT=$selected_port
detected_ip=''
# Test fixtures replace /dev/tty with regular files; avoid making their
# behavior depend on an external IP lookup service.
if [[ -t 9 ]]; then
detected_ip=$(detect_public_ipv4 || true)
fi
if [[ -n "$detected_ip" ]]; then
default_origin="http://${detected_ip}:${selected_port}"
printf '已探测公网 IPv4:%s\n' "$detected_ip" > "$PROMPT_OUTPUT"
else
default_origin=''
printf '未能自动获取公网 IPv4,请手动填写访问地址。\n' > "$PROMPT_OUTPUT"
fi
while :; do
prompt_value '实际访问地址(回车使用自动探测地址,也可填写域名)' "$default_origin"
origin=$PROMPT_REPLY
if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then
INSTALL_PUBLIC_ORIGIN=$origin
break
fi
printf '地址无效:请输入不含路径、凭据或通配监听地址的 http:// 或 https:// 地址。\n' > "$PROMPT_OUTPUT"
done
INSTALL_ALLOW_INSECURE_HTTP=false
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* ]]; then
{
printf '\n警告:直连 HTTP 不加密,登录信息和账目数据可能被窃听。\n'
printf '仅在受控局域网或你明确接受风险时继续。\n'
} > "$PROMPT_OUTPUT"
while :; do
prompt_value '确认允许公网 HTTP?输入 yes 继续,其他内容取消' 'no'
answer=$PROMPT_REPLY
case "$answer" in
yes|YES|Yes|y|Y) INSTALL_ALLOW_INSECURE_HTTP=true; break ;;
no|NO|No|n|N|'') die '已取消:公网 HTTP 必须明确确认;请改用 HTTPS 或重新运行安装器' ;;
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
esac
done
fi
fi
exec 9<&-
stage_done "网络配置已选择:${INSTALL_HOST}:${INSTALL_PORT}"
}
[[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false'
[[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false'
@@ -104,6 +433,7 @@ while (($#)); do
--keep-releases) KEEP_RELEASES=${2:?missing value for --keep-releases}; shift ;;
--allow-downgrade) ALLOW_DOWNGRADE=true ;;
--allow-unsigned) ALLOW_UNSIGNED=1; REQUIRE_SIGNATURE=false ;;
--non-interactive) NON_INTERACTIVE=1 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
@@ -197,15 +527,23 @@ assert_allowed_url() {
download() {
local url=$1 out=$2 max_bytes=${3:-$((MAX_RELEASE_MB * 1024 * 1024))}
local current="$url" headers status location actual origin scheme authority
local -a curl_args=(--proto '=https' --tlsv1.2 --fail --show-error --max-redirs 0
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes"
--retry 2 --retry-connrefused)
# Keep CI and journal output clean, while showing curl's standard progress
# bar during an interactive SSH/terminal installation.
if [[ -t 2 ]]; then
curl_args+=(--progress-bar)
else
curl_args+=(--silent)
fi
require_https "$url"
assert_allowed_url "$url"
[[ ! -L "$out" && ! -e "$out" ]] || die "download destination already exists: $out"
for _redirect in 0 1 2 3; do
headers="${out}.headers-${RANDOM}-$$"
status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes" \
--retry 2 --retry-connrefused --output "$out" --dump-header "$headers" \
--write-out '%{http_code}' "$current" 2>/dev/null) || status=000
status=$(curl "${curl_args[@]}" --output "$out" --dump-header "$headers" \
--write-out '%{http_code}' "$current") || status=000
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
rm -f -- "$headers"
break
@@ -506,6 +844,17 @@ stop_existing_services() {
# Stop the path trigger first so it cannot launch the privileged updater while
# the data tree is being repaired.
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
case "$unit" in
tallynote.service)
if systemctl is-enabled --quiet "$unit"; then INSTALL_WAS_ENABLED=1; fi
;;
tallynote-update.path)
if systemctl is-enabled --quiet "$unit"; then INSTALL_PATH_WAS_ENABLED=1; fi
;;
tallynote-update.service)
if systemctl is-enabled --quiet "$unit"; then INSTALL_UPDATE_WAS_ENABLED=1; fi
;;
esac
if systemctl is-active --quiet "$unit"; then
case "$unit" in
tallynote.service) INSTALL_WAS_ACTIVE=1 ;;
@@ -519,6 +868,17 @@ stop_existing_services() {
rollback_install_if_needed() {
local result=$? rollback_tmp
if (( INSTALL_COMMITTED == 0 && INSTALL_SYSTEMD_TOUCHED == 1 )) && command -v systemctl >/dev/null 2>&1; then
# The failed install may have started units that were inactive before the
# attempt. Stop them before restoring files so systemd never keeps running
# code from a release directory that rollback is about to remove.
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
systemctl stop "$unit" >/dev/null 2>&1 || true
done
if (( INSTALL_WAS_ENABLED == 0 )); then systemctl disable tallynote.service >/dev/null 2>&1 || true; fi
if (( INSTALL_PATH_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.path >/dev/null 2>&1 || true; fi
if (( INSTALL_UPDATE_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.service >/dev/null 2>&1 || true; fi
fi
if (( INSTALL_SWITCHED == 1 && INSTALL_COMMITTED == 0 )); then
if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then
rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
@@ -539,13 +899,16 @@ rollback_install_if_needed() {
chmod 700 "$DATA_DIR" 2>/dev/null || true
DATA_DIR_TEMP_ROOT=0
fi
if (( INSTALL_COMMITTED == 0 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
if (( INSTALL_COMMITTED == 0 && INSTALL_BACKUP_COMPLETE == 1 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
local backup_name target
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-uninstall tallynote.env update-signing-key.pub; do
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do
case "$backup_name" in
tallynote.env) target="$CONFIG_DIR/tallynote.env" ;;
update-signing-key.pub) target="$CONFIG_DIR/update-signing-key.pub" ;;
tallynote-uninstall) target="/usr/local/sbin/tallynote-uninstall" ;;
tallynote-admin-init) target="$ADMIN_INIT_PATH" ;;
tallynote-update) target="/usr/local/sbin/tallynote-update" ;;
tallynote-update-runner) target="/usr/local/libexec/tallynote-update-runner" ;;
*) target="/etc/systemd/system/$backup_name" ;;
esac
[[ ! -L "$target" ]] || continue
@@ -557,6 +920,7 @@ rollback_install_if_needed() {
done
fi
if command -v systemctl >/dev/null 2>&1; then
if (( INSTALL_SYSTEMD_TOUCHED == 1 )); then systemctl daemon-reload >/dev/null 2>&1 || true; fi
if (( INSTALL_WAS_ACTIVE == 1 )); then systemctl start tallynote.service 2>/dev/null || true; fi
if (( INSTALL_UPDATE_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.service 2>/dev/null || true; fi
if (( INSTALL_PATH_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.path 2>/dev/null || true; fi
@@ -571,28 +935,41 @@ backup_install_files() {
local directory=$1 target name
mkdir -p "$directory"
chmod 700 "$directory"
for name in tallynote.service tallynote-update.service tallynote-update.path; do
target="/etc/systemd/system/$name"
[[ ! -L "$target" ]] || die "现有 systemd 单元不能是符号链接:$target"
# Validate every target before copying any of them. If validation fails, the
# installer has not changed an existing file and rollback must not infer that
# a partial backup is safe to restore from.
for name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do
case "$name" in
tallynote.env) target="$CONFIG_DIR/$name" ;;
update-signing-key.pub) target="$CONFIG_DIR/$name" ;;
tallynote-uninstall) target="/usr/local/sbin/$name" ;;
tallynote-admin-init) target="$ADMIN_INIT_PATH" ;;
tallynote-update) target="/usr/local/sbin/$name" ;;
tallynote-update-runner) target="/usr/local/libexec/$name" ;;
*) target="/etc/systemd/system/$name" ;;
esac
[[ ! -L "$target" ]] || die "现有安装文件不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有 systemd 单元不是普通文件:$target"
cp -a -- "$target" "$directory/$name"
[[ -f "$target" ]] || die "现有安装文件不是普通文件:$target"
fi
done
for name in tallynote.env update-signing-key.pub; do
target="$CONFIG_DIR/$name"
[[ ! -L "$target" ]] || die "现有配置不能是符号链接:$target"
for name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do
case "$name" in
tallynote.env) target="$CONFIG_DIR/$name" ;;
update-signing-key.pub) target="$CONFIG_DIR/$name" ;;
tallynote-uninstall) target="/usr/local/sbin/$name" ;;
tallynote-admin-init) target="$ADMIN_INIT_PATH" ;;
tallynote-update) target="/usr/local/sbin/$name" ;;
tallynote-update-runner) target="/usr/local/libexec/$name" ;;
*) target="/etc/systemd/system/$name" ;;
esac
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有配置不是普通文件:$target"
cp -a -- "$target" "$directory/$name"
cp -a -- "$target" "$directory/$name" || die "无法备份现有安装文件:$target"
[[ -f "$directory/$name" ]] || die "现有安装文件备份不完整:$target"
fi
done
target="/usr/local/sbin/tallynote-uninstall"
[[ ! -L "$target" ]] || die "现有卸载器不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有卸载器不是普通文件:$target"
cp -a -- "$target" "$directory/tallynote-uninstall"
fi
INSTALL_BACKUP_COMPLETE=1
}
read_env_value() {
@@ -611,6 +988,69 @@ validate_env_value() {
[[ ${#value} -le 4096 ]] || die "$label 过长"
}
validate_listen_host() {
local value=$1 label=${2:-监听地址}
validate_env_value "$value" "$label"
if [[ "$value" == *:* ]]; then
[[ "$value" =~ ^[0-9A-Fa-f:]+$ ]] || die "$label 必须是有效的 IPv6 地址或主机名"
elif [[ "$value" =~ ^[0-9.]+$ ]]; then
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "$label 必须是有效的 IPv4 地址或主机名"
local octet
IFS='.' read -r -a _host_octets <<< "$value"
for octet in "${_host_octets[@]}"; do
(( 10#$octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
done
else
[[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名"
[[ "$value" != *..* && "$value" != *.-* && "$value" != *-.* ]] || die "$label 包含不受支持的主机名"
fi
}
validate_listen_port() {
local value=$1 label=${2:-监听端口}
[[ "$value" =~ ^[1-9][0-9]*$ && "$value" -le 65535 ]] || die "$label 必须是 1-65535 的整数"
}
validate_public_origin() {
# Keep the optional origin port defined under `set -u`. Origins without an
# explicit port (for example https://example.test) are valid and should
# proceed to the default-port handling below.
local value=$1 authority host path_part origin_port='' suffix
case "$value" in
http://*|https://*) ;;
*) die '公开访问地址必须是 http:// 或 https:// 地址' ;;
esac
[[ "$value" != *[[:space:]]* && "$value" != *[[:cntrl:]]* && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die '公开访问地址包含不受支持的字符'
authority=${value#*://}
authority=${authority%%/*}
[[ -n "$authority" ]] || die '公开访问地址缺少主机名'
if [[ "$authority" == \[*\]* ]]; then
host=${authority#\[}; host=${host%%\]*}
suffix=${authority#*\]}
if [[ -n "$suffix" ]]; then
[[ "$suffix" =~ ^:([0-9]+)$ ]] || die '公开访问地址端口无效'
origin_port=${BASH_REMATCH[1]}
fi
else
if [[ "$authority" == *:* ]]; then
[[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效'
host=${BASH_REMATCH[1]}
origin_port=${BASH_REMATCH[2]}
else
host=$authority
fi
fi
[[ -n "$host" ]] || die '公开访问地址缺少主机名'
[[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名'
validate_listen_host "$host" '公开访问地址主机'
if [[ -n "$origin_port" ]]; then
[[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数'
fi
path_part=${value#*://}
path_part=${path_part#"$authority"}
[[ -z "$path_part" || "$path_part" == "/" ]] || die '公开访问地址不能包含路径'
}
validate_semver() {
local value=$1 prerelease part
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
@@ -630,14 +1070,14 @@ validate_install_path() {
}
validate_existing_env() {
local file=$1 value metadata_host
local file=$1 value metadata_host host port origin allow_insecure cookie_secure
[[ ! -L "$file" && -f "$file" ]] || die '现有环境文件不是普通文件'
[[ "$(stat_uid "$file")" == 0 ]] || die '现有环境文件必须由 root 拥有'
local mode_bits
mode_bits=$(stat_mode_bits "$file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
local key key_count
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
key_count=$(env_key_count "$file" "$key")
[[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key"
done
@@ -647,6 +1087,61 @@ validate_existing_env() {
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then
host=$(read_env_value "$file" TALLYNOTE_HOST)
validate_listen_host "$host" '环境文件中的监听地址'
else
host=127.0.0.1
fi
if (( $(env_key_count "$file" TALLYNOTE_PORT) )); then
port=$(read_env_value "$file" TALLYNOTE_PORT)
validate_listen_port "$port" '环境文件中的监听端口'
else
port=3000
fi
if (( $(env_key_count "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) )); then
allow_insecure=$(read_env_value "$file" TALLYNOTE_ALLOW_INSECURE_HTTP)
[[ "$allow_insecure" == true || "$allow_insecure" == false ]] || die '环境文件中的公网 HTTP 开关必须是 true 或 false'
else
allow_insecure=false
fi
if (( $(env_key_count "$file" TALLYNOTE_COOKIE_SECURE) )); then
cookie_secure=$(read_env_value "$file" TALLYNOTE_COOKIE_SECURE)
[[ "$cookie_secure" == true || "$cookie_secure" == false ]] || die '环境文件中的安全 Cookie 配置必须是 true 或 false'
else
cookie_secure=''
fi
if (( $(env_key_count "$file" TALLYNOTE_PUBLIC_ORIGIN) )); then
origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN)
validate_env_value "$origin" '环境文件中的公开访问地址'
else
local origin_host=$host
[[ "$origin_host" == *:* && "$origin_host" != \[* ]] && origin_host="[$origin_host]"
origin="http://${origin_host}:${port}"
fi
validate_public_origin "$origin"
local origin_host_for_policy=${origin#*://}
if [[ "$origin_host_for_policy" == \[*\]* ]]; then
origin_host_for_policy=${origin_host_for_policy#\[}
origin_host_for_policy=${origin_host_for_policy%%\]*}
else
origin_host_for_policy=${origin_host_for_policy%%:*}
fi
if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then
case "$origin_host_for_policy" in
127.0.0.1|localhost|::1) ;;
*) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
esac
fi
if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then
case "$origin_host_for_policy" in
127.0.0.1|localhost|::1) ;;
*) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;;
esac
fi
if [[ "$origin" == https://* && "$cookie_secure" == false ]]; then
die '环境文件中的 HTTPS 公开地址必须启用安全 Cookie'
fi
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
if [[ -n "$value" ]]; then
validate_env_value "$value" '环境文件更新源'
@@ -659,14 +1154,17 @@ validate_existing_env() {
install_release() {
local archive=$1 version=$2 tmp release_dir current_tmp=''
tmp=$(mktemp -d)
trap 'rm -rf "$tmp" "$current_tmp" 2>/dev/null || true' RETURN
# RETURN traps survive the function that installs them. Clear the trap from
# inside its first invocation so a later function cannot evaluate the local
# temporary path after it has gone out of scope under `set -u`.
trap 'trap - RETURN; if [[ -n "${tmp-}" ]]; then rm -rf -- "$tmp" 2>/dev/null || true; fi; if [[ -n "${current_tmp-}" ]]; then rm -f -- "$current_tmp" 2>/dev/null || true; fi' RETURN
safe_extract "$archive" "$tmp/unpacked"
normalize_release_tree "$tmp/unpacked"
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root'
[[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote'
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/server/cli/admin-init.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units'
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" ]] || die 'release archive is missing update/uninstall support files'
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" && -x "$tmp/unpacked/bin/tallynote-admin-init" ]] || die 'release archive is missing update/uninstall/admin-init support files'
grep -Eq '"version"[[:space:]]*:[[:space:]]*"'"$version"'"([,}]|[[:space:]])' "$tmp/unpacked/package.json" || die 'release package version does not match requested version'
ensure_root_directory "$PREFIX" 755
ensure_root_directory "$PREFIX/releases" 755
@@ -715,6 +1213,7 @@ prune_releases() {
}
main() {
stage '检查运行环境、权限和目标架构'
# These variables are useful for isolated tests, but a root install must
# never execute an untrusted PATH entry supplied through sudo's environment.
if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then
@@ -724,6 +1223,35 @@ main() {
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
fi
detect_platform
configure_network_interactively
validate_listen_host "$INSTALL_HOST"
validate_listen_port "$INSTALL_PORT"
if (( APPLY && NETWORK_INTERACTIVE )); then
check_requested_port "$INSTALL_PORT"
fi
if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then
die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin'
fi
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true || "$INSTALL_ALLOW_INSECURE_HTTP" == false ]] || die 'TALLYNOTE_ALLOW_INSECURE_HTTP 必须是 true 或 false'
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
validate_env_value "$INSTALL_PUBLIC_ORIGIN" '公开访问地址'
validate_public_origin "$INSTALL_PUBLIC_ORIGIN"
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* && "$INSTALL_ALLOW_INSECURE_HTTP" != true ]]; then
public_host=${INSTALL_PUBLIC_ORIGIN#http://}
if [[ "$public_host" == \[*\]* ]]; then
public_host=${public_host#\[}
public_host=${public_host%%\]*}
else
public_host=${public_host%%:*}
fi
case "$public_host" in
127.0.0.1|localhost|::1) ;;
*) die '公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
esac
fi
elif [[ "$INSTALL_HOST" != 127.0.0.1 && "$INSTALL_HOST" != localhost && "$INSTALL_HOST" != ::1 ]]; then
die '监听非本机地址时必须提供 TALLYNOTE_PUBLIC_ORIGIN(例如 http://服务器IP:3000)'
fi
[[ "$KEEP_RELEASES" =~ ^[1-9][0-9]*$ ]] || die '--keep-releases must be a positive integer'
validate_install_path "$PREFIX" '安装目录'
validate_install_path "$DATA_DIR" '数据目录'
@@ -737,14 +1265,21 @@ main() {
# TALLYNOTE_RELEASE_ALLOWED_HOSTS when the operator has reviewed it.
append_allowed_host "$(url_host "$RELEASE_API_URL")"
append_allowed_host "$(url_host "$REPOSITORY_URL")"
stage_done "运行环境可用:${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}"
if [[ "$VERSION" == "latest" ]]; then
if (( ! APPLY )); then
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
stage '预览最新版本解析(dry-run 不访问 Release)'
log 'version: latest (release lookup skipped in dry-run)'
log 'dry-run: pass --version VERSION to preview an exact artifact'
stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd'
return 0
fi
stage '从 Release API 获取最新版本'
resolve_latest_version
stage_done "已解析最新版本:${VERSION#v}"
else
stage "使用指定版本:${VERSION#v}"
fi
validate_semver "$VERSION" || die 'version must be a semantic version (for example 1.2.3)'
VERSION=${VERSION#v}
@@ -755,15 +1290,21 @@ main() {
die "拒绝安装不高于当前版本的 release:当前 $current_version,候选 $VERSION(如确需降级请使用 --allow-downgrade)"
fi
fi
stage '准备 Release 下载地址和发布包'
release_urls
local artifact archive checksum signature artifact_url work release_dir
artifact=${RELEASE_FILE:+$(basename -- "$RELEASE_FILE")}
artifact=${artifact:-tallynote-${VERSION}-linux-${TALLYNOTE_ARCH}-${TALLYNOTE_LIBC}.tar.gz}
[[ "$artifact" =~ ^[A-Za-z0-9][A-Za-z0-9._+\-]*\.(tar\.gz|tgz|tar)$ ]] || die 'release 文件名无效'
artifact_url="$RELEASE_BASE_URL/$artifact"
stage_done 'Release 下载地址已准备'
log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}"
log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR"
if (( ! APPLY )); then log 'dry-run: no download, extraction, or systemd changes'; return 0; fi
if (( ! APPLY )); then
log 'dry-run: no download, extraction, or systemd changes'
stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd'
return 0
fi
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行'
[[ $EUID -eq 0 ]] || die '安装必须以 root 运行'
for command_name in curl sha256sum tar install sed awk find systemctl; do
@@ -777,6 +1318,7 @@ main() {
INSTALL_BACKUP_DIR="$work/original"
trap rollback_install_if_needed EXIT
archive="$work/$artifact"
stage "获取发布包:$artifact"
if [[ -n "$RELEASE_FILE" && -f "$RELEASE_FILE" && ! -L "$RELEASE_FILE" ]]; then
cp -- "$RELEASE_FILE" "$archive"
chmod 600 "$archive"
@@ -785,8 +1327,10 @@ main() {
[[ -z "$RELEASE_FILE" ]] || die '本地 release 文件不存在或是符号链接'
download "$artifact_url" "$archive"
fi
stage_done '发布包已下载并通过大小限制'
checksum="$work/SHA256SUMS"
SHA256_URL=${SHA256_URL:-$RELEASE_BASE_URL/SHA256SUMS}
stage '获取 SHA-256 校验清单'
if [[ -n "$SHA256_FILE" && -f "$SHA256_FILE" && ! -L "$SHA256_FILE" ]]; then
cp -- "$SHA256_FILE" "$checksum"
chmod 600 "$checksum"
@@ -795,9 +1339,11 @@ main() {
[[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接'
download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024))
fi
stage_done 'SHA-256 校验清单已准备'
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
signature=''
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" ]]; then
stage '获取发布签名'
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc}
signature="$work/$artifact.asc"
@@ -806,10 +1352,19 @@ main() {
signature="$work/SHA256SUMS.sig"
fi
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
stage_done '发布签名已准备'
fi
stage '校验 SHA-256 和发布签名'
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
stage_done '发布包校验通过'
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
if [[ -L "$PREFIX/current" || -e "$PREFIX/current" ]]; then
INSTALL_FIRST_INSTALL=0
else
INSTALL_FIRST_INSTALL=1
fi
stage '停止旧服务并准备安装、配置和数据目录'
id tallynote >/dev/null 2>&1 || useradd --system --user-group --home-dir "$DATA_DIR" --shell /usr/sbin/nologin tallynote
backup_install_files "$INSTALL_BACKUP_DIR"
stop_existing_services
@@ -821,28 +1376,45 @@ main() {
if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then
validate_existing_env "$CONFIG_DIR/tallynote.env"
fi
# During upgrades, the existing environment remains authoritative unless a
# new port was explicitly supplied. Check the effective listener port after
# stopping the old service so an unrelated process cannot claim it.
local effective_port=$INSTALL_PORT
if [[ -z "${TALLYNOTE_PORT+x}" && -f "$CONFIG_DIR/tallynote.env" ]]; then
effective_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
effective_port=${effective_port:-3000}
fi
check_requested_port "$effective_port"
stage_done '目录、权限和旧服务状态已准备'
stage "解包、校验包结构并原子切换到版本 ${VERSION#v}"
install_release "$archive" "$VERSION"
stage_done "版本 ${VERSION#v} 已切换为当前版本"
release_dir="$PREFIX/releases/$VERSION"
[[ -f "$release_dir/systemd/tallynote.service" && -f "$release_dir/systemd/tallynote-update.service" && -f "$release_dir/systemd/tallynote-update.path" ]] || die 'release package is missing systemd unit files'
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" ]] || die 'release package is missing update/uninstall support files'
install -d -m 755 /usr/local/libexec /etc/systemd/system
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" && -x "$release_dir/bin/tallynote-admin-init" && -f "$release_dir/dist/server/cli/admin-init.js" ]] || die 'release package is missing update/uninstall/admin-init support files'
stage '安装 systemd 单元、更新辅助程序和卸载器'
install -d -m 755 /usr/local/sbin /usr/local/libexec /etc/systemd/system
local unit_tmp
unit_tmp=$(mktemp -d)
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service"
sed "s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$unit_tmp/tallynote-admin-init"
install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service
install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service
install -o root -g root -m 644 "$unit_tmp/tallynote-update.path" /etc/systemd/system/tallynote-update.path
install -o root -g root -m 755 "$unit_tmp/tallynote-admin-init" "$ADMIN_INIT_PATH"
rm -rf "$unit_tmp"
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
local env_created=0
if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env"
chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env"
env_created=1
fi
ensure_env_key() {
local key=$1 value=$2
@@ -855,6 +1427,40 @@ main() {
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
fi
}
set_env_key() {
local key=$1 value=$2 escaped
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效'
validate_env_value "$value" "$key"
escaped=${value//\\/\\\\}
escaped=${escaped//&/\\&}
escaped=${escaped//|/\\|}
if grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then
sed -i "s|^${key}=.*|${key}=${escaped}|" "$CONFIG_DIR/tallynote.env"
else
if [[ -s "$CONFIG_DIR/tallynote.env" && "$(tail -c 1 "$CONFIG_DIR/tallynote.env")" != $'\n' ]]; then
printf '\n' >> "$CONFIG_DIR/tallynote.env"
fi
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
fi
}
# A fresh install gets the requested network settings. On upgrades, only
# explicitly supplied values change the existing administrator config.
if (( env_created )) || [[ -n "${TALLYNOTE_HOST+x}" ]]; then set_env_key TALLYNOTE_HOST "$INSTALL_HOST"; fi
if (( env_created )) || [[ -n "${TALLYNOTE_PORT+x}" ]]; then set_env_key TALLYNOTE_PORT "$INSTALL_PORT"; fi
if (( env_created )); then
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
elif [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" ]]; then
local generated_origin_host=$INSTALL_HOST
[[ "$generated_origin_host" == *:* && "$generated_origin_host" != \[* ]] && generated_origin_host="[$generated_origin_host]"
set_env_key TALLYNOTE_PUBLIC_ORIGIN "http://${generated_origin_host}:${INSTALL_PORT}"
fi
if [[ "$INSTALL_PUBLIC_ORIGIN" == https://* ]]; then set_env_key TALLYNOTE_COOKIE_SECURE true; fi
set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"
elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
fi
if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi
ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX"
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR"
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
@@ -881,13 +1487,56 @@ main() {
fi
chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env"
stage_done 'systemd 单元、更新辅助程序和卸载器已安装'
stage '重新加载 systemd 并启动 TallyNote'
systemctl daemon-reload
INSTALL_SYSTEMD_TOUCHED=1
systemctl enable --now tallynote.service tallynote-update.path
local health_host health_port
health_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
health_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
health_host=${health_host:-$INSTALL_HOST}
health_port=${health_port:-$INSTALL_PORT}
stage "检查本机健康接口(${health_host}:${health_port})"
if ! wait_for_service_health "$health_host" "$health_port"; then
log "本机健康检查失败:http://${health_host}:${health_port}/health"
systemctl status tallynote.service --no-pager -l || true
if command -v journalctl >/dev/null 2>&1; then
journalctl -u tallynote.service -n 30 --no-pager || true
fi
die 'TallyNote 服务未通过健康检查;安装未完成,请根据上面的 systemd 日志修复后重试'
fi
stage_done '本机健康检查通过,服务正在监听'
if [[ "$health_host" == 127.0.0.1 || "$health_host" == localhost || "$health_host" == ::1 ]]; then
log '当前监听仅限本机;公网或其他设备无法直接访问,请重新安装并选择 0.0.0.0,或配置 HTTPS 反向代理'
else
log '当前监听已绑定非本机地址;若外部仍无法连接,请检查云安全组、主机防火墙和公网 IP/NAT'
fi
stage_done 'TallyNote 服务已启用并启动'
stage '清理旧版本并完成安装'
prune_releases
stage_done '旧版本清理完成'
INSTALL_COMMITTED=1
trap - EXIT
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
INSTALL_WORK_DIR=''
log 'installed; inspect with systemctl status tallynote.service'
stage_done "安装完成:TallyNote ${VERSION#v}"
run_initial_admin_wizard
local access_url access_host access_port configured_host configured_port
access_url=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PUBLIC_ORIGIN 2>/dev/null || true)
if [[ -z "$access_url" ]]; then
configured_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
configured_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
access_host=${configured_host:-$INSTALL_HOST}
access_port=${configured_port:-$INSTALL_PORT}
if [[ "$access_host" == 0.0.0.0 ]]; then
access_host=$(detect_public_ipv4 || true)
fi
[[ -n "$access_host" ]] || access_host=$INSTALL_HOST
[[ "$access_host" == *:* && "$access_host" != \[* ]] && access_host="[$access_host]"
access_url="http://${access_host}:${access_port}"
fi
log "访问地址:$access_url"
log '查看服务状态:systemctl status tallynote.service'
}
main "$@"
@@ -0,0 +1,2 @@
ALTER TABLE update_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'apply' CHECK(operation IN ('download','apply'));
CREATE INDEX IF NOT EXISTS update_jobs_operation_idx ON update_jobs(operation, status, created_at);
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE update_jobs ADD COLUMN downloaded_bytes INTEGER;
ALTER TABLE update_jobs ADD COLUMN download_started_at INTEGER;
ALTER TABLE update_jobs ADD COLUMN download_speed_bps INTEGER;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.2",
"version": "1.1.29",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+26 -2
View File
@@ -27,7 +27,11 @@ pnpm build
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
cp -a dist/. "$stage/dist/"
# Copy only the production build outputs. In particular, do not carry a
# stale dist/web-next directory from a previous local preview build.
cp -a dist/server "$stage/dist/"
cp -a dist/shared "$stage/dist/"
cp -a dist/web "$stage/dist/"
cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
@@ -36,7 +40,7 @@ cp uninstall.sh "$stage/uninstall.sh"
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
node_path=$(command -v node)
cp -L "$node_path" "$stage/runtime/bin/node"
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
# pnpm's default linker creates symlinks. A release archive is deliberately
# symlink-free so the installer can reject traversal links deterministically.
@@ -46,6 +50,26 @@ find "$stage" -type l -delete
mkdir -p "$OUT_DIR"
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
# The application-only asset is used by the online updater. It deliberately
# excludes the stable runtime (Node and production dependencies), systemd
# helpers and installer files; the updater overlays it on the currently
# installed, already-validated runtime before atomically switching releases.
app_stage=$(mktemp -d)
trap 'rm -rf "$stage" "$app_stage"' EXIT
mkdir -p "$app_stage/dist" "$app_stage/migrations" "$app_stage/bin" "$app_stage/scripts" "$app_stage/systemd"
cp -a "$stage/dist/server" "$app_stage/dist/"
cp -a "$stage/dist/shared" "$app_stage/dist/"
cp -a "$stage/dist/web" "$app_stage/dist/"
cp -a "$stage/migrations/." "$app_stage/migrations/"
cp -a "$stage/bin/." "$app_stage/bin/"
cp -a "$stage/scripts/." "$app_stage/scripts/"
cp -a "$stage/systemd/." "$app_stage/systemd/"
cp "$stage/package.json" "$app_stage/package.json"
cp "$stage/uninstall.sh" "$app_stage/uninstall.sh"
runtime_hash=$(sha256sum pnpm-lock.yaml | awk '{print $1}')
app_archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.update-${runtime_hash}.tar.gz"
tar -C "$app_stage" -czf "$app_archive" --owner=0 --group=0 --numeric-owner .
# Keep the sidecar useful when a caller builds more than one architecture into
# the same directory. The publishing script recomputes this list immediately
# before signing, so stale or hand-edited entries can never reach a Release.
+104 -3
View File
@@ -24,6 +24,7 @@ DRY_RUN=0
AUTH_CONFIG=''
SUMS_TMP=''
SIG_TMP=''
RELEASE_NOTES_TMP=''
SIGNATURE_GENERATED=0
usage() {
@@ -43,6 +44,81 @@ EOF
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
log() { printf 'release publisher: %s\n' "$*"; }
generate_release_notes() {
local current=${TAG#v} previous='' subject kind line count=0
local -a commits
commits=()
# A workflow checks out the tag with history. Prefer an explicitly supplied
# notes file for mirrors, then derive notes from the immutable tag range.
if [[ -n "${TALLYNOTE_RELEASE_NOTES_FILE:-}" && -f "$TALLYNOTE_RELEASE_NOTES_FILE" ]]; then
# Read at most the API's bounded notes size without a pipe that can turn a
# deliberately truncated input into a SIGPIPE failure under pipefail.
LC_ALL=C awk 'BEGIN { remaining = 65536 } { if (remaining <= 0) exit; line=$0; gsub(/[[:cntrl:]]/, "", line); bytes=length(line)+1; if (bytes > remaining) { print substr(line, 1, remaining); exit } print line; remaining-=bytes }' "$TALLYNOTE_RELEASE_NOTES_FILE"
return
fi
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "$line" == "v${current}" ]] && continue
previous="$line"
break
done < <(git tag --sort=-version:refname --list 'v*')
if [[ -n "$previous" && "$previous" != "v${current}" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log --format='%s' "${previous}..${TAG}")
else
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log -n 30 --format='%s' "$TAG")
fi
fi
printf '# TallyNote %s\n\n' "$current"
if [[ -n "$previous" ]]; then
printf '> 从 `%s` 到 `%s` 的变更\n\n' "$previous" "v${current}"
else
printf '> 本版本变更\n\n'
fi
local -a features fixes improvements docs other
features=(); fixes=(); improvements=(); docs=(); other=()
for subject in "${commits[@]-}"; do
# Do not expose merge noise or the synthetic release commit in user notes.
[[ "$subject" != Merge\ * && "$subject" != release:* ]] || continue
kind=${subject%%:*}
if [[ "$subject" == *:* ]]; then subject=${subject#*: }; fi
subject=${subject# }
[[ -n "$subject" ]] || continue
case "$kind" in
feat|feature) features+=("$subject") ;;
fix|bugfix) fixes+=("$subject") ;;
refactor|perf|style|improvement) improvements+=("$subject") ;;
docs|doc|test|tests) docs+=("$subject") ;;
*) other+=("$subject") ;;
esac
done
print_group() {
local title=$1; shift
local item
(($# > 0)) || return 0
printf '## %s\n\n' "$title"
for item in "$@"; do printf -- '- %s\n' "$item"; done
printf '\n'
}
((${#features[@]})) && print_group '新增功能' "${features[@]}"
((${#fixes[@]})) && print_group '问题修复' "${fixes[@]}"
((${#improvements[@]})) && print_group '优化与重构' "${improvements[@]}"
((${#docs[@]})) && print_group '文档与测试' "${docs[@]}"
((${#other[@]})) && print_group '其他变更' "${other[@]}"
if (( ${#features[@]} + ${#fixes[@]} + ${#improvements[@]} + ${#docs[@]} + ${#other[@]} == 0 )); then
printf '本版本包含内部维护更新。\n'
fi
}
validate_semver() {
local value=$1 prerelease part
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
@@ -128,7 +204,8 @@ fi
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
assets=()
full_assets=()
update_assets=()
for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file")
@@ -136,9 +213,16 @@ for file in "$ASSET_DIR"/*.tar.gz; do
asset_version=${name#tallynote-}
asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
assets+=("$file")
if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then
update_assets+=("$file")
else
full_assets+=("$file")
fi
done
assets=("${full_assets[@]}")
if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
@@ -161,6 +245,7 @@ cleanup() {
if [[ -n "$AUTH_CONFIG" ]]; then rm -f -- "$AUTH_CONFIG"; fi
if [[ -n "$SUMS_TMP" ]]; then rm -f -- "$SUMS_TMP"; fi
if [[ -n "$SIG_TMP" ]]; then rm -f -- "$SIG_TMP"; fi
if [[ -n "$RELEASE_NOTES_TMP" ]]; then rm -f -- "$RELEASE_NOTES_TMP"; fi
}
trap cleanup EXIT
if [[ -n "$SIGNING_KEY_FILE" ]]; then
@@ -196,6 +281,13 @@ command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
# Keep the release body deterministic and human-readable. Gitea renders this
# Markdown in the Release page; the update API later exposes the same body as
# text for the safe client-side Markdown renderer.
RELEASE_NOTES_TMP=$(mktemp)
generate_release_notes > "$RELEASE_NOTES_TMP"
release_notes=$(<"$RELEASE_NOTES_TMP")
api_curl() {
"$CURL_BIN" --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
@@ -213,8 +305,17 @@ release_json=$(mktemp)
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取 Gitea Release'
if [[ "$status" == 200 ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
existing_body=$(jq -r '.body // ""' "$release_json")
# Older releases used a one-line placeholder. Upgrade that placeholder when
# a tag is republished, while leaving deliberately authored release notes
# untouched.
if [[ "$existing_body" == "TallyNote $TAG" || -z "$existing_body" ]]; then
patch_body=$(jq -cn --arg body "$release_notes" '{body:$body}')
patch_status=$(api_curl_status -X PATCH -H 'Content-Type: application/json' -d "$patch_body" -o /dev/null -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/$release_id") || die '无法更新 Gitea Release 日志'
[[ "$patch_status" == 2* ]] || die "无法更新 Gitea Release 日志(HTTP $patch_status)"
fi
elif [[ "$status" == 404 ]]; then
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "TallyNote $TAG" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "$release_notes" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
create_status=$(api_curl_status -H 'Content-Type: application/json' -d "$body" -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases") || die '无法创建 Gitea Release'
if [[ "$create_status" == 2* ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
+194 -26
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,40 +26,151 @@ 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'
was_active=0
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
restore_initial_service() {
local result=$?
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
return "$result"
}
trap restore_initial_service EXIT
systemctl stop "$SERVICE_NAME"
request_operation='apply'
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
request_operation=$(sed -n 's/.*"operation"[[:space:]]*:[[:space:]]*"\(download\|apply\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
[[ "$request_operation" == download || "$request_operation" == apply ]] || request_operation='apply'
fi
# Capture the request id before any privileged preflight can fail. The
# request file is an application-owned one-shot marker; removing it on an
# early runner failure lets the server-side lease reaper release the DB row.
job_id=''
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
fi
old_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
switched=0
handled=0
STATE_CREATED=0
heartbeat_pid=''
heartbeat_owner=$$
write_update_state() {
write_recovery_state() {
local phase=$1 temporary
temporary="$PREFIX/.update-state-$$-${RANDOM}.tmp"
[[ ! -e "$temporary" && ! -L "$temporary" ]] || return 1
printf 'job_id=%s\nold_target=%s\nphase=%s\n' "$job_id" "$old_target" "$phase" > "$temporary"
chmod 600 "$temporary"
mv -Tf -- "$temporary" "$STATE_FILE"
STATE_CREATED=1
}
clear_update_state() {
clear_recovery_state() {
[[ ! -L "$STATE_FILE" ]] || return 1
rm -f -- "$STATE_FILE"
STATE_CREATED=0
}
stop_heartbeat() {
if [[ -n "$heartbeat_pid" ]]; then
kill "$heartbeat_pid" 2>/dev/null || true
wait "$heartbeat_pid" 2>/dev/null || true
heartbeat_pid=''
fi
}
heartbeat() {
# Keep the lease fresh during long downloads/backups, but stop on a hard
# runner kill so an orphaned child cannot keep the recovery marker alive.
while kill -0 "$heartbeat_owner" 2>/dev/null; do
sleep 10 || exit 0
[[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] || exit 0
touch "$STATE_FILE" 2>/dev/null || exit 0
done
}
start_heartbeat() {
stop_heartbeat
heartbeat &
heartbeat_pid=$!
}
# This trap covers failures before the normal apply cleanup trap is installed,
# including a missing runtime, an invalid current link, and a failed service
# stop. It deliberately does not remove a pre-existing recovery marker.
preflight_cleanup() {
local result=$?
stop_heartbeat
if (( result != 0 )); then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
if (( STATE_CREATED == 1 )); then clear_recovery_state || true; fi
fi
return "$result"
}
trap preflight_cleanup EXIT
# Downloading is intentionally handled while the main service remains up.
# The CLI persists the validated payload under the root-owned workspace and
# leaves the job staged for a later apply request.
if [[ "$request_operation" == download ]]; then
# A previous download runner may have been interrupted after creating its
# marker. Clear only that download marker and retry the idempotent request.
if [[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] && grep -q '^phase=download$' "$STATE_FILE"; then
clear_recovery_state || die '无法清理上一次下载状态'
fi
write_recovery_state download || die '无法写入更新恢复状态'
cleanup_download() {
local result=$?
stop_heartbeat
if (( result != 0 )); then
# The CLI normally records failed itself. If it died before opening the
# database, the expired marker/request will be reconciled by the app.
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
fi
clear_recovery_state || true
return "$result"
}
trap cleanup_download EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
start_heartbeat
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
set +e
"$node_bin" "$cli" --request-file "$REQUEST_FILE"
download_result=$?
set -e
if (( download_result != 0 )); then
# The CLI normally records failed itself. Retry the explicit finalization
# for failures that happen before its catch handler can persist the row,
# then remove the one-shot request so a failed download cannot keep the
# path unit in a permanently triggered state.
download_job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
if [[ "$download_job_id" =~ ^[0-9a-f-]{36}$ ]]; then
for _ in 1 2 3; do
if "$node_bin" "$cli" --finalize-job "$download_job_id" --finalize-status failed --message '更新下载失败' >/dev/null 2>&1; then break; fi
sleep 1
done
fi
rm -f -- "$REQUEST_FILE"
exit "$download_result"
fi
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
restore_initial_service() {
local result=$?
stop_heartbeat
if (( result != 0 )); then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
if (( STATE_CREATED == 1 )); then clear_recovery_state || true; fi
fi
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
return "$result"
}
trap restore_initial_service EXIT
old_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
handled=0
write_update_state() { write_recovery_state "$1"; }
clear_update_state() { clear_recovery_state; }
finalize_state_job() {
local node=$1 status=$2 state_job=$3
[[ "$state_job" =~ ^[0-9a-f-]{36}$ && -n "$node" ]] || return 1
@@ -75,6 +190,15 @@ recover_stale_state() {
[[ "$state_job" =~ ^[0-9a-f-]{36}$ ]] || die 'update state job id is invalid'
[[ "$state_old" == "$PREFIX/releases/"* && -d "$state_old" && ! -L "$state_old" ]] || die 'update state target is invalid'
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
if [[ "$state_phase" == download && "$current_target" == "$state_old" ]]; then
# Downloading never changes the active release. If the runner was killed
# after the CLI staged its payload but before it removed the recovery
# marker, keep the request available for an idempotent retry. Treating
# every stale download marker as a failed apply would discard a usable
# staged payload and leave the browser showing a misleading failure.
clear_update_state || true
return 0
fi
if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
@@ -88,6 +212,27 @@ recover_stale_state() {
done
return 1
fi
if [[ "$current_target" == "$state_old" ]]; then
# The process may have restored the old release before it was killed. In
# that case the old link is already safe to serve, but the database row
# can still be `applying`; finish it as failed before clearing recovery
# markers so the UI does not poll forever.
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
if finalize_state_job "$recovery_node" failed "$state_job"; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
clear_update_state || true
return 11
fi
# A crash before the CLI created its job row is safe to retry. Preserve
# the request while dropping only the stale state marker.
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
clear_update_state || true
return 0
fi
clear_update_state || true
return 0
fi
if [[ "$current_target" != "$state_old" ]]; then
rollback_link="$PREFIX/.current-recovery-$$-${RANDOM}.tmp"
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
@@ -132,10 +277,26 @@ fi
[[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]] || exit 0
# Only create the marker for this invocation after any marker from a previous
# interrupted run has been reconciled. Otherwise the freshly-created `running`
# marker is indistinguishable from stale recovery state and the runner can
# finalize its own queued job as failed before the update CLI starts.
if [[ ! -e "$STATE_FILE" ]]; then
write_recovery_state running || die '无法写入更新恢复状态'
fi
start_heartbeat
if ! systemctl stop "$SERVICE_NAME"; then
die '无法停止 TallyNote 服务'
fi
rollback_current() {
local current_target rollback_link
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
[[ "$current_target" == "$old_target" ]] && return 0
if [[ "$current_target" == "$old_target" ]]; then
# An earlier failure branch may already have restored the link. Keep the
# marker truthful so the EXIT trap can still finalize the job.
return 0
fi
rollback_link="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
ln -s -- "$old_target" "$rollback_link" || return 1
@@ -143,13 +304,20 @@ rollback_current() {
rm -f -- "$rollback_link" 2>/dev/null || true
return 1
fi
switched=0
}
finalize_failed_job() {
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 0
"$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 1
# Give SQLite a moment to release a transient lock before declaring the
# recovery itself failed.
for _ in 1 2 3; do
if "$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
finalize_completed_job() {
@@ -161,9 +329,13 @@ finalize_completed_job() {
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
cleanup_after_update() {
local result=$? rollback_ok=1
stop_heartbeat
if (( result != 0 && handled == 0 )); then
if ! rollback_current; then rollback_ok=0; fi
if (( rollback_ok == 1 && switched == 0 )); then
# Once the old release is active again, always try to close the job. The
# previous marker could remain set when an earlier branch had already
# rolled back before entering this EXIT trap, leaving `applying` forever.
if (( rollback_ok == 1 )); then
if finalize_failed_job; then
rm -f -- "$REQUEST_FILE"
clear_update_state || true
@@ -179,7 +351,6 @@ cleanup_after_update() {
}
trap cleanup_after_update EXIT
write_update_state running || exit 1
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
@@ -194,15 +365,12 @@ if (( update_result != 0 )); then
exit "$update_result"
fi
if [[ "$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)" != "$old_target" ]]; then
switched=1
fi
write_update_state health-check || exit 1
systemctl start "$SERVICE_NAME"
healthy=0
for _ in $(seq 1 30); do
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HEALTH_HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
sleep 1
done
+510
View File
@@ -2,14 +2,48 @@
set -Eeuo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service"
grep -Eq '^PathExists=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
grep -Eq '^PathChanged=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
grep -Eq '^PathChanged=/opt/tallynote$' "$root/systemd/tallynote-update.path"
if grep -Eq '^ConditionPathExists=' "$root/systemd/tallynote-update.service"; then
echo 'update service must not require only the request file' >&2
exit 1
fi
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
grep -q 'dry-run' <<<"$output"
grep -q '\[阶段\] 检查运行环境' <<<"$output"
grep -q '\[完成\] dry-run 预览完成' <<<"$output"
output=$(bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
grep -q 'release: 1.2.3' <<<"$output"
grep -q '\[阶段\] 使用指定版本:1.2.3' <<<"$output"
if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.test/releases >/dev/null 2>&1; then
echo 'expected non-HTTPS URL to fail' >&2
exit 1
fi
if TALLYNOTE_HOST=0.0.0.0 bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
echo 'expected non-local listener without public origin to fail' >&2
exit 1
fi
output=$(TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
TALLYNOTE_ALLOW_INSECURE_HTTP=true \
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
grep -q 'release: 1.2.3' <<<"$output"
output=$(TALLYNOTE_HOST=::1 TALLYNOTE_PORT=3443 \
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
grep -q 'release: 1.2.3' <<<"$output"
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=65536 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 TALLYNOTE_ALLOW_INSECURE_HTTP=true \
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
echo 'expected invalid listener port to fail' >&2
exit 1
fi
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
echo 'expected public HTTP without explicit opt-in to fail' >&2
exit 1
fi
tmp=$(mktemp -d)
cleanup_tmp() {
if [[ -d "$tmp" ]]; then
@@ -54,12 +88,295 @@ bash -c '
chmod 700 "$mode_dir"
[[ "$(stat_mode_bits "$mode_dir")" == 448 ]]
mkdir -p "$owner_parent"
# CI runs this shell suite as root. Make the parent genuinely non-root in
# that environment so the assertion exercises the ownership guard instead
# of accidentally passing because root-owned parents are allowed.
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
chown 65534:65534 "$owner_parent"
fi
if (assert_path_chain "$owner_parent/child") >/dev/null 2>&1; then
echo "expected non-root path parent to fail" >&2
exit 1
fi
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
# The port probe must distinguish a listening TCP port from a free one.
port_tools="$tmp/port-tools"
mkdir -p "$port_tools"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "LISTEN 0 128 127.0.0.1:3443 0.0.0.0:*"' > "$port_tools/ss"
chmod 755 "$port_tools/ss"
bash -c '
script=$1
tools=$2
set --
source "$script"
PATH="$tools:$PATH"
state=0
port_listener_state 3443 || state=$?
[[ "$state" == 1 ]]
state=0
port_listener_state 3444 || state=$?
[[ "$state" == 0 ]]
' _ "$installer_lib" "$port_tools"
# The lsof fallback must treat its normal "no matches" exit status as a free
# port, while still reporting a listener when it returns a PID.
lsof_tools="$tmp/lsof-tools"
mkdir -p "$lsof_tools"
printf '%s\n' '#!/usr/bin/env bash' 'exit 127' > "$lsof_tools/ss"
printf '%s\n' '#!/usr/bin/env bash' 'case "$*" in *TCP:3443*) printf "%s\\n" 4242; exit 0 ;; *) exit 1 ;; esac' > "$lsof_tools/lsof"
chmod 755 "$lsof_tools/ss" "$lsof_tools/lsof"
bash -c '
script=$1
tools=$2
set --
source "$script"
PATH="$tools:$PATH"
state=0
port_listener_state 3443 || state=$?
[[ "$state" == 1 ]]
state=0
port_listener_state 3444 || state=$?
[[ "$state" == 0 ]]
' _ "$installer_lib" "$lsof_tools"
# Public-IP discovery accepts a valid IPv4 response and rejects malformed
# values without making the test depend on an external service.
bash -c '
script=$1
set --
source "$script"
PUBLIC_IP_URL=https://ip.example.test
curl() { printf "%s\\n" "198.51.100.7"; }
[[ "$(detect_public_ipv4)" == "198.51.100.7" ]]
curl() { printf "%s\\n" "999.1.1.1"; }
if detect_public_ipv4 >/dev/null 2>&1; then
echo "expected invalid public IPv4 response to fail" >&2
exit 1
fi
' _ "$installer_lib"
# The service health probe maps wildcard listeners to loopback and must return
# promptly when the local endpoint is healthy.
bash -c '
script=$1
set --
source "$script"
curl() { [[ "$*" == *"http://127.0.0.1:3011/health"* ]] || return 1; }
wait_for_service_health 0.0.0.0 3011
' _ "$installer_lib"
# Exercise the privileged runner's normal apply hand-off with portable command
# shims. In particular, the freshly-created running marker must not be treated
# as stale state before the update CLI gets a chance to process the request.
runner_root="$tmp/runner"
runner_prefix="$runner_root/prefix"
runner_data="$runner_root/data"
runner_tools="$runner_root/tools"
mkdir -p "$runner_prefix/releases/1.0.0/runtime/bin" "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools"
ln -s "$runner_prefix/releases/1.0.0" "$runner_prefix/current"
printf '%s\n' '{"jobId":"00000000-0000-4000-8000-000000000001","operation":"apply"}' > "$runner_data/update-request.json"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "$*" >> "$TALLYNOTE_NODE_TRACE"' 'exit 0' > "$runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$runner_tools/readlink"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-Tf" ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi' > "$runner_tools/mv"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$runner_tools/curl"
chmod 755 "$runner_prefix/releases/1.0.0/runtime/bin/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl"
runner_script="$runner_root/runner.sh"
runner_path="$runner_tools:/usr/sbin:/usr/bin:/sbin:/bin"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script"
chmod 755 "$runner_script"
runner_prefix_physical=$(cd "$runner_prefix" && pwd -P)
runner_data_physical=$(cd "$runner_data" && pwd -P)
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script"
grep -q -- '--request-file' "$runner_root/node.log"
grep -q -- '--finalize-job' "$runner_root/node.log"
[[ ! -e "$runner_data/update-request.json" ]]
[[ ! -e "$runner_prefix/.update-state" ]]
# A stale download marker must be recoverable without finalizing the staged
# download as a failed apply. The next runner invocation should retry the
# request and let the CLI preserve/refresh its staged workspace.
download_runner_root="$tmp/download-runner"
download_runner_prefix="$download_runner_root/prefix"
download_runner_data="$download_runner_root/data"
download_runner_tools="$download_runner_root/tools"
mkdir -p "$download_runner_prefix/releases/1.0.0/runtime/bin" "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools"
ln -s "$download_runner_prefix/releases/1.0.0" "$download_runner_prefix/current"
# The request has already been consumed; only the stale download marker is
# left, which is the narrow recovery window covered by this fixture.
download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P)
download_runner_data_physical=$(cd "$download_runner_data" && pwd -P)
printf '%s\n' 'job_id=00000000-0000-4000-8000-000000000002' "old_target=$download_runner_prefix_physical/releases/1.0.0" 'phase=download' > "$download_runner_prefix/.update-state"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"' 'exit 0' > "$download_runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$download_runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$download_runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$download_runner_tools/readlink"
cat >"$download_runner_tools/stat" <<'EOF'
#!/usr/bin/env bash
case "$*" in
*"-c %u"*|*"-f %u"*) printf '0\n' ;;
*"-c %a"*|*"-f %Lp"*) printf '600\n' ;;
*) /usr/bin/stat "$@" ;;
esac
EOF
chmod 755 "$download_runner_prefix/releases/1.0.0/runtime/bin/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat"
download_runner_script="$download_runner_root/runner.sh"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$download_runner_tools:/usr/sbin:/usr/bin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script"
chmod 755 "$download_runner_script"
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script"
[[ ! -e "$download_runner_root/node.log" ]]
[[ ! -e "$download_runner_prefix/.update-state" ]]
# A RETURN trap installed by install_release must be cleared while its local
# temporary variables still exist; otherwise set -u fails at the end of main.
release_fixture="$tmp/release-fixture"
mkdir -p "$release_fixture/dist/server/cli" "$release_fixture/dist/web" "$release_fixture/bin" \
"$release_fixture/scripts" "$release_fixture/runtime/bin" "$release_fixture/systemd"
printf '%s\n' '{"version":"1.0.0"}' > "$release_fixture/package.json"
printf '%s\n' server > "$release_fixture/dist/server/index.js"
printf '%s\n' cli > "$release_fixture/dist/server/cli/admin-init.js"
printf '%s\n' web > "$release_fixture/dist/web/index.html"
printf '%s\n' '#!/bin/sh' > "$release_fixture/bin/tallynote"
cp "$root/bin/tallynote-admin-init" "$release_fixture/bin/tallynote-admin-init"
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update.sh"
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update-runner.sh"
printf '%s\n' '#!/bin/sh' > "$release_fixture/uninstall.sh"
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote.service"
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.service"
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.path"
printf '%s\n' 'TALLYNOTE_HOST=127.0.0.1' > "$release_fixture/systemd/tallynote.env.example"
chmod 755 "$release_fixture/bin/tallynote" "$release_fixture/bin/tallynote-admin-init" "$release_fixture/scripts"/*.sh "$release_fixture/uninstall.sh"
release_archive="$tmp/release-fixture.tar.gz"
tar -C "$release_fixture" -czf "$release_archive" .
bash -c '
script=$1
archive=$2
destination=$3
set --
source "$script"
PREFIX="$destination/prefix"
ensure_root_directory() { mkdir -p "$1"; }
chown() { :; }
mv() {
if [[ "${1:-}" == -Tf ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi
}
install_release "$archive" 1.0.0
[[ -x "$PREFIX/releases/1.0.0/bin/tallynote-admin-init" ]]
set_env_key() { local key=$1 value=$2 escaped; :; }
set_env_key test value
' _ "$installer_lib" "$release_archive" "$tmp/install-release"
# The installed path unit must watch both the data-directory request and the
# release-prefix recovery marker after custom paths are substituted.
rendered_path="$tmp/rendered-update.path"
sed "s#/opt/tallynote#$tmp/custom-prefix#g; s#/var/lib/tallynote#$tmp/custom-data#g" \
"$root/systemd/tallynote-update.path" > "$rendered_path"
grep -Fxq "PathExists=$tmp/custom-data/update-request.json" "$rendered_path"
grep -Fxq "PathChanged=$tmp/custom-data/update-request.json" "$rendered_path"
grep -Fxq "PathExists=$tmp/custom-prefix/.update-state" "$rendered_path"
grep -Fxq "PathChanged=$tmp/custom-prefix/.update-state" "$rendered_path"
grep -Fxq "PathChanged=$tmp/custom-prefix" "$rendered_path"
# The production admin wrapper must load a release-relative runtime, change to
# the release root, and forward CLI arguments without requiring pnpm.
wrapper_prefix="$tmp/wrapper-prefix"
mkdir -p "$wrapper_prefix/releases/1.0.0/runtime/bin" "$wrapper_prefix/releases/1.0.0/dist/server/cli"
ln -s "$wrapper_prefix/releases/1.0.0" "$wrapper_prefix/current"
printf '%s\n' '#!/usr/bin/env bash' 'pwd -P > "$TALLYNOTE_WRAPPER_LOG"' 'printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"' > "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
chmod 755 "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js"
# This fixture verifies release-relative execution and argument forwarding.
# Force the wrapper's non-root branch so the root CI runner does not need a
# real `tallynote` service account or a privileged runuser hand-off; that
# privilege boundary is validated by the production checks themselves.
env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
bash "$root/bin/tallynote-admin-init" --generate
wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P)
grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log"
grep -Fxq -- '--generate' "$tmp/wrapper.log"
# The first-install prompt is optional and must support an explicit later
# initialization path without blocking the rest of the install.
admin_wizard_dir="$tmp/admin-wizard"
mkdir -p "$admin_wizard_dir"
printf '%s\n' yes remaining-input > "$admin_wizard_dir/input"
: > "$admin_wizard_dir/output"
cat > "$admin_wizard_dir/admin-init" <<'EOF'
#!/usr/bin/env bash
if [[ "${1:-}" == --check ]]; then
printf '%s\n' empty
else
printf '%s\n' initialized > "$TALLYNOTE_ADMIN_WIZARD_RESULT"
fi
EOF
chmod 755 "$admin_wizard_dir/admin-init"
bash -c '
script=$1
dir=$2
set --
source "$script"
INSTALL_FIRST_INSTALL=1
NON_INTERACTIVE=0
PROMPT_INPUT="$dir/input"
PROMPT_OUTPUT="$dir/output"
ADMIN_INIT_PATH="$dir/admin-init"
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/result"
export TALLYNOTE_ADMIN_WIZARD_RESULT
run_initial_admin_wizard
[[ -f "$dir/result" ]]
' _ "$installer_lib" "$admin_wizard_dir"
# An upgrade must never reopen the first-admin wizard, even if a damaged or
# deliberately empty database would otherwise report an uninitialized state.
printf '%s\n' yes > "$admin_wizard_dir/upgrade-input"
rm -f "$admin_wizard_dir/upgrade-result"
bash -c '
script=$1
dir=$2
set --
source "$script"
INSTALL_FIRST_INSTALL=0
NON_INTERACTIVE=0
PROMPT_INPUT="$dir/upgrade-input"
PROMPT_OUTPUT="$dir/upgrade-output"
ADMIN_INIT_PATH="$dir/admin-init"
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/upgrade-result"
export TALLYNOTE_ADMIN_WIZARD_RESULT
run_initial_admin_wizard
[[ ! -e "$dir/upgrade-result" ]]
' _ "$installer_lib" "$admin_wizard_dir"
# Validation or password errors after the base service is committed must leave
# the installation usable and point the operator at the standalone command.
failed_wizard_dir="$tmp/failed-admin-wizard"
mkdir -p "$failed_wizard_dir"
printf '%s\n' yes > "$failed_wizard_dir/input"
: > "$failed_wizard_dir/output"
cat >"$failed_wizard_dir/admin-init" <<'EOF'
#!/usr/bin/env bash
if [[ "${1:-}" == --check ]]; then
printf '%s\n' empty
exit 0
fi
exit 1
EOF
chmod 755 "$failed_wizard_dir/admin-init"
bash -c '
script=$1
dir=$2
set --
source "$script"
INSTALL_FIRST_INSTALL=1
NON_INTERACTIVE=0
PROMPT_INPUT="$dir/input"
PROMPT_OUTPUT="$dir/output"
ADMIN_INIT_PATH="$dir/admin-init"
run_initial_admin_wizard
[[ -x "$dir/admin-init" ]]
' _ "$installer_lib" "$failed_wizard_dir"
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
# when the first value looks valid (systemd uses the later value).
duplicate_env="$tmp/duplicate.env"
@@ -77,6 +394,199 @@ bash -c '
fi
' _ "$installer_lib" "$duplicate_env"
# Existing installations must validate the network settings they preserve on
# upgrade, including the direct-IP HTTP combination used by the documented
# installer command.
network_env="$tmp/network.env"
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
'TALLYNOTE_PORT=3000' \
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
'TALLYNOTE_ALLOW_INSECURE_HTTP=true' > "$network_env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$network_env"
if sed 's/^TALLYNOTE_PORT=.*/TALLYNOTE_PORT=65536/' "$network_env" > "$tmp/invalid-port.env"; then
if bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/invalid-port.env" >/dev/null 2>&1; then
echo 'expected invalid existing listener port to fail' >&2
exit 1
fi
fi
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
'TALLYNOTE_PORT=3000' \
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
'TALLYNOTE_ALLOW_INSECURE_HTTP=false' > "$tmp/public-http-without-opt-in.env"
if bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/public-http-without-opt-in.env" >/dev/null 2>&1; then
echo 'expected public HTTP without opt-in in existing env to fail' >&2
exit 1
fi
bash -c '
script=$1
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_public_origin "http://[2001:db8::10]:3000"
# A standard HTTPS origin may omit its default port; this must remain valid
# under the installer strict unset-variable mode.
validate_public_origin "https://example.test"
' _ "$installer_lib"
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
'TALLYNOTE_PORT=3000' \
'TALLYNOTE_PUBLIC_ORIGIN=https://tallynote.example.com' \
'TALLYNOTE_COOKIE_SECURE=true' > "$tmp/public-https.env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/public-https.env"
printf '%s\n' \
'TALLYNOTE_HOST=::1' \
'TALLYNOTE_PORT=3443' > "$tmp/ipv6-default-origin.env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/ipv6-default-origin.env"
if bash -c '
script=$1
set --
source "$script"
validate_public_origin "http://example.test:65536"
' _ "$installer_lib" >/dev/null 2>&1; then
echo 'expected invalid public origin port to fail' >&2
exit 1
fi
# A fresh interactive install reads from the controlling terminal even when
# the installer script itself is piped from curl. The test substitutes files
# for that terminal and verifies both listener choices without touching the
# host filesystem.
interactive_dir="$tmp/interactive"
mkdir -p "$interactive_dir/config"
printf '\n\n' > "$interactive_dir/local-input"
: > "$interactive_dir/local-output"
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/local-input"
PROMPT_OUTPUT="$dir/local-output"
configure_network_interactively
[[ "$INSTALL_HOST" == 127.0.0.1 ]]
[[ "$INSTALL_PORT" == 3000 ]]
[[ "$INSTALL_PUBLIC_ORIGIN" == http://127.0.0.1:3000 ]]
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == false ]]
' _ "$installer_lib" "$interactive_dir"
printf '2\n3443\nhttp://203.0.113.10:3443\nyes\n' > "$interactive_dir/public-input"
: > "$interactive_dir/public-output"
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/public-input"
PROMPT_OUTPUT="$dir/public-output"
configure_network_interactively
[[ "$INSTALL_HOST" == 0.0.0.0 ]]
[[ "$INSTALL_PORT" == 3443 ]]
[[ "$INSTALL_PUBLIC_ORIGIN" == http://203.0.113.10:3443 ]]
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true ]]
' _ "$installer_lib" "$interactive_dir"
printf '2\n3000\nhttp://203.0.113.10:3000\nno\n' > "$interactive_dir/refuse-input"
: > "$interactive_dir/refuse-output"
if env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/refuse-input"
PROMPT_OUTPUT="$dir/refuse-output"
configure_network_interactively
' _ "$installer_lib" "$interactive_dir" >/dev/null 2>&1; then
echo 'expected public HTTP confirmation refusal to stop configuration' >&2
exit 1
fi
# Explicit environment variables take precedence over the prompt, including
# when a terminal is available.
env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/local-input"
PROMPT_OUTPUT="$dir/local-output"
if interactive_network_available; then
echo "expected explicit network environment to skip prompt" >&2
exit 1
fi
' _ "$installer_lib" "$interactive_dir"
# A release archive is extracted under umask 077, then explicitly normalized
# so the tallynote system user can traverse and execute the shipped tree.
source_tmp="$tmp/source"
+17 -1
View File
@@ -34,8 +34,9 @@ make_fixture() {
done
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update"
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner"
printf '%s\n' '#!/usr/bin/env bash' 'exec /opt/tallynote/current/runtime/bin/node /opt/tallynote/current/dist/server/cli/admin-init.js' > "$fixture/usr/local/sbin/tallynote-admin-init"
cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall"
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-uninstall"
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-admin-init" "$fixture/usr/local/sbin/tallynote-uninstall"
printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db"
printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db"
}
@@ -73,6 +74,7 @@ run_uninstall "$fixture"
[[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]]
[[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]]
[[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]]
[[ ! -e "$fixture/usr/local/sbin/tallynote-admin-init" ]]
grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null
grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null
path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
@@ -98,6 +100,20 @@ run_uninstall "$fixture" --purge-data --yes --purge-config
[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]]
[[ ! -e "$fixture/etc/tallynote" ]]
# In production the service user owns the data directory. The target itself
# must be accepted while its parent directories remain root-owned. This test
# is meaningful only when the suite runs as root on a host with that account;
# ordinary developer runs continue with the portable fixture coverage above.
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
if [[ "$EUID" == 0 && -n "$tallynote_uid" && "$tallynote_uid" != "$(id -u)" ]]; then
fixture="$tmp/service-user-data"
make_fixture "$fixture"
make_systemctl "$fixture"
chown -R "$tallynote_uid" "$fixture/var/lib/tallynote"
TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID="$tallynote_uid" run_uninstall "$fixture" --purge-data --yes
[[ ! -e "$fixture/var/lib/tallynote" ]]
fi
# A custom data path must not overlap the release prefix; otherwise removing
# releases could destroy data that the default uninstall promises to keep.
fixture="$tmp/overlap"
+117 -32
View File
@@ -23,6 +23,7 @@ import {
permanentDeleteSchema,
statusUpdateSchema,
updateApplySchema,
updateDownloadSchema,
versionSchema,
type AttachmentKind,
type ExpenseStatus,
@@ -58,6 +59,7 @@ import {
checkForUpdate,
publicCheckFromCache,
publicUpdateJob,
reconcileOrphanedUpdateJobs,
readCachedRelease,
writeUpdateRequest,
type UpdateRequest,
@@ -94,7 +96,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 +107,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 +117,13 @@ function enforceUpdateCooldown(
database: DatabaseContext["sqlite"],
config: AppConfig,
adminId: string,
operation: "check" | "apply",
operation: "check" | "download" | "apply",
reply: FastifyReply,
): void {
): number {
const state = updateRateState(database, adminId);
const now = Date.now();
const previous = operation === "check" ? state.checkedAt : state.appliedAt;
const cooldown = operation === "check" ? config.updateCheckCooldownMs : config.updateApplyCooldownMs;
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
const cooldown = operation === "check" ? config.updateCheckCooldownMs : operation === "download" ? config.updateDownloadCooldownMs : config.updateApplyCooldownMs;
if (cooldown > 0 && previous > 0 && now - previous < cooldown) {
const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000));
reply.header("Retry-After", retryAfter);
@@ -130,7 +132,9 @@ function enforceUpdateCooldown(
: "更新操作过于频繁,请稍后再试");
}
if (operation === "check") state.checkedAt = now;
else if (operation === "download") state.downloadedAt = now;
else state.appliedAt = now;
return now;
}
function adminSelect(alias = ""): string {
@@ -588,6 +592,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 +613,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 +627,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
"frame-ancestors": ["'none'"],
"base-uri": ["'none'"],
"form-action": ["'self'"],
...(config.isLocalOrigin ? { "upgrade-insecure-requests": null } : {}),
...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}),
},
},
});
@@ -637,19 +648,6 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return payload;
});
app.addHook("onRequest", async (request) => {
if (!unsafeMethods.has(request.method) || !request.url.startsWith("/api/")) return;
const origin = request.headers.origin;
const allowed = new Set([config.publicOrigin]);
if (!config.isProduction) {
allowed.add("http://127.0.0.1:5173");
allowed.add("http://localhost:5173");
}
if (typeof origin !== "string" || !allowed.has(origin)) {
throw new AppError(403, "ORIGIN_FORBIDDEN", "请求来源不受信任");
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error));
if (error instanceof ZodError) {
@@ -930,13 +928,24 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
// Release metadata and task state should never be stored by an upstream
// proxy or a shared browser cache.
reply.header("Cache-Control", "no-store");
reconcileOrphanedUpdateJobs(database.sqlite, config);
const cached = publicCheckFromCache(database.sqlite, config);
// Status is a live control surface, not an update history endpoint.
// Terminal failures/cancellations from a previous attempt must not be
// replayed as if the operator had just started an update. They remain in
// the database/audit log, while this endpoint exposes only an actionable
// task (or the latest successful completion for confirmation).
const row = database.sqlite.prepare(`
SELECT id, status, version, platform, asset_name AS assetName,
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs
WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")})
ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record<string, unknown> | undefined;
return {
...cached,
strategy: config.updateStrategy,
@@ -945,11 +954,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
});
app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const rateState = updateRateState(database.sqlite, request.auth!.admin.id);
const previousCheckedAt = rateState.checkedAt;
let reservedCheckedAt: number | null = null;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
// Disabled/dev installs do not contact a release endpoint, so repeated
// checks are local status reads and should remain immediately usable.
if (config.updateStrategy !== "disabled") {
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
reservedCheckedAt = enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
}
const result = await checkForUpdate(database.sqlite, config);
writeAudit(database.sqlite, {
@@ -968,6 +981,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
return { ...result, strategy: config.updateStrategy };
} catch (error) {
// A failed upstream request is not a successful check. Release the
// reservation only when this request still owns it, so a concurrent
// successful check cannot have its cooldown overwritten.
if (reservedCheckedAt !== null && rateState.checkedAt === reservedCheckedAt) rateState.checkedAt = previousCheckedAt;
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
@@ -985,9 +1002,41 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
let applyAuditRecorded = false;
let applyAuditTarget: string | undefined;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") {
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
}
if (input.jobId) {
const stagedJobId = input.jobId;
const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined;
if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载");
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 +1104,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 +1134,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 +1151,47 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
}
});
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const input = updateDownloadSchema.parse(request.body);
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply);
const checked = await checkForUpdate(database.sqlite, config);
const version = input.version.replace(/^v/i, "");
if (!checked.latest || checked.latest.version !== version || !checked.latest.isNewer || !checked.latest.compatible || !checked.latest.integrityReady) throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新");
const cached = readCachedRelease(database.sqlite, config);
const cachedAsset = cached?.asset;
if (!cached || !cachedAsset?.sha256 || cached.version !== version) throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新");
const now = Date.now();
const id = randomUUID();
database.sqlite.transaction(() => {
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now);
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.download_requested", targetType: "update", targetId: id, after: { version } });
}).immediate();
try {
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);
reconcileOrphanedUpdateJobs(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, status, version, platform, asset_name AS assetName,
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE id=? AND admin_id=?
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
if (!row) notFound("更新任务不存在");
+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}` : "已创建首位管理员。");
+179 -15
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3";
@@ -10,6 +10,7 @@ import { writeAudit } from "../audit.js";
import {
atomicSwitchDirectory,
atomicSwitchRelease,
applicationUpdateRuntimeHash,
compareSemver,
createSafeArchive,
detectPlatform,
@@ -19,6 +20,7 @@ import {
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
selectReleaseAsset,
sanitizeAssetName,
validateHttpsUrl,
@@ -26,11 +28,12 @@ import {
type ReleaseMetadata,
type UrlPolicy,
} from "../update.js";
import { attachSidecarHash } from "../update-service.js";
import { ACTIVE_UPDATE_STATUSES, attachSidecarHash } from "../update-service.js";
import type { UpdateJobStatus } from "../../shared/contracts.js";
const updateRequestFileSchema = z.object({
jobId: z.string().uuid(),
operation: z.enum(["download", "apply"]).default("apply"),
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
metadataUrl: z.string().url(),
assetUrl: z.string().url(),
@@ -96,6 +99,8 @@ export type UpdateRunOptions = UrlPolicy & {
jobId?: string | undefined;
publicKey?: string | undefined;
requireSignature?: boolean | undefined;
operation?: "download" | "apply" | undefined;
stagedPath?: string | undefined;
};
export type UpdateRunResult = {
@@ -140,21 +145,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 +182,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
values.requestId ?? null,
values.requestedAt ?? null,
values.startedAt ?? null,
effectiveOperation,
values.status,
values.version,
values.platform,
@@ -205,9 +214,16 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(metadataUrl, options);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
} catch {
// Fall back to the full archive when the current installation predates
// runtime fingerprints or is missing deployment provenance.
}
let asset = options.assetUrl && !options.requireSignature
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
: selectReleaseAsset(release, platform);
: selectReleaseAsset(release, platform, runtimeHash);
if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [],
@@ -238,9 +254,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 +285,64 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
writeJob(options.sqlite, jobId, {
status: "queued", version: resolved.version, platform: platform.target,
operation, status: "queued", version: resolved.version, platform: platform.target,
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
requestId: options.requestId, requestedAt: Date.now(),
});
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
const workspace = await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
let keepWorkspace = false;
const workspace = operation === "download"
? path.join(path.resolve(options.stagingDir), `update-${jobId}`)
: await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
if (operation === "download") await mkdir(workspace, { recursive: false, mode: 0o700 });
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
try {
updateJob(options.sqlite, jobId, { status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
const progressStartedAt = Date.now();
let lastProgressWrite = 0;
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, {
...options,
onProgress: (downloadedBytes, totalBytes) => {
const now = Date.now();
if (!options.sqlite || now - lastProgressWrite < 250) return;
lastProgressWrite = now;
const elapsed = Math.max(1, now - progressStartedAt);
const speedBps = Math.round(downloadedBytes * 1000 / elapsed);
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloadedBytes, totalBytes, progressStartedAt, speedBps, now, jobId);
},
});
if (options.sqlite) {
const finishedAt = Date.now();
const elapsed = Math.max(1, finishedAt - progressStartedAt);
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloaded.size, downloaded.size, progressStartedAt, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId);
}
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
updateJob(options.sqlite, jobId, { status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
updateJob(options.sqlite, jobId, { operation, status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
const currentInfo = await lstat(currentRelease).catch(() => null);
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry);
const sourceInfo = await lstat(source).catch(() => null);
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
}
}
await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
updateJob(options.sqlite, jobId, { status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath });
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 +367,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";
@@ -318,7 +392,9 @@ export function finalizeUpdateJob(
FROM update_jobs WHERE id=?
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
if (!row) throw new Error("更新任务不存在");
if (row.status !== "applying" && row.status !== "completed" && row.status !== "failed") throw new Error("更新任务状态不允许完成");
const canComplete = row.status === "applying" || row.status === "completed";
const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed";
if (status === "completed" ? !canComplete : !canFail) throw new Error("更新任务状态不允许完成");
const now = Date.now();
const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null;
sqlite.transaction(() => {
@@ -335,6 +411,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 +508,43 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
prepareDataDirectories(config);
if (request) await ensurePrivilegedWorkspace(stagingDir);
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
const release = acquireInstanceLock(config);
// The download phase intentionally runs beside the live app so users keep
// access while the archive is fetched and staged. SQLite WAL plus the
// configured busy timeout serializes writes; the exclusive process lock is
// reserved for apply/rollback, when the service is stopped by systemd.
const release = request?.operation === "download" ? () => undefined : acquireInstanceLock(config);
const database = openDatabase(config);
try {
if (request?.operation === "apply") {
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined;
if (staged?.status === "staged" && staged.operation === "apply") {
if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
const root = path.resolve(config.updateWorkspaceDir);
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
await applyStagedUpdate({
sqlite: database.sqlite,
jobId: request.jobId,
version: request.version,
stagedPath: candidate,
currentDir,
currentLink: request.currentLink,
releasesDir: request.releasesDir,
workspaceRoot: root,
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
dataBackupSource: config.dataDir,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
});
console.log(`更新已切换:${request.version}`);
return;
}
if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效");
// A direct one-click request starts in queued/apply. Older clients do
// not have a separate download step, so fall through to runUpdate,
// which downloads, verifies, backs up, and switches the release in one
// transaction. A staged request still takes the branch above.
}
const result = await runUpdate({
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
@@ -398,6 +561,7 @@ 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: config.updateRequireSignature,
+18 -2
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 {
@@ -122,6 +130,7 @@ export function loadConfig() {
timezone,
trustProxy: trustProxyEnv(),
cookieSecure,
allowInsecureHttp,
appVersion,
updateMetadataUrl,
updateAllowedHosts,
@@ -142,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,
@@ -165,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 }) };
}
+4
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(),
@@ -147,6 +148,9 @@ export const updateJobs = sqliteTable("update_jobs", {
downloadPath: text("download_path"),
backupPath: text("backup_path"),
sizeBytes: integer("size_bytes"),
downloadedBytes: integer("downloaded_bytes"),
downloadStartedAt: integer("download_started_at"),
downloadSpeedBps: integer("download_speed_bps"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
requestedAt: integer("requested_at"),
+3
View File
@@ -3,6 +3,7 @@ import { loadConfig, prepareDataDirectories, acquireInstanceLock } from "./confi
import { openDatabase } from "./db/index.js";
import { buildApp } from "./app.js";
import { cleanupOrphanedExports, expireExports, resumeExports } from "./exporter.js";
import { reconcileOrphanedUpdateJobs } from "./update-service.js";
const config = loadConfig();
prepareDataDirectories(config);
@@ -18,6 +19,7 @@ async function start() {
await expireExports(database.sqlite, config);
await cleanupOrphanedExports(database.sqlite, config);
await resumeExports(database.sqlite, config);
reconcileOrphanedUpdateJobs(database.sqlite, config);
const app = await buildApp(database, config);
const janitor = setInterval(() => {
void cleanupStaging(config);
@@ -27,6 +29,7 @@ async function start() {
void processFileDeletions(database.sqlite, config);
void expireExports(database.sqlite, config);
void cleanupOrphanedExports(database.sqlite, config);
reconcileOrphanedUpdateJobs(database.sqlite, config);
}, 60_000);
const shutdown = async () => {
clearInterval(janitor);
+204 -1
View File
@@ -1,7 +1,9 @@
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3";
import { writeAudit } from "./audit.js";
import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
@@ -11,9 +13,11 @@ import {
fetchReleaseText,
isNewerVersion,
parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
RELEASE_NOTES_MAX_BYTES,
type ReleaseAsset,
type ReleaseMetadata,
} from "./update.js";
@@ -29,12 +33,21 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
"applying",
];
// A queued job normally starts within seconds and an applying job completes
// after the service health check. The runner refreshes its recovery marker as
// a lease while doing long downloads/backups; only an expired lease permits
// the server to reclaim an active row.
export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000;
export type CachedRelease = {
checkedAt: number;
metadataUrl: string;
version: string;
tagName?: string;
releaseName?: string;
publishedAt?: string;
notes?: string;
releaseUrl?: string;
platform: string;
signatureVerified?: boolean;
asset?: {
@@ -53,7 +66,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 +81,7 @@ export type UpdateCheckResult = {
export type UpdateRequest = {
jobId: string;
operation?: "download" | "apply";
version: string;
metadataUrl: string;
assetUrl: string;
@@ -76,6 +93,7 @@ export type UpdateRequest = {
currentLink: string;
releasesDir: string;
dataDir: string;
stagedPath?: string;
};
function setting(database: Database.Database, key: string): string | undefined {
@@ -187,7 +205,14 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
} catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
}
let asset = selectReleaseAsset(metadata, platform);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml")));
} catch {
// Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime.
}
let asset = selectReleaseAsset(metadata, platform, runtimeHash);
let signatureVerified = false;
if (asset) {
const integrity = await attachSidecarHash(metadata, asset, {
@@ -206,7 +231,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 +255,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 +276,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 +300,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 +319,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,13 +349,19 @@ 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,
assetName: row.assetName ?? null,
sizeBytes: row.sizeBytes ?? null,
downloadedBytes: row.downloadedBytes ?? null,
downloadStartedAt: row.downloadStartedAt ?? null,
downloadSpeedBps: row.downloadSpeedBps ?? null,
// Do not expose filesystem paths, command output, or upstream response
// text through the authenticated status endpoint. Detailed diagnostics
// remain in the server journal for operators.
@@ -323,5 +369,162 @@ 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 } : {}),
};
}
function markerMtime(filePath: string): number | null {
try {
const info = lstatSync(filePath);
return info.isFile() ? info.mtimeMs : null;
} catch {
return null;
}
}
function removeExpiredRequest(filePath: string, now: number): void {
try {
const info = lstatSync(filePath);
if (!info.isFile() && !info.isSymbolicLink()) return;
if (now - info.mtimeMs < ORPHANED_UPDATE_TIMEOUT_MS) return;
unlinkSync(filePath);
} catch {
// The root runner may own the marker during a recovery race. The DB
// transition below is still enough to release the browser queue.
}
}
function requestJobId(filePath: string): string | null {
try {
const info = lstatSync(filePath);
if (!info.isFile() || info.isSymbolicLink()) return null;
const value = JSON.parse(readFileSync(filePath, "utf8")) as { jobId?: unknown };
return typeof value.jobId === "string" && /^[0-9a-f-]{36}$/.test(value.jobId) ? value.jobId : null;
} catch {
return null;
}
}
function currentReleaseVersion(config: AppConfig): string | null {
try {
const target = realpathSync(config.currentLink);
const releases = realpathSync(config.releasesDir);
if (!target.startsWith(`${releases}${path.sep}`)) return null;
return path.basename(target);
} catch {
return null;
}
}
/**
* Release an update row left behind after its privileged runner lease expired.
* This is deliberately conservative: staged downloads remain available for an
* explicit apply, and a fresh request/state marker means the runner still owns
* recovery.
*/
export function reconcileOrphanedUpdateJobs(database: Database.Database, config: AppConfig, now = Date.now()): number {
const placeholders = ACTIVE_UPDATE_STATUSES.map(() => "?").join(",");
const rows = database.prepare(`
SELECT id, status, operation, version, admin_id AS adminId, request_id AS requestId,
updated_at AS updatedAt
FROM update_jobs
WHERE status IN (${placeholders})
ORDER BY updated_at ASC
`).all(...ACTIVE_UPDATE_STATUSES) as Array<{ id: string; status: UpdateJobStatus; operation: "download" | "apply"; version: string; adminId: string | null; requestId: string | null; updatedAt: number | null }>;
if (rows.length === 0) return 0;
const statePath = path.join(config.installPrefix, ".update-state");
const requestMtime = markerMtime(config.updateRequestPath);
const stateMtime = markerMtime(statePath);
const requestPresent = requestMtime !== null;
const statePresent = stateMtime !== null;
const requestFresh = requestPresent && now - (requestMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
const stateFresh = statePresent && now - (stateMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
// A staged download is normally kept for an explicit apply. The one
// exception is the hand-off window where the API has already changed the
// operation to `apply` but crashed before writing the request file. That
// row is still safe to retry and must not block the queue forever.
const releaseVersion = currentReleaseVersion(config);
let reconciled = 0;
const reconciledIds = new Set<string>();
for (const row of rows) {
if (typeof row.updatedAt !== "number" || now - row.updatedAt < ORPHANED_UPDATE_TIMEOUT_MS) continue;
// The runner refreshes the state marker while a download is in flight.
// A stale request/state marker therefore no longer protects an orphaned
// row forever, while a fresh marker remains owned by the runner.
if (row.status === "staged") {
if (row.operation !== "apply" || requestFresh || stateFresh) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET operation='download', error_message=NULL, updated_at=?
WHERE id=? AND status='staged' AND operation='apply' AND updated_at=?
`).run(now, row.id, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: "success",
before: { status: row.status, operation: row.operation, version: row.version },
after: { status: "staged", operation: "download", version: row.version, reason: "apply_request_missing" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
continue;
}
if (requestFresh || stateFresh) continue;
const status: "completed" | "failed" = row.status === "applying" && releaseVersion === row.version ? "completed" : "failed";
const errorMessage = status === "failed" ? "更新任务超时,已释放更新队列" : null;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET status=?, error_message=?, completed_at=?, updated_at=?
WHERE id=? AND status=? AND updated_at=?
`).run(status, errorMessage, now, now, row.id, row.status, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: status === "completed" ? "success" : "failure",
before: { status: row.status, version: row.version },
after: { status, version: row.version, reason: "orphaned_timeout" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
}
// Prevent a stale request from being replayed after its DB row has been
// marked failed. The path is fixed by the server configuration and the
// operation is safe even when a root runner is racing with this call.
// A download runner refreshes the state marker while it is still using the
// request. Keep the request until that lease also expires; otherwise a
// long download can lose its job id and fail to finalize its row.
const queuedRequestId = requestPresent ? requestJobId(config.updateRequestPath) : null;
const queuedRequest = queuedRequestId ? rows.find((row) => row.id === queuedRequestId) : undefined;
const requestStillNeeded = Boolean(
queuedRequest
&& ACTIVE_UPDATE_STATUSES.includes(queuedRequest.status)
&& !reconciledIds.has(queuedRequest.id)
&& !(queuedRequest.status === "staged" && queuedRequest.operation === "download"),
);
if (!stateFresh
&& (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS))
&& !requestStillNeeded) {
removeExpiredRequest(config.updateRequestPath, now);
}
return reconciled;
}
+91 -4
View File
@@ -35,10 +35,24 @@ export type ReleaseAsset = {
export type ReleaseMetadata = {
version: string;
tagName?: string;
releaseName?: string;
publishedAt?: string;
/** Plain-text release notes, bounded to keep API/cache payloads small. */
notes?: string;
releaseUrl?: string;
assets: ReleaseAsset[];
};
const APPLICATION_UPDATE_ASSET = /\.update-([a-f0-9]{64})\.tar\.gz$/i;
export function applicationUpdateRuntimeHash(assetName: string): string | undefined {
return APPLICATION_UPDATE_ASSET.exec(assetName)?.[1]?.toLowerCase();
}
export function runtimeHashFromLockfile(lockfile: string | Buffer): string {
return createHash("sha256").update(lockfile).digest("hex");
}
export type UrlPolicy = {
/** Host names or HTTPS URLs which are allowed for requests. */
allowedHosts?: readonly string[] | undefined;
@@ -143,6 +157,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 +292,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,
};
}
@@ -312,7 +389,7 @@ export async function fetchReleaseBytes(
}
}
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): ReleaseAsset | undefined {
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform(), runtimeHash?: string): ReleaseAsset | undefined {
const platformCandidates = release.assets.filter((asset) => {
const name = asset.name.toLowerCase();
return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase()));
@@ -331,7 +408,15 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl
const target = platform.target.toLowerCase();
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
});
return candidates[0];
const normalizedRuntimeHash = runtimeHash?.trim().toLowerCase();
if (normalizedRuntimeHash && /^[a-f0-9]{64}$/.test(normalizedRuntimeHash)) {
const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash);
if (applicationUpdate) return applicationUpdate;
}
// Older clients choose the first matching asset. Releases therefore keep
// the traditional full archive first, while current clients explicitly
// opt into a compatible application-only asset.
return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name));
}
export function sanitizeAssetName(value: string): string {
@@ -356,7 +441,7 @@ export async function verifySha256(filePath: string, expected: string): Promise<
export async function downloadReleaseAsset(
url: string | URL,
destination: string,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined; onProgress?: ((downloadedBytes: number, totalBytes: number | null) => void) | undefined } = {},
): Promise<{ size: number; sha256: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(url, options);
@@ -379,6 +464,7 @@ export async function downloadReleaseAsset(
}
if (response.status < 200 || response.status >= 300 || !response.body) throw new Error("更新文件下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const totalBytes = Number.isSafeInteger(declared) && declared > 0 ? declared : null;
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
if (declared > maxBytes) throw new Error("更新文件超过大小限制");
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
@@ -387,6 +473,7 @@ export async function downloadReleaseAsset(
const hash = createHash("sha256");
const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) {
size += chunk.length;
options.onProgress?.(size, totalBytes);
if (size > maxBytes) return callback(new Error("更新文件超过大小限制"));
hash.update(chunk);
callback(null, chunk);
+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 = {
+9
View File
@@ -4,6 +4,15 @@ Description=Watch for TallyNote release update requests
[Path]
PathExists=/var/lib/tallynote/update-request.json
PathChanged=/var/lib/tallynote/update-request.json
# The recovery marker lives beside the release link. Watching it as well
# allows systemd to resume reconciliation when the runner is interrupted
# after consuming the request but before clearing its state file.
PathExists=/opt/tallynote/.update-state
PathChanged=/opt/tallynote/.update-state
# Keep a directory-level fallback because some systemd/inotify versions skip
# dotfiles when watching an individual path. State writes are atomic renames,
# so the containing directory changes even when the marker itself is hidden.
PathChanged=/opt/tallynote
Unit=tallynote-update.service
[Install]
+7 -2
View File
@@ -2,7 +2,6 @@
Description=TallyNote privileged release updater
After=network-online.target
Wants=network-online.target
ConditionPathExists=/var/lib/tallynote/update-request.json
[Service]
Type=oneshot
@@ -12,10 +11,16 @@ WorkingDirectory=/opt/tallynote/current
EnvironmentFile=-/etc/tallynote/tallynote.env
ExecStart=/usr/local/libexec/tallynote-update-runner
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
# Downloads, archive validation and data backups can exceed systemd's 90s
# default start timeout on a slower server. Keep one update job alive long
# enough to finish or reach its own health-check/recovery path.
TimeoutStartSec=30min
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
+2
View File
@@ -4,12 +4,14 @@ 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=false
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
# Optional: configure a root-managed Ed25519 public key and set
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
+5 -1
View File
@@ -14,13 +14,17 @@ Environment=PATH=/opt/tallynote/current/runtime/bin:/usr/sbin:/usr/bin:/sbin:/bi
ExecStart=/opt/tallynote/current/bin/tallynote
Restart=on-failure
RestartSec=5s
# Do not let a wedged Node process hold an update stop forever.
TimeoutStopSec=30s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
InaccessiblePaths=/opt/tallynote/.update-work
ProtectHome=true
PrivateDevices=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# Fastify logs the addresses of wildcard listeners. Node's libuv uses the
# Linux netlink family while enumerating interfaces for that log message.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
+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 });
}
});
});
+45 -3
View File
@@ -87,10 +87,52 @@ describe("TallyNote API", () => {
expect(missing.json().error.requestId).toBeTruthy();
});
it("拒绝没有 Origin 的写请求", async () => {
it("显式允许的公网 HTTP 不会把静态资源升级到 HTTPS", async () => {
const publicHttpConfig = {
...config,
publicOrigin: "http://192.0.2.10:3999",
isLocalOrigin: false,
allowInsecureHttp: true,
cookieSecure: false,
};
const publicHttpApp = await buildApp(database, publicHttpConfig);
try {
const response = await publicHttpApp.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.headers["content-security-policy"]).not.toContain("upgrade-insecure-requests");
expect(response.headers["strict-transport-security"]).toBeUndefined();
expect(response.headers["cross-origin-opener-policy"]).toBeUndefined();
expect(response.headers["origin-agent-cluster"]).toBeUndefined();
} finally {
await publicHttpApp.close();
}
});
it("HTTPS 仍保留传输安全响应头", async () => {
const secureConfig = {
...config,
publicOrigin: "https://example.test:3999",
isLocalOrigin: false,
allowInsecureHttp: false,
cookieSecure: true,
};
const secureApp = await buildApp(database, secureConfig);
try {
const response = await secureApp.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.headers["content-security-policy"]).toContain("upgrade-insecure-requests");
expect(response.headers["strict-transport-security"]).toContain("max-age=");
expect(response.headers["cross-origin-opener-policy"]).toBe("same-origin");
expect(response.headers["origin-agent-cluster"]).toBe("?1");
} finally {
await secureApp.close();
}
});
it("反向代理缺少 Origin 时仍允许登录请求进入认证流程", async () => {
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe("ORIGIN_FORBIDDEN");
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("INVALID_CREDENTIALS");
});
it("将非法 JSON、伪造请求 ID 处理为结构化 400", async () => {
+3 -1
View File
@@ -41,9 +41,11 @@ describe("数据库迁移", () => {
{ name: "0001_invoice_missing_reason.sql" },
{ name: "0002_update_jobs.sql" },
{ name: "0003_update_job_ownership.sql" },
{ name: "0004_update_download_apply.sql" },
{ name: "0005_update_progress.sql" },
]);
const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>;
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at"]));
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation", "downloaded_bytes", "download_started_at", "download_speed_bps"]));
expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null });
migrated.sqlite.close();
migrated = openDatabase(config);
+23 -1
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,6 +48,7 @@ describe("部署安全配置", () => {
expect(loadConfig().trustProxy).toBe(1);
});
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
+68 -9
View File
@@ -59,10 +59,10 @@ describe("更新 API", () => {
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.1.3-${detectPlatform().target}-glibc.tar.gz`;
const asset = `tallynote-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.3", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
: new Response(JSON.stringify({ tag_name: "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,27 @@ describe("更新 API", () => {
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "1.1.3", compatible: true, integrityReady: true, isNewer: true });
expect(checked.json().latest).toMatchObject({ version: "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.3", confirm: true } });
// Cooldown is scoped to the authenticated administrator, not the whole
// database or release endpoint.
const otherSession = await login("update-admin-other");
const otherChecked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: otherSession.cookies, "x-csrf-token": otherSession.csrf }, payload: {} });
expect(otherChecked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "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.3", 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.3", confirm: true } });
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "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,22 +99,69 @@ 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.3" } });
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0" } });
expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig();
expect(disabledConfig.updateStrategy).toBe("disabled");
});
it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => {
const session = await login("update-history");
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string };
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at)
VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?)
`).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000);
const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(initial.statusCode).toBe(200);
expect(initial.json().job).toBeNull();
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "1.2.0", isNewer: true });
});
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
const owner = await login("update-owner");
const other = await login("update-other");
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.3", confirm: true } });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "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,8 +179,14 @@ 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.3", confirm: true } });
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
expect(response.statusCode).toBe(502);
// A failed upstream check must not reserve the per-admin cooldown; an
// operator can retry immediately after fixing the release endpoint.
const check = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(check.statusCode).toBe(502);
const retry = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(retry.statusCode).toBe(502);
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
expect(audit?.outcome).toBe("failure");
});
+227 -8
View File
@@ -1,11 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "node:fs/promises";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir, utimes } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
import {
atomicSwitchRelease,
applicationUpdateRuntimeHash,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
@@ -16,15 +17,16 @@ import {
normalizeReleasePermissions,
sanitizeAssetName,
selectReleaseAsset,
runtimeHashFromLockfile,
validateHttpsUrl,
} from "../server/update.js";
import { runUpdate } from "../server/cli/update.js";
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
import { validateUpdateRequest } from "../server/cli/update.js";
import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js";
import { checkForUpdate, ORPHANED_UPDATE_TIMEOUT_MS, reconcileOrphanedUpdateJobs, verifyReleaseSignature } from "../server/update-service.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_INSTALL_PREFIX", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const originalFetch = globalThis.fetch;
afterEach(() => {
@@ -50,6 +52,17 @@ describe("更新安全工具", () => {
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
});
it("优先选择运行时匹配的轻量更新包,并对旧客户端保留完整包回退", () => {
const runtimeHash = runtimeHashFromLockfile("lockfile-v1\n");
const full = { name: "tallynote-1.2.0-linux-x64-glibc.tar.gz", url: "https://updates.example/full" };
const app = { name: `tallynote-1.2.0-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" };
const release = { version: "1.2.0", assets: [full, app] };
expect(applicationUpdateRuntimeHash(app.name)).toBe(runtimeHash);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"), runtimeHash)).toEqual(app);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))).toEqual(full);
expect(applicationUpdateRuntimeHash(full.name)).toBeUndefined();
});
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
@@ -207,6 +220,212 @@ describe("更新安全工具", () => {
}
});
it("更新器支持旧客户端创建的 queued/apply 直接更新请求", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-direct-"));
const previousFetch = globalThis.fetch;
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(config.releasesDir, { recursive: true, mode: 0o755 });
const oldRelease = path.join(config.releasesDir, config.appVersion);
await mkdir(path.join(oldRelease, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(oldRelease, "dist", "marker"), "old");
await symlink(oldRelease, config.currentLink);
const source = path.join(root, "source");
await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(source, "dist", "marker"), "new");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
const bytes = await readFile(archive);
const digest = createHash("sha256").update(bytes).digest("hex");
const jobId = randomUUID();
database = openDatabase(config);
const now = Date.now();
const assetName = `tallynote-1.2.0-${detectPlatform().target}.tar.gz`;
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url,
expected_sha256, created_at, updated_at, requested_at)
VALUES (?, 'apply', 'queued', '1.2.0', ?, ?, ?, ?, ?, ?)
`).run(jobId, detectPlatform().target, "https://updates.example/" + assetName, digest, now, now, now);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] }));
if (url.endsWith("SHA256SUMS")) return new Response(`${digest} ${assetName}\n`);
return new Response(bytes, { headers: { "content-length": String(bytes.length) } });
}) as typeof fetch;
await runUpdate({
metadataUrl: config.updateMetadataUrl,
version: "1.2.0",
currentVersion: config.appVersion,
currentDir: config.currentLink,
stagingDir: path.join(root, "staging"),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
allowedHosts: config.updateAllowedHosts,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
deferCompletion: true,
operation: "apply",
jobId,
sqlite: database.sqlite,
fetchImpl: globalThis.fetch,
});
expect(await readFile(path.join(config.currentLink, "dist", "marker"), "utf8")).toBe("new");
const row = database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(jobId);
expect(row).toEqual({ operation: "apply", status: "applying" });
finalizeUpdateJob(database.sqlite, jobId, "failed");
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
} finally {
globalThis.fetch = previousFetch;
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("在请求和恢复标记丢失后收敛孤儿任务,但保留 staged 下载", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-reconcile-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true });
await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const insert = database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const queuedId = randomUUID();
const applyingId = randomUUID();
const stagedId = randomUUID();
const stagedApplyId = randomUUID();
insert.run(queuedId, "apply", "queued", "1.2.0", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt);
insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt);
insert.run(stagedId, "download", "staged", "1.2.0", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
insert.run(stagedApplyId, "apply", "staged", "1.2.0", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
const now = Date.now();
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(applyingId)).toEqual({ status: "completed" });
expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "staged", operation: "download" });
expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedApplyId)).toEqual({ status: "staged", operation: "download" });
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("下载心跳有效时不回收任务或删除仍在使用的请求文件", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-heartbeat-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true });
await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'download', 'downloading', '1.2.0', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/download.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "download" }));
const statePath = path.join(config.installPrefix, ".update-state");
await writeFile(statePath, `job_id=${jobId}\nold_target=${path.join(config.releasesDir, config.appVersion)}\nphase=download\n`);
const now = Date.now();
await utimes(config.updateRequestPath, new Date(staleAt), new Date(staleAt));
await utimes(statePath, new Date(now), new Date(now));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "downloading" });
expect(await stat(config.updateRequestPath)).toBeTruthy();
const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1;
await utimes(statePath, new Date(staleAt), new Date(staleAt));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
await expect(stat(config.updateRequestPath)).rejects.toThrow();
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("队列任务有新请求标记时可被重新检查,标记过期后才回收", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-queued-marker-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'queued', '1.2.0', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/queued.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "apply" }));
const now = Date.now();
await utimes(config.updateRequestPath, new Date(now), new Date(now));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "queued" });
expect(await stat(config.updateRequestPath)).toBeTruthy();
const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1;
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
await expect(stat(config.updateRequestPath)).rejects.toThrow();
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-"));
try {
@@ -273,17 +492,17 @@ describe("更新元数据缓存", () => {
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.1.3-${detectPlatform().target}-glibc.tar.gz`;
const platformAsset = `tallynote-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.3", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
: new Response(JSON.stringify({ tag_name: "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.3", 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 {
+30 -6
View File
@@ -10,6 +10,7 @@ umask 077
TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false}
TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-}
TEST_DATA_OWNER_UID=${TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID:-}
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
@@ -18,6 +19,7 @@ SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
SYSTEMCTL_BIN=systemctl
SYSTEMCTL_AVAILABLE=0
SYSTEMCTL_TIMEOUT_SECONDS=${TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS:-30}
PURGE_DATA=0
PURGE_CONFIG=0
YES=0
@@ -51,6 +53,7 @@ if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TE
fi
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
[[ "$SYSTEMCTL_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || die 'TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS must be a positive integer'
while (($#)); do
case "$1" in
@@ -103,7 +106,7 @@ allowed_data_owner() {
local path=$1 uid tallynote_uid
uid=$(stat_uid "$path")
if [[ "$TEST_MODE" == true ]]; then
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
[[ "$uid" == "$(id -u)" || "$uid" == 0 || ( -n "$TEST_DATA_OWNER_UID" && "$uid" == "$TEST_DATA_OWNER_UID" ) ]]
return
fi
[[ "$uid" == 0 ]] && return 0
@@ -130,7 +133,13 @@ validate_parent_chain() {
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
if [[ -e "$current" ]]; then
[[ -d "$current" ]] || die "路径不是目录:$current"
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
# The target itself is checked by validate_target with its path-specific
# owner policy (data may belong to the tallynote service user). Keep all
# ancestor directories root-owned, but do not apply that policy twice to
# the final target.
if [[ "$current" != "$target" ]]; then
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
fi
local mode_bits
mode_bits=$(stat_mode_bits "$current")
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
@@ -236,14 +245,14 @@ assert_test_scope() {
managed_file() {
local target=$1 label=$2
case "$label" in
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|uninstaller)
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|admin\ initializer|uninstaller)
grep -Eiq 'tallynote|TallyNote' "$target" || return 1
if [[ "$label" == 'path unit' ]]; then
grep -Fq "$DATA_DIR" "$target" || return 1
elif [[ "$label" == *unit ]]; then
grep -Fq "$PREFIX" "$target" || return 1
else
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote' "$target" || return 1
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote|admin-init.js' "$target" || return 1
fi
;;
environment\ file)
@@ -289,7 +298,13 @@ run_systemctl() {
else
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
fi
"$SYSTEMCTL_BIN" "$@"
# A stuck systemd/dbus call must not leave the uninstaller looking frozen.
# Test fixtures intentionally bypass the external timeout command.
if [[ "$TEST_MODE" != true ]] && command -v timeout >/dev/null 2>&1; then
timeout "$SYSTEMCTL_TIMEOUT_SECONDS" "$SYSTEMCTL_BIN" "$@"
else
"$SYSTEMCTL_BIN" "$@"
fi
}
stop_services() {
@@ -304,8 +319,10 @@ stop_services() {
done
return 0
fi
log "正在停止 TallyNote 服务(systemd 操作超时 ${SYSTEMCTL_TIMEOUT_SECONDS} 秒)"
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
active=0
log "检查服务:$unit"
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
active=1
else
@@ -316,13 +333,17 @@ stop_services() {
esac
fi
if (( active )); then
run_systemctl stop "$unit" || die "无法停止服务:$unit"
log "停止服务:$unit"
run_systemctl stop "$unit" || die "无法停止服务:$unit(如果 systemd 正在等待进程退出,请稍后重试)"
log "已停止服务:$unit"
fi
if [[ -e "$UNIT_DIR/$unit" ]]; then
log "禁用服务:$unit"
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
fi
done
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
log 'systemd 服务已停止并禁用'
}
validate_systemctl() {
@@ -457,13 +478,16 @@ main() {
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
fi
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
log '开始移除 TallyNote 文件和服务配置'
stop_services
remove_prefix
log '发布文件和更新组件已移除'
remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit'
remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit'
remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit'
remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper'
remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner'
remove_file_if_owned "$SBIN_DIR/tallynote-admin-init" 'admin initializer'
remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller'
remove_config
remove_data
+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")} />;
@@ -66,7 +66,7 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC
</Drawer>
<Dialog visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"}</Dialog>
<Dialog visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。</Dialog>
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert"><AlertCircle size={16} />{removeError}</div>}</>}</Dialog>
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert">{removeError}</div>}</>}</Dialog>
<Dialog visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
</>;
}
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
export type MockScenario =
| "latest" // 已是最新
| "available" // 发现新版本(待下载)
| "downloading_30" // 下载中 30%
| "downloading_85" // 下载中 85% + 高速
| "staged" // 下载完成已校验,待立即更新
| "backing_up" // 正在备份数据
| "applying" // 正在原子切换并重启中(倒计时)
| "completed" // 更新完成
| "failed_verify" // 完整性校验失败
| "disabled"; // 手动模式未配置源
export interface MockUpdateState {
info: any;
title: string;
description: string;
}
export const MOCK_SCENARIOS: Record<MockScenario, MockUpdateState> = {
latest: {
title: "版本健康(已是最新)",
description: "展示当前运行版本已是最新,各项指标正常,无待处理任务",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 600_000,
latest: {
version: "1.1.22",
tagName: "v1.1.22",
releaseName: "v1.1.22 稳定版",
publishedAt: new Date(Date.now() - 3600_000 * 24).toISOString(),
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: false,
assetName: "tallynote-1.1.22-linux-x64-glibc.tar.gz",
assetSize: 120540160,
notes: "### TallyNote 1.1.22\n\n- 优化反向代理下登录兼容性\n- 增强安全审计与防重放机制\n- 前端组件性能深度优化",
},
job: null,
},
},
available: {
title: "发现新版本(待下载)",
description: "检查到官方发布了更高版本,显示更新日志与文件校验信息,可点击下载",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 60_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
releaseName: "v1.1.23 重大更新",
publishedAt: new Date(Date.now() - 1800_000).toISOString(),
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
notes: "### TallyNote 1.1.23\n\n- 【新功能】系统更新中心全面重构,支持动态速率流光进度条与平滑重启倒计时\n- 【交互】优化抽屉展开动效与手机端自适应导航\n- 【安全】发布包支持双重 Ed25519 签名与 SHA-256 清单交叉校验",
},
job: null,
},
},
downloading_30: {
title: "下载更新中(进度 38%)",
description: "展示真实下载速率、已下载字节数与动态流光进度条",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
},
job: {
id: "mock-job-001",
operation: "download",
status: "downloading",
version: "1.1.23",
platform: "x64/glibc",
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
sizeBytes: 121000000,
downloadedBytes: 46200000,
downloadStartedAt: Date.now() - 10000,
downloadSpeedBps: 8800000, // 8.4 MB/s
createdAt: Date.now() - 10000,
updatedAt: Date.now(),
},
},
},
downloading_85: {
title: "下载冲刺中(进度 88%)",
description: "高速冲刺状态,即将触发 SHA-256 校验",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
},
job: {
id: "mock-job-002",
operation: "download",
status: "downloading",
version: "1.1.23",
platform: "x64/glibc",
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
sizeBytes: 121000000,
downloadedBytes: 106480000,
downloadStartedAt: Date.now() - 15000,
downloadSpeedBps: 12500000, // 11.9 MB/s
createdAt: Date.now() - 15000,
updatedAt: Date.now(),
},
},
},
staged: {
title: "下载完成(待立即应用)",
description: "更新包与签名均已校验就绪,随时可以安全点击【立即更新】",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
notes: "### TallyNote 1.1.23\n\n- 更新包已完整解压检验通过,具备升级条件。",
},
job: {
id: "mock-job-003",
operation: "download",
status: "staged",
version: "1.1.23",
platform: "x64/glibc",
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
sizeBytes: 121000000,
downloadedBytes: 121000000,
createdAt: Date.now() - 60000,
updatedAt: Date.now() - 5000,
},
},
},
backing_up: {
title: "数据备份中(更新保护)",
description: "正在为 /var/lib/tallynote 生成自动还原快照",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: { version: "1.1.23", tagName: "v1.1.23", compatible: true, integrityReady: true, signatureReady: true, isNewer: true },
job: {
id: "mock-job-004",
operation: "apply",
status: "backing_up",
version: "1.1.23",
platform: "x64/glibc",
createdAt: Date.now() - 20000,
updatedAt: Date.now() - 2000,
},
},
},
applying: {
title: "服务平滑重启中(倒计时中)",
description: "已原子切换版本,systemd 正在热重启,前端实时探活",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: { version: "1.1.23", tagName: "v1.1.23", compatible: true, integrityReady: true, signatureReady: true, isNewer: true },
job: {
id: "mock-job-005",
operation: "apply",
status: "applying",
version: "1.1.23",
platform: "x64/glibc",
applyQueuedAt: Date.now() - 12000,
restartWindowSeconds: 30,
restartDeadline: Date.now() + 18000,
createdAt: Date.now() - 25000,
updatedAt: Date.now() - 2000,
},
},
},
completed: {
title: "更新成功完成",
description: "新版本健康检查通过,已平滑无感升级至最新",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.23",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 30_000,
latest: { version: "1.1.23", tagName: "v1.1.23", compatible: true, integrityReady: true, signatureReady: true, isNewer: false },
job: {
id: "mock-job-006",
operation: "apply",
status: "completed",
version: "1.1.23",
platform: "x64/glibc",
completedAt: Date.now() - 10000,
createdAt: Date.now() - 45000,
updatedAt: Date.now() - 10000,
},
},
},
failed_verify: {
title: "更新失败状态(安全拦截)",
description: "模拟签名不匹配或发布包篡改时的安全拦截展示与错误提示",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: false,
signatureReady: false,
isNewer: true,
},
job: {
id: "mock-job-007",
operation: "download",
status: "failed",
version: "1.1.23",
platform: "x64/glibc",
errorMessage: "发布包 SHA-256 校验与清单不一致,系统已自动阻断并保护原有数据。",
createdAt: Date.now() - 30000,
updatedAt: Date.now() - 5000,
},
},
},
disabled: {
title: "手动源码模式",
description: "未接入 systemd 时的只读说明与命令引导展示",
info: {
configured: false,
strategy: "disabled",
currentVersion: "1.1.22",
platform: { target: "macOS/darwin", os: "darwin", arch: "arm64" },
checkedAt: Date.now() - 3600_000,
latest: null,
job: null,
},
},
};
+585 -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; }
@@ -396,6 +410,34 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
@keyframes tn-app-enter { from { opacity: 0; } to { opacity: 1; } }
@keyframes tn-auth-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.t-dialog { border: 1px solid var(--tn-border); border-radius: 4px; box-shadow: 0 18px 42px rgba(8, 47, 118, .18); }
/* TDesign Dialog global no-icon & pure baseline typography normalization */
.t-dialog__header .t-icon:not(.t-icon-close),
.t-dialog__body .t-icon,
.t-dialog .t-icon.t-is-info,
.t-dialog .t-icon.t-is-success,
.t-dialog .t-icon.t-is-warning,
.t-dialog .t-icon.t-is-error {
display: none !important;
}
.t-dialog__header {
font-size: 16px;
font-weight: 600;
color: var(--tn-navy-900);
line-height: 1.4;
margin-bottom: 8px;
gap: 0 !important;
}
.t-dialog__header-content {
display: block !important;
width: 100%;
}
.t-dialog__body {
font-size: 13.5px;
line-height: 1.65;
color: var(--tn-text);
padding: 8px 0 20px 0;
}
.t-dialog__mask { background: var(--td-mask-active) !important; }
.t-dialog__footer .t-button { min-width: 76px; }
@media (max-width: 900px) {
@@ -523,3 +565,545 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
/* ==========================================================================
TallyNote Update Center (Redesigned UI & Interactive Styles)
========================================================================== */
/* Mock Console Controller */
.tn-mock-console {
margin-bottom: 16px;
padding: 14px 18px;
background: #f8fbff;
border: 1px dashed var(--td-brand-color-3);
border-radius: 4px;
}
.tn-mock-console-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.tn-mock-console-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--tn-navy-900);
}
.tn-mock-console-tip {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-mock-scenario-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tn-scenario-chip {
padding: 5px 11px;
font-size: 12px;
border: 1px solid var(--tn-border);
border-radius: 3px;
background: #ffffff;
color: var(--tn-text-secondary);
cursor: pointer;
transition: all 0.16s ease;
}
.tn-scenario-chip:hover {
border-color: var(--td-brand-color-4);
color: var(--td-brand-color);
background: var(--td-brand-color-1);
}
.tn-scenario-chip.active {
background: var(--td-brand-color);
border-color: var(--td-brand-color);
color: #ffffff;
font-weight: 600;
box-shadow: 0 2px 6px rgba(23, 92, 211, 0.2);
}
/* Update Page Actions */
.tn-update-page-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* 4 Metrics Grid */
.tn-update-metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.tn-metric-card .t-card__body {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px 18px;
min-height: 104px;
}
.tn-metric-content {
width: 100%;
}
.tn-metric-label {
display: block;
font-size: 12px;
color: var(--tn-text-secondary);
margin-bottom: 4px;
}
.tn-metric-value {
font-size: 20px;
font-weight: 700;
color: var(--tn-navy-900);
font-family: "Plus Jakarta Sans Variable", sans-serif;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.tn-metric-foot {
margin-top: 6px;
font-size: 12px;
color: var(--tn-text-muted);
}
/* Stepper Surface */
.tn-stepper-surface {
margin-bottom: 16px;
padding: 20px;
}
.tn-stepper-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.tn-section-heading {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: var(--tn-text);
}
.tn-section-subheading {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-stepper-wrap {
padding: 10px 0 20px;
}
.tn-stepper-wrap .t-steps {
max-width: 860px;
margin: 0 auto;
}
.tn-stepper-wrap {
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--td-brand-color-3) transparent;
}
.tn-stepper-wrap .t-steps {
min-width: 680px;
}
/* Job Runtime Panel (Active download & progress) */
.tn-job-runtime-panel {
margin-top: 14px;
padding: 16px;
background: #f8fbff;
border: 1px solid var(--td-brand-color-2);
border-radius: 4px;
}
.tn-job-runtime-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.tn-job-status-badge {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--tn-navy-900);
min-width: 0;
overflow-wrap: anywhere;
}
.tn-pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--td-brand-color);
box-shadow: 0 0 0 0 rgba(23, 92, 211, 0.7);
animation: tn-pulse 1.8s infinite;
}
@keyframes tn-pulse {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(23, 92, 211, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(23, 92, 211, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(23, 92, 211, 0); }
}
.tn-job-subtext {
color: var(--tn-text-secondary);
font-size: 12px;
font-family: "Plus Jakarta Sans Variable", sans-serif;
font-variant-numeric: tabular-nums;
}
.tn-speed-indicator {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.tn-speed-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 3px;
background: #ffffff;
border: 1px solid var(--td-brand-color-3);
color: var(--td-brand-color);
font-size: 12px;
font-weight: 600;
font-family: "Plus Jakarta Sans Variable", sans-serif;
font-variant-numeric: tabular-nums;
}
.tn-eta-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
background: #f2f5f9;
color: var(--tn-text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* Progress Bar with modern stream light effect */
.tn-progress-stream {
position: relative;
height: 8px;
background: #e1e9f4;
border-radius: 999px;
overflow: hidden;
margin-bottom: 12px;
}
.tn-progress-stream-bar {
height: 100%;
background: linear-gradient(90deg, #175cd3 0%, #3d7be5 100%);
border-radius: inherit;
transition: width 0.35s ease;
position: relative;
}
.tn-progress-stream-bar::after {
content: "";
position: absolute;
top: 0; left: 0; bottom: 0; right: 0;
background-image: linear-gradient(
-45deg,
rgba(255, 255, 255, 0.25) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.25) 50%,
rgba(255, 255, 255, 0.25) 75%,
transparent 75%,
transparent
);
background-size: 30px 30px;
animation: tn-stream-flow 1.5s linear infinite;
}
@keyframes tn-stream-flow {
0% { background-position: 0 0; }
100% { background-position: 30px 30px; }
}
.tn-job-runtime-desc {
font-size: 12px;
color: var(--tn-text-secondary);
line-height: 1.6;
}
/* Restarting State Banner */
.tn-restarting-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #fff8e6;
border: 1px solid #ffd666;
border-radius: 3px;
color: #8c5b00;
}
.tn-restarting-spinner {
color: #faad14;
}
/* Release Surface */
.tn-release-surface {
margin-bottom: 16px;
padding: 20px;
}
.tn-release-notes-surface {
margin-bottom: 16px;
padding: 20px;
}
.tn-release-notes-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.tn-release-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.tn-release-tag-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.tn-release-title {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
color: var(--tn-navy-900);
}
.tn-release-time {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-release-notes-box {
margin: 0;
padding: 12px 16px;
background: #f8fafc;
border: 1px solid var(--tn-border);
border-left: 3px solid var(--td-brand-color);
border-radius: 3px;
}
.tn-release-notes-heading {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--tn-navy-900);
margin-bottom: 6px;
}
.tn-release-notes-content {
font-size: 13px;
color: var(--tn-text-secondary);
line-height: 1.6;
max-height: 140px;
overflow-y: auto;
}
.tn-markdown-notes {
color: inherit;
line-height: 1.7;
overflow-wrap: anywhere;
}
.tn-markdown-notes p,
.tn-markdown-notes h3,
.tn-markdown-notes h4,
.tn-markdown-notes h5 {
margin: 0 0 8px;
}
.tn-markdown-notes p:last-child,
.tn-markdown-notes ul:last-child,
.tn-markdown-notes pre:last-child,
.tn-markdown-notes h3:last-child,
.tn-markdown-notes h4:last-child,
.tn-markdown-notes h5:last-child {
margin-bottom: 0;
}
.tn-markdown-notes h3,
.tn-markdown-notes h4,
.tn-markdown-notes h5 {
color: var(--tn-navy-900);
font-weight: 700;
}
.tn-markdown-notes h3 { font-size: 15px; }
.tn-markdown-notes h4 { font-size: 14px; }
.tn-markdown-notes h5 { font-size: 13px; }
.tn-markdown-notes ul {
margin: 0 0 8px;
padding-left: 20px;
}
.tn-markdown-notes li { margin: 3px 0; }
.tn-markdown-notes strong { color: var(--tn-navy-900); font-weight: 700; }
.tn-markdown-notes code {
padding: 1px 4px;
border: 1px solid var(--tn-border-subtle);
border-radius: 3px;
background: #f2f5f9;
color: var(--tn-navy-900);
font-family: "Plus Jakarta Sans Variable", ui-monospace, monospace;
font-size: .92em;
}
.tn-markdown-notes pre {
margin: 0 0 8px;
padding: 10px 12px;
overflow-x: auto;
border: 1px solid var(--tn-border-subtle);
border-radius: 3px;
background: #f8fafc;
white-space: pre-wrap;
}
.tn-markdown-notes pre code { padding: 0; border: 0; background: transparent; }
.tn-markdown-notes a { color: var(--td-brand-color); text-decoration: underline; text-underline-offset: 2px; }
.tn-markdown-notes-compact { font-size: 13px; }
/* Facts Grid */
.tn-facts-grid {
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-fact-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.tn-fact-label {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-fact-value {
font-size: 14px;
color: var(--tn-text);
font-weight: 600;
overflow-wrap: anywhere;
}
.tn-fact-hint {
font-size: 11px;
color: var(--tn-text-muted);
}
.tn-release-card-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
/* Dialog content styles */
.tn-confirm-dialog-content {
font-size: 13px;
line-height: 1.6;
color: var(--tn-text);
}
.tn-update-safety-tips {
margin-top: 10px;
padding: 10px 14px;
background: #f6f8fb;
border-radius: 3px;
border: 1px solid var(--tn-border-subtle);
font-size: 12px;
color: var(--tn-text-secondary);
display: flex;
flex-direction: column;
gap: 4px;
}
.tn-full-notes-modal {
margin: 0;
padding: 12px;
background: #f8fafc;
border-radius: 3px;
font-size: 13px;
line-height: 1.6;
color: var(--tn-text);
max-height: 50vh;
overflow-y: auto;
}
.tn-empty-surface {
padding: 36px 20px;
text-align: center;
}
.tn-empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--tn-text-secondary);
font-size: 13px;
}
.tn-inline-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 3px;
color: #d48806;
font-size: 13px;
}
/* Responsive adjustments */
@media (max-width: 992px) {
.tn-update-metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tn-facts-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.tn-update-metrics-grid {
grid-template-columns: 1fr;
}
.tn-mock-console-header {
flex-direction: column;
align-items: flex-start;
}
.tn-release-header {
flex-direction: column;
}
.tn-release-notes-header {
align-items: flex-start;
flex-direction: column;
}
.tn-release-notes-header .t-button {
width: 100%;
}
.tn-release-card-actions {
flex-direction: column;
width: 100%;
}
.tn-release-card-actions .t-button {
width: 100%;
}
.tn-stepper-wrap {
margin-inline: -4px;
padding-inline: 4px;
}
.tn-stepper-wrap .t-steps {
min-width: 620px;
}
.tn-job-runtime-header {
align-items: stretch;
}
.tn-job-status-badge,
.tn-speed-indicator {
width: 100%;
}
.tn-speed-indicator {
justify-content: flex-start;
}
}
+1 -1
View File
@@ -771,7 +771,7 @@ function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) =
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && (!job || job.status === "failed" || job.version !== latest.version));
return <div className="page update-page">
<div className="page-head"><div><div className="eyebrow">系统</div><h1>系统更新</h1></div><div className="head-actions"><Button onClick={() => void load()} disabled={loading || checking}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => void check()} disabled={loading || checking || hasActiveJob}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
<div className="page-head"><div><div className="eyebrow">系统</div><h1>系统更新</h1></div><div className="head-actions"><Button onClick={() => void load()} disabled={loading || checking}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => void check()} disabled={loading || checking}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}
{loading ? <div className="update-loading"><Loader2 className="spin" size={22} />正在读取版本信息</div> : info && <>
<div className="update-overview">