Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b431fe167e | ||
|
|
344985f514 | ||
|
|
c518890fc3 | ||
|
|
1925676fc9 | ||
|
|
37ffc27ff5 | ||
|
|
5dcf9d0f61 | ||
|
|
26fbec49ad | ||
|
|
3cedcb901b | ||
|
|
bac10b6fdf | ||
|
|
eeeec54d10 | ||
|
|
bcc63b8117 | ||
|
|
a268eb5fe9 | ||
|
|
4395317651 | ||
|
|
4b9c80cc3a | ||
|
|
4434acf697 |
+8
-3
@@ -5,6 +5,10 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
|
||||
TALLYNOTE_TRUST_PROXY=false
|
||||
TALLYNOTE_COOKIE_SECURE=false
|
||||
# Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct
|
||||
# access. HTTP on a non-local Origin is opt-in; use HTTPS behind a proxy in
|
||||
# production.
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=false
|
||||
TALLYNOTE_SESSION_IDLE_HOURS=24
|
||||
TALLYNOTE_SESSION_ABSOLUTE_HOURS=168
|
||||
TALLYNOTE_EXPORT_TTL_MINUTES=15
|
||||
@@ -27,9 +31,10 @@ TALLYNOTE_INSTALL_PREFIX=./
|
||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||
TALLYNOTE_UPDATE_MAX_MB=512
|
||||
# One-click/systemd updates require an Ed25519 signature over SHA256SUMS.
|
||||
# Keep this file root-readable and point to a root-managed public key.
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
||||
# SHA-256 is always required. Detached Ed25519 signatures are optional; set
|
||||
# this to true only when a root-managed public key is configured below.
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
|
||||
@@ -32,10 +32,9 @@ jobs:
|
||||
pnpm test
|
||||
- name: Build Linux release
|
||||
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
|
||||
- name: Create and publish signed Gitea Release
|
||||
- name: Create and publish Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY: ${{ secrets.TALLYNOTE_RELEASE_SIGNING_KEY }}
|
||||
run: ./scripts/publish-gitea-release.sh "$GITHUB_REF_NAME" ./release
|
||||
|
||||
# Linux x86 (i386/i686) is intentionally not published: Node.js 24 and the
|
||||
|
||||
@@ -23,7 +23,6 @@ TallyNote_报销资料_xxxxxxxx.zip
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm admin:init
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -43,7 +42,7 @@ pnpm build:next
|
||||
|
||||
`build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。
|
||||
|
||||
首次初始化会要求交互式输入管理员密码。也可以使用 `pnpm admin:init -- --username admin --display-name 管理员 --generate` 生成一次性临时密码。
|
||||
本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo tallynote-admin-init` 即可。也可以使用 `sudo tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。
|
||||
|
||||
默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。
|
||||
|
||||
@@ -51,29 +50,96 @@ pnpm build:next
|
||||
|
||||
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
|
||||
|
||||
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS` 和 `SHA256SUMS.sig`。安装器默认 dry-run,只有显式 `--apply` 才会下载或写盘;正式安装必须提供独立核对过的 Ed25519 公钥:
|
||||
发布包必须包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、生产依赖、匹配架构的 Node runtime、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
|
||||
| sudo bash -s -- --apply --version 1.1.0 \
|
||||
--signing-key /root/tallynote-update.pub \
|
||||
--update-public-key-file /root/tallynote-update.pub
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
指定版本时,脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 获取归档、`SHA256SUMS` 和签名。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。`--allow-unsigned` 仅供隔离开发机测试,不能用于公网或真实财务数据。
|
||||
安装命令保持简洁。首次在 SSH/终端中安装时,安装器会交互询问监听方式、端口和公开访问地址:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入;选择稍后创建也不会阻塞服务启动,之后执行 `sudo tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。
|
||||
|
||||
监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动后,安装器会先请求本机 `/health`;只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时该链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。
|
||||
|
||||
安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
|
||||
```
|
||||
|
||||
脚本会从公开仓库的 latest Release 获取当前架构归档和 `SHA256SUMS`,并在安装前始终校验 SHA-256。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。需要固定版本或预览时,仍可使用 `TALLYNOTE_VERSION`、`--version` 或 `--dry-run` 等高级选项。
|
||||
|
||||
安装过程会持续输出带统一前缀的阶段日志,不会在下载、校验或启动服务时静默等待。交互式 SSH/终端中会先显示网络配置选择,下载时还会显示 curl 进度条;非交互式运行(例如 CI)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化):
|
||||
|
||||
```text
|
||||
tallynote installer: [阶段] 检查运行环境、权限和目标架构
|
||||
tallynote installer: [完成] 运行环境可用:x64/glibc
|
||||
tallynote installer: [阶段] 从 Release API 获取最新版本
|
||||
tallynote installer: [完成] 已解析最新版本:1.1.2
|
||||
tallynote installer: [完成] Release 下载地址已准备
|
||||
tallynote installer: [阶段] 获取发布包:tallynote-1.1.2-linux-x64-glibc.tar.gz
|
||||
tallynote installer: [完成] 发布包已下载并通过大小限制
|
||||
tallynote installer: [阶段] 获取 SHA-256 校验清单
|
||||
tallynote installer: [完成] SHA-256 校验清单已准备
|
||||
tallynote installer: [阶段] 校验 SHA-256 和发布签名
|
||||
tallynote installer: [完成] 发布包校验通过
|
||||
tallynote installer: [阶段] 解包、校验包结构并原子切换到版本 1.1.2
|
||||
tallynote installer: [完成] 版本 1.1.2 已切换为当前版本
|
||||
tallynote installer: [阶段] 安装 systemd 单元、更新辅助程序和卸载器
|
||||
tallynote installer: [完成] systemd 单元、更新辅助程序和卸载器已安装
|
||||
tallynote installer: [阶段] 重新加载 systemd 并启动 TallyNote
|
||||
tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote 1.1.2
|
||||
tallynote installer: 访问地址:http://127.0.0.1:3000
|
||||
tallynote installer: 查看服务状态:systemctl status tallynote.service
|
||||
```
|
||||
|
||||
任何阶段失败都会以 `tallynote installer:` 前缀写出原因并立即停止;不会把不完整版本切换为当前版本。
|
||||
|
||||
如需额外启用签名校验,在环境中设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;不设置时不会要求公钥或 `SHA256SUMS.sig`。
|
||||
|
||||
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。
|
||||
|
||||
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`,默认仅监听 `127.0.0.1:3000`。
|
||||
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。
|
||||
|
||||
升级有两种方式:
|
||||
|
||||
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单、SHA-256 和 Ed25519 签名校验的请求写入队列;root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源、验证签名,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
1. 后台进入“系统更新”,点击“检查更新”后可先“下载更新包”,等待校验完成,再点击“立即更新”。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。下载阶段主服务保持运行;应用阶段才会停机、备份、切换和健康检查,页面会显示重启倒计时并自动重试连接。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。
|
||||
|
||||
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
|
||||
|
||||
### 卸载
|
||||
|
||||
安装完成后会提供 `/usr/local/sbin/tallynote-admin-init` 和 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序、管理员初始化命令和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-uninstall
|
||||
```
|
||||
|
||||
如果确认不再需要数据库、附件、暂存、导出和更新备份,必须显式同时提供 `--purge-data --yes`:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
|
||||
```
|
||||
|
||||
卸载检测到未完成的更新状态时会停止并要求人工确认;确认更新已停止后再加 `--force`。也可以直接从公开仓库获取同一脚本执行普通卸载:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
|
||||
|
||||
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
|
||||
|
||||
### 构建发布包
|
||||
@@ -82,17 +148,17 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm release:build 1.1.0 ./release
|
||||
pnpm release:build 1.1.2 ./release
|
||||
```
|
||||
|
||||
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS` 与 `SHA256SUMS.sig`;当前仓库还没有首个 tag/release 时,后台会明确显示不可用,不会下载未验证文件。CI 需要 `GITEA_TOKEN` 和 `TALLYNOTE_RELEASE_SIGNING_KEY` secrets。
|
||||
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS`;如果 CI 提供签名私钥,还会额外上传 `SHA256SUMS.sig`。CI 只需要 `GITEA_TOKEN`;签名私钥属于可选增强。
|
||||
|
||||
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.0` 与 `v1.1.0`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
|
||||
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.2` 与 `v1.1.2`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: 1.1.0"
|
||||
git tag -a v1.1.0 -m "TallyNote 1.1.0"
|
||||
git commit -m "release: 1.1.2"
|
||||
git tag -a v1.1.2 -m "TallyNote 1.1.2"
|
||||
git push origin main --follow-tags
|
||||
```
|
||||
|
||||
@@ -109,4 +175,4 @@ docker compose run --rm --no-deps tallynote node dist/server/cli/admin-init.js -
|
||||
|
||||
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
|
||||
|
||||
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256/签名的归档、路径穿越、特殊文件和符号链接;附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
||||
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256 的归档、路径穿越、特殊文件和符号链接;启用签名要求时也会拒绝无有效签名的归档。附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Production entry point for first-admin setup. The installer keeps the
|
||||
# EnvironmentFile root-readable only, so parse simple KEY=VALUE assignments
|
||||
# without sourcing arbitrary shell code.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
|
||||
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
|
||||
CONFIG_FILE="$CONFIG_DIR/tallynote.env"
|
||||
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}
|
||||
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
|
||||
|
||||
die() { printf 'tallynote admin-init: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
load_environment_file() {
|
||||
[[ -e "$CONFIG_FILE" ]] || return 0
|
||||
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || die '环境文件不是安全的普通文件'
|
||||
local uid mode_bits line key value
|
||||
uid=$(stat -c '%u' "$CONFIG_FILE" 2>/dev/null || stat -f '%u' "$CONFIG_FILE")
|
||||
[[ "$uid" == 0 ]] || die '环境文件必须由 root 拥有'
|
||||
mode_bits=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || stat -f '%Lp' "$CONFIG_FILE")
|
||||
[[ "$mode_bits" =~ ^[0-7]+$ ]] || die '无法读取环境文件权限'
|
||||
(( (8#$mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
[[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || die '环境文件包含无法识别的配置行'
|
||||
key=${BASH_REMATCH[1]}
|
||||
value=${BASH_REMATCH[2]}
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "环境文件中的 $key 包含控制字符"
|
||||
export "$key=$value"
|
||||
done < "$CONFIG_FILE"
|
||||
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-$PREFIX}
|
||||
}
|
||||
|
||||
resolve_release_root() {
|
||||
local root prefix_root
|
||||
[[ "$PREFIX" = /* && "$PREFIX" != *$'\n'* && "$PREFIX" != *$'\r'* ]] || die '安装目录无效'
|
||||
[[ -L "$PREFIX/current" ]] || die '当前 release 链接不存在'
|
||||
prefix_root=$(readlink -f -- "$PREFIX" 2>/dev/null || realpath "$PREFIX" 2>/dev/null || true)
|
||||
[[ -n "$prefix_root" && -d "$prefix_root" && ! -L "$prefix_root" ]] || die '安装目录不安全'
|
||||
root=$(readlink -f -- "$PREFIX/current" 2>/dev/null || realpath "$PREFIX/current" 2>/dev/null || true)
|
||||
[[ -n "$root" && "$root" == "$prefix_root/releases/"* && -d "$root" && ! -L "$root" ]] || die '当前 release 链接不安全'
|
||||
printf '%s' "$root"
|
||||
}
|
||||
|
||||
run_as_service_user() {
|
||||
local root=$1 node=$2 cli=$3
|
||||
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
|
||||
local service_uid
|
||||
service_uid=$(id -u tallynote 2>/dev/null) || die '找不到 tallynote 服务用户,拒绝以 root 身份执行管理员初始化'
|
||||
[[ "$service_uid" =~ ^[1-9][0-9]*$ ]] || die 'tallynote 服务用户 UID 无效,拒绝以 root 身份执行管理员初始化'
|
||||
command -v runuser >/dev/null 2>&1 || die '找不到 runuser,无法以 tallynote 用户初始化'
|
||||
local -a environment=(
|
||||
"NODE_ENV=production"
|
||||
"TALLYNOTE_DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}"
|
||||
"TALLYNOTE_INSTALL_PREFIX=${TALLYNOTE_INSTALL_PREFIX:-$PREFIX}"
|
||||
"TALLYNOTE_TRUST_PROXY=${TALLYNOTE_TRUST_PROXY:-false}"
|
||||
"TALLYNOTE_UPDATE_STRATEGY=${TALLYNOTE_UPDATE_STRATEGY:-systemd}"
|
||||
"TALLYNOTE_HOST=${TALLYNOTE_HOST:-127.0.0.1}"
|
||||
"TALLYNOTE_PORT=${TALLYNOTE_PORT:-3000}"
|
||||
"TALLYNOTE_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN:-http://127.0.0.1:3000}"
|
||||
"TALLYNOTE_COOKIE_SECURE=${TALLYNOTE_COOKIE_SECURE:-false}"
|
||||
"TALLYNOTE_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP:-false}"
|
||||
"TALLYNOTE_TIMEZONE=${TALLYNOTE_TIMEZONE:-Asia/Shanghai}"
|
||||
"TALLYNOTE_UPDATE_METADATA_URL=${TALLYNOTE_UPDATE_METADATA_URL:-https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest}"
|
||||
"TALLYNOTE_UPDATE_ALLOWED_HOSTS=${TALLYNOTE_UPDATE_ALLOWED_HOSTS:-git.awaioi.com}"
|
||||
"TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=${TALLYNOTE_UPDATE_REQUIRE_SIGNATURE:-false}"
|
||||
"TALLYNOTE_UPDATE_MAX_MB=${TALLYNOTE_UPDATE_MAX_MB:-512}"
|
||||
"TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS:-60}"
|
||||
"TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS:-15}"
|
||||
"TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS:-15}"
|
||||
"TALLYNOTE_MAX_FILE_MB=${TALLYNOTE_MAX_FILE_MB:-20}"
|
||||
"TALLYNOTE_MAX_FILES_PER_REQUEST=${TALLYNOTE_MAX_FILES_PER_REQUEST:-20}"
|
||||
"TALLYNOTE_MAX_RECORD_MB=${TALLYNOTE_MAX_RECORD_MB:-100}"
|
||||
"TALLYNOTE_MAX_TOTAL_MB=${TALLYNOTE_MAX_TOTAL_MB:-2048}"
|
||||
"TALLYNOTE_MAX_CONCURRENT_EXPORTS=${TALLYNOTE_MAX_CONCURRENT_EXPORTS:-2}"
|
||||
"TALLYNOTE_MAX_EXPORT_RECORDS=${TALLYNOTE_MAX_EXPORT_RECORDS:-5000}"
|
||||
"TALLYNOTE_MAX_EXPORT_MB=${TALLYNOTE_MAX_EXPORT_MB:-1024}"
|
||||
"TALLYNOTE_MAX_EXPORT_STORAGE_MB=${TALLYNOTE_MAX_EXPORT_STORAGE_MB:-2048}"
|
||||
"TALLYNOTE_SESSION_IDLE_HOURS=${TALLYNOTE_SESSION_IDLE_HOURS:-24}"
|
||||
"TALLYNOTE_SESSION_ABSOLUTE_HOURS=${TALLYNOTE_SESSION_ABSOLUTE_HOURS:-168}"
|
||||
"TALLYNOTE_EXPORT_TTL_MINUTES=${TALLYNOTE_EXPORT_TTL_MINUTES:-15}"
|
||||
)
|
||||
[[ -n "${TALLYNOTE_UPDATE_PUBLIC_KEY:-}" ]] && environment+=("TALLYNOTE_UPDATE_PUBLIC_KEY=$TALLYNOTE_UPDATE_PUBLIC_KEY")
|
||||
if [[ -n "${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}" ]]; then
|
||||
environment+=("TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$TALLYNOTE_UPDATE_PUBLIC_KEY_FILE")
|
||||
fi
|
||||
if [[ -n "${TALLYNOTE_UPDATE_HELPER_PATH:-}" ]]; then
|
||||
environment+=("TALLYNOTE_UPDATE_HELPER_PATH=$TALLYNOTE_UPDATE_HELPER_PATH")
|
||||
fi
|
||||
runuser -u tallynote -- env -i "${environment[@]}" PATH="$PATH" "$node" "$cli" "${@:4}"
|
||||
else
|
||||
"$node" "$cli" "${@:4}"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
load_environment_file
|
||||
local root node cli systemctl service_was_active=0 result check_only=0
|
||||
for argument in "$@"; do
|
||||
[[ "$argument" == "--check" ]] && check_only=1
|
||||
done
|
||||
root=$(resolve_release_root)
|
||||
node="$root/runtime/bin/node"
|
||||
[[ -x "$node" ]] || node=$(command -v node || true)
|
||||
[[ -n "$node" && -x "$node" ]] || die '找不到 Node.js runtime'
|
||||
cli="$root/dist/server/cli/admin-init.js"
|
||||
[[ -f "$cli" && ! -L "$cli" ]] || die '管理员初始化程序不存在'
|
||||
|
||||
# Validate the privilege boundary before stopping an active service. A
|
||||
# damaged installation must fail closed without causing avoidable downtime.
|
||||
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
|
||||
local service_uid
|
||||
service_uid=$(id -u tallynote 2>/dev/null) || die '找不到 tallynote 服务用户,拒绝以 root 身份执行管理员初始化'
|
||||
[[ "$service_uid" =~ ^[1-9][0-9]*$ ]] || die 'tallynote 服务用户 UID 无效,拒绝以 root 身份执行管理员初始化'
|
||||
command -v runuser >/dev/null 2>&1 || die '找不到 runuser,无法以 tallynote 用户初始化'
|
||||
fi
|
||||
|
||||
# admin-init uses the same instance lock as the web process. Pause an active
|
||||
# service for the duration, then restore exactly its previous active state.
|
||||
systemctl=$(command -v systemctl || true)
|
||||
if (( ! check_only )) && [[ "${EUID:-$(id -u)}" == 0 && -n "$systemctl" && -x "$systemctl" ]] && "$systemctl" is-active --quiet "$SERVICE_NAME"; then
|
||||
service_was_active=1
|
||||
printf 'tallynote admin-init: 暂停服务以完成管理员初始化\n' >&2
|
||||
"$systemctl" stop "$SERVICE_NAME" || die '无法暂停 TallyNote 服务'
|
||||
fi
|
||||
restore_service() {
|
||||
local exit_code=$?
|
||||
if (( service_was_active )); then
|
||||
printf 'tallynote admin-init: 恢复 TallyNote 服务\n' >&2
|
||||
"$systemctl" start "$SERVICE_NAME" || printf 'tallynote admin-init: 警告:服务恢复失败,请执行 systemctl start %s\n' "$SERVICE_NAME" >&2
|
||||
fi
|
||||
return "$exit_code"
|
||||
}
|
||||
trap restore_service EXIT
|
||||
|
||||
cd -- "$root"
|
||||
set +e
|
||||
run_as_service_user "$root" "$node" "$cli" "$@"
|
||||
result=$?
|
||||
set -e
|
||||
exit "$result"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+59
-18
@@ -6,48 +6,75 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
|
||||
|
||||
## 自动发布
|
||||
|
||||
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.0`)会触发 `.gitea/workflows/release.yml`:
|
||||
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.2`)会触发 `.gitea/workflows/release.yml`:
|
||||
|
||||
1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。
|
||||
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
|
||||
3. 用 Ed25519 私钥生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和签名。
|
||||
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
|
||||
|
||||
在仓库的 Actions secrets 配置:
|
||||
|
||||
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
|
||||
- `TALLYNOTE_RELEASE_SIGNING_KEY`:Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
||||
- `TALLYNOTE_RELEASE_SIGNING_KEY`:可选的 Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
||||
|
||||
也可以在 Linux 发布机上手动执行:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm check && pnpm test
|
||||
pnpm release:build 1.1.0 ./release
|
||||
pnpm release:build 1.1.2 ./release
|
||||
GITHUB_REPOSITORY=awaioi/TallyNote \
|
||||
GITEA_TOKEN=... \
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE=/root/secrets/tallynote-release.key \
|
||||
./scripts/publish-gitea-release.sh v1.1.0 ./release
|
||||
./scripts/publish-gitea-release.sh v1.1.2 ./release
|
||||
```
|
||||
|
||||
发布资产名称必须包含当前平台,例如 `tallynote-1.1.0-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。
|
||||
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
|
||||
|
||||
## curl 安装
|
||||
|
||||
安装器默认只做 dry-run;只有显式 `--apply` 才会下载或写盘。正式安装必须同时提供 Ed25519 公钥和 `SHA256SUMS.sig`,公钥应通过独立的受信渠道核对指纹。下面示例假设公钥已安全放在服务器 `/root/tallynote-update.pub`:
|
||||
安装器默认直接获取并安装 latest Release。它始终校验 `SHA256SUMS` 中的 SHA-256,不要求公钥或签名文件:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
|
||||
| sudo bash -s -- --apply --version 1.1.0 \
|
||||
--signing-key /root/tallynote-update.pub \
|
||||
--update-public-key-file /root/tallynote-update.pub
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档、`SHA256SUMS` 和 `SHA256SUMS.sig`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。
|
||||
首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动后,安装器会先请求本机 `/health`,只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
|
||||
```
|
||||
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。
|
||||
|
||||
安装器会在每个关键阶段输出统一格式的日志,便于在 SSH 或 systemd 安装会话中确认进度;交互式终端下载时还会显示 curl 进度条,CI 或日志重定向时则保持纯文本输出:
|
||||
|
||||
```text
|
||||
tallynote installer: [阶段] 检查运行环境、权限和目标架构
|
||||
tallynote installer: [阶段] 从 Release API 获取最新版本
|
||||
tallynote installer: [阶段] 获取发布包:tallynote-<版本>-linux-x64-glibc.tar.gz
|
||||
tallynote installer: [阶段] 获取 SHA-256 校验清单
|
||||
tallynote installer: [阶段] 校验 SHA-256 和发布签名
|
||||
tallynote installer: [阶段] 解包、校验包结构并原子切换到版本 <版本>
|
||||
tallynote installer: [完成] 版本 <版本> 已切换为当前版本
|
||||
tallynote installer: [阶段] 安装 systemd 单元、更新辅助程序和卸载器
|
||||
tallynote installer: [完成] systemd 单元、更新辅助程序和卸载器已安装
|
||||
tallynote installer: [阶段] 重新加载 systemd 并启动 TallyNote
|
||||
tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote <版本>
|
||||
tallynote installer: 访问地址:http://127.0.0.1:<端口>
|
||||
```
|
||||
|
||||
每个阶段完成时会输出 `[完成]`;错误会立即以 `tallynote installer:` 前缀输出,不会静默等待或切换半成品版本。
|
||||
|
||||
如需启用签名校验,设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;后台更新同样可通过 `TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 开启。默认关闭签名要求,方便公开自维护仓库直接更新。
|
||||
|
||||
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
|
||||
|
||||
`--allow-unsigned` 只用于隔离的开发/测试主机,不能用于公网或保存真实财务数据的服务器。安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。
|
||||
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`,并提供 `/usr/local/sbin/tallynote-admin-init` 作为生产环境首次管理员初始化入口。`--allow-unsigned` 作为旧版本兼容参数保留。
|
||||
|
||||
安装布局:
|
||||
|
||||
@@ -61,17 +88,31 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
/etc/tallynote/tallynote.env
|
||||
```
|
||||
|
||||
## 卸载与数据保留
|
||||
|
||||
默认卸载只移除发布代码、systemd 单元、更新辅助程序和已知配置,数据目录与更新备份不会删除:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-uninstall
|
||||
```
|
||||
|
||||
只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。
|
||||
|
||||
卸载过程中会输出每个 systemd 单元的检查、停止、禁用和删除阶段;systemd/dbus 调用默认 30 秒超时,避免长时间无反馈。可用 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整。
|
||||
|
||||
## 后台一键更新
|
||||
|
||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL`、`TALLYNOTE_UPDATE_ALLOWED_HOSTS` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且同时通过 SHA-256 与 Ed25519 签名验证的资产;缺少任一项时“更新”按钮保持禁用。
|
||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
|
||||
|
||||
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata、清单和签名,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
|
||||
|
||||
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-update --rollback
|
||||
```
|
||||
|
||||
更新检查和应用接口带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。
|
||||
更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`;首次安装时可在交互提示中选择 `0.0.0.0` 和真实的服务器 IP/域名。直连 HTTP 会暴露未加密的会话和数据,只适合受控网络;绑定域名后必须改为 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。自动化安装可使用 `--non-interactive` 或显式网络环境变量。
|
||||
|
||||
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
|
||||
|
||||
|
||||
+711
-52
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE update_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'apply' CHECK(operation IN ('download','apply'));
|
||||
CREATE INDEX IF NOT EXISTS update_jobs_operation_idx ON update_jobs(operation, status, created_at);
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
@@ -20,6 +20,7 @@
|
||||
"check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web-next.json --noEmit",
|
||||
"check:next": "tsc -p tsconfig.web-next.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:installer": "bash scripts/test-installer.sh && bash scripts/test-uninstaller.sh",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
|
||||
@@ -32,10 +32,11 @@ cp -a migrations/. "$stage/migrations/"
|
||||
cp package.json pnpm-lock.yaml "$stage/"
|
||||
cp -a bin/. "$stage/bin/"
|
||||
cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
|
||||
cp uninstall.sh "$stage/uninstall.sh"
|
||||
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
|
||||
node_path=$(command -v node)
|
||||
cp -L "$node_path" "$stage/runtime/bin/node"
|
||||
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node"
|
||||
chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
|
||||
|
||||
# pnpm's default linker creates symlinks. A release archive is deliberately
|
||||
# symlink-free so the installer can reject traversal links deterministically.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Publish one immutable, signed release to a Gitea-compatible API. The script
|
||||
# is intentionally separate from the workflow so operators can dry-run the
|
||||
# exact same asset selection locally without ever exposing a signing key.
|
||||
# Publish one immutable release to a Gitea-compatible API. SHA256SUMS is
|
||||
# always generated; an Ed25519 detached signature is added when a signing key
|
||||
# is supplied. The script remains separate from the workflow so operators can
|
||||
# dry-run the exact same asset selection locally without exposing a key.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
@@ -23,6 +24,7 @@ DRY_RUN=0
|
||||
AUTH_CONFIG=''
|
||||
SUMS_TMP=''
|
||||
SIG_TMP=''
|
||||
SIGNATURE_GENERATED=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -30,8 +32,12 @@ Usage: publish-gitea-release.sh TAG [ASSET_DIR] [--dry-run]
|
||||
|
||||
Required in publish mode:
|
||||
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
|
||||
|
||||
Optional:
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
|
||||
or TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
||||
|
||||
Without a signing key, the release is published with SHA256SUMS only.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -116,7 +122,9 @@ API_ROOT=${API_ROOT%/}
|
||||
validate_api_root "$API_ROOT"
|
||||
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
|
||||
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
|
||||
if [[ -n "$SIGNING_KEY_FILE" || -n "$SIGNING_KEY_VALUE" ]]; then
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signing a release'
|
||||
fi
|
||||
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
|
||||
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
|
||||
|
||||
@@ -164,8 +172,6 @@ elif [[ -n "$SIGNING_KEY_VALUE" ]]; then
|
||||
chmod 600 "$temporary_key"
|
||||
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
|
||||
unset SIGNING_KEY_VALUE
|
||||
else
|
||||
[[ "$DRY_RUN" -eq 1 ]] || die 'TALLYNOTE_RELEASE_SIGNING_KEY_FILE or TALLYNOTE_RELEASE_SIGNING_KEY is required'
|
||||
fi
|
||||
if [[ -n "$temporary_key" ]]; then
|
||||
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
|
||||
@@ -174,16 +180,18 @@ if [[ -n "$temporary_key" ]]; then
|
||||
chmod 600 "$SIG_TMP"
|
||||
mv -f -- "$SIG_TMP" "$SIG_FILE"
|
||||
SIG_TMP=''
|
||||
SIGNATURE_GENERATED=1
|
||||
fi
|
||||
|
||||
log "tag: $TAG"
|
||||
log "assets: ${#assets[@]} archive(s), SHA256SUMS${temporary_key:+, SHA256SUMS.sig}"
|
||||
asset_summary="assets: ${#assets[@]} archive(s), SHA256SUMS"
|
||||
if (( SIGNATURE_GENERATED )); then asset_summary+=", SHA256SUMS.sig"; fi
|
||||
log "$asset_summary"
|
||||
if (( DRY_RUN )); then
|
||||
log 'dry-run: no API request was sent'
|
||||
exit 0
|
||||
fi
|
||||
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
|
||||
[[ -s "$SIG_FILE" ]] || die 'signature was not generated'
|
||||
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
|
||||
write_auth_config
|
||||
unset TOKEN
|
||||
@@ -230,7 +238,8 @@ assets_endpoint="$API_ROOT/repos/$repo_path/releases/$release_id/assets"
|
||||
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
|
||||
while IFS=$'\t' read -r existing_id existing_name; do
|
||||
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
|
||||
for candidate in "${assets[@]}" "$SUMS_FILE" "$SIG_FILE"; do
|
||||
candidates=("${assets[@]}" "$SUMS_FILE" "$SIG_FILE")
|
||||
for candidate in "${candidates[@]}"; do
|
||||
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
|
||||
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
|
||||
done
|
||||
@@ -245,5 +254,7 @@ upload_asset() {
|
||||
}
|
||||
for file in "${assets[@]}"; do upload_asset "$file"; done
|
||||
upload_asset "$SUMS_FILE"
|
||||
upload_asset "$SIG_FILE"
|
||||
if (( SIGNATURE_GENERATED )); then
|
||||
upload_asset "$SIG_FILE"
|
||||
fi
|
||||
log "published $TAG to $REPOSITORY"
|
||||
|
||||
@@ -13,6 +13,10 @@ STATE_FILE="$PREFIX/.update-state"
|
||||
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
|
||||
HOST=${TALLYNOTE_HOST:-127.0.0.1}
|
||||
PORT=${TALLYNOTE_PORT:-3000}
|
||||
HEALTH_HOST=$HOST
|
||||
if [[ "$HEALTH_HOST" == 0.0.0.0 ]]; then HEALTH_HOST=127.0.0.1; fi
|
||||
if [[ "$HEALTH_HOST" == :: ]]; then HEALTH_HOST=::1; fi
|
||||
if [[ "$HEALTH_HOST" == *:* && "$HEALTH_HOST" != \[* ]]; then HEALTH_HOST="[$HEALTH_HOST]"; fi
|
||||
|
||||
die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
|
||||
@@ -22,6 +26,26 @@ die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
|
||||
old_target=$(readlink -f -- "$CURRENT_LINK")
|
||||
[[ "$old_target" == "$PREFIX/releases/"* && -d "$old_target" ]] || die 'current release target is invalid'
|
||||
|
||||
request_operation='apply'
|
||||
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
|
||||
request_operation=$(sed -n 's/.*"operation"[[:space:]]*:[[:space:]]*"\(download\|apply\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
|
||||
[[ "$request_operation" == download || "$request_operation" == apply ]] || request_operation='apply'
|
||||
fi
|
||||
|
||||
# Downloading is intentionally handled while the main service remains up.
|
||||
# The CLI persists the validated payload under the root-owned workspace and
|
||||
# leaves the job staged for a later apply request.
|
||||
if [[ "$request_operation" == download ]]; then
|
||||
node_bin="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
|
||||
[[ -n "$node_bin" ]] || die 'node runtime not found'
|
||||
cli="$CURRENT_LINK/dist/server/cli/update.js"
|
||||
[[ -f "$cli" ]] || die 'update CLI not found in current release'
|
||||
"$node_bin" "$cli" --request-file "$REQUEST_FILE" || exit $?
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
was_active=0
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
|
||||
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
|
||||
@@ -202,7 +226,7 @@ write_update_state health-check || exit 1
|
||||
systemctl start "$SERVICE_NAME"
|
||||
healthy=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
|
||||
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HEALTH_HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
||||
@@ -2,14 +2,41 @@
|
||||
set -Eeuo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
|
||||
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service"
|
||||
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service"
|
||||
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'dry-run' <<<"$output"
|
||||
grep -q '\[阶段\] 检查运行环境' <<<"$output"
|
||||
grep -q '\[完成\] dry-run 预览完成' <<<"$output"
|
||||
output=$(bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
grep -q '\[阶段\] 使用指定版本:1.2.3' <<<"$output"
|
||||
if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected non-HTTPS URL to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
if TALLYNOTE_HOST=0.0.0.0 bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected non-local listener without public origin to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
output=$(TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=true \
|
||||
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
output=$(TALLYNOTE_HOST=::1 TALLYNOTE_PORT=3443 \
|
||||
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=65536 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 TALLYNOTE_ALLOW_INSECURE_HTTP=true \
|
||||
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected invalid listener port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
|
||||
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP without explicit opt-in to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
tmp=$(mktemp -d)
|
||||
cleanup_tmp() {
|
||||
if [[ -d "$tmp" ]]; then
|
||||
@@ -37,6 +64,13 @@ fi
|
||||
# main invocation lets this subprocess source the exact production code.
|
||||
installer_lib="$tmp/install-lib.sh"
|
||||
sed '$d' "$root/install.sh" > "$installer_lib"
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
[[ "$APPLY" -eq 1 ]]
|
||||
[[ "$REQUIRE_SIGNATURE" == false ]]
|
||||
' _ "$installer_lib"
|
||||
bash -c '
|
||||
script=$1
|
||||
mode_dir=$2
|
||||
@@ -53,6 +87,205 @@ bash -c '
|
||||
fi
|
||||
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
|
||||
|
||||
# The port probe must distinguish a listening TCP port from a free one.
|
||||
port_tools="$tmp/port-tools"
|
||||
mkdir -p "$port_tools"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "LISTEN 0 128 127.0.0.1:3443 0.0.0.0:*"' > "$port_tools/ss"
|
||||
chmod 755 "$port_tools/ss"
|
||||
bash -c '
|
||||
script=$1
|
||||
tools=$2
|
||||
set --
|
||||
source "$script"
|
||||
PATH="$tools:$PATH"
|
||||
state=0
|
||||
port_listener_state 3443 || state=$?
|
||||
[[ "$state" == 1 ]]
|
||||
state=0
|
||||
port_listener_state 3444 || state=$?
|
||||
[[ "$state" == 0 ]]
|
||||
' _ "$installer_lib" "$port_tools"
|
||||
|
||||
# The lsof fallback must treat its normal "no matches" exit status as a free
|
||||
# port, while still reporting a listener when it returns a PID.
|
||||
lsof_tools="$tmp/lsof-tools"
|
||||
mkdir -p "$lsof_tools"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exit 127' > "$lsof_tools/ss"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'case "$*" in *TCP:3443*) printf "%s\\n" 4242; exit 0 ;; *) exit 1 ;; esac' > "$lsof_tools/lsof"
|
||||
chmod 755 "$lsof_tools/ss" "$lsof_tools/lsof"
|
||||
bash -c '
|
||||
script=$1
|
||||
tools=$2
|
||||
set --
|
||||
source "$script"
|
||||
PATH="$tools:$PATH"
|
||||
state=0
|
||||
port_listener_state 3443 || state=$?
|
||||
[[ "$state" == 1 ]]
|
||||
state=0
|
||||
port_listener_state 3444 || state=$?
|
||||
[[ "$state" == 0 ]]
|
||||
' _ "$installer_lib" "$lsof_tools"
|
||||
|
||||
# Public-IP discovery accepts a valid IPv4 response and rejects malformed
|
||||
# values without making the test depend on an external service.
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
PUBLIC_IP_URL=https://ip.example.test
|
||||
curl() { printf "%s\\n" "198.51.100.7"; }
|
||||
[[ "$(detect_public_ipv4)" == "198.51.100.7" ]]
|
||||
curl() { printf "%s\\n" "999.1.1.1"; }
|
||||
if detect_public_ipv4 >/dev/null 2>&1; then
|
||||
echo "expected invalid public IPv4 response to fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib"
|
||||
|
||||
# The service health probe maps wildcard listeners to loopback and must return
|
||||
# promptly when the local endpoint is healthy.
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
curl() { [[ "$*" == *"http://127.0.0.1:3011/health"* ]] || return 1; }
|
||||
wait_for_service_health 0.0.0.0 3011
|
||||
' _ "$installer_lib"
|
||||
|
||||
# A RETURN trap installed by install_release must be cleared while its local
|
||||
# temporary variables still exist; otherwise set -u fails at the end of main.
|
||||
release_fixture="$tmp/release-fixture"
|
||||
mkdir -p "$release_fixture/dist/server/cli" "$release_fixture/dist/web" "$release_fixture/bin" \
|
||||
"$release_fixture/scripts" "$release_fixture/runtime/bin" "$release_fixture/systemd"
|
||||
printf '%s\n' '{"version":"1.0.0"}' > "$release_fixture/package.json"
|
||||
printf '%s\n' server > "$release_fixture/dist/server/index.js"
|
||||
printf '%s\n' cli > "$release_fixture/dist/server/cli/admin-init.js"
|
||||
printf '%s\n' web > "$release_fixture/dist/web/index.html"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/bin/tallynote"
|
||||
cp "$root/bin/tallynote-admin-init" "$release_fixture/bin/tallynote-admin-init"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update-runner.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$release_fixture/uninstall.sh"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote.service"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.service"
|
||||
printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.path"
|
||||
printf '%s\n' 'TALLYNOTE_HOST=127.0.0.1' > "$release_fixture/systemd/tallynote.env.example"
|
||||
chmod 755 "$release_fixture/bin/tallynote" "$release_fixture/bin/tallynote-admin-init" "$release_fixture/scripts"/*.sh "$release_fixture/uninstall.sh"
|
||||
release_archive="$tmp/release-fixture.tar.gz"
|
||||
tar -C "$release_fixture" -czf "$release_archive" .
|
||||
bash -c '
|
||||
script=$1
|
||||
archive=$2
|
||||
destination=$3
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX="$destination/prefix"
|
||||
ensure_root_directory() { mkdir -p "$1"; }
|
||||
chown() { :; }
|
||||
mv() {
|
||||
if [[ "${1:-}" == -Tf ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi
|
||||
}
|
||||
install_release "$archive" 1.0.0
|
||||
[[ -x "$PREFIX/releases/1.0.0/bin/tallynote-admin-init" ]]
|
||||
set_env_key() { local key=$1 value=$2 escaped; :; }
|
||||
set_env_key test value
|
||||
' _ "$installer_lib" "$release_archive" "$tmp/install-release"
|
||||
|
||||
# The production admin wrapper must load a release-relative runtime, change to
|
||||
# the release root, and forward CLI arguments without requiring pnpm.
|
||||
wrapper_prefix="$tmp/wrapper-prefix"
|
||||
mkdir -p "$wrapper_prefix/releases/1.0.0/runtime/bin" "$wrapper_prefix/releases/1.0.0/dist/server/cli"
|
||||
ln -s "$wrapper_prefix/releases/1.0.0" "$wrapper_prefix/current"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'pwd -P > "$TALLYNOTE_WRAPPER_LOG"' 'printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"' > "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
|
||||
chmod 755 "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
|
||||
printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js"
|
||||
TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
|
||||
bash "$root/bin/tallynote-admin-init" --generate
|
||||
wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P)
|
||||
grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log"
|
||||
grep -Fxq -- '--generate' "$tmp/wrapper.log"
|
||||
|
||||
# The first-install prompt is optional and must support an explicit later
|
||||
# initialization path without blocking the rest of the install.
|
||||
admin_wizard_dir="$tmp/admin-wizard"
|
||||
mkdir -p "$admin_wizard_dir"
|
||||
printf '%s\n' yes remaining-input > "$admin_wizard_dir/input"
|
||||
: > "$admin_wizard_dir/output"
|
||||
cat > "$admin_wizard_dir/admin-init" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${1:-}" == --check ]]; then
|
||||
printf '%s\n' empty
|
||||
else
|
||||
printf '%s\n' initialized > "$TALLYNOTE_ADMIN_WIZARD_RESULT"
|
||||
fi
|
||||
EOF
|
||||
chmod 755 "$admin_wizard_dir/admin-init"
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
INSTALL_FIRST_INSTALL=1
|
||||
NON_INTERACTIVE=0
|
||||
PROMPT_INPUT="$dir/input"
|
||||
PROMPT_OUTPUT="$dir/output"
|
||||
ADMIN_INIT_PATH="$dir/admin-init"
|
||||
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/result"
|
||||
export TALLYNOTE_ADMIN_WIZARD_RESULT
|
||||
run_initial_admin_wizard
|
||||
[[ -f "$dir/result" ]]
|
||||
' _ "$installer_lib" "$admin_wizard_dir"
|
||||
|
||||
# An upgrade must never reopen the first-admin wizard, even if a damaged or
|
||||
# deliberately empty database would otherwise report an uninitialized state.
|
||||
printf '%s\n' yes > "$admin_wizard_dir/upgrade-input"
|
||||
rm -f "$admin_wizard_dir/upgrade-result"
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
INSTALL_FIRST_INSTALL=0
|
||||
NON_INTERACTIVE=0
|
||||
PROMPT_INPUT="$dir/upgrade-input"
|
||||
PROMPT_OUTPUT="$dir/upgrade-output"
|
||||
ADMIN_INIT_PATH="$dir/admin-init"
|
||||
TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/upgrade-result"
|
||||
export TALLYNOTE_ADMIN_WIZARD_RESULT
|
||||
run_initial_admin_wizard
|
||||
[[ ! -e "$dir/upgrade-result" ]]
|
||||
' _ "$installer_lib" "$admin_wizard_dir"
|
||||
|
||||
# Validation or password errors after the base service is committed must leave
|
||||
# the installation usable and point the operator at the standalone command.
|
||||
failed_wizard_dir="$tmp/failed-admin-wizard"
|
||||
mkdir -p "$failed_wizard_dir"
|
||||
printf '%s\n' yes > "$failed_wizard_dir/input"
|
||||
: > "$failed_wizard_dir/output"
|
||||
cat >"$failed_wizard_dir/admin-init" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${1:-}" == --check ]]; then
|
||||
printf '%s\n' empty
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
EOF
|
||||
chmod 755 "$failed_wizard_dir/admin-init"
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
INSTALL_FIRST_INSTALL=1
|
||||
NON_INTERACTIVE=0
|
||||
PROMPT_INPUT="$dir/input"
|
||||
PROMPT_OUTPUT="$dir/output"
|
||||
ADMIN_INIT_PATH="$dir/admin-init"
|
||||
run_initial_admin_wizard
|
||||
[[ -x "$dir/admin-init" ]]
|
||||
' _ "$installer_lib" "$failed_wizard_dir"
|
||||
|
||||
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
|
||||
# when the first value looks valid (systemd uses the later value).
|
||||
duplicate_env="$tmp/duplicate.env"
|
||||
@@ -70,15 +303,207 @@ bash -c '
|
||||
fi
|
||||
' _ "$installer_lib" "$duplicate_env"
|
||||
|
||||
# Existing installations must validate the network settings they preserve on
|
||||
# upgrade, including the direct-IP HTTP combination used by the documented
|
||||
# installer command.
|
||||
network_env="$tmp/network.env"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
|
||||
'TALLYNOTE_ALLOW_INSECURE_HTTP=true' > "$network_env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$network_env"
|
||||
if sed 's/^TALLYNOTE_PORT=.*/TALLYNOTE_PORT=65536/' "$network_env" > "$tmp/invalid-port.env"; then
|
||||
if bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/invalid-port.env" >/dev/null 2>&1; then
|
||||
echo 'expected invalid existing listener port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
|
||||
'TALLYNOTE_ALLOW_INSECURE_HTTP=false' > "$tmp/public-http-without-opt-in.env"
|
||||
if bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/public-http-without-opt-in.env" >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP without opt-in in existing env to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_public_origin "http://[2001:db8::10]:3000"
|
||||
' _ "$installer_lib"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=https://tallynote.example.com' \
|
||||
'TALLYNOTE_COOKIE_SECURE=true' > "$tmp/public-https.env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/public-https.env"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=::1' \
|
||||
'TALLYNOTE_PORT=3443' > "$tmp/ipv6-default-origin.env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/ipv6-default-origin.env"
|
||||
if bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
validate_public_origin "http://example.test:65536"
|
||||
' _ "$installer_lib" >/dev/null 2>&1; then
|
||||
echo 'expected invalid public origin port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A fresh interactive install reads from the controlling terminal even when
|
||||
# the installer script itself is piped from curl. The test substitutes files
|
||||
# for that terminal and verifies both listener choices without touching the
|
||||
# host filesystem.
|
||||
interactive_dir="$tmp/interactive"
|
||||
mkdir -p "$interactive_dir/config"
|
||||
printf '\n\n' > "$interactive_dir/local-input"
|
||||
: > "$interactive_dir/local-output"
|
||||
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/local-input"
|
||||
PROMPT_OUTPUT="$dir/local-output"
|
||||
configure_network_interactively
|
||||
[[ "$INSTALL_HOST" == 127.0.0.1 ]]
|
||||
[[ "$INSTALL_PORT" == 3000 ]]
|
||||
[[ "$INSTALL_PUBLIC_ORIGIN" == http://127.0.0.1:3000 ]]
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == false ]]
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
printf '2\n3443\nhttp://203.0.113.10:3443\nyes\n' > "$interactive_dir/public-input"
|
||||
: > "$interactive_dir/public-output"
|
||||
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/public-input"
|
||||
PROMPT_OUTPUT="$dir/public-output"
|
||||
configure_network_interactively
|
||||
[[ "$INSTALL_HOST" == 0.0.0.0 ]]
|
||||
[[ "$INSTALL_PORT" == 3443 ]]
|
||||
[[ "$INSTALL_PUBLIC_ORIGIN" == http://203.0.113.10:3443 ]]
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true ]]
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
printf '2\n3000\nhttp://203.0.113.10:3000\nno\n' > "$interactive_dir/refuse-input"
|
||||
: > "$interactive_dir/refuse-output"
|
||||
if env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/refuse-input"
|
||||
PROMPT_OUTPUT="$dir/refuse-output"
|
||||
configure_network_interactively
|
||||
' _ "$installer_lib" "$interactive_dir" >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP confirmation refusal to stop configuration' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Explicit environment variables take precedence over the prompt, including
|
||||
# when a terminal is available.
|
||||
env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/local-input"
|
||||
PROMPT_OUTPUT="$dir/local-output"
|
||||
if interactive_network_available; then
|
||||
echo "expected explicit network environment to skip prompt" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
# A release archive is extracted under umask 077, then explicitly normalized
|
||||
# so the tallynote system user can traverse and execute the shipped tree.
|
||||
source_tmp="$tmp/source"
|
||||
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
|
||||
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
|
||||
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
|
||||
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
|
||||
chmod 755 "$source_tmp/uninstall.sh"
|
||||
archive_tmp="$tmp/release.tar.gz"
|
||||
tar -C "$source_tmp" -czf "$archive_tmp" .
|
||||
bash -c '
|
||||
@@ -92,8 +517,23 @@ bash -c '
|
||||
[[ "$(stat_mode "$destination/dist")" == 755 ]]
|
||||
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
|
||||
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
|
||||
[[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]]
|
||||
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
|
||||
|
||||
# A normal public-release install only needs the detached SHA-256 manifest;
|
||||
# absence of a signature and public key must not block archive verification.
|
||||
checksum_tmp="$tmp/SHA256SUMS"
|
||||
(cd "$(dirname -- "$archive_tmp")" && sha256sum "$(basename -- "$archive_tmp")") > "$checksum_tmp"
|
||||
bash -c '
|
||||
script=$1
|
||||
archive=$2
|
||||
checksum=$3
|
||||
set --
|
||||
source "$script"
|
||||
REQUIRE_SIGNATURE=false
|
||||
verify_archive "$archive" "$checksum" "" ""
|
||||
' _ "$installer_lib" "$archive_tmp" "$checksum_tmp"
|
||||
|
||||
# Newline/control characters in release configuration must never become extra
|
||||
# systemd EnvironmentFile assignments.
|
||||
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
root=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
|
||||
bash -n "$root/uninstall.sh"
|
||||
|
||||
tmp=$(cd "$(mktemp -d)" && pwd -P)
|
||||
cleanup() { rm -rf -- "$tmp" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
make_fixture() {
|
||||
local fixture=$1
|
||||
mkdir -p "$fixture/opt/tallynote/releases/1.1.1/dist" \
|
||||
"$fixture/opt/tallynote/.update-work" \
|
||||
"$fixture/var/lib/tallynote/files" \
|
||||
"$fixture/var/lib/tallynote/staging" \
|
||||
"$fixture/var/lib/tallynote/exports" \
|
||||
"$fixture/var/lib/tallynote-backups" \
|
||||
"$fixture/etc/tallynote" \
|
||||
"$fixture/etc/systemd/system" \
|
||||
"$fixture/usr/local/sbin" \
|
||||
"$fixture/usr/local/libexec"
|
||||
printf '%s\n' 'release' > "$fixture/opt/tallynote/releases/1.1.1/dist/index.js"
|
||||
ln -s "$fixture/opt/tallynote/releases/1.1.1" "$fixture/opt/tallynote/current"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
|
||||
"TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$fixture/etc/tallynote/update-signing-key.pub" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
chmod 600 "$fixture/etc/tallynote/tallynote.env"
|
||||
printf '%s\n' 'fake public key' > "$fixture/etc/tallynote/update-signing-key.pub"
|
||||
for unit in tallynote.service tallynote-update.service tallynote-update.path; do
|
||||
printf '%s\n' "Description=TallyNote $unit" "WorkingDirectory=$fixture/opt/tallynote/current" "PathExists=$fixture/var/lib/tallynote/update-request.json" > "$fixture/etc/systemd/system/$unit"
|
||||
done
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exec /opt/tallynote/current/runtime/bin/node /opt/tallynote/current/dist/server/cli/admin-init.js' > "$fixture/usr/local/sbin/tallynote-admin-init"
|
||||
cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall"
|
||||
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-admin-init" "$fixture/usr/local/sbin/tallynote-uninstall"
|
||||
printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db"
|
||||
printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db"
|
||||
}
|
||||
|
||||
make_systemctl() {
|
||||
local fixture=$1
|
||||
cat > "$fixture/systemctl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
printf '%s\n' "$*" >> "$TALLYNOTE_TEST_SYSTEMCTL_LOG"
|
||||
case "${1:-}" in
|
||||
is-active) exit 0 ;;
|
||||
stop|disable|daemon-reload) exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod 755 "$fixture/systemctl"
|
||||
}
|
||||
|
||||
run_uninstall() {
|
||||
local fixture=$1
|
||||
TALLYNOTE_UNINSTALL_TEST_MODE=true \
|
||||
TALLYNOTE_UNINSTALL_ROOT="$fixture" \
|
||||
TALLYNOTE_SYSTEMCTL_BIN="$fixture/systemctl" \
|
||||
TALLYNOTE_TEST_SYSTEMCTL_LOG="$fixture/systemctl.log" \
|
||||
bash "$root/uninstall.sh" "${@:2}"
|
||||
}
|
||||
|
||||
fixture="$tmp/normal"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
run_uninstall "$fixture"
|
||||
[[ -d "$fixture/var/lib/tallynote" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
[[ -d "$fixture/var/lib/tallynote-backups" && -f "$fixture/var/lib/tallynote-backups/backup.db" ]]
|
||||
[[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]]
|
||||
[[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]]
|
||||
[[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]]
|
||||
[[ ! -e "$fixture/usr/local/sbin/tallynote-admin-init" ]]
|
||||
grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null
|
||||
grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null
|
||||
path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
|
||||
update_stop=$(grep -n '^stop tallynote-update.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
|
||||
main_stop=$(grep -n '^stop tallynote.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
|
||||
(( path_stop < update_stop && update_stop < main_stop ))
|
||||
|
||||
# Re-running after the first uninstall is harmless and does not touch data.
|
||||
run_uninstall "$fixture"
|
||||
[[ -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# Purge requires the explicit acknowledgement flag and must fail before any
|
||||
# application files are removed.
|
||||
fixture="$tmp/purge"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
if run_uninstall "$fixture" --purge-data >/dev/null 2>&1; then
|
||||
echo 'expected --purge-data without --yes to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
run_uninstall "$fixture" --purge-data --yes --purge-config
|
||||
[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]]
|
||||
[[ ! -e "$fixture/etc/tallynote" ]]
|
||||
|
||||
# In production the service user owns the data directory. The target itself
|
||||
# must be accepted while its parent directories remain root-owned. This test
|
||||
# is meaningful only when the suite runs as root on a host with that account;
|
||||
# ordinary developer runs continue with the portable fixture coverage above.
|
||||
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
|
||||
if [[ "$EUID" == 0 && -n "$tallynote_uid" && "$tallynote_uid" != "$(id -u)" ]]; then
|
||||
fixture="$tmp/service-user-data"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
chown -R "$tallynote_uid" "$fixture/var/lib/tallynote"
|
||||
TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID="$tallynote_uid" run_uninstall "$fixture" --purge-data --yes
|
||||
[[ ! -e "$fixture/var/lib/tallynote" ]]
|
||||
fi
|
||||
|
||||
# A custom data path must not overlap the release prefix; otherwise removing
|
||||
# releases could destroy data that the default uninstall promises to keep.
|
||||
fixture="$tmp/overlap"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/opt/tallynote/releases/data" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
mkdir -p "$fixture/opt/tallynote/releases/data"
|
||||
printf '%s\n' protected > "$fixture/opt/tallynote/releases/data/keep.db"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected overlapping data path to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -f "$fixture/opt/tallynote/releases/data/keep.db" ]]
|
||||
|
||||
# Trailing-slash aliases are rejected before the lexical overlap guard can be
|
||||
# bypassed.
|
||||
fixture="$tmp/trailing"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/" \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected trailing slash path to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# Dot-component aliases are rejected as well; textual paths must be canonical
|
||||
# before the managed-directory containment checks run.
|
||||
fixture="$tmp/dot"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/." \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected dot path component to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# Pending update state blocks destructive work until an operator overrides it.
|
||||
fixture="$tmp/pending"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' pending > "$fixture/opt/tallynote/.update-state"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected pending update state to block uninstall' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# A current link escaping the release tree is rejected without deleting data.
|
||||
fixture="$tmp/link"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
rm -f "$fixture/opt/tallynote/current"
|
||||
ln -s "$fixture/outside" "$fixture/opt/tallynote/current"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected unsafe current symlink to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -L "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# dry-run must not call systemctl or remove files.
|
||||
fixture="$tmp/dry-run"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
run_uninstall "$fixture" --dry-run >/dev/null
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
[[ ! -e "$fixture/systemctl.log" ]]
|
||||
|
||||
printf '%s\n' 'uninstaller shell tests passed'
|
||||
+88
-15
@@ -23,6 +23,7 @@ import {
|
||||
permanentDeleteSchema,
|
||||
statusUpdateSchema,
|
||||
updateApplySchema,
|
||||
updateDownloadSchema,
|
||||
versionSchema,
|
||||
type AttachmentKind,
|
||||
type ExpenseStatus,
|
||||
@@ -94,7 +95,7 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const sessionCookie = "tally_session";
|
||||
const csrfCookie = "tally_csrf";
|
||||
|
||||
type UpdateRateState = { checkedAt: number; appliedAt: number };
|
||||
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
|
||||
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
|
||||
|
||||
function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState {
|
||||
@@ -105,7 +106,7 @@ function updateRateState(database: DatabaseContext["sqlite"], adminId: string):
|
||||
}
|
||||
let state = states.get(adminId);
|
||||
if (!state) {
|
||||
state = { checkedAt: 0, appliedAt: 0 };
|
||||
state = { checkedAt: 0, downloadedAt: 0, appliedAt: 0 };
|
||||
states.set(adminId, state);
|
||||
}
|
||||
return state;
|
||||
@@ -115,13 +116,13 @@ function enforceUpdateCooldown(
|
||||
database: DatabaseContext["sqlite"],
|
||||
config: AppConfig,
|
||||
adminId: string,
|
||||
operation: "check" | "apply",
|
||||
operation: "check" | "download" | "apply",
|
||||
reply: FastifyReply,
|
||||
): void {
|
||||
const state = updateRateState(database, adminId);
|
||||
const now = Date.now();
|
||||
const previous = operation === "check" ? state.checkedAt : state.appliedAt;
|
||||
const cooldown = operation === "check" ? config.updateCheckCooldownMs : config.updateApplyCooldownMs;
|
||||
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
|
||||
const cooldown = operation === "check" ? config.updateCheckCooldownMs : operation === "download" ? config.updateDownloadCooldownMs : config.updateApplyCooldownMs;
|
||||
if (cooldown > 0 && previous > 0 && now - previous < cooldown) {
|
||||
const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000));
|
||||
reply.header("Retry-After", retryAfter);
|
||||
@@ -130,6 +131,7 @@ function enforceUpdateCooldown(
|
||||
: "更新操作过于频繁,请稍后再试");
|
||||
}
|
||||
if (operation === "check") state.checkedAt = now;
|
||||
else if (operation === "download") state.downloadedAt = now;
|
||||
else state.appliedAt = now;
|
||||
}
|
||||
|
||||
@@ -588,6 +590,13 @@ function conflict(database: DatabaseContext, id: string): never {
|
||||
}
|
||||
|
||||
export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
// Helmet's defaults include `upgrade-insecure-requests`, HSTS, COOP and
|
||||
// Origin-Agent-Cluster. Those headers are appropriate for HTTPS, but an
|
||||
// explicitly opted-in HTTP deployment must remain HTTP all the way through
|
||||
// the asset graph; otherwise browsers upgrade `/assets/*` to HTTPS and the
|
||||
// plain HTTP listener appears as a blank page. Keep the transport-sensitive
|
||||
// headers protocol-aware while retaining the other hardening headers.
|
||||
const secureOrigin = config.publicOrigin.startsWith("https:");
|
||||
const app = Fastify({
|
||||
logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false,
|
||||
// Fastify's runtime accepts a numeric hop count, while its v5 typings do
|
||||
@@ -602,10 +611,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(helmet, {
|
||||
...(config.isLocalOrigin ? { hsts: false } : {}),
|
||||
...(!secureOrigin || config.isLocalOrigin ? { hsts: false } : {}),
|
||||
frameguard: { action: "deny" },
|
||||
referrerPolicy: { policy: "no-referrer" },
|
||||
crossOriginOpenerPolicy: { policy: "same-origin" },
|
||||
...(secureOrigin ? { crossOriginOpenerPolicy: { policy: "same-origin" }, originAgentCluster: true } : { crossOriginOpenerPolicy: false, originAgentCluster: false }),
|
||||
crossOriginResourcePolicy: { policy: "same-origin" },
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
@@ -616,7 +625,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
"frame-ancestors": ["'none'"],
|
||||
"base-uri": ["'none'"],
|
||||
"form-action": ["'self'"],
|
||||
...(config.isLocalOrigin ? { "upgrade-insecure-requests": null } : {}),
|
||||
...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -932,9 +941,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
const cached = publicCheckFromCache(database.sqlite, config);
|
||||
const row = database.sqlite.prepare(`
|
||||
SELECT id, status, version, platform, asset_name AS assetName,
|
||||
SELECT id, operation, status, version, platform, asset_name AS assetName,
|
||||
size_bytes AS sizeBytes, error_message AS errorMessage,
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
|
||||
requested_at AS applyQueuedAt
|
||||
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
|
||||
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
return {
|
||||
@@ -988,6 +998,37 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
if (config.updateStrategy !== "systemd") {
|
||||
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
|
||||
}
|
||||
if (input.jobId) {
|
||||
const stagedJobId = input.jobId;
|
||||
const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined;
|
||||
if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载");
|
||||
if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
|
||||
const now = Date.now();
|
||||
const active = database.sqlite.transaction(() => {
|
||||
const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined;
|
||||
if (conflictRow) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
const changed = database.sqlite.prepare("UPDATE update_jobs SET operation='apply', error_message=NULL, requested_at=?, request_id=?, updated_at=? WHERE id=? AND status='staged' AND operation='download'").run(now, request.id, now, stagedJobId);
|
||||
if (changed.changes !== 1) throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: stagedJobId, after: { version: staged.version, staged: true } });
|
||||
return { id: stagedJobId, now };
|
||||
}).immediate();
|
||||
applyAuditTarget = stagedJobId;
|
||||
if (!staged.expectedSha256 || !/^[a-f0-9]{64}$/i.test(staged.expectedSha256)) {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("暂存更新缺少有效校验值", Date.now(), active.id);
|
||||
throw new AppError(409, "UPDATE_NOT_VERIFIED", "暂存更新缺少有效校验值,请重新下载");
|
||||
}
|
||||
try {
|
||||
await writeUpdateRequest(config, { jobId: active.id, operation: "apply", version: staged.version, metadataUrl: config.updateMetadataUrl, assetUrl: staged.assetUrl, assetName: staged.assetName ?? "staged", expectedSha256: staged.expectedSha256, requestedAt: active.now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
|
||||
} catch {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("无法创建系统更新请求", Date.now(), active.id);
|
||||
applyAuditRecorded = true;
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: active.id, outcome: "failure" });
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id: active.id, status: "staged", version: staged.version, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
|
||||
}
|
||||
// Preserve the actionable in-progress response for duplicate clicks before
|
||||
// applying the per-admin cooldown.
|
||||
const activeBeforeCheck = database.sqlite.prepare(`
|
||||
@@ -1055,8 +1096,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
return { id, now };
|
||||
}).immediate();
|
||||
applyAuditTarget = active.id;
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
operation: "apply",
|
||||
version: requestedVersion,
|
||||
metadataUrl: cached.metadataUrl,
|
||||
assetUrl: releaseAsset.url,
|
||||
@@ -1084,7 +1126,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion } });
|
||||
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
|
||||
} catch (error) {
|
||||
if (!applyAuditRecorded) {
|
||||
writeAudit(database.sqlite, {
|
||||
@@ -1101,12 +1143,43 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
|
||||
const input = updateDownloadSchema.parse(request.body);
|
||||
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
|
||||
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
|
||||
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply);
|
||||
const checked = await checkForUpdate(database.sqlite, config);
|
||||
const version = input.version.replace(/^v/i, "");
|
||||
if (!checked.latest || checked.latest.version !== version || !checked.latest.isNewer || !checked.latest.compatible || !checked.latest.integrityReady) throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新");
|
||||
const cached = readCachedRelease(database.sqlite, config);
|
||||
const cachedAsset = cached?.asset;
|
||||
if (!cached || !cachedAsset?.sha256 || cached.version !== version) throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新");
|
||||
const now = Date.now();
|
||||
const id = randomUUID();
|
||||
database.sqlite.transaction(() => {
|
||||
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
|
||||
if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now);
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.download_requested", targetType: "update", targetId: id, after: { version } });
|
||||
}).immediate();
|
||||
try {
|
||||
await writeUpdateRequest(config, { jobId: id, operation: "download", version, metadataUrl: cached.metadataUrl, assetUrl: cachedAsset.url, assetName: cachedAsset.name, expectedSha256: cachedAsset.sha256, requestedAt: now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
|
||||
} catch {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=?").run("无法创建系统更新请求", Date.now(), id);
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } });
|
||||
});
|
||||
|
||||
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
|
||||
const id = z.string().uuid().parse((request.params as { id: string }).id);
|
||||
const row = database.sqlite.prepare(`
|
||||
SELECT id, status, version, platform, asset_name AS assetName,
|
||||
SELECT id, operation, status, version, platform, asset_name AS assetName,
|
||||
size_bytes AS sizeBytes, error_message AS errorMessage,
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
|
||||
requested_at AS applyQueuedAt
|
||||
FROM update_jobs WHERE id=? AND admin_id=?
|
||||
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
if (!row) notFound("更新任务不存在");
|
||||
|
||||
+101
-20
@@ -1,7 +1,7 @@
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { openDatabase } from "../db/index.js";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { openDatabase, openDatabaseReadOnly } from "../db/index.js";
|
||||
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js";
|
||||
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword } from "../security.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
@@ -11,42 +11,120 @@ function arg(name: string): string | undefined {
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
// A terminal paste can contain more than one line. Keep the unread tail for
|
||||
// the next prompt instead of silently discarding credentials after the first
|
||||
// newline.
|
||||
let pendingInput = "";
|
||||
let pendingSkipLf = false;
|
||||
|
||||
async function readSecret(prompt: string): Promise<string> {
|
||||
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
|
||||
output.write(prompt);
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let value = "";
|
||||
let escapeSequence = false;
|
||||
let cleaned = false;
|
||||
const decoder = new StringDecoder("utf8");
|
||||
const wasRaw = Boolean(input.isRaw);
|
||||
const onData = (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8");
|
||||
if (text === "\u0003") {
|
||||
cleanup();
|
||||
reject(new Error("已取消"));
|
||||
} else if (text === "\r" || text === "\n") {
|
||||
cleanup();
|
||||
output.write("\n");
|
||||
resolve(value);
|
||||
} else if (text === "\u007f") {
|
||||
value = value.slice(0, -1);
|
||||
} else if (!text.includes("\u001b")) {
|
||||
value += text;
|
||||
}
|
||||
};
|
||||
const initialInput = pendingInput;
|
||||
pendingInput = "";
|
||||
let onData: (chunk: Buffer | string) => void;
|
||||
let onSignal: () => void;
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
input.off("data", onData);
|
||||
input.off("error", onInputError);
|
||||
process.off("SIGINT", onSignal);
|
||||
process.off("SIGTERM", onSignal);
|
||||
input.setRawMode?.(wasRaw);
|
||||
input.pause();
|
||||
};
|
||||
const finish = (error?: Error) => {
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else {
|
||||
output.write("\n");
|
||||
resolve(value);
|
||||
}
|
||||
};
|
||||
const onInputError = (error: Error) => finish(error);
|
||||
onSignal = () => finish(new Error("已取消"));
|
||||
const consume = (text: string) => {
|
||||
let offset = 0;
|
||||
for (const character of text) {
|
||||
offset += character.length;
|
||||
if (pendingSkipLf) {
|
||||
if (character === "\n") {
|
||||
pendingSkipLf = false;
|
||||
continue;
|
||||
}
|
||||
pendingSkipLf = false;
|
||||
}
|
||||
if (character === "\u0003") {
|
||||
finish(new Error("已取消"));
|
||||
return;
|
||||
}
|
||||
if (escapeSequence) {
|
||||
if (/[A-Za-z~]/.test(character)) escapeSequence = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\u001b") {
|
||||
escapeSequence = true;
|
||||
} else if (character === "\r" || character === "\n") {
|
||||
const tail = text.slice(offset);
|
||||
pendingInput = tail.startsWith("\n") && character === "\r" ? tail.slice(1) : tail;
|
||||
pendingSkipLf = character === "\r" && !tail.startsWith("\n");
|
||||
finish();
|
||||
return;
|
||||
} else if (character === "\u007f" || character === "\b") {
|
||||
value = value.slice(0, -1);
|
||||
} else {
|
||||
value += character;
|
||||
}
|
||||
}
|
||||
};
|
||||
onData = (chunk) => {
|
||||
consume(typeof chunk === "string" ? chunk : decoder.write(chunk));
|
||||
};
|
||||
input.resume();
|
||||
input.setRawMode?.(true);
|
||||
process.once("SIGINT", onSignal);
|
||||
process.once("SIGTERM", onSignal);
|
||||
input.once("error", onInputError);
|
||||
input.on("data", onData);
|
||||
if (initialInput) consume(initialInput);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
if (checkOnly) {
|
||||
let database;
|
||||
try {
|
||||
database = openDatabaseReadOnly(config);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
console.log("empty");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const hasAdminsTable = database.sqlite
|
||||
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'admins'")
|
||||
.get();
|
||||
const existing = hasAdminsTable
|
||||
? database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number }
|
||||
: { count: 0 };
|
||||
console.log(existing.count > 0 ? "initialized" : "empty");
|
||||
} finally {
|
||||
database.sqlite.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
prepareDataDirectories(config);
|
||||
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
||||
const release = acquireInstanceLock(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
@@ -64,6 +142,9 @@ async function main() {
|
||||
if (policyError) throw new Error(policyError);
|
||||
const normalized = normalizeUsername(username);
|
||||
if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符");
|
||||
if ([...normalized].length > 64) throw new Error("用户名最多 64 个字符");
|
||||
const normalizedDisplayName = displayName.normalize("NFKC").trim();
|
||||
if ([...normalizedDisplayName].length < 1 || [...normalizedDisplayName].length > 80) throw new Error("显示名称必须为 1-80 个字符");
|
||||
const passwordHash = await hashPassword(password);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
@@ -74,14 +155,14 @@ async function main() {
|
||||
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
|
||||
must_change_password, auth_version, version, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?)
|
||||
`).run(id, username.normalize("NFKC").trim(), normalized, displayName.trim(), passwordHash, now);
|
||||
`).run(id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, now);
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: `cli:${randomUUID()}`,
|
||||
actorUsername: "cli",
|
||||
action: "admin.initialized",
|
||||
targetType: "admin",
|
||||
targetId: id,
|
||||
after: { username: normalized, displayName: displayName.trim(), status: "active" },
|
||||
after: { username: normalized, displayName: normalizedDisplayName, status: "active" },
|
||||
});
|
||||
})();
|
||||
console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。");
|
||||
|
||||
+129
-11
@@ -31,6 +31,7 @@ import type { UpdateJobStatus } from "../../shared/contracts.js";
|
||||
|
||||
const updateRequestFileSchema = z.object({
|
||||
jobId: z.string().uuid(),
|
||||
operation: z.enum(["download", "apply"]).default("apply"),
|
||||
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
|
||||
metadataUrl: z.string().url(),
|
||||
assetUrl: z.string().url(),
|
||||
@@ -96,6 +97,8 @@ export type UpdateRunOptions = UrlPolicy & {
|
||||
jobId?: string | undefined;
|
||||
publicKey?: string | undefined;
|
||||
requireSignature?: boolean | undefined;
|
||||
operation?: "download" | "apply" | undefined;
|
||||
stagedPath?: string | undefined;
|
||||
};
|
||||
|
||||
export type UpdateRunResult = {
|
||||
@@ -140,21 +143,24 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
requestId?: string | undefined;
|
||||
requestedAt?: number | undefined;
|
||||
startedAt?: number | undefined;
|
||||
operation?: "download" | "apply" | undefined;
|
||||
}): void {
|
||||
if (!sqlite) return;
|
||||
const now = Date.now();
|
||||
const effectiveOperation = values.operation ?? (sqlite.prepare("SELECT operation FROM update_jobs WHERE id=?").get(jobId) as { operation?: "download" | "apply" } | undefined)?.operation ?? "apply";
|
||||
sqlite.prepare(`
|
||||
INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at,
|
||||
status, version, platform, release_url, asset_name, asset_url,
|
||||
operation, status, version, platform, release_url, asset_name, asset_url,
|
||||
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
|
||||
created_at, updated_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
|
||||
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
|
||||
request_id=COALESCE(excluded.request_id, update_jobs.request_id),
|
||||
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
|
||||
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
|
||||
operation=excluded.operation,
|
||||
status=excluded.status, version=excluded.version, platform=excluded.platform,
|
||||
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
|
||||
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
|
||||
@@ -174,6 +180,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
values.requestId ?? null,
|
||||
values.requestedAt ?? null,
|
||||
values.startedAt ?? null,
|
||||
effectiveOperation,
|
||||
values.status,
|
||||
values.version,
|
||||
values.platform,
|
||||
@@ -238,9 +245,28 @@ async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Validate a queued staged directory before a root process consumes it. */
|
||||
async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise<string> {
|
||||
const rootResolved = path.resolve(workspaceRoot);
|
||||
const rootInfo = await lstat(rootResolved).catch(() => null);
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) {
|
||||
throw new Error("更新工作目录权限无效");
|
||||
}
|
||||
const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); });
|
||||
const resolved = path.resolve(candidate);
|
||||
if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
const info = await lstat(resolved).catch(() => null);
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效");
|
||||
const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); });
|
||||
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
return real;
|
||||
}
|
||||
|
||||
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
|
||||
const platform = options.platform ?? detectPlatform();
|
||||
const jobId = options.jobId ?? randomUUID();
|
||||
const operation = options.operation ?? "apply";
|
||||
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
|
||||
try {
|
||||
resolved = await resolveRelease(options, platform);
|
||||
@@ -250,27 +276,36 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
|
||||
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
|
||||
writeJob(options.sqlite, jobId, {
|
||||
status: "queued", version: resolved.version, platform: platform.target,
|
||||
operation, status: "queued", version: resolved.version, platform: platform.target,
|
||||
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
|
||||
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
|
||||
requestId: options.requestId, requestedAt: Date.now(),
|
||||
});
|
||||
|
||||
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
|
||||
const workspace = await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
||||
let keepWorkspace = false;
|
||||
const workspace = operation === "download"
|
||||
? path.join(path.resolve(options.stagingDir), `update-${jobId}`)
|
||||
: await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
||||
if (operation === "download") await mkdir(workspace, { recursive: false, mode: 0o700 });
|
||||
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
|
||||
try {
|
||||
updateJob(options.sqlite, jobId, { status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
|
||||
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
|
||||
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
|
||||
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
|
||||
updateJob(options.sqlite, jobId, { status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
|
||||
updateJob(options.sqlite, jobId, { operation, status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
|
||||
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
|
||||
const stagedDir = path.join(workspace, "payload");
|
||||
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
|
||||
await normalizeReleasePermissions(stagedDir);
|
||||
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
||||
updateJob(options.sqlite, jobId, { status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath });
|
||||
updateJob(options.sqlite, jobId, { operation, status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: workspace });
|
||||
|
||||
if (operation === "download") {
|
||||
keepWorkspace = true;
|
||||
return { jobId, version: resolved.version, asset: resolved.asset, archivePath };
|
||||
}
|
||||
|
||||
let backupArchivePath: string | undefined;
|
||||
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
||||
@@ -295,8 +330,10 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
updateJob(options.sqlite, jobId, { status: options.deferCompletion ? "applying" : "completed", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: switchedBackup ?? backupArchivePath, ...(options.deferCompletion ? {} : { completedAt }) });
|
||||
return { jobId, version: resolved.version, asset: resolved.asset, archivePath, ...(backupArchivePath ? { backupArchivePath } : {}), ...(switchedBackup ? { backupDir: switchedBackup } : {}) };
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
clearTransientJobPath(options.sqlite, jobId);
|
||||
if (!keepWorkspace) {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
clearTransientJobPath(options.sqlite, jobId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackVersion = resolved?.version ?? options.version ?? "0.0.0";
|
||||
@@ -335,6 +372,59 @@ export function finalizeUpdateJob(
|
||||
})();
|
||||
}
|
||||
|
||||
export async function applyStagedUpdate(options: {
|
||||
sqlite: Database.Database;
|
||||
jobId: string;
|
||||
version: string;
|
||||
stagedPath: string;
|
||||
currentDir: string;
|
||||
currentLink: string;
|
||||
releasesDir: string;
|
||||
backupArchivePath?: string;
|
||||
dataBackupArchivePath?: string;
|
||||
dataBackupSource?: string;
|
||||
maxBytes?: number;
|
||||
dataBackupMaxBytes?: number;
|
||||
workspaceRoot?: string;
|
||||
}): Promise<void> {
|
||||
const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record<string, unknown> | undefined;
|
||||
if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态");
|
||||
if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致");
|
||||
const stagedPath = options.workspaceRoot
|
||||
? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot)
|
||||
: options.stagedPath;
|
||||
const payload = path.join(stagedPath, "payload");
|
||||
const payloadInfo = await lstat(payload).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
|
||||
await normalizeReleasePermissions(payload);
|
||||
let switchedBackup: string | undefined;
|
||||
let committed = false;
|
||||
try {
|
||||
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.dataBackupArchivePath });
|
||||
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, { maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024 });
|
||||
}
|
||||
if (options.backupArchivePath) {
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath });
|
||||
const source = await realpath(options.currentDir).catch(() => options.currentDir);
|
||||
await createSafeArchive(source, options.backupArchivePath, { maxBytes: options.maxBytes ?? 512 * 1024 * 1024 });
|
||||
}
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath, startedAt: Date.now() });
|
||||
switchedBackup = (await atomicSwitchRelease(payload, options.currentLink, options.releasesDir, options.version)).previousTarget;
|
||||
committed = true;
|
||||
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (!committed) {
|
||||
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "failed", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), errorMessage: safeErrorMessage(error) });
|
||||
clearTransientJobPath(options.sqlite, options.jobId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, backupPath: switchedBackup ?? options.backupArchivePath });
|
||||
clearTransientJobPath(options.sqlite, options.jobId);
|
||||
}
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
@@ -379,9 +469,36 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
prepareDataDirectories(config);
|
||||
if (request) await ensurePrivilegedWorkspace(stagingDir);
|
||||
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
|
||||
const release = acquireInstanceLock(config);
|
||||
// The download phase intentionally runs beside the live app so users keep
|
||||
// access while the archive is fetched and staged. SQLite WAL plus the
|
||||
// configured busy timeout serializes writes; the exclusive process lock is
|
||||
// reserved for apply/rollback, when the service is stopped by systemd.
|
||||
const release = request?.operation === "download" ? () => undefined : acquireInstanceLock(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
if (request?.operation === "apply") {
|
||||
const staged = database.sqlite.prepare("SELECT download_path AS downloadPath, version FROM update_jobs WHERE id=? AND status='staged' AND operation='apply'").get(request.jobId) as { downloadPath: string | null; version: string } | undefined;
|
||||
if (!staged?.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
|
||||
const root = path.resolve(config.updateWorkspaceDir);
|
||||
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
|
||||
await applyStagedUpdate({
|
||||
sqlite: database.sqlite,
|
||||
jobId: request.jobId,
|
||||
version: request.version,
|
||||
stagedPath: candidate,
|
||||
currentDir,
|
||||
currentLink: request.currentLink,
|
||||
releasesDir: request.releasesDir,
|
||||
workspaceRoot: root,
|
||||
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
||||
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
|
||||
dataBackupSource: config.dataDir,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
});
|
||||
console.log(`更新已切换:${request.version}`);
|
||||
return;
|
||||
}
|
||||
const result = await runUpdate({
|
||||
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
|
||||
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
|
||||
@@ -398,9 +515,10 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
currentVersion: config.appVersion,
|
||||
...(deferCompletion ? { deferCompletion: true } : {}),
|
||||
...(request?.operation === "download" ? { operation: "download" as const } : {}),
|
||||
...(request ? { jobId: request.jobId } : {}),
|
||||
publicKey: config.updatePublicKey,
|
||||
requireSignature: request ? true : config.updateRequireSignature,
|
||||
requireSignature: config.updateRequireSignature,
|
||||
sqlite: database.sqlite,
|
||||
});
|
||||
console.log(`更新完成:${result.version}`);
|
||||
|
||||
+22
-3
@@ -69,7 +69,8 @@ export function loadConfig() {
|
||||
const installPrefix = path.resolve(process.env.TALLYNOTE_INSTALL_PREFIX ?? (updateStrategyRaw === "systemd" ? path.dirname(projectRoot) : projectRoot));
|
||||
const host = process.env.TALLYNOTE_HOST ?? "127.0.0.1";
|
||||
const port = integerEnv("TALLYNOTE_PORT", 3000, 1);
|
||||
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${host}:${port}`;
|
||||
const originHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${originHost}:${port}`;
|
||||
let parsedOrigin: URL;
|
||||
try {
|
||||
parsedOrigin = new URL(publicOrigin);
|
||||
@@ -88,7 +89,14 @@ export function loadConfig() {
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production" || process.env.TALLYNOTE_ENV === "production";
|
||||
const cookieSecure = booleanEnv("TALLYNOTE_COOKIE_SECURE", parsedOrigin.protocol === "https:");
|
||||
// Direct IP access is useful during a first deployment, but it is not
|
||||
// encrypted. Keep this explicitly opt-in so a public install cannot
|
||||
// accidentally expose session cookies over HTTP.
|
||||
const allowInsecureHttp = booleanEnv("TALLYNOTE_ALLOW_INSECURE_HTTP", false);
|
||||
const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (["0.0.0.0", "::"].includes(publicHost)) {
|
||||
throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名");
|
||||
}
|
||||
const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost);
|
||||
const appVersion = (() => {
|
||||
try {
|
||||
@@ -104,7 +112,10 @@ export function loadConfig() {
|
||||
|| "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest";
|
||||
const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS");
|
||||
const updatePublicKey = updatePublicKeyEnv();
|
||||
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", updateStrategyRaw === "systemd");
|
||||
// Public releases always require HTTPS, host allowlisting, and SHA-256.
|
||||
// Detached signatures remain an opt-in hardening layer so a self-hosted
|
||||
// public repository can use one-click updates without provisioning a key.
|
||||
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", false);
|
||||
if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) {
|
||||
throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd");
|
||||
}
|
||||
@@ -119,6 +130,7 @@ export function loadConfig() {
|
||||
timezone,
|
||||
trustProxy: trustProxyEnv(),
|
||||
cookieSecure,
|
||||
allowInsecureHttp,
|
||||
appVersion,
|
||||
updateMetadataUrl,
|
||||
updateAllowedHosts,
|
||||
@@ -139,6 +151,7 @@ export function loadConfig() {
|
||||
// cooldown so an authenticated account cannot turn the endpoint into an
|
||||
// outbound request flood; set to 0 only for controlled test environments.
|
||||
updateCheckCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS", 60) * 1000,
|
||||
updateDownloadCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS", 15) * 1000,
|
||||
updateApplyCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS", 15) * 1000,
|
||||
isLocalOrigin: localOrigin,
|
||||
dataDir,
|
||||
@@ -162,7 +175,13 @@ export function loadConfig() {
|
||||
isProduction,
|
||||
};
|
||||
|
||||
if (!localOrigin && (parsedOrigin.protocol !== "https:" || !cookieSecure)) {
|
||||
if (!localOrigin && parsedOrigin.protocol !== "https:" && !allowInsecureHttp) {
|
||||
throw new Error("公网 HTTP 访问必须显式启用 TALLYNOTE_ALLOW_INSECURE_HTTP=true;生产环境建议使用 HTTPS");
|
||||
}
|
||||
if (!localOrigin && parsedOrigin.protocol !== "https:" && cookieSecure) {
|
||||
throw new Error("HTTP public origin 不能启用安全 Cookie");
|
||||
}
|
||||
if (!localOrigin && parsedOrigin.protocol === "https:" && !cookieSecure) {
|
||||
throw new Error("公网部署必须使用 HTTPS 并启用安全 Cookie");
|
||||
}
|
||||
if (parsedOrigin.protocol === "https:" && !cookieSecure) {
|
||||
|
||||
+22
-1
@@ -1,6 +1,6 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AppConfig } from "../config.js";
|
||||
@@ -44,3 +44,24 @@ export function openDatabase(config: AppConfig): DatabaseContext {
|
||||
if (foreignKeys !== 1) throw new Error("SQLite 外键未启用");
|
||||
return { sqlite, db: drizzle(sqlite, { schema }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an existing database without creating directories, changing journal
|
||||
* mode, running migrations, or changing file permissions. This is used by
|
||||
* administrative status checks that must be side-effect free.
|
||||
*/
|
||||
export function openDatabaseReadOnly(config: AppConfig): DatabaseContext {
|
||||
const info = lstatSync(config.dbPath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`数据库文件不是安全的普通文件:${config.dbPath}`);
|
||||
const sqlite = new Database(config.dbPath, { readonly: true, fileMustExist: true });
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
sqlite.pragma("busy_timeout = 5000");
|
||||
sqlite.pragma("temp_store = MEMORY");
|
||||
sqlite.pragma("query_only = ON");
|
||||
const foreignKeys = sqlite.pragma("foreign_keys", { simple: true });
|
||||
if (foreignKeys !== 1) {
|
||||
sqlite.close();
|
||||
throw new Error("SQLite 外键未启用");
|
||||
}
|
||||
return { sqlite, db: drizzle(sqlite, { schema }) };
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ export const updateJobs = sqliteTable("update_jobs", {
|
||||
adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }),
|
||||
sessionHash: text("session_hash"),
|
||||
requestId: text("request_id"),
|
||||
operation: text("operation", { enum: ["download", "apply"] }).notNull().default("apply"),
|
||||
status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(),
|
||||
version: text("version").notNull(),
|
||||
platform: text("platform").notNull(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
sanitizeAssetName,
|
||||
selectReleaseAsset,
|
||||
validateHttpsUrl,
|
||||
RELEASE_NOTES_MAX_BYTES,
|
||||
type ReleaseAsset,
|
||||
type ReleaseMetadata,
|
||||
} from "./update.js";
|
||||
@@ -34,7 +35,10 @@ export type CachedRelease = {
|
||||
metadataUrl: string;
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
platform: string;
|
||||
signatureVerified?: boolean;
|
||||
asset?: {
|
||||
@@ -53,7 +57,10 @@ export type UpdateCheckResult = {
|
||||
latest: {
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
compatible: boolean;
|
||||
integrityReady: boolean;
|
||||
signatureReady: boolean;
|
||||
@@ -65,6 +72,7 @@ export type UpdateCheckResult = {
|
||||
|
||||
export type UpdateRequest = {
|
||||
jobId: string;
|
||||
operation?: "download" | "apply";
|
||||
version: string;
|
||||
metadataUrl: string;
|
||||
assetUrl: string;
|
||||
@@ -76,6 +84,7 @@ export type UpdateRequest = {
|
||||
currentLink: string;
|
||||
releasesDir: string;
|
||||
dataDir: string;
|
||||
stagedPath?: string;
|
||||
};
|
||||
|
||||
function setting(database: Database.Database, key: string): string | undefined {
|
||||
@@ -206,7 +215,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
metadataUrl,
|
||||
version: safeVersion,
|
||||
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
|
||||
...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}),
|
||||
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
|
||||
...(metadata.notes ? { notes: metadata.notes } : {}),
|
||||
...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}),
|
||||
platform: platform.target,
|
||||
signatureVerified,
|
||||
...(asset ? {
|
||||
@@ -227,7 +239,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
latest: {
|
||||
version: safeVersion,
|
||||
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
|
||||
...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}),
|
||||
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
|
||||
...(metadata.notes ? { notes: metadata.notes } : {}),
|
||||
...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}),
|
||||
compatible: Boolean(asset),
|
||||
integrityReady: Boolean(asset?.sha256 && (!config.updateRequireSignature || signatureVerified)),
|
||||
signatureReady: !config.updateRequireSignature || signatureVerified,
|
||||
@@ -245,6 +260,9 @@ export function readCachedRelease(database: Database.Database, config: AppConfig
|
||||
if (!value || typeof value !== "object" || typeof value.version !== "string" || typeof value.metadataUrl !== "string" || typeof value.platform !== "string") return null;
|
||||
parseSemver(value.version);
|
||||
const metadataUrl = validateHttpsUrl(value.metadataUrl, policy(config)).toString();
|
||||
if (value.releaseName !== undefined && (typeof value.releaseName !== "string" || value.releaseName.length > 200 || /[\u0000-\u001f\u007f]/.test(value.releaseName))) return null;
|
||||
if (value.notes !== undefined && (typeof value.notes !== "string" || Buffer.byteLength(value.notes, "utf8") > RELEASE_NOTES_MAX_BYTES)) return null;
|
||||
if (value.releaseUrl !== undefined) validateHttpsUrl(value.releaseUrl, policy(config));
|
||||
if (value.signatureVerified !== undefined && typeof value.signatureVerified !== "boolean") return null;
|
||||
if (value.asset) {
|
||||
if (typeof value.asset.name !== "string" || typeof value.asset.url !== "string") return null;
|
||||
@@ -266,7 +284,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon
|
||||
return { configured: config.updateStrategy !== "disabled", currentVersion: config.appVersion, platform, checkedAt: cached?.checkedAt ?? 0, latest: cached ? {
|
||||
version: cached.version,
|
||||
...(cached.tagName ? { tagName: cached.tagName } : {}),
|
||||
...(cached.releaseName ? { releaseName: cached.releaseName } : {}),
|
||||
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
|
||||
...(cached.notes ? { notes: cached.notes } : {}),
|
||||
...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}),
|
||||
compatible,
|
||||
integrityReady: compatible && Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
|
||||
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
|
||||
@@ -282,7 +303,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon
|
||||
latest: {
|
||||
version: cached.version,
|
||||
...(cached.tagName ? { tagName: cached.tagName } : {}),
|
||||
...(cached.releaseName ? { releaseName: cached.releaseName } : {}),
|
||||
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
|
||||
...(cached.notes ? { notes: cached.notes } : {}),
|
||||
...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}),
|
||||
compatible: Boolean(cached.asset),
|
||||
integrityReady: Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
|
||||
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
|
||||
@@ -309,8 +333,11 @@ export async function writeUpdateRequest(config: AppConfig, request: UpdateReque
|
||||
export function publicUpdateJob(row: Record<string, unknown> | undefined): Record<string, unknown> | null {
|
||||
if (!row) return null;
|
||||
const hasError = typeof row.errorMessage === "string" && row.errorMessage.length > 0;
|
||||
const updatedAt = typeof row.updatedAt === "number" ? row.updatedAt : null;
|
||||
const expectedRecoveryAt = row.status === "applying" && updatedAt !== null ? updatedAt + 30_000 : null;
|
||||
return {
|
||||
id: row.id,
|
||||
operation: row.operation ?? "apply",
|
||||
status: row.status,
|
||||
version: row.version,
|
||||
platform: row.platform,
|
||||
@@ -323,5 +350,8 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
completedAt: row.completedAt ?? null,
|
||||
...(row.applyQueuedAt ? { applyQueuedAt: row.applyQueuedAt } : {}),
|
||||
...(expectedRecoveryAt ? { expectedRecoveryAt } : {}),
|
||||
...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
+68
-1
@@ -35,7 +35,11 @@ export type ReleaseAsset = {
|
||||
export type ReleaseMetadata = {
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
/** Plain-text release notes, bounded to keep API/cache payloads small. */
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
|
||||
@@ -143,6 +147,57 @@ function metadataError(): Error {
|
||||
}
|
||||
|
||||
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const RELEASE_NOTES_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function releaseNotesText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||
// Gitea exposes both Markdown (body/body_html) and releaseNotes depending on
|
||||
// endpoint/version. Keep the browser contract text-only and bounded.
|
||||
const text = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p\s*>/gi, "\n\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim();
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
if (bytes.length <= RELEASE_NOTES_MAX_BYTES) return text;
|
||||
return bytes.subarray(0, RELEASE_NOTES_MAX_BYTES).toString("utf8").replace(/\uFFFD$/u, "") + "\n[内容已截断]";
|
||||
}
|
||||
|
||||
function releaseNameText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
||||
return text.length > 0 ? text.slice(0, 200) : undefined;
|
||||
}
|
||||
|
||||
/** Gitea installations behind a reverse proxy sometimes emit internal HTTP
|
||||
* asset URLs. Rebind those URLs to the already trusted HTTPS release origin,
|
||||
* while continuing to reject arbitrary HTTPS hosts and credentials. */
|
||||
function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): string {
|
||||
let candidate: URL;
|
||||
try {
|
||||
candidate = new URL(value, current);
|
||||
} catch {
|
||||
throw new Error("更新地址无效");
|
||||
}
|
||||
if (candidate.username || candidate.password) throw new Error("更新地址不允许携带凭据");
|
||||
try {
|
||||
return validateHttpsUrl(candidate, { ...options, baseUrl: current }).toString();
|
||||
} catch {
|
||||
if (candidate.protocol !== "http:") throw new Error("更新地址必须使用 HTTPS");
|
||||
const rebound = new URL(current);
|
||||
rebound.pathname = candidate.pathname;
|
||||
rebound.search = candidate.search;
|
||||
rebound.hash = "";
|
||||
return validateHttpsUrl(rebound, { ...options, baseUrl: current }).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a fetch body without ever buffering more than the caller's bound. */
|
||||
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise<Buffer> {
|
||||
@@ -227,12 +282,24 @@ export async function fetchReleaseMetadata(
|
||||
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
|
||||
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
|
||||
}
|
||||
assets.push({ name, url: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
||||
assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
||||
}
|
||||
const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html);
|
||||
const releaseName = releaseNameText(item.name ?? item.releaseName);
|
||||
let releaseUrl: string | undefined;
|
||||
if (typeof item.html_url === "string" || typeof item.url === "string") {
|
||||
try {
|
||||
const candidate = typeof item.html_url === "string" ? item.html_url : item.url as string;
|
||||
releaseUrl = releaseResourceUrl(candidate, current, options);
|
||||
} catch { /* omit invalid optional release page URL */ }
|
||||
}
|
||||
return {
|
||||
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
|
||||
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
|
||||
...(releaseName ? { releaseName } : {}),
|
||||
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(releaseUrl ? { releaseUrl } : {}),
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -15,7 +15,9 @@ Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
NoNewPrivileges=true
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
# Keep the updater compatible with the same Node/libuv interface discovery
|
||||
# path while retaining an explicit socket-family allowlist.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
|
||||
@@ -4,12 +4,15 @@ TALLYNOTE_DATA_DIR=/var/lib/tallynote
|
||||
TALLYNOTE_INSTALL_PREFIX=/opt/tallynote
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
|
||||
TALLYNOTE_COOKIE_SECURE=false
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=false
|
||||
TALLYNOTE_TIMEZONE=Asia/Shanghai
|
||||
TALLYNOTE_UPDATE_STRATEGY=systemd
|
||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
# Configure a root-managed Ed25519 public key before enabling one-click updates.
|
||||
# Optional: configure a root-managed Ed25519 public key and set
|
||||
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
|
||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||
|
||||
@@ -20,7 +20,9 @@ ProtectSystem=strict
|
||||
InaccessiblePaths=/opt/tallynote/.update-work
|
||||
ProtectHome=true
|
||||
PrivateDevices=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
# Fastify logs the addresses of wildcard listeners. Node's libuv uses the
|
||||
# Linux netlink family while enumerating interfaces for that log message.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,48 @@ describe("TallyNote API", () => {
|
||||
expect(missing.json().error.requestId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("显式允许的公网 HTTP 不会把静态资源升级到 HTTPS", async () => {
|
||||
const publicHttpConfig = {
|
||||
...config,
|
||||
publicOrigin: "http://192.0.2.10:3999",
|
||||
isLocalOrigin: false,
|
||||
allowInsecureHttp: true,
|
||||
cookieSecure: false,
|
||||
};
|
||||
const publicHttpApp = await buildApp(database, publicHttpConfig);
|
||||
try {
|
||||
const response = await publicHttpApp.inject({ method: "GET", url: "/health" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-security-policy"]).not.toContain("upgrade-insecure-requests");
|
||||
expect(response.headers["strict-transport-security"]).toBeUndefined();
|
||||
expect(response.headers["cross-origin-opener-policy"]).toBeUndefined();
|
||||
expect(response.headers["origin-agent-cluster"]).toBeUndefined();
|
||||
} finally {
|
||||
await publicHttpApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("HTTPS 仍保留传输安全响应头", async () => {
|
||||
const secureConfig = {
|
||||
...config,
|
||||
publicOrigin: "https://example.test:3999",
|
||||
isLocalOrigin: false,
|
||||
allowInsecureHttp: false,
|
||||
cookieSecure: true,
|
||||
};
|
||||
const secureApp = await buildApp(database, secureConfig);
|
||||
try {
|
||||
const response = await secureApp.inject({ method: "GET", url: "/health" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-security-policy"]).toContain("upgrade-insecure-requests");
|
||||
expect(response.headers["strict-transport-security"]).toContain("max-age=");
|
||||
expect(response.headers["cross-origin-opener-policy"]).toBe("same-origin");
|
||||
expect(response.headers["origin-agent-cluster"]).toBe("?1");
|
||||
} finally {
|
||||
await secureApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("拒绝没有 Origin 的写请求", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
@@ -41,9 +41,10 @@ describe("数据库迁移", () => {
|
||||
{ name: "0001_invoice_missing_reason.sql" },
|
||||
{ name: "0002_update_jobs.sql" },
|
||||
{ name: "0003_update_job_ownership.sql" },
|
||||
{ name: "0004_update_download_apply.sql" },
|
||||
]);
|
||||
const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>;
|
||||
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at"]));
|
||||
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation"]));
|
||||
expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null });
|
||||
migrated.sqlite.close();
|
||||
migrated = openDatabase(config);
|
||||
|
||||
+26
-3
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { loadConfig, prepareDataDirectories } from "../server/config.js";
|
||||
|
||||
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
|
||||
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
|
||||
|
||||
afterEach(() => { for (const key of keys) delete process.env[key]; });
|
||||
|
||||
@@ -13,11 +13,32 @@ describe("部署安全配置", () => {
|
||||
it("公网 HTTP 或 HTTPS 非安全 Cookie 一律拒绝", () => {
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://example.test";
|
||||
expect(() => loadConfig()).toThrow(/HTTPS/);
|
||||
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
|
||||
expect(loadConfig().allowInsecureHttp).toBe(true);
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
||||
expect(() => loadConfig()).toThrow(/安全 Cookie/);
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "false";
|
||||
expect(() => loadConfig()).toThrow(/安全 Cookie/);
|
||||
});
|
||||
|
||||
it("允许显式配置服务器 IP 的直连 HTTP,并拒绝通配 Origin", () => {
|
||||
process.env.TALLYNOTE_HOST = "0.0.0.0";
|
||||
process.env.TALLYNOTE_PORT = "3000";
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://192.0.2.10:3000";
|
||||
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "false";
|
||||
expect(loadConfig()).toMatchObject({ host: "0.0.0.0", port: 3000, publicOrigin: "http://192.0.2.10:3000", allowInsecureHttp: true });
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://0.0.0.0:3000";
|
||||
expect(() => loadConfig()).toThrow(/通配监听地址/);
|
||||
});
|
||||
|
||||
it("为 IPv6 监听地址生成合法的默认 Origin", () => {
|
||||
process.env.TALLYNOTE_HOST = "::1";
|
||||
process.env.TALLYNOTE_PORT = "3000";
|
||||
expect(loadConfig().publicOrigin).toBe("http://[::1]:3000");
|
||||
});
|
||||
|
||||
it("生产环境不接受任意 trust proxy", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.TALLYNOTE_TRUST_PROXY = "true";
|
||||
@@ -27,14 +48,16 @@ describe("部署安全配置", () => {
|
||||
expect(loadConfig().trustProxy).toBe(1);
|
||||
});
|
||||
|
||||
it("systemd 更新必须绑定主机白名单并默认要求签名", () => {
|
||||
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
||||
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
|
||||
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
|
||||
const config = loadConfig();
|
||||
expect(config.updateRequireSignature).toBe(true);
|
||||
expect(config.updateRequireSignature).toBe(false);
|
||||
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
|
||||
expect(loadConfig().updateRequireSignature).toBe(true);
|
||||
});
|
||||
|
||||
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
|
||||
|
||||
@@ -59,10 +59,10 @@ describe("更新 API", () => {
|
||||
|
||||
function mockRelease() {
|
||||
const digest = "c".repeat(64);
|
||||
const asset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const asset = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`;
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(`${digest} ${asset}\n`, { status: 200 })
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.1", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
||||
: new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
||||
}
|
||||
|
||||
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
|
||||
@@ -70,21 +70,21 @@ describe("更新 API", () => {
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, isNewer: true });
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.2.0", compatible: true, integrityReady: true, isNewer: true });
|
||||
expect(checked.headers["cache-control"]).toBe("no-store");
|
||||
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(tooSoon.statusCode).toBe(429);
|
||||
expect(tooSoon.headers["retry-after"]).toBeDefined();
|
||||
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
const jobId = applied.json().job.id as string;
|
||||
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
|
||||
expect(request).toMatchObject({ jobId, version: "1.1.1", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||
expect(request).toMatchObject({ jobId, version: "1.2.0", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
|
||||
|
||||
mockRelease();
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
||||
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||
@@ -93,9 +93,38 @@ describe("更新 API", () => {
|
||||
expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"]));
|
||||
});
|
||||
|
||||
it("先下载并暂存更新包,再由同一管理员认领应用", async () => {
|
||||
const session = await login("update-staged");
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
expect(downloaded.statusCode).toBe(202);
|
||||
const downloadJobId = downloaded.json().job.id as string;
|
||||
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.2.0" });
|
||||
const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string };
|
||||
expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" });
|
||||
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" });
|
||||
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message='test', updated_at=? WHERE id=?").run(Date.now(), downloadJobId);
|
||||
|
||||
const stagedId = randomUUID();
|
||||
const now = Date.now();
|
||||
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.2.0", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now);
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.2.0", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" });
|
||||
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" });
|
||||
const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string };
|
||||
expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) });
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.2.0", confirm: true } });
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
||||
});
|
||||
|
||||
it("缺少确认或未启用 systemd 时不接受更新", async () => {
|
||||
const session = await login();
|
||||
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1" } });
|
||||
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0" } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
|
||||
const disabledConfig = loadConfig();
|
||||
@@ -108,7 +137,7 @@ describe("更新 API", () => {
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
const jobId = applied.json().job.id as string;
|
||||
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
|
||||
@@ -126,7 +155,7 @@ describe("更新 API", () => {
|
||||
it("应用前重新校验失败时写入失败审计", async () => {
|
||||
const session = await login("update-audit");
|
||||
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
|
||||
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
||||
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
expect(response.statusCode).toBe(502);
|
||||
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
|
||||
expect(audit?.outcome).toBe("failure");
|
||||
|
||||
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
const digest = "b".repeat(64);
|
||||
const platformAsset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const platformAsset = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const sums = `${digest} ${platformAsset}\n`;
|
||||
const signature = sign(null, Buffer.from(sums), privateKey);
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
|
||||
? new Response(signature)
|
||||
: input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(sums)
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.1", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||
: new Response(JSON.stringify({ tag_name: "v1.2.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
const result = await checkForUpdate(database.sqlite, config);
|
||||
expect(result.latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||
expect(result.latest).toMatchObject({ version: "1.2.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
|
||||
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
|
||||
} finally {
|
||||
|
||||
Executable
+497
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# TallyNote native uninstaller. The default operation removes only the
|
||||
# application and service integration; the database and attachments stay in
|
||||
# place until --purge-data --yes is explicitly requested.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
|
||||
TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false}
|
||||
TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-}
|
||||
TEST_DATA_OWNER_UID=${TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID:-}
|
||||
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
|
||||
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
|
||||
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
|
||||
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}
|
||||
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
|
||||
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
|
||||
SYSTEMCTL_BIN=systemctl
|
||||
SYSTEMCTL_AVAILABLE=0
|
||||
SYSTEMCTL_TIMEOUT_SECONDS=${TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS:-30}
|
||||
PURGE_DATA=0
|
||||
PURGE_CONFIG=0
|
||||
YES=0
|
||||
DRY_RUN=0
|
||||
FORCE=0
|
||||
EXPLICIT_PREFIX=0
|
||||
EXPLICIT_DATA=0
|
||||
EXPLICIT_CONFIG=0
|
||||
|
||||
die() { printf 'tallynote uninstaller: %s\n' "$*" >&2; exit 1; }
|
||||
log() { printf 'tallynote uninstaller: %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: tallynote-uninstall [--yes] [--purge-data] [--purge-config]
|
||||
[--dry-run] [--force]
|
||||
[--prefix PATH] [--data-dir PATH] [--config-dir PATH]
|
||||
|
||||
By default, remove the TallyNote release tree, systemd units, update helpers,
|
||||
and known configuration files. The database, attachments, staging, exports,
|
||||
update queue, and update backups are preserved. Data removal requires both
|
||||
--purge-data and --yes. --force is only for an operator who has verified that
|
||||
no update is in progress; it overrides the pending-update guard.
|
||||
EOF
|
||||
}
|
||||
|
||||
is_true() { [[ "$1" == true || "$1" == 1 ]]; }
|
||||
|
||||
if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TEST_MODE" != 0 ]]; then
|
||||
die 'TALLYNOTE_UNINSTALL_TEST_MODE must be true or false'
|
||||
fi
|
||||
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
|
||||
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
|
||||
[[ "$SYSTEMCTL_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || die 'TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS must be a positive integer'
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--yes) YES=1 ;;
|
||||
--purge-data) PURGE_DATA=1 ;;
|
||||
--purge-config) PURGE_CONFIG=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--force) FORCE=1 ;;
|
||||
--prefix) PREFIX=${2:?missing value for --prefix}; EXPLICIT_PREFIX=1; shift ;;
|
||||
--data-dir) DATA_DIR=${2:?missing value for --data-dir}; EXPLICIT_DATA=1; shift ;;
|
||||
--config-dir) CONFIG_DIR=${2:?missing value for --config-dir}; EXPLICIT_CONFIG=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ -n "$TEST_ROOT" ]] || die 'test mode requires TALLYNOTE_UNINSTALL_ROOT'
|
||||
[[ "$TEST_ROOT" = /* && "$TEST_ROOT" != *'..'* && "$TEST_ROOT" != *'//'* && "$TEST_ROOT" != *$'\n'* && "$TEST_ROOT" != *$'\r'* ]] || die 'test root is invalid'
|
||||
(( EXPLICIT_PREFIX )) || PREFIX=${TALLYNOTE_PREFIX:-$TEST_ROOT/opt/tallynote}
|
||||
(( EXPLICIT_DATA )) || DATA_DIR=${TALLYNOTE_DATA_DIR:-$TEST_ROOT/var/lib/tallynote}
|
||||
(( EXPLICIT_CONFIG )) || CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-$TEST_ROOT/etc/tallynote}
|
||||
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-$TEST_ROOT/etc/systemd/system}
|
||||
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-$TEST_ROOT/usr/local/sbin}
|
||||
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-$TEST_ROOT/usr/local/libexec}
|
||||
SYSTEMCTL_BIN=${TALLYNOTE_SYSTEMCTL_BIN:-systemctl}
|
||||
fi
|
||||
|
||||
stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; }
|
||||
stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; }
|
||||
stat_mode_bits() {
|
||||
local mode
|
||||
mode=$(stat_mode "$1")
|
||||
[[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1"
|
||||
printf '%d' "$((8#$mode))"
|
||||
}
|
||||
|
||||
allowed_owner() {
|
||||
local path=$1 uid
|
||||
uid=$(stat_uid "$path")
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
|
||||
else
|
||||
[[ "$uid" == 0 ]]
|
||||
fi
|
||||
}
|
||||
|
||||
allowed_data_owner() {
|
||||
local path=$1 uid tallynote_uid
|
||||
uid=$(stat_uid "$path")
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 || ( -n "$TEST_DATA_OWNER_UID" && "$uid" == "$TEST_DATA_OWNER_UID" ) ]]
|
||||
return
|
||||
fi
|
||||
[[ "$uid" == 0 ]] && return 0
|
||||
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
|
||||
[[ -n "$tallynote_uid" && "$uid" == "$tallynote_uid" ]]
|
||||
}
|
||||
|
||||
validate_path_value() {
|
||||
local value=$1 label=$2
|
||||
[[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径"
|
||||
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"//"* && "$value" != *"/../"* && "$value" != */.. && "$value" != *"/./"* && "$value" != */. && "$value" != / && "$value" != */ ]] || die "$label 包含不受支持的路径字符"
|
||||
case "$value" in
|
||||
/opt|/var|/etc|/usr|/usr/local|/bin|/sbin|/home|/root|/tmp) die "$label 不能指向系统顶层目录" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_parent_chain() {
|
||||
local target=$1 current=/ component relative
|
||||
relative=${target#/}
|
||||
IFS='/' read -r -a _parts <<< "$relative"
|
||||
for component in "${_parts[@]}"; do
|
||||
[[ -n "$component" ]] || continue
|
||||
current="${current%/}/$component"
|
||||
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
|
||||
if [[ -e "$current" ]]; then
|
||||
[[ -d "$current" ]] || die "路径不是目录:$current"
|
||||
# The target itself is checked by validate_target with its path-specific
|
||||
# owner policy (data may belong to the tallynote service user). Keep all
|
||||
# ancestor directories root-owned, but do not apply that policy twice to
|
||||
# the final target.
|
||||
if [[ "$current" != "$target" ]]; then
|
||||
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
|
||||
fi
|
||||
local mode_bits
|
||||
mode_bits=$(stat_mode_bits "$current")
|
||||
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
validate_target() {
|
||||
local target=$1 label=$2 owner_check=allowed_owner
|
||||
[[ "${3:-}" == data ]] && owner_check=allowed_data_owner
|
||||
validate_path_value "$target" "$label"
|
||||
validate_parent_chain "$target"
|
||||
if [[ -e "$target" || -L "$target" ]]; then
|
||||
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
|
||||
fi
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local file=$1 key=$2
|
||||
sed -n "s/^${key}=//p" "$file" | head -n 1
|
||||
}
|
||||
|
||||
env_key_count() {
|
||||
local file=$1 key=$2
|
||||
awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file"
|
||||
}
|
||||
|
||||
load_config() {
|
||||
local env_file=$CONFIG_DIR/tallynote.env value key count
|
||||
[[ -e "$env_file" || -L "$env_file" ]] || return 0
|
||||
[[ -f "$env_file" && ! -L "$env_file" ]] || die '环境文件不是普通文件'
|
||||
allowed_owner "$env_file" || die '环境文件的所有者不受信任'
|
||||
local mode_bits
|
||||
mode_bits=$(stat_mode_bits "$env_file")
|
||||
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
|
||||
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR; do
|
||||
count=$(env_key_count "$env_file" "$key")
|
||||
[[ "$count" == 0 || "$count" == 1 ]] || die "环境文件包含重复配置:$key"
|
||||
done
|
||||
if (( ! EXPLICIT_PREFIX )); then
|
||||
value=$(read_env_value "$env_file" TALLYNOTE_INSTALL_PREFIX)
|
||||
[[ -z "$value" ]] || PREFIX=$value
|
||||
fi
|
||||
if (( ! EXPLICIT_DATA )); then
|
||||
value=$(read_env_value "$env_file" TALLYNOTE_DATA_DIR)
|
||||
[[ -z "$value" ]] || DATA_DIR=$value
|
||||
fi
|
||||
}
|
||||
|
||||
path_inside() {
|
||||
local child=$1 parent=$2
|
||||
[[ "$child" == "$parent"/* ]]
|
||||
}
|
||||
|
||||
assert_disjoint_paths() {
|
||||
local left left_label right right_label
|
||||
local -a labels=(prefix data config unit sbin libexec)
|
||||
for left_label in "${labels[@]}"; do
|
||||
case "$left_label" in
|
||||
prefix) left=$PREFIX ;;
|
||||
data) left=$DATA_DIR ;;
|
||||
config) left=$CONFIG_DIR ;;
|
||||
unit) left=$UNIT_DIR ;;
|
||||
sbin) left=$SBIN_DIR ;;
|
||||
libexec) left=$LIBEXEC_DIR ;;
|
||||
esac
|
||||
for right_label in "${labels[@]}"; do
|
||||
[[ "$left_label" == "$right_label" ]] && continue
|
||||
case "$right_label" in
|
||||
prefix) right=$PREFIX ;;
|
||||
data) right=$DATA_DIR ;;
|
||||
config) right=$CONFIG_DIR ;;
|
||||
unit) right=$UNIT_DIR ;;
|
||||
sbin) right=$SBIN_DIR ;;
|
||||
libexec) right=$LIBEXEC_DIR ;;
|
||||
esac
|
||||
if [[ "$left" == "$right" ]] || path_inside "$left" "$right" || path_inside "$right" "$left"; then
|
||||
die "卸载目录不能互相嵌套:$left 与 $right"
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
assert_test_scope() {
|
||||
[[ "$TEST_MODE" == true ]] || return 0
|
||||
[[ -d "$TEST_ROOT" && ! -L "$TEST_ROOT" ]] || die 'test root must be an existing directory'
|
||||
validate_parent_chain "$TEST_ROOT"
|
||||
allowed_owner "$TEST_ROOT" || die 'test root owner is not trusted'
|
||||
local value label
|
||||
for label in PREFIX DATA_DIR CONFIG_DIR UNIT_DIR SBIN_DIR LIBEXEC_DIR; do
|
||||
case "$label" in
|
||||
PREFIX) value=$PREFIX ;;
|
||||
DATA_DIR) value=$DATA_DIR ;;
|
||||
CONFIG_DIR) value=$CONFIG_DIR ;;
|
||||
UNIT_DIR) value=$UNIT_DIR ;;
|
||||
SBIN_DIR) value=$SBIN_DIR ;;
|
||||
LIBEXEC_DIR) value=$LIBEXEC_DIR ;;
|
||||
esac
|
||||
[[ "$value" == "$TEST_ROOT"/* ]] || die "test mode path escapes TALLYNOTE_UNINSTALL_ROOT: $value"
|
||||
done
|
||||
}
|
||||
|
||||
managed_file() {
|
||||
local target=$1 label=$2
|
||||
case "$label" in
|
||||
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|admin\ initializer|uninstaller)
|
||||
grep -Eiq 'tallynote|TallyNote' "$target" || return 1
|
||||
if [[ "$label" == 'path unit' ]]; then
|
||||
grep -Fq "$DATA_DIR" "$target" || return 1
|
||||
elif [[ "$label" == *unit ]]; then
|
||||
grep -Fq "$PREFIX" "$target" || return 1
|
||||
else
|
||||
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote|admin-init.js' "$target" || return 1
|
||||
fi
|
||||
;;
|
||||
environment\ file)
|
||||
grep -q '^TALLYNOTE_INSTALL_PREFIX=' "$target" || return 1
|
||||
grep -q '^TALLYNOTE_DATA_DIR=' "$target" || return 1
|
||||
[[ "$(read_env_value "$target" TALLYNOTE_INSTALL_PREFIX)" == "$PREFIX" ]] || return 1
|
||||
[[ "$(read_env_value "$target" TALLYNOTE_DATA_DIR)" == "$DATA_DIR" ]] || return 1
|
||||
;;
|
||||
update\ public\ key)
|
||||
[[ -f "$CONFIG_DIR/tallynote.env" ]] || return 1
|
||||
[[ "$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_UPDATE_PUBLIC_KEY_FILE)" == "$target" ]] || return 1
|
||||
;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_release_tree() {
|
||||
local tree=$1 owner_check=${2:-allowed_owner}
|
||||
[[ -d "$tree" && ! -L "$tree" ]] || die "发布目录无效:$tree"
|
||||
"$owner_check" "$tree" || die "发布目录的所有者不受信任:$tree"
|
||||
if find "$tree" -type l -print -quit | grep -q .; then
|
||||
die "发布目录包含符号链接:$tree"
|
||||
fi
|
||||
if find "$tree" ! -type d ! -type f -print -quit | grep -q .; then
|
||||
die "发布目录包含不支持的文件类型:$tree"
|
||||
fi
|
||||
local node mode_bits
|
||||
while IFS= read -r node; do
|
||||
"$owner_check" "$node" || die "发布目录节点的所有者不受信任:$node"
|
||||
mode_bits=$(stat_mode_bits "$node")
|
||||
(( (mode_bits & 18) == 0 )) || die "发布目录节点权限过宽:$node"
|
||||
done < <(find "$tree" -print)
|
||||
}
|
||||
|
||||
pending_update() {
|
||||
[[ -e "$PREFIX/.update-state" || -L "$PREFIX/.update-state" || -e "$DATA_DIR/update-request.json" || -L "$DATA_DIR/update-request.json" ]]
|
||||
}
|
||||
|
||||
run_systemctl() {
|
||||
(( DRY_RUN )) && return 0
|
||||
if [[ "$SYSTEMCTL_BIN" == */* ]]; then
|
||||
[[ -x "$SYSTEMCTL_BIN" ]] || return 0
|
||||
else
|
||||
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
|
||||
fi
|
||||
# A stuck systemd/dbus call must not leave the uninstaller looking frozen.
|
||||
# Test fixtures intentionally bypass the external timeout command.
|
||||
if [[ "$TEST_MODE" != true ]] && command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$SYSTEMCTL_TIMEOUT_SECONDS" "$SYSTEMCTL_BIN" "$@"
|
||||
else
|
||||
"$SYSTEMCTL_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_services() {
|
||||
local unit active status
|
||||
if (( DRY_RUN )); then
|
||||
log 'dry-run: would stop/disable systemd units in path -> updater -> app order'
|
||||
return 0
|
||||
fi
|
||||
if (( ! SYSTEMCTL_AVAILABLE )); then
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
[[ ! -e "$UNIT_DIR/$unit" ]] || die 'systemctl 不可用,无法安全停止已安装服务'
|
||||
done
|
||||
return 0
|
||||
fi
|
||||
log "正在停止 TallyNote 服务(systemd 操作超时 ${SYSTEMCTL_TIMEOUT_SECONDS} 秒)"
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
active=0
|
||||
log "检查服务:$unit"
|
||||
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
|
||||
active=1
|
||||
else
|
||||
status=$?
|
||||
case "$status" in
|
||||
3|4) ;;
|
||||
*) die "无法读取服务状态:$unit" ;;
|
||||
esac
|
||||
fi
|
||||
if (( active )); then
|
||||
log "停止服务:$unit"
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit(如果 systemd 正在等待进程退出,请稍后重试)"
|
||||
log "已停止服务:$unit"
|
||||
fi
|
||||
if [[ -e "$UNIT_DIR/$unit" ]]; then
|
||||
log "禁用服务:$unit"
|
||||
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
|
||||
fi
|
||||
done
|
||||
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
|
||||
log 'systemd 服务已停止并禁用'
|
||||
}
|
||||
|
||||
validate_systemctl() {
|
||||
local resolved uid mode_bits
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
if [[ "$SYSTEMCTL_BIN" == */* && -x "$SYSTEMCTL_BIN" ]]; then
|
||||
SYSTEMCTL_AVAILABLE=1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
resolved=$(command -v systemctl 2>/dev/null || true)
|
||||
if [[ -z "$resolved" ]]; then
|
||||
SYSTEMCTL_AVAILABLE=0
|
||||
return 0
|
||||
fi
|
||||
[[ -x "$resolved" && ! -L "$resolved" ]] || die 'systemctl 必须是可信的普通可执行文件'
|
||||
uid=$(stat_uid "$resolved")
|
||||
mode_bits=$(stat_mode_bits "$resolved")
|
||||
[[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die 'systemctl 必须由 root 拥有且不可被其他用户写入'
|
||||
SYSTEMCTL_BIN=$resolved
|
||||
SYSTEMCTL_AVAILABLE=1
|
||||
}
|
||||
|
||||
remove_file_if_owned() {
|
||||
local target=$1 label=$2
|
||||
[[ -e "$target" || -L "$target" ]] || return 0
|
||||
if [[ -L "$target" || ! -f "$target" ]]; then
|
||||
log "warning: 保留非普通文件:$target"
|
||||
return 0
|
||||
fi
|
||||
if ! allowed_owner "$target"; then
|
||||
log "warning: 保留非本安装创建的文件:$target"
|
||||
return 0
|
||||
fi
|
||||
if ! managed_file "$target" "$label"; then
|
||||
log "warning: 保留内容不匹配的文件:$target"
|
||||
return 0
|
||||
fi
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove $label $target"
|
||||
else
|
||||
rm -f -- "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_tree() {
|
||||
local target=$1 label=$2 owner_check=${3:-allowed_owner}
|
||||
[[ -e "$target" || -L "$target" ]] || return 0
|
||||
[[ -d "$target" && ! -L "$target" ]] || die "$label 不是安全目录:$target"
|
||||
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
|
||||
validate_release_tree "$target" "$owner_check"
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove $label $target"
|
||||
else
|
||||
rm -rf -- "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_prefix() {
|
||||
local current=$PREFIX/current current_target releases=$PREFIX/releases
|
||||
if [[ -L "$current" ]]; then
|
||||
current_target=$(readlink "$current")
|
||||
[[ "$current_target" = "$PREFIX/releases/"* && "$current_target" != *'..'* ]] || die 'current 符号链接指向安装目录之外'
|
||||
[[ -d "$current_target" && ! -L "$current_target" ]] || die 'current 目标不是安全目录'
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove current link $current"
|
||||
else
|
||||
rm -f -- "$current"
|
||||
fi
|
||||
elif [[ -e "$current" ]]; then
|
||||
log "warning: 保留非符号链接 current:$current"
|
||||
fi
|
||||
remove_tree "$releases" 'releases'
|
||||
remove_tree "$PREFIX/.update-work" 'update work'
|
||||
remove_file_if_owned "$PREFIX/.update-state" 'update state'
|
||||
if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then
|
||||
allowed_owner "$PREFIX" || die "安装目录的所有者不受信任:$PREFIX"
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove empty install directory if empty: $PREFIX"
|
||||
else
|
||||
rmdir -- "$PREFIX" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
remove_config() {
|
||||
remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key'
|
||||
remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file'
|
||||
if (( PURGE_CONFIG )) && [[ -d "$CONFIG_DIR" && ! -L "$CONFIG_DIR" ]]; then
|
||||
allowed_owner "$CONFIG_DIR" || die '配置目录的所有者不受信任'
|
||||
if (( DRY_RUN )); then log "dry-run: remove config directory if safe: $CONFIG_DIR"; else rmdir -- "$CONFIG_DIR" 2>/dev/null || true; fi
|
||||
fi
|
||||
}
|
||||
|
||||
remove_data() {
|
||||
local backup_dir
|
||||
backup_dir=$(dirname -- "$DATA_DIR")/tallynote-backups
|
||||
if (( PURGE_DATA )); then
|
||||
(( YES )) || die '--purge-data 必须同时提供 --yes'
|
||||
remove_tree "$DATA_DIR" 'data' allowed_data_owner
|
||||
remove_tree "$backup_dir" 'backup data'
|
||||
else
|
||||
log "保留数据目录:$DATA_DIR"
|
||||
if [[ -d "$backup_dir" ]]; then
|
||||
log "保留备份目录:$backup_dir"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ "$TEST_MODE" != true ]]; then
|
||||
[[ $EUID -eq 0 ]] || die '卸载必须以 root 运行(请使用 sudo)'
|
||||
fi
|
||||
if (( PURGE_DATA && ! YES )); then
|
||||
die '--purge-data 必须同时提供 --yes'
|
||||
fi
|
||||
validate_path_value "$CONFIG_DIR" '配置目录'
|
||||
validate_target "$CONFIG_DIR" '配置目录'
|
||||
assert_test_scope
|
||||
load_config
|
||||
validate_target "$PREFIX" '安装目录'
|
||||
validate_target "$DATA_DIR" '数据目录' data
|
||||
validate_target "$CONFIG_DIR" '配置目录'
|
||||
validate_target "$UNIT_DIR" 'systemd 单元目录'
|
||||
validate_target "$SBIN_DIR" 'sbin 目录'
|
||||
validate_target "$LIBEXEC_DIR" 'libexec 目录'
|
||||
assert_test_scope
|
||||
assert_disjoint_paths
|
||||
validate_systemctl
|
||||
if (( ! FORCE )) && pending_update; then
|
||||
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
|
||||
fi
|
||||
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
|
||||
log '开始移除 TallyNote 文件和服务配置'
|
||||
stop_services
|
||||
remove_prefix
|
||||
log '发布文件和更新组件已移除'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper'
|
||||
remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-admin-init" 'admin initializer'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller'
|
||||
remove_config
|
||||
remove_data
|
||||
log 'uninstall complete'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -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")} />;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal, Zap } from "lucide-react";
|
||||
import { Button, Dialog, Tag } from "tdesign-react";
|
||||
import { ApiError, api } from "../../services/api";
|
||||
import { dateText } from "../expenses/date";
|
||||
@@ -7,10 +7,23 @@ import { ErrorBanner, Page, Surface } from "../common";
|
||||
import type { Notify } from "../expenses/types";
|
||||
|
||||
type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
|
||||
type UpdateJob = { id: string; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt: number; updatedAt: number; completedAt?: number | null };
|
||||
type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: { version: string; tagName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number } | null; job: UpdateJob | null };
|
||||
type UpdateJob = { id: string; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null };
|
||||
type LatestRelease = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number; notes?: string | null; releaseNotes?: string | null; body?: string | null; htmlUrl?: string | null };
|
||||
type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: LatestRelease | null; job: UpdateJob | null };
|
||||
const active = new Set<JobStatus>(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]);
|
||||
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "准备完成", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
|
||||
const pollable = new Set<JobStatus>(["queued", "downloading", "verifying", "backing_up", "applying"]);
|
||||
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "下载完成,等待应用", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
|
||||
|
||||
function timestamp(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const n = typeof value === "number" ? value : Date.parse(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return n < 10_000_000_000 ? n * 1000 : n;
|
||||
}
|
||||
function notesFor(latest: LatestRelease): string | null {
|
||||
const value = latest.notes ?? latest.releaseNotes ?? latest.body;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
@@ -19,95 +32,91 @@ export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { tim
|
||||
const [error, setError] = useState("");
|
||||
const [pollError, setPollError] = useState("");
|
||||
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<"download" | "apply">("download");
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reloadReady, setReloadReady] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const announced = useRef<string | null>(null);
|
||||
const checkInFlight = useRef(false);
|
||||
const applyInFlight = useRef(false);
|
||||
const actionInFlight = useRef(false);
|
||||
const disconnected = useRef(false);
|
||||
const recoveredNotice = useRef(false);
|
||||
|
||||
const load = async () => { setLoading(true); setError(""); try { setInfo(await api<UpdateInfo>("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } };
|
||||
useEffect(() => { void load(); }, []);
|
||||
const job = info?.job;
|
||||
const applyQueuedAt = timestamp(job?.applyQueuedAt);
|
||||
const restartAt = timestamp(job?.restartDeadline)
|
||||
?? timestamp(job?.restartAt)
|
||||
?? timestamp(job?.expectedRecoveryAt)
|
||||
?? (job?.operation === "apply" && applyQueuedAt ? applyQueuedAt + (job.restartWindowSeconds ?? 30) * 1000 : null)
|
||||
?? (job?.status === "applying" && job.updatedAt ? timestamp(job.updatedAt)! + 30_000 : null);
|
||||
const restartSeconds = restartAt ? Math.max(0, Math.ceil((restartAt - now) / 1000)) : null;
|
||||
useEffect(() => { if (!restartAt) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [restartAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const job = info?.job;
|
||||
if (!job) { setPollError(""); return; }
|
||||
const announceCompletion = (completedJob: UpdateJob) => {
|
||||
if (completedJob.status === "completed" && announced.current !== completedJob.id) {
|
||||
announced.current = completedJob.id;
|
||||
setReloadReady(true);
|
||||
notify?.("更新完成,请重新加载页面", "success");
|
||||
}
|
||||
};
|
||||
if (job.status === "completed") {
|
||||
setPollError("");
|
||||
announceCompletion(job);
|
||||
return;
|
||||
}
|
||||
if (!active.has(job.status)) { setPollError(""); return; }
|
||||
let disposed = false;
|
||||
let timer: number | undefined;
|
||||
let failureCount = 0;
|
||||
const shouldPoll = Boolean(job && (pollable.has(job.status) || (job.status === "staged" && job.operation === "apply")));
|
||||
if (!shouldPoll || !job) { setPollError(""); return; }
|
||||
let disposed = false; let timer: number | undefined; let failures = 0;
|
||||
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
|
||||
if (disposed) return;
|
||||
failureCount = 0;
|
||||
setPollError("");
|
||||
failures = 0;
|
||||
if (disconnected.current && !recoveredNotice.current) { recoveredNotice.current = true; notify?.("服务已恢复,更新状态已刷新", "success"); }
|
||||
disconnected.current = false; setPollError("");
|
||||
setInfo(current => current ? { ...current, job: result.job } : current);
|
||||
announceCompletion(result.job);
|
||||
if (active.has(result.job.status)) schedule(1500);
|
||||
} catch (caught) {
|
||||
if (result.job.status === "completed" && announced.current !== result.job.id) { announced.current = result.job.id; setReloadReady(true); notify?.("更新完成,请重新加载页面", "success"); }
|
||||
if (pollable.has(result.job.status) || (result.job.status === "staged" && result.job.operation === "apply")) schedule(1500);
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
failureCount += 1;
|
||||
setPollError(`${(caught as Error).message}。更新任务仍在后台运行,页面会自动重试。`);
|
||||
schedule(Math.min(1500 * (2 ** Math.min(failureCount, 3)), 12_000));
|
||||
failures += 1; disconnected.current = true; recoveredNotice.current = false;
|
||||
setPollError(`服务暂时不可用${restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面会自动重试"}。更新任务仍在后台运行。`);
|
||||
schedule(Math.min(1500 * (2 ** Math.min(failures, 3)), 12_000));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
|
||||
}, [info?.job?.id, info?.job?.status, notify]);
|
||||
}, [job?.id, job?.status, job?.operation, notify]);
|
||||
|
||||
const check = async () => {
|
||||
if (checkInFlight.current) return;
|
||||
checkInFlight.current = true;
|
||||
setChecking(true);
|
||||
setError("");
|
||||
checkInFlight.current = true; setChecking(true); setError("");
|
||||
try {
|
||||
const result = await api<Omit<UpdateInfo, "job"> & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" });
|
||||
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null }));
|
||||
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
|
||||
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null })); notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
|
||||
} catch (caught) {
|
||||
// A configured release source is intentionally rate-limited. A repeated
|
||||
// click should still be useful: show the cached status instead of a
|
||||
// blocking error, while preserving the server-side flood protection.
|
||||
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
|
||||
try {
|
||||
await load();
|
||||
const seconds = caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查";
|
||||
notify?.(`已显示最近一次检查结果${seconds}`, "info");
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to the normal error surface if the status read fails.
|
||||
}
|
||||
}
|
||||
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") { try { await load(); notify?.(`已显示最近一次检查结果${caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查"}`, "info"); return; } catch { /* fall through */ } }
|
||||
setError((caught as Error).message);
|
||||
} finally {
|
||||
checkInFlight.current = false;
|
||||
setChecking(false);
|
||||
}
|
||||
} finally { checkInFlight.current = false; setChecking(false); }
|
||||
};
|
||||
const submitAction = async () => {
|
||||
if (!confirmVersion || actionInFlight.current) return;
|
||||
actionInFlight.current = true; setActionBusy(true); setError("");
|
||||
try {
|
||||
const endpoint = confirmAction === "download" ? "/api/update/download" : "/api/update/apply";
|
||||
const body = confirmAction === "download" ? { version: confirmVersion, confirm: true } : { jobId: job?.id, version: confirmVersion, confirm: true };
|
||||
const result = await api<{ job: UpdateJob }>(endpoint, { method: "POST", body: JSON.stringify(body) });
|
||||
setConfirmVersion(null); setReloadReady(false); setInfo(current => current ? { ...current, job: result.job } : current); notify?.(confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启", "info");
|
||||
} catch (caught) { setError((caught as Error).message); } finally { actionInFlight.current = false; setActionBusy(false); }
|
||||
};
|
||||
const apply = async () => { if (!confirmVersion || applyInFlight.current) return; applyInFlight.current = true; setApplying(true); setError(""); try { const result = await api<{ job: UpdateJob }>("/api/update/apply", { method: "POST", body: JSON.stringify({ version: confirmVersion, confirm: true }) }); setConfirmVersion(null); setInfo(current => current ? { ...current, job: result.job } : current); notify?.("更新请求已提交,服务会短暂重启", "info"); } catch (e) { setError((e as Error).message); } finally { applyInFlight.current = false; setApplying(false); } };
|
||||
const latest = info?.latest; const job = info?.job; const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !job || info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job && !active.has(job.status));
|
||||
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
|
||||
|
||||
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
|
||||
{error && <ErrorBanner message={error} onRetry={() => void load()} />}
|
||||
{pollError && <ErrorBanner message={pollError} />}
|
||||
const latest = info?.latest; const hasActiveJob = Boolean(job && active.has(job.status));
|
||||
const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version);
|
||||
const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && !sameCompleted && (!job || job.version !== latest.version || job.status === "failed" || job.status === "cancelled"));
|
||||
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job?.operation === "download" && job.status === "staged" && job.version === latest.version);
|
||||
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
|
||||
const notes = latest ? notesFor(latest) : null;
|
||||
|
||||
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading || hasActiveJob} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
|
||||
{error && <ErrorBanner message={error} onRetry={() => void load()} />}{pollError && <ErrorBanner message={pollError} />}
|
||||
{loading ? <div className="tn-empty" role="status" aria-live="polite">正在读取版本信息…</div> : info && <>
|
||||
<div className="tn-update-grid"><Surface className="tn-update-block"><Server size={20} /><span className="tn-eyebrow">当前版本</span><strong className="tn-update-value">v{info.currentVersion}</strong><small>运行平台:{info.platform.target}</small></Surface><Surface className="tn-update-block"><ShieldCheck size={20} /><span className="tn-eyebrow">更新方式</span><strong>{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}</strong><small>{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}</small></Surface></div>
|
||||
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div><div className="tn-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canApply && <Button theme="primary" onClick={() => setConfirmVersion(latest.version)} disabled={applying} icon={<Download size={16} />}>更新到 v{latest.version}</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
|
||||
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.releaseName || latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div>{notes && <div className="tn-release-notes"><span className="tn-eyebrow">Release notes</span><div>{notes}</div></div>}<div className="tn-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canDownload && <Button theme="primary" onClick={() => { setConfirmAction("download"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "download"} icon={<Download size={16} />}>下载更新包</Button>}{canApply && <Button theme="primary" onClick={() => { setConfirmAction("apply"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "apply"} icon={<Zap size={16} />}>立即更新</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
|
||||
{!info.configured && <div className="tn-update-explainer"><Terminal size={17} /><div><strong>当前为手动更新模式</strong><p>源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。</p></div></div>}
|
||||
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>更新服务正在后台运行,页面会自动刷新状态。</small></>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
|
||||
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>{job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}</small>{restartSeconds !== null && (job.status === "applying" || disconnected.current) && <div className="tn-restart-countdown" role="status">服务正在重启,预计 {restartSeconds} 秒后恢复</div>}</>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
|
||||
</>}
|
||||
<Dialog visible={Boolean(confirmVersion)} header="确认系统更新" confirmBtn={{ content: "确认更新", theme: "primary", loading: applying, disabled: applying }} cancelBtn="取消" onClose={() => { if (!applying) setConfirmVersion(null); }} onConfirm={() => void apply()} onCancel={() => { if (!applying) setConfirmVersion(null); }}>将更新到 v{confirmVersion}。服务会短暂重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。</Dialog>
|
||||
<Dialog visible={Boolean(confirmVersion)} header={confirmAction === "download" ? "下载更新包" : "确认立即更新"} confirmBtn={{ content: confirmAction === "download" ? "开始下载" : "立即更新", theme: "primary", loading: actionBusy, disabled: actionBusy }} cancelBtn="取消" onClose={() => { if (!actionBusy) setConfirmVersion(null); }} onConfirm={() => void submitAction()} onCancel={() => { if (!actionBusy) setConfirmVersion(null); }}>{confirmAction === "download" ? `将下载并校验 v${confirmVersion},完成后可选择立即更新。` : `将应用已下载的 v${confirmVersion}。服务会短暂重启,更新前会备份数据目录。`}</Dialog>
|
||||
</Page>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user