Compare commits

...
7 Commits
Author SHA1 Message Date
Qiufeng 4aa4511cc3 release: 1.4.0
TallyNote release / linux-x64 (push) Successful in 6m5s
2026-09-17 13:12:26 +08:00
Qiufeng ae8966baf6 fix: 修复在线更新暂存链路并增加全局 API 限流备底
- 新增 server/rate-limit.ts:进程内固定窗口限流器,无数据库写入
- server/app.ts 注册全局 preHandler,仅作用于 /api/*,超限返回 429 与 Retry-After
- 提取 isApiPath 统一 onSend、preHandler 与 404 的路径判断
- 更新任务冲突判定改用 ACTIVE_UPDATE_CONFLICT_SQL,staged/download 产物不再阻塞新任务
- cancelUpdateJob 调用补上 await,避免结果恒为 pending Promise
- server/cli/update.ts 增加特权工作区所有权校验与暂存路径重建逻辑
- 新增 tests/rate-limit.test.ts 与 tests/update-apply-staging.test.ts
2026-09-17 13:12:20 +08:00
Qiufeng 5afcd98ebd release: 1.3.4 2026-09-11 18:28:03 +08:00
Qiufeng 511fc5d785 release: 1.3.3
TallyNote release / linux-x64 (push) Successful in 6m40s
2026-09-11 08:21:34 +08:00
Qiufeng 32e0057bad fix: set operation to apply when staging update completes
TallyNote release / linux-x64 (push) Successful in 6m40s
downloadAndStageUpdate set status=staged but left operation=download,
causing the root runner CLI to reject the apply (operation check failed)
and silently skip the version switch. The symlink stayed on the old version
while the runner reported SUCCESS.
release: 1.3.2
2026-09-11 08:11:27 +08:00
Qiufeng c55ce25939 fix: use version 9.9.9 in test mocks to avoid version comparison failures
TallyNote release / linux-x64 (push) Successful in 6m20s
2026-09-11 01:55:48 +08:00
Qiufeng d01ad74121 release: 1.3.1
TallyNote release / linux-x64 (push) Failing after 2m54s
- online update refactor: synchronous web-process download
- real-time download progress visible in frontend
- eliminates 'waiting for system scheduler' stuck state
release: 1.3.1
2026-09-11 01:35:16 +08:00
29 changed files with 1595 additions and 303 deletions
+5 -3
View File
@@ -48,9 +48,9 @@ pnpm build:next
## 无 Docker 安装(systemd)
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
安装器正式支持 **Linux x86_64(x64,glibc)**,应用包也可以在匹配的原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。安装器托管的 Node.js 24.20.0 仅覆盖 Node.js 官方提供的 x64/arm64 glibc 归档;musl 或 ARMv7 主机必须预先提供可用的系统 Node.js 24+,否则安装器会明确拒绝。Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持。
发布包必须包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、生产依赖、匹配架构的 Node runtime、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
发布包只包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、CI 在目标 Linux 架构上预编译的生产依赖、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh` 和 `SHA256SUMS`,不再携带 Node.js 二进制、源码或开发依赖。安装器检查系统 Node.js 是否为 24+;符合要求时直接复用,不符合时从 `nodejs.org` 下载并校验一次,后续应用更新不会重复下载运行时。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
@@ -115,7 +115,7 @@ tallynote installer: 查看服务状态:systemctl status tallynote.service
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--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`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;没有系统 Node.js 24+ 时,安装器会额外创建带管理标记的 `/opt/tallynote/nodejs/`。切换通过临时链接和原子重命名完成。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`。
升级有两种方式:
@@ -182,3 +182,5 @@ docker compose run --rm --no-deps tallynote node dist/server/cli/admin-init.js -
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256 的归档、路径穿越、特殊文件和符号链接;启用签名要求时也会拒绝无有效签名的归档。附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
<!-- v1.3.1: online update refactor — synchronous web-process download -->
+12 -3
View File
@@ -1,8 +1,17 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
NODE="$ROOT/runtime/bin/node"
[[ -x "$NODE" ]] || NODE=$(command -v node || true)
[[ -n "$NODE" ]] || { printf 'TallyNote: Node.js runtime not found\n' >&2; exit 127; }
NODE=${TALLYNOTE_NODE:-}
node_is_usable() {
local candidate=$1 major
[[ -n "$candidate" && -x "$candidate" ]] || return 1
major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)
[[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]]
}
node_is_usable "$NODE" || NODE=$(command -v node || true)
node_is_usable "$NODE" || { printf 'TallyNote: Node.js 24+ not found; run the installer again\n' >&2; exit 127; }
exec "$NODE" "$ROOT/dist/server/index.js" "$@"
+11 -4
View File
@@ -4,7 +4,7 @@ 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
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
umask 077
@@ -15,6 +15,13 @@ SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
die() { printf 'tallynote admin-init: %s\n' "$*" >&2; exit 1; }
node_is_usable() {
local candidate=$1 major
[[ -n "$candidate" && -x "$candidate" ]] || return 1
major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)
[[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]]
}
load_environment_file() {
[[ -e "$CONFIG_FILE" ]] || return 0
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || die '环境文件不是安全的普通文件'
@@ -105,9 +112,9 @@ main() {
[[ "$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'
node=${TALLYNOTE_NODE:-}
node_is_usable "$node" || node=$(command -v node || true)
node_is_usable "$node" || die '找不到 Node.js 24+'
cli="$root/dist/server/cli/admin-init.js"
[[ -f "$cli" && ! -L "$cli" ]] || die '管理员初始化程序不存在'
+7 -4
View File
@@ -1,8 +1,10 @@
# Release、安装与更新
TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`、`argon2`、`sharp` 和 Node runtime 都包含原生代码,不能在 macOS 上交叉打包后冒充 Linux。
TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`、`argon2` 和 `sharp` 都包含原生代码,不能在 macOS 上交叉打包后冒充 Linux。Node.js 不再进入每个发布包;安装器负责复用系统 Node.js 24+,或从 Node.js 官方 HTTPS 归档下载并校验一次。
正式支持:Linux x86_64/amd64;脚本和安装器也支持在原生 runner 上提供 Linux aarch64/arm64(glibc 或 musl)。当前仓库 workflow 只生成 x64,arm64 必须使用对应 runner 单独构建发布。ARMv7/ARM32 只在你拥有对应 runner 和完整依赖构建结果时实验使用。Linux x86 32 位(i386、i686、ia32)明确不支持,Node.js 24 及原生依赖没有可维护的正式构建,因此安装器会拒绝它。
正式支持:Linux x86_64/amd64(glibc);发布脚本也可在原生 Linux aarch64/arm64 runner 上构建对应应用包。当前仓库 workflow 只生成 x64,arm64 需要在匹配的 runner 上单独构建发布。安装器托管的 Node.js 24.20.0 仅覆盖 Node.js 官方提供的 x64/arm64 glibc 归档;musl 或 ARMv7 主机必须预先提供可用的系统 Node.js 24+,否则安装器会明确拒绝,而不会请求不存在的官方归档。Linux x86 32 位(i386、i686、ia32)明确不支持。
首次安装按 `TALLYNOTE_NODE`、系统 `node`、安装器托管运行时的顺序选择 Node.js。没有满足 24+ 的系统 Node.js 时,安装器从 `https://nodejs.org/dist/v24.20.0/` 下载匹配架构的归档和 `SHASUMS256.txt`,通过 SHA-256 校验后放入 `/opt/tallynote/nodejs/`,并把路径写入 `/etc/tallynote/tallynote.env`。发布包和应用更新都不会再次携带或下载 Node.js;卸载器只删除带 TallyNote 管理标记的运行时目录。
## 自动发布
@@ -30,7 +32,7 @@ GITEA_TOKEN=... \
./scripts/publish-gitea-release.sh v1.1.2 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。构建脚本会同时生成完整安装包和轻量更新包:`tallynote-1.1.2-linux-x64-glibc.tar.gz` 用于首次安装,`tallynote-1.1.2-linux-x64-glibc.update-<锁文件 SHA256>.tar.gz` 仅用于复用现有运行时的后台更新。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。当前发布流程只生成这一份完整生产包:包内包含构建后的 `dist/`、目标 Linux 架构上预编译的生产 `node_modules/`、迁移文件、systemd 单元和安装/更新/卸载辅助脚本,但不包含 Node.js 二进制、源码或开发依赖。首次安装和后台应用更新都使用同一份完整包;主机上的 Node.js 24+ 由安装器一次性准备并在后续更新中复用。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
## curl 安装
@@ -83,6 +85,7 @@ tallynote installer: 访问地址:http://127.0.0.1:<端口>
```text
/opt/tallynote/releases/<version>/ # 只读发布代码
/opt/tallynote/current -> releases/<version>
/opt/tallynote/nodejs/ # 主机没有 Node.js 24+ 时由安装器管理
/opt/tallynote/.update-work/ # 0700 root:root,root 更新器临时工作区
/opt/tallynote/.update-state # root 更新状态标记,异常中断后用于恢复
/var/lib/tallynote/ # SQLite、附件、暂存和导出
@@ -106,7 +109,7 @@ sudo /usr/local/sbin/tallynote-uninstall
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;更新器下载同一份完整生产包,流式校验 SHA-256、解包并暂存,成功后只显示“已下载,等待应用”,不会自动重启。管理员点击“立即更新”后才写入 root 更新请求,应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
+200 -17
View File
@@ -3,7 +3,7 @@ set -Eeuo pipefail
# TallyNote native installer. Installs the latest release by default; use
# --dry-run to preview without changing the host.
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
umask 077
@@ -34,6 +34,11 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300}
RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-}
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname}
NODE_MIN_MAJOR=24
NODE_VERSION=${TALLYNOTE_NODE_VERSION:-24.20.0}
NODE_PATH=${TALLYNOTE_NODE:-}
NODE_INSTALL_ROOT=$PREFIX/nodejs
NODE_VERSION_DETECTED=''
# Service network settings are written to the systemd EnvironmentFile on a
# fresh install. Existing values are preserved unless the corresponding
# TALLYNOTE_* variable is explicitly supplied to the installer.
@@ -57,6 +62,7 @@ INSTALL_PREVIOUS_TARGET=''
INSTALL_NEW_RELEASE=''
INSTALL_WORK_DIR=''
INSTALL_BACKUP_DIR=''
INSTALL_UNIT_TMP=''
INSTALL_BACKUP_COMPLETE=0
INSTALL_WAS_ACTIVE=0
INSTALL_PATH_WAS_ACTIVE=0
@@ -65,6 +71,12 @@ INSTALL_WAS_ENABLED=0
INSTALL_PATH_WAS_ENABLED=0
INSTALL_UPDATE_WAS_ENABLED=0
INSTALL_SYSTEMD_TOUCHED=0
INSTALL_NODE_CREATED=0
INSTALL_NODE_TARGET=''
INSTALL_NODE_MARKER_CREATED=0
INSTALL_NODE_MARKER=''
INSTALL_NODE_ROOT_CREATED=0
NODE_INSTALL_TMP=''
ADMIN_INIT_PATH=/usr/local/sbin/tallynote-admin-init
INSTALL_FIRST_INSTALL=0
DATA_DIR_TEMP_ROOT=0
@@ -402,6 +414,7 @@ configure_network_interactively() {
[[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false'
[[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false'
[[ "$SIGNATURE_FORMAT" == ed25519 || "$SIGNATURE_FORMAT" == gpg ]] || die '签名格式必须是 ed25519 或 gpg'
[[ "$NODE_VERSION" =~ ^24\.[0-9]+\.[0-9]+$ ]] || die 'TALLYNOTE_NODE_VERSION 必须是 24.x.y 版本号'
[[ "$MAX_RELEASE_MB" =~ ^[1-9][0-9]*$ && "$MAX_EXTRACT_MB" =~ ^[1-9][0-9]*$ && "$MAX_ARCHIVE_ENTRIES" =~ ^[1-9][0-9]*$ ]] || die '安装资源限制必须是正整数'
[[ "$CONNECT_TIMEOUT" =~ ^[1-9][0-9]*$ && "$MAX_TIME" =~ ^[1-9][0-9]*$ ]] || die '安装超时配置必须是正整数'
@@ -461,6 +474,146 @@ detect_platform() {
export TALLYNOTE_ARCH TALLYNOTE_LIBC
}
node_major_version() {
local candidate=$1 value
[[ -x "$candidate" ]] || return 1
value=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)
[[ "$value" =~ ^[0-9]+$ ]] || return 1
printf '%s' "$value"
}
node_is_legacy_embedded() {
local candidate=$1 resolved
[[ -n "$candidate" ]] || return 1
resolved=$(readlink -f -- "$candidate" 2>/dev/null || realpath "$candidate" 2>/dev/null || printf '%s' "$candidate")
[[ "$resolved" == "$PREFIX/current/runtime/"* || "$resolved" == "$PREFIX/releases/"*/runtime/* ]]
}
node_is_usable() {
local candidate=$1 major resolved uid mode_bits
[[ -n "$candidate" && -x "$candidate" ]] || return 1
resolved=$(readlink -f -- "$candidate" 2>/dev/null || realpath "$candidate" 2>/dev/null || printf '%s' "$candidate")
[[ -x "$resolved" ]] || return 1
if (( EUID == 0 )); then
uid=$(stat_uid "$resolved")
mode_bits=$(stat_mode_bits "$resolved")
[[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || return 1
fi
major=$(node_major_version "$candidate") || return 1
(( major >= NODE_MIN_MAJOR )) || return 1
NODE_VERSION_DETECTED=$("$candidate" -p 'process.versions.node' 2>/dev/null || true)
[[ -n "$NODE_VERSION_DETECTED" ]]
}
node_archive_name() {
local platform
case "${TALLYNOTE_LIBC}:${TALLYNOTE_ARCH}" in
glibc:x64) platform=linux-x64 ;;
glibc:arm64) platform=linux-arm64 ;;
*) die "Node.js 官方未提供当前平台的 ${NODE_MIN_MAJOR}+ 归档(${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC});请先安装可用的系统 Node.js 24+ 后重试" ;;
esac
printf 'node-v%s-%s.tar.xz' "$NODE_VERSION" "$platform"
}
install_managed_node() {
local archive checksum archive_name expected actual tmp extracted target marker
archive_name=$(node_archive_name)
tmp=$(mktemp -d)
NODE_INSTALL_TMP=$tmp
archive="$tmp/$archive_name"
checksum="$tmp/SHASUMS256.txt"
stage "系统未找到 Node.js ${NODE_MIN_MAJOR}+,下载官方运行时 ${NODE_VERSION}"
append_allowed_host nodejs.org
download "https://nodejs.org/dist/v${NODE_VERSION}/${archive_name}" "$archive" $((256 * 1024 * 1024))
download "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" "$checksum" $((4 * 1024 * 1024))
expected=$(awk -v name="$archive_name" '$2 == name { print $1; exit }' "$checksum")
[[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'Node.js 官方校验清单中没有匹配归档'
actual=$(sha256sum "$archive" | awk '{print $1}')
[[ "${actual,,}" == "${expected,,}" ]] || die 'Node.js 官方归档 SHA-256 校验失败'
if [[ ! -e "$NODE_INSTALL_ROOT" && ! -L "$NODE_INSTALL_ROOT" ]]; then
INSTALL_NODE_ROOT_CREATED=1
fi
ensure_root_directory "$NODE_INSTALL_ROOT" 755
tar -xJf "$archive" -C "$tmp"
extracted="$tmp/${archive_name%.tar.xz}"
[[ -d "$extracted" && -x "$extracted/bin/node" ]] || die 'Node.js 官方归档结构无效'
target="$NODE_INSTALL_ROOT/${archive_name%.tar.xz}"
[[ ! -e "$target" && ! -L "$target" ]] || die "Node.js 目标目录已存在:$target"
mv -- "$extracted" "$target"
INSTALL_NODE_CREATED=1
INSTALL_NODE_TARGET=$target
marker="$NODE_INSTALL_ROOT/.tallynote-managed"
if [[ -e "$marker" || -L "$marker" ]]; then
[[ -f "$marker" && ! -L "$marker" && "$(sed -n '1p' "$marker" 2>/dev/null)" == tallynote-managed-node-v1 ]] || die 'Node.js 管理目录标记无效'
else
printf 'tallynote-managed-node-v1\n' > "$marker"
chmod 600 "$marker"
INSTALL_NODE_MARKER_CREATED=1
INSTALL_NODE_MARKER=$marker
fi
chown -R root:root "$target"
chown root:root "$marker"
NODE_PATH="$target/bin/node"
rm -rf -- "$tmp"
NODE_INSTALL_TMP=''
node_is_usable "$NODE_PATH" || die '已安装的 Node.js 运行时无法通过版本检查'
stage_done "Node.js ${NODE_VERSION_DETECTED} 已安装并记录为共享运行时"
}
cleanup_node_install_if_needed() {
local result=$? marker
if [[ -n "$NODE_INSTALL_TMP" && -d "$NODE_INSTALL_TMP" ]]; then
rm -rf -- "$NODE_INSTALL_TMP" 2>/dev/null || true
NODE_INSTALL_TMP=''
fi
if (( INSTALL_COMMITTED == 0 && INSTALL_NODE_CREATED == 1 )); then
if [[ -n "$INSTALL_NODE_TARGET" && -d "$INSTALL_NODE_TARGET" && ! -L "$INSTALL_NODE_TARGET" ]]; then
rm -rf -- "$INSTALL_NODE_TARGET" 2>/dev/null || true
fi
marker=$INSTALL_NODE_MARKER
if (( INSTALL_NODE_MARKER_CREATED == 1 )) && [[ -n "$marker" && -f "$marker" && ! -L "$marker" ]]; then
rm -f -- "$marker" 2>/dev/null || true
fi
if (( INSTALL_NODE_ROOT_CREATED == 1 )) && [[ -d "$NODE_INSTALL_ROOT" && ! -L "$NODE_INSTALL_ROOT" ]]; then
rmdir -- "$NODE_INSTALL_ROOT" 2>/dev/null || true
fi
INSTALL_NODE_CREATED=0
fi
return "$result"
}
ensure_node_runtime() {
local candidate='' managed_node marker
[[ "$NODE_INSTALL_ROOT" == "$PREFIX"/* ]] || die 'Node.js 管理目录必须位于 TallyNote 安装目录内'
if [[ -n "$NODE_PATH" ]] && ! node_is_legacy_embedded "$NODE_PATH" && node_is_usable "$NODE_PATH"; then
stage_done "复用已配置的 Node.js ${NODE_VERSION_DETECTED}:$NODE_PATH"
return 0
fi
candidate=$(command -v node || true)
if [[ -n "$candidate" ]] && node_is_usable "$candidate"; then
NODE_PATH=$candidate
stage_done "复用系统 Node.js ${NODE_VERSION_DETECTED}:$NODE_PATH"
return 0
fi
marker="$NODE_INSTALL_ROOT/.tallynote-managed"
if [[ -f "$marker" && ! -L "$marker" && "$(sed -n '1p' "$marker" 2>/dev/null)" == tallynote-managed-node-v1 ]]; then
while IFS= read -r managed_node; do
[[ -n "$managed_node" ]] || continue
if node_is_usable "$managed_node"; then
NODE_PATH=$managed_node
stage_done "复用已安装的 Node.js ${NODE_VERSION_DETECTED}:$NODE_PATH"
return 0
fi
done < <(find "$NODE_INSTALL_ROOT" -mindepth 3 -maxdepth 3 -type f -path '*/bin/node' -print 2>/dev/null | sort -V -r)
fi
if (( ! APPLY )); then
log "dry-run: 当前主机需要 Node.js ${NODE_MIN_MAJOR}+;正式安装时将从 nodejs.org 下载并校验"
return 0
fi
[[ $EUID -eq 0 ]] || die '安装 Node.js 运行时必须以 root 运行'
install_managed_node
}
require_https() {
local value=$1
case "$value" in https://*) ;; *) die "release endpoints must use HTTPS: $value" ;; esac
@@ -711,7 +864,7 @@ normalize_release_tree() {
fi
find "$root" -type d -exec chmod 755 {} +
find "$root" -type f -exec chmod 644 {} +
for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/* "$root/uninstall.sh"; do
for item in "$root/bin"/* "$root/scripts"/*.sh "$root/uninstall.sh"; do
[[ -f "$item" && ! -L "$item" ]] || continue
chmod 755 "$item"
done
@@ -868,6 +1021,10 @@ stop_existing_services() {
rollback_install_if_needed() {
local result=$? rollback_tmp
# This helper intentionally returns the original exit status when used as
# the early EXIT trap. Once called from this rollback trap, swallow that
# status so errexit cannot skip restoration of the previous installation.
cleanup_node_install_if_needed || true
if (( INSTALL_COMMITTED == 0 && INSTALL_SYSTEMD_TOUCHED == 1 )) && command -v systemctl >/dev/null 2>&1; then
# The failed install may have started units that were inactive before the
# attempt. Stop them before restoring files so systemd never keeps running
@@ -928,6 +1085,10 @@ rollback_install_if_needed() {
if [[ -n "$INSTALL_WORK_DIR" && -d "$INSTALL_WORK_DIR" ]]; then
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
fi
if [[ -n "$INSTALL_UNIT_TMP" && -d "$INSTALL_UNIT_TMP" && ! -L "$INSTALL_UNIT_TMP" ]]; then
rm -rf -- "$INSTALL_UNIT_TMP" 2>/dev/null || true
fi
INSTALL_UNIT_TMP=''
return "$result"
}
@@ -1077,7 +1238,7 @@ validate_existing_env() {
mode_bits=$(stat_mode_bits "$file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
local key key_count
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_CONFIG_DIR TALLYNOTE_NODE TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
key_count=$(env_key_count "$file" "$key")
[[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key"
done
@@ -1085,6 +1246,12 @@ validate_existing_env() {
[[ -z "$value" || "${value%/}" == "${PREFIX%/}" ]] || die '环境文件中的安装目录与本次安装不一致'
value=$(read_env_value "$file" TALLYNOTE_DATA_DIR)
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
value=$(read_env_value "$file" TALLYNOTE_CONFIG_DIR)
[[ -z "$value" || "${value%/}" == "${CONFIG_DIR%/}" ]] || die '环境文件中的配置目录与本次安装不一致'
value=$(read_env_value "$file" TALLYNOTE_NODE)
if [[ -n "$value" ]]; then
validate_env_value "$value" '环境文件中的 Node.js 路径'
fi
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then
@@ -1161,6 +1328,7 @@ install_release() {
safe_extract "$archive" "$tmp/unpacked"
normalize_release_tree "$tmp/unpacked"
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root'
[[ ! -e "$tmp/unpacked/runtime" ]] || die 'release archive must not contain an embedded Node.js runtime'
[[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote'
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/server/cli/admin-init.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units'
@@ -1224,6 +1392,9 @@ main() {
fi
detect_platform
configure_network_interactively
if [[ -z "$NODE_PATH" && -f "$CONFIG_DIR/tallynote.env" ]]; then
NODE_PATH=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_NODE 2>/dev/null || true)
fi
validate_listen_host "$INSTALL_HOST"
validate_listen_port "$INSTALL_PORT"
if (( APPLY && NETWORK_INTERACTIVE )); then
@@ -1310,6 +1481,11 @@ main() {
for command_name in curl sha256sum tar install sed awk find systemctl; do
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
done
# Install the cleanup trap before downloading a managed Node.js runtime. A
# failed runtime download or extraction must not leave a partial toolchain
# behind even when release acquisition has not started yet.
trap cleanup_node_install_if_needed EXIT
ensure_node_runtime
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signature verification is enabled'
fi
@@ -1394,24 +1570,26 @@ main() {
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" && -x "$release_dir/bin/tallynote-admin-init" && -f "$release_dir/dist/server/cli/admin-init.js" ]] || die 'release package is missing update/uninstall/admin-init support files'
stage '安装 systemd 单元、更新辅助程序和卸载器'
install -d -m 755 /usr/local/sbin /usr/local/libexec /etc/systemd/system
local unit_tmp
unit_tmp=$(mktemp -d)
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$unit_tmp/tallynote-admin-init"
install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service
install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service
install -o root -g root -m 644 "$unit_tmp/tallynote-update.path" /etc/systemd/system/tallynote-update.path
install -o root -g root -m 755 "$unit_tmp/tallynote-admin-init" "$ADMIN_INIT_PATH"
rm -rf "$unit_tmp"
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
INSTALL_UNIT_TMP=$(mktemp -d)
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/systemd/tallynote.service" > "$INSTALL_UNIT_TMP/tallynote.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/systemd/tallynote-update.service" > "$INSTALL_UNIT_TMP/tallynote-update.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$INSTALL_UNIT_TMP/tallynote-update.path"
sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$INSTALL_UNIT_TMP/tallynote-admin-init"
install -o root -g root -m 644 "$INSTALL_UNIT_TMP/tallynote.service" /etc/systemd/system/tallynote.service
install -o root -g root -m 644 "$INSTALL_UNIT_TMP/tallynote-update.service" /etc/systemd/system/tallynote-update.service
install -o root -g root -m 644 "$INSTALL_UNIT_TMP/tallynote-update.path" /etc/systemd/system/tallynote-update.path
install -o root -g root -m 755 "$INSTALL_UNIT_TMP/tallynote-admin-init" "$ADMIN_INIT_PATH"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/scripts/tallynote-update.sh" > "$INSTALL_UNIT_TMP/tallynote-update.sh"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/scripts/tallynote-update-runner.sh" > "$INSTALL_UNIT_TMP/tallynote-update-runner.sh"
install -o root -g root -m 755 "$INSTALL_UNIT_TMP/tallynote-update.sh" /usr/local/sbin/tallynote-update
install -o root -g root -m 755 "$INSTALL_UNIT_TMP/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
rm -rf -- "$INSTALL_UNIT_TMP"
INSTALL_UNIT_TMP=''
install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
local env_created=0
if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env"
chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env"
env_created=1
@@ -1463,6 +1641,11 @@ main() {
if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi
ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX"
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR"
ensure_env_key TALLYNOTE_CONFIG_DIR "$CONFIG_DIR"
configured_node=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_NODE 2>/dev/null || true)
if [[ -z "$configured_node" ]] || node_is_legacy_embedded "$configured_node" || ! node_is_usable "$configured_node"; then
set_env_key TALLYNOTE_NODE "$NODE_PATH"
fi
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL"
ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS"
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
@@ -29,30 +29,21 @@
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^9.2.1",
"@fastify/static": "^10.1.3",
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
"@reduxjs/toolkit": "2.12.0",
"archiver": "^8.0.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.2.0",
"drizzle-orm": "^0.45.2",
"echarts": "6.1.0",
"echarts-for-react": "3.0.6",
"exceljs": "^4.4.0",
"fast-xml-parser": "^5.2.5",
"fastify": "^5.4.0",
"less": "4.4.1",
"lucide-react": "^0.542.0",
"pdf-lib": "^1.17.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "9.2.0",
"react-router-dom": "7.18.3",
"sharp": "^0.35.4",
"tdesign-react": "1.18.2",
"yauzl": "^3.2.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
"@reduxjs/toolkit": "2.12.0",
"@playwright/test": "^1.55.0",
"@types/archiver": "^8.0.0",
"@types/better-sqlite3": "^7.6.13",
@@ -63,6 +54,15 @@
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1",
"drizzle-kit": "^0.31.4",
"echarts": "6.1.0",
"echarts-for-react": "3.0.6",
"less": "4.4.1",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "9.2.0",
"react-router-dom": "7.18.3",
"tdesign-react": "1.18.2",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vite": "^7.1.3",
+33 -33
View File
@@ -23,12 +23,6 @@ importers:
'@fastify/static':
specifier: ^10.1.3
version: 10.1.3
'@fontsource-variable/plus-jakarta-sans':
specifier: 5.3.0
version: 5.3.0
'@reduxjs/toolkit':
specifier: 2.12.0
version: 2.12.0(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
archiver:
specifier: ^8.0.0
version: 8.0.0
@@ -41,12 +35,6 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)
echarts:
specifier: 6.1.0
version: 6.1.0
echarts-for-react:
specifier: 3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.8)
exceljs:
specifier: ^4.4.0
version: 4.4.0
@@ -56,33 +44,12 @@ importers:
fastify:
specifier: ^5.4.0
version: 5.12.1
less:
specifier: 4.4.1
version: 4.4.1
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.2.8)
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
react:
specifier: ^19.1.1
version: 19.2.8
react-dom:
specifier: ^19.1.1
version: 19.2.8(react@19.2.8)
react-redux:
specifier: 9.2.0
version: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
react-router-dom:
specifier: 7.18.3
version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@24.13.3)
tdesign-react:
specifier: 1.18.2
version: 1.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
yauzl:
specifier: ^3.2.0
version: 3.4.0
@@ -90,9 +57,15 @@ importers:
specifier: ^4.1.5
version: 4.4.3
devDependencies:
'@fontsource-variable/plus-jakarta-sans':
specifier: 5.3.0
version: 5.3.0
'@playwright/test':
specifier: ^1.55.0
version: 1.62.1
'@reduxjs/toolkit':
specifier: 2.12.0
version: 2.12.0(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
'@types/archiver':
specifier: ^8.0.0
version: 8.0.0
@@ -120,6 +93,33 @@ importers:
drizzle-kit:
specifier: ^0.31.4
version: 0.31.10
echarts:
specifier: 6.1.0
version: 6.1.0
echarts-for-react:
specifier: 3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.8)
less:
specifier: 4.4.1
version: 4.4.1
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.2.8)
react:
specifier: ^19.1.1
version: 19.2.8
react-dom:
specifier: ^19.1.1
version: 19.2.8(react@19.2.8)
react-redux:
specifier: 9.2.0
version: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
react-router-dom:
specifier: 7.18.3
version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
tdesign-react:
specifier: 1.18.2
version: 1.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
tsx:
specifier: ^4.20.5
version: 4.23.12
+6 -7
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Build a self-contained release on the target Linux architecture. Native
# addons (SQLite, Argon2 and image processing) must be installed on the same
# architecture/libc as the artifact.
# Build a production release on the target Linux architecture. Native addons
# (SQLite, Argon2 and image processing) must be installed on the same
# architecture/libc as the artifact. Node.js itself is deliberately managed
# by the installer outside each release so application updates stay small.
ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
VERSION=${1:-}
OUT_DIR=${2:-$ROOT/release}
@@ -28,7 +29,7 @@ cd "$ROOT"
pnpm build
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd"
# Copy only the production build outputs. In particular, do not carry a
# stale dist/web-next directory from a previous local preview build.
cp -a dist/server "$stage/dist/"
@@ -40,9 +41,7 @@ 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/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/uninstall.sh"
# pnpm's default linker creates symlinks. A release archive is deliberately
# symlink-free so the installer can reject traversal links deterministically.
+3 -6
View File
@@ -5,7 +5,7 @@ set -Eeuo pipefail
# 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
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
umask 077
@@ -205,7 +205,6 @@ fi
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
full_assets=()
update_assets=()
for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file")
@@ -214,13 +213,11 @@ for file in "$ASSET_DIR"/*.tar.gz; do
asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then
update_assets+=("$file")
else
full_assets+=("$file")
die "不再发布轻量更新资产:$name;请只保留完整生产包"
fi
full_assets+=("$file")
done
assets=("${full_assets[@]}")
if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
+31 -17
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
umask 077
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
CONFIG_FILE="$CONFIG_DIR/tallynote.env"
REQUEST_FILE="$DATA_DIR/update-request.json"
CURRENT_LINK="$PREFIX/current"
STATE_FILE="$PREFIX/.update-state"
@@ -22,6 +24,27 @@ if [[ "$HEALTH_HOST" == *:* && "$HEALTH_HOST" != \[* ]]; then HEALTH_HOST="[$HEA
die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
node_is_usable() {
local candidate=$1 major
[[ -n "$candidate" && -x "$candidate" ]] || return 1
major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)
[[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]]
}
resolve_node() {
local candidate=${TALLYNOTE_NODE:-}
if [[ -z "$candidate" && -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]]; then
candidate=$(sed -n 's/^TALLYNOTE_NODE=//p' "$CONFIG_FILE" | head -n 1)
fi
if node_is_usable "$candidate"; then
printf '%s' "$candidate"
return 0
fi
candidate=$(command -v node || true)
node_is_usable "$candidate" || return 1
printf '%s' "$candidate"
}
# The runner may exit during any of the checks below. Install its EXIT cleanup
# before doing privileged preflight so a partial invocation never leaves a
# heartbeat or lock behind.
@@ -202,9 +225,7 @@ if [[ "$request_operation" == download ]]; then
trap 'exit 143' TERM
trap 'exit 130' INT
start_heartbeat
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
node_bin=$(resolve_node) || die 'Node.js 24+ not found'
cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
set +e
@@ -243,8 +264,7 @@ restore_initial_service() {
return "$result"
}
trap restore_initial_service EXIT
old_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
old_node=$(resolve_node) || die 'Node.js 24+ not found'
handled=0
write_update_state() { write_recovery_state "$1"; }
@@ -287,8 +307,7 @@ recover_stale_state() {
return 0
fi
if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
recovery_node=$(resolve_node) || die 'Node.js 24+ not found'
for _ in 1 2 3; do
if finalize_state_job "$recovery_node" completed "$state_job"; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
@@ -304,8 +323,7 @@ recover_stale_state() {
# that case the old link is already safe to serve, but the database row
# can still be `applying`; finish it as failed before clearing recovery
# markers so the UI does not poll forever.
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
recovery_node=$(resolve_node) || die 'Node.js 24+ not found'
if finalize_state_job "$recovery_node" failed "$state_job"; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
clear_update_state || true
@@ -328,8 +346,7 @@ recover_stale_state() {
rm -f -- "$rollback_link" 2>/dev/null || true
return 1
fi
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
recovery_node=$(resolve_node) || die 'Node.js 24+ not found'
if ! finalize_state_job "$recovery_node" failed "$state_job"; then
# If the original queue is still present, retry it from the restored old
# release; a crash before the CLI wrote its job row is recoverable this
@@ -439,9 +456,7 @@ cleanup_after_update() {
}
trap cleanup_after_update EXIT
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
node_bin=$(resolve_node) || die 'Node.js 24+ not found'
cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
@@ -483,8 +498,7 @@ if (( was_active == 0 )); then
fi
write_update_state finalizing || exit 1
final_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$final_node" ]] || final_node=$(command -v node || true)
final_node=$(resolve_node) || die 'Node.js 24+ not found'
if [[ "$job_id" =~ ^[0-9a-f-]{36}$ ]]; then
finalized=0
for _ in 1 2 3; do
+16 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
umask 077
@@ -10,9 +10,22 @@ umask 077
# extraction and atomic release switching.
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-${TALLYNOTE_PREFIX:-/opt/tallynote}}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
CONFIG_FILE="$CONFIG_DIR/tallynote.env"
REQUEST_FILE=${TALLYNOTE_UPDATE_REQUEST_FILE:-$DATA_DIR/update-request.json}
NODE=${TALLYNOTE_NODE:-}
node_is_usable() {
local candidate=$1 major
[[ -n "$candidate" && -x "$candidate" ]] || return 1
major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)
[[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]]
}
if [[ -z "$NODE" && -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]]; then
NODE=$(sed -n 's/^TALLYNOTE_NODE=//p' "$CONFIG_FILE" | head -n 1)
fi
die() { printf 'tallynote update: %s\n' "$*" >&2; exit 1; }
version_sort_desc() {
if sort -V </dev/null >/dev/null 2>&1; then
@@ -71,10 +84,9 @@ if [[ -x /usr/local/libexec/tallynote-update-runner ]]; then
exec /usr/local/libexec/tallynote-update-runner
fi
if [[ -z "$NODE" ]]; then
NODE="$PREFIX/current/runtime/bin/node"
[[ -x "$NODE" ]] || NODE=$(command -v node || true)
NODE=$(command -v node || true)
fi
[[ -n "$NODE" ]] || die 'node runtime not found'
node_is_usable "$NODE" || die 'Node.js 24+ not found'
CLI="$PREFIX/current/dist/server/cli/update.js"
[[ -f "$CLI" ]] || die 'update CLI not found'
+18 -18
View File
@@ -173,23 +173,23 @@ runner_root="$tmp/runner"
runner_prefix="$runner_root/prefix"
runner_data="$runner_root/data"
runner_tools="$runner_root/tools"
mkdir -p "$runner_prefix/releases/1.0.0/runtime/bin" "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools"
mkdir -p "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools"
ln -s "$runner_prefix/releases/1.0.0" "$runner_prefix/current"
printf '%s\n' '{"jobId":"00000000-0000-4000-8000-000000000001","operation":"apply"}' > "$runner_data/update-request.json"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "$*" >> "$TALLYNOTE_NODE_TRACE"' 'exit 0' > "$runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else printf "%s\n" "$*" >> "$TALLYNOTE_NODE_TRACE"; fi' 'exit 0' > "$runner_tools/node"
printf '%s\n' cli > "$runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$runner_tools/readlink"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-Tf" ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi' > "$runner_tools/mv"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$runner_tools/curl"
chmod 755 "$runner_prefix/releases/1.0.0/runtime/bin/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl"
chmod 755 "$runner_tools/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl"
runner_script="$runner_root/runner.sh"
runner_path="$runner_tools:/usr/sbin:/usr/bin:/sbin:/bin"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script"
sed "s#PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script"
chmod 755 "$runner_script"
runner_prefix_physical=$(cd "$runner_prefix" && pwd -P)
runner_data_physical=$(cd "$runner_data" && pwd -P)
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script"
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE="$runner_tools/node" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script"
grep -q -- '--request-file' "$runner_root/node.log"
grep -q -- '--finalize-job' "$runner_root/node.log"
[[ ! -e "$runner_data/update-request.json" ]]
@@ -202,14 +202,14 @@ download_runner_root="$tmp/download-runner"
download_runner_prefix="$download_runner_root/prefix"
download_runner_data="$download_runner_root/data"
download_runner_tools="$download_runner_root/tools"
mkdir -p "$download_runner_prefix/releases/1.0.0/runtime/bin" "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools"
mkdir -p "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools"
ln -s "$download_runner_prefix/releases/1.0.0" "$download_runner_prefix/current"
# The request has already been consumed; only the stale download marker is
# left, which is the narrow recovery window covered by this fixture.
download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P)
download_runner_data_physical=$(cd "$download_runner_data" && pwd -P)
printf '%s\n' 'job_id=00000000-0000-4000-8000-000000000002' "old_target=$download_runner_prefix_physical/releases/1.0.0" 'phase=download' > "$download_runner_prefix/.update-state"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"' 'exit 0' > "$download_runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"; fi' 'exit 0' > "$download_runner_tools/node"
printf '%s\n' cli > "$download_runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$download_runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$download_runner_tools/readlink"
@@ -221,11 +221,11 @@ case "$*" in
*) /usr/bin/stat "$@" ;;
esac
EOF
chmod 755 "$download_runner_prefix/releases/1.0.0/runtime/bin/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat"
chmod 755 "$download_runner_tools/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat"
download_runner_script="$download_runner_root/runner.sh"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$download_runner_tools:/usr/sbin:/usr/bin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script"
sed "s#PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#PATH=$download_runner_tools:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script"
chmod 755 "$download_runner_script"
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script"
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_NODE="$download_runner_tools/node" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script"
[[ ! -e "$download_runner_root/node.log" ]]
[[ ! -e "$download_runner_prefix/.update-state" ]]
@@ -233,7 +233,7 @@ env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE
# 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"
"$release_fixture/scripts" "$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"
@@ -282,16 +282,16 @@ grep -Fxq "PathChanged=$tmp/custom-prefix" "$rendered_path"
# The production admin wrapper must load a release-relative runtime, change to
# the release root, and forward CLI arguments without requiring pnpm.
wrapper_prefix="$tmp/wrapper-prefix"
mkdir -p "$wrapper_prefix/releases/1.0.0/runtime/bin" "$wrapper_prefix/releases/1.0.0/dist/server/cli"
mkdir -p "$wrapper_prefix/releases/1.0.0/dist/server/cli" "$tmp/wrapper-tools"
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' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else pwd -P > "$TALLYNOTE_WRAPPER_LOG"; printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"; fi' > "$tmp/wrapper-tools/node"
chmod 755 "$tmp/wrapper-tools/node"
printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js"
# This fixture verifies release-relative execution and argument forwarding.
# Force the wrapper's non-root branch so the root CI runner does not need a
# real `tallynote` service account or a privileged runuser hand-off; that
# privilege boundary is validated by the production checks themselves.
env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_NODE="$tmp/wrapper-tools/node" 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"
@@ -590,13 +590,12 @@ env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
# 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"
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts"
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/bin/tallynote" "$source_tmp/scripts/runner.sh"
chmod 755 "$source_tmp/uninstall.sh"
archive_tmp="$tmp/release.tar.gz"
tar -C "$source_tmp" -czf "$archive_tmp" .
@@ -612,6 +611,7 @@ bash -c '
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
[[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]]
[[ ! -e "$destination/runtime" ]]
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
# A normal public-release install only needs the detached SHA-256 manifest;
+1 -1
View File
@@ -34,7 +34,7 @@ make_fixture() {
done
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update"
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner"
printf '%s\n' '#!/usr/bin/env bash' 'exec /opt/tallynote/current/runtime/bin/node /opt/tallynote/current/dist/server/cli/admin-init.js' > "$fixture/usr/local/sbin/tallynote-admin-init"
printf '%s\n' '#!/usr/bin/env bash' 'exec /usr/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"
+43 -8
View File
@@ -54,9 +54,11 @@ import {
validateNewPassword,
verifyPassword,
} from "./security.js";
import { createRateLimiter } from "./rate-limit.js";
import { isNewerVersion } from "./update.js";
import {
ACTIVE_UPDATE_STATUSES,
ACTIVE_UPDATE_CONFLICT_SQL,
checkForUpdate,
currentReleaseVersion,
publicCheckFromCache,
@@ -100,6 +102,11 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const sessionCookie = "tally_session";
const csrfCookie = "tally_csrf";
/** API paths are the only requests the global rate limiter and cache rules own. */
function isApiPath(url: string): boolean {
return url.split("?", 1)[0]!.startsWith("/api/");
}
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
@@ -644,7 +651,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
// retained by a browser, reverse proxy or shared cache. Keep this global so
// future authenticated routes inherit the same privacy boundary.
app.addHook("onSend", async (request, reply, payload) => {
if (request.url.split("?", 1)[0]!.startsWith("/api/")) {
if (isApiPath(request.url)) {
reply.header("Cache-Control", "no-store");
reply.header("Pragma", "no-cache");
reply.header("Vary", "Cookie");
@@ -1028,7 +1035,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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;
// A reusable `staged/download` artifact must never block applying a
// *different* staged job: otherwise a leftover row keeps the queue
// permanently busy and the operator can never apply an update.
const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} 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", "更新任务正在处理中,请稍候");
@@ -1053,9 +1063,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
}
// Preserve the actionable in-progress response for duplicate clicks before
// applying the per-admin cooldown.
// Same rule as the download path: a reusable staged download is an
// artifact, not a running task, and must not block apply.
const activeBeforeCheck = database.sqlite.prepare(`
SELECT id FROM update_jobs
WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")})
WHERE ${ACTIVE_UPDATE_CONFLICT_SQL}
ORDER BY created_at DESC LIMIT 1
`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (activeBeforeCheck) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
@@ -1079,7 +1091,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
const active = database.sqlite.transaction(() => {
const existing = database.sqlite.prepare(`
SELECT id, status FROM update_jobs
WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")})
WHERE ${ACTIVE_UPDATE_CONFLICT_SQL}
ORDER BY created_at DESC LIMIT 1
`).get(...ACTIVE_UPDATE_STATUSES) as { id: string; status: UpdateJobStatus } | undefined;
if (existing) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
@@ -1169,7 +1181,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
const input = updateDownloadSchema.parse(request.body);
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
// A finished `staged/download` row is a reusable artifact, not a running
// task, so it does not block a new download. Apply-phase rows still do.
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} 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);
@@ -1181,7 +1195,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
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;
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} 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 } });
@@ -1201,7 +1215,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
app.post("/api/update/cancel", { preHandler: guard(database, config) }, async (request, reply) => {
reconcileOrphanedUpdateJobs(database.sqlite, config);
const body = (request.body && typeof request.body === "object" ? request.body : {}) as { jobId?: string };
const result = cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId);
// `cancelUpdateJob` awaits its staging-workspace cleanup, so the caller must
// await it too; without the await `result` is a pending Promise and this
// branch would always report failure even after a successful cancel.
const result = await cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId);
if (!result.cancelled) {
throw new AppError(409, "CANNOT_CANCEL", result.message || "无法取消当前更新任务");
}
@@ -1870,6 +1887,24 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return reply.send(await safeReadStream(config.exportsDir, job.filePath));
});
// Global anti-flood backstop for the API surface. It is registered after all
// /api/* routes so it covers every one of them, but the path check keeps
// static assets, `/health` and the SPA fallback out of the limiter. The
// per-feature limits (login lockout, dangerous-operation re-auth, update
// cooldowns) stay authoritative; this only bounds raw request volume.
const apiRateLimiter = createRateLimiter({ limit: config.apiRateLimitPerMinute, windowMs: 60 * 1000 });
app.addHook("preHandler", async (request, reply) => {
if (!isApiPath(request.url)) return;
// `request.ip` already honours the validated trustProxy configuration, so
// the counted address is the one the deployment declared. The limiter must
// never parse X-Forwarded-For itself, otherwise a client could spoof its
// way around the limit.
const decision = apiRateLimiter.check(request.ip);
if (decision.allowed) return;
reply.header("Retry-After", decision.retryAfterSeconds);
throw new AppError(429, "RATE_LIMITED", "请求过于频繁,请稍后再试");
});
const hasWeb = existsSync(config.webDir);
if (hasWeb) {
// Serve the Vite asset graph as well as the SPA entry. API routes are
@@ -1879,7 +1914,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
// Keep API errors structured even when the production frontend has not been
// built yet (for example in a clean CI checkout or an API-only process).
app.setNotFoundHandler((request, reply) => {
if (request.url.split("?", 1)[0]!.startsWith("/api/")) {
if (isApiPath(request.url)) {
return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在")));
}
if (hasWeb) return reply.sendFile("index.html");
+315 -65
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3";
@@ -10,7 +10,6 @@ import { writeAudit } from "../audit.js";
import {
atomicSwitchDirectory,
atomicSwitchRelease,
applicationUpdateRuntimeHash,
compareSemver,
createSafeArchive,
detectPlatform,
@@ -20,10 +19,10 @@ import {
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
selectReleaseAsset,
sanitizeAssetName,
validateHttpsUrl,
verifySha256,
type ReleaseAsset,
type ReleaseMetadata,
type UrlPolicy,
@@ -223,16 +222,9 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(metadataUrl, options);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
} catch {
// Fall back to the full archive when the current installation predates
// runtime fingerprints or is missing deployment provenance.
}
let asset = options.assetUrl && !options.requireSignature
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
: selectReleaseAsset(release, platform, runtimeHash);
: selectReleaseAsset(release, platform);
if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [],
@@ -253,35 +245,243 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
return { asset: { name: sanitizeAssetName(options.assetName ?? path.basename(assetUrl.pathname)), url: assetUrl.toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }, version: options.version };
}
async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
/**
* Ownership expectation for a directory consumed by the privileged updater.
*
* `-1` disables the uid comparison while keeping the symlink and mode checks.
* Production always passes a concrete uid (0 for the root-owned
* `<installPrefix>/.update-work`), so the check never depends on the effective
* uid of the current process and remains runnable from a non-root test.
*/
export type DirectoryOwnerUid = number;
/** The staging area owned by the unprivileged web process and the private
* root-owned workspace are deliberately separate trust domains. */
export class StagedWorkspaceError extends Error {
readonly reason: string;
constructor(message: string, reason: string) {
super(message);
this.name = "StagedWorkspaceError";
this.reason = reason;
}
}
/** Owner uid of an existing path, or -1 when it cannot be inspected. */
export async function directoryOwnerUid(targetPath: string): Promise<DirectoryOwnerUid> {
const info = await lstat(path.resolve(targetPath)).catch(() => null);
return info?.uid ?? -1;
}
/**
* Resolve a privileged workspace root to its canonical path.
*
* The root itself may be reached through a symlinked ancestor (for example
* `/tmp` on macOS), so only the final component is required to be a real,
* non-symlink directory with private permissions and the expected owner.
*/
async function canonicalizePrivilegedRoot(directory: string, expectedUid: DirectoryOwnerUid, message: string): Promise<string> {
const resolved = path.resolve(directory);
await mkdir(resolved, { recursive: true, mode: 0o700 });
const info = await lstat(resolved).catch(() => null);
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0 || uid !== 0) {
throw new Error("更新工作目录必须是 root 拥有且权限为 0700");
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || (expectedUid >= 0 && info.uid !== expectedUid)) {
throw new Error(message);
}
return resolved;
const real = await realpath(resolved).catch(() => { throw new Error(message); });
const realInfo = await lstat(real).catch(() => null);
if (!realInfo?.isDirectory() || realInfo.isSymbolicLink() || (realInfo.mode & 0o077) !== 0 || (expectedUid >= 0 && realInfo.uid !== expectedUid)) {
throw new Error(message);
}
return real;
}
/** 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("更新工作目录无效"); });
/** Assert that `candidate` is a real, private, expected-owner directory below `root`. */
async function assertStagedDirectory(candidate: string, root: string, expectedUid: DirectoryOwnerUid): Promise<string> {
const resolved = path.resolve(candidate);
if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效");
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
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("更新暂存路径无效");
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) throw new StagedWorkspaceError("更新暂存目录权限无效", "staged_workspace_insecure");
if (expectedUid >= 0 && info.uid !== expectedUid) throw new StagedWorkspaceError("更新暂存目录属主无效", "staged_workspace_insecure");
const real = await realpath(resolved).catch(() => { throw new StagedWorkspaceError("更新暂存目录无效", "staged_workspace_invalid"); });
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
return real;
}
async function ensurePrivilegedWorkspace(directory: string, expectedUid: DirectoryOwnerUid): Promise<string> {
return canonicalizePrivilegedRoot(directory, expectedUid, "更新工作目录必须是 root 拥有且权限为 0700");
}
/**
* Rebuild the staged workspace location for `jobId` instead of trusting the
* `download_path` column: the runner clears that column whenever it releases
* a workspace (`clearTransientJobPath`), and a nulled column cannot be used to
* find a payload that is still on disk waiting for the apply step.
*
* Candidate order:
* 1. `<stagingRoot>/update-<jobId>` (web download workspace)
* 2. `<stagingRoot>/update-<jobId>-*` (mkdtemp variant)
* 3. the recorded `download_path`, but only while it stays inside the root
*/
export async function locateStagedWorkspace(options: {
jobId: string;
downloadPath?: string | null | undefined;
stagingRoot: string;
expectedUid: DirectoryOwnerUid;
}): Promise<string> {
const root = await canonicalizePrivilegedRoot(options.stagingRoot, options.expectedUid, "更新暂存根目录权限无效");
const prefix = `update-${options.jobId}`;
const candidates = [path.join(root, prefix)];
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
for (const entry of entries.filter((candidate) => candidate.name.startsWith(`${prefix}-`)).sort((a, b) => a.name.localeCompare(b.name))) {
candidates.push(path.join(root, entry.name));
}
const recorded = options.downloadPath?.trim();
if (recorded && path.isAbsolute(recorded)) {
const resolvedRecorded = path.resolve(recorded);
if (resolvedRecorded.startsWith(`${root}${path.sep}`)) candidates.push(resolvedRecorded);
// A recorded path outside the staging root is never consumed. Reject it
// loudly when it exists so the operator sees the real cause instead of a
// generic "re-download" message.
else if (await lstat(resolvedRecorded).catch(() => null)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
}
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
// An existing candidate must satisfy every constraint: skipping it would
// hand the root process whatever else happens to sit in the staging area.
if (!(await lstat(resolved).catch(() => null))) continue;
return assertStagedDirectory(resolved, root, options.expectedUid);
}
throw new StagedWorkspaceError("暂存目录已不存在,请重新下载", "staged_workspace_missing");
}
/** Copy a verified payload tree without following or preserving symlinks. */
async function copyReleaseTree(source: string, target: string): Promise<void> {
await mkdir(target, { recursive: false, mode: 0o700 });
const entries = await readdir(source, { withFileTypes: true });
for (const entry of entries) {
const from = path.join(source, entry.name);
const to = path.join(target, entry.name);
if (entry.isSymbolicLink()) throw new Error("更新暂存内容包含符号链接");
if (entry.isDirectory()) await copyReleaseTree(from, to);
else if (entry.isFile()) await copyFile(from, to);
else throw new Error("更新暂存内容包含不受支持的文件类型");
}
}
const STAGED_ARCHIVE_PATTERN = /\.(?:tar\.gz|tgz|tar|zip)$/i;
async function assertStagedArchiveIntegrity(source: string, expectedSha256: string | null | undefined): Promise<void> {
const expected = expectedSha256?.trim().toLowerCase();
if (!expected) return;
if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error("更新暂存校验值无效");
const entries = await readdir(source, { withFileTypes: true }).catch(() => []);
const archive = entries
.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && STAGED_ARCHIVE_PATTERN.test(entry.name))
.sort((a, b) => a.name.localeCompare(b.name))[0];
if (!archive) throw new Error("更新暂存归档缺失,无法校验完整性");
const archivePath = path.join(source, archive.name);
const info = await lstat(archivePath).catch(() => null);
if (!info?.isFile() || info.isSymbolicLink()) throw new Error("更新暂存归档无效");
if (!(await verifySha256(archivePath, expected))) throw new Error("更新文件 SHA-256 校验失败");
}
/**
* Snapshot the web-staged payload into a root-owned workspace before the apply
* flow touches it. The copy is what closes the TOCTOU window: the unprivileged
* web user keeps write access to its own staging directory, so the privileged
* process must never execute content that lives there.
*
* A copy (not a rename) is required because the data directory and the install
* prefix are frequently separate mounts, where `rename` fails with EXDEV.
*/
export async function preparePrivateApplyWorkspace(options: {
jobId: string;
source: string;
privateRoot: string;
expectedUid: DirectoryOwnerUid;
expectedSha256?: string | null | undefined;
}): Promise<string> {
const root = await canonicalizePrivilegedRoot(options.privateRoot, options.expectedUid, "更新工作目录必须是 root 拥有且权限为 0700");
const source = path.resolve(options.source);
const sourcePayload = path.join(source, "payload");
const sourcePayloadInfo = await lstat(sourcePayload).catch(() => null);
if (!sourcePayloadInfo?.isDirectory() || sourcePayloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
// Second integrity check right before the copy, so a payload swapped after
// the download verification is rejected instead of promoted to a release.
await assertStagedArchiveIntegrity(source, options.expectedSha256);
const target = path.join(root, `apply-${options.jobId}`);
const existing = await lstat(target).catch(() => null);
if (existing) await rm(target, { recursive: true, force: true }).catch(() => undefined);
try {
// Create the container explicitly: `copyReleaseTree` intentionally uses a
// non-recursive mkdir so a pre-existing/symlinked target can never be
// silently reused, and the parent must therefore already exist.
await mkdir(target, { recursive: false, mode: 0o700 });
await copyReleaseTree(sourcePayload, path.join(target, "payload"));
await normalizeReleasePermissions(path.join(target, "payload"));
const copied = await lstat(path.join(target, "payload")).catch(() => null);
if (!copied?.isDirectory() || copied.isSymbolicLink()) throw new Error("更新暂存内容复制失败");
return target;
} catch (error) {
await rm(target, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
/** Reason code attached to failures that must reach the UI verbatim. */
function failureReason(error: unknown): string {
const reason = (error as { reason?: unknown } | null)?.reason;
return typeof reason === "string" && /^[a-z0-9_]{1,64}$/.test(reason) ? reason : "apply_precheck_failed";
}
/**
* Persist a real failure reason from the privileged apply path.
*
* `writeJob`/`updateJob` cannot be used here: their final-state guard
* (`WHERE update_jobs.status NOT IN (...)`) protects terminal rows, and it also
* makes the runner's own progress writes a no-op once a row is terminal. This
* helper issues an independent, guarded UPDATE so the true cause is visible in
* the UI instead of the runner's generic health-check message.
*/
export function failUpdateJobWithReason(
sqlite: Database.Database | undefined,
jobId: string,
message: string,
options: { reason?: string } = {},
): boolean {
if (!sqlite) return false;
const safe = safeErrorMessage(message.length ? new Error(message) : new Error("更新失败"));
try {
return sqlite.transaction(() => {
const row = sqlite.prepare("SELECT status, version, request_id AS requestId, admin_id AS adminId FROM update_jobs WHERE id=?").get(jobId) as {
status: UpdateJobStatus; version: string; requestId: string | null; adminId: string | null;
} | undefined;
if (!row || row.status === "completed" || row.status === "cancelled" || row.status === "failed") return false;
const now = Date.now();
const updated = sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=COALESCE(completed_at, ?), updated_at=? WHERE id=? AND status=?").run(safe, now, now, jobId, row.status);
if (updated.changes !== 1) return false;
writeAudit(sqlite, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.failed",
targetType: "update",
targetId: jobId,
outcome: "failure",
before: { status: row.status, version: row.version },
after: { status: "failed", version: row.version, reason: options.reason ?? "apply_precheck_failed", error: safe },
});
return true;
})();
} catch {
// The database may not be open (or the row may not exist) when a request is
// rejected during preflight. Losing the diagnostic write must never turn a
// clean rejection into a crash.
return false;
}
}
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
const platform = options.platform ?? detectPlatform();
const jobId = options.jobId ?? randomUUID();
@@ -339,17 +539,8 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
const currentInfo = await lstat(currentRelease).catch(() => null);
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry);
const sourceInfo = await lstat(source).catch(() => null);
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
}
}
const embeddedRuntime = await lstat(path.join(stagedDir, "runtime")).catch(() => null);
if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime");
await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
@@ -455,13 +646,17 @@ export async function applyStagedUpdate(options: {
maxBytes?: number;
dataBackupMaxBytes?: number;
workspaceRoot?: string;
/** Expected owner of `workspaceRoot`. Defaults to uid 0 (the installer
* provisions `<installPrefix>/.update-work` as root-owned 0700). Tests inject
* the current user so the check never depends on `process.getuid()`. */
workspaceOwnerUid?: DirectoryOwnerUid;
}): 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;
? await canonicalizePrivilegedRoot(options.workspaceRoot, options.workspaceOwnerUid ?? 0, "更新工作目录权限无效")
: path.resolve(options.stagedPath);
const payload = path.join(stagedPath, "payload");
const payloadInfo = await lstat(payload).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
@@ -499,7 +694,21 @@ function arg(name: string): string | undefined {
return index >= 0 ? process.argv[index + 1] : undefined;
}
export async function main(config: AppConfig = loadConfig()): Promise<void> {
/**
* Ownership expectations the privileged entry point uses for the two trust
* domains it consumes. They are injectable so the apply flow can be exercised
* end-to-end from a non-root test process: production always uses the defaults
* (root-owned `<installPrefix>/.update-work` and the `dataDir` owner for the
* unprivileged staging area) and never consults `process.getuid()`.
*/
export type UpdateMainOverrides = {
/** Expected owner of the unprivileged staging root (`config.stagingDir`). */
stagingOwnerUid?: DirectoryOwnerUid;
/** Expected owner of the root-only private workspace (`config.updateWorkspaceDir`). */
workspaceOwnerUid?: DirectoryOwnerUid;
};
export async function main(config: AppConfig = loadConfig(), overrides: UpdateMainOverrides = {}): Promise<void> {
const finalizeJobId = arg("--finalize-job");
if (finalizeJobId) {
const finalStatus = arg("--finalize-status");
@@ -536,7 +745,9 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
const dataBackupArchive = arg("--data-backup") ?? (request ? path.join(path.dirname(config.dataDir), "tallynote-backups", `data-${request.jobId}.tar.gz`) : undefined);
const allowedHosts = process.argv.flatMap((value, index) => value === "--allow-host" && process.argv[index + 1] ? [process.argv[index + 1]!] : []);
prepareDataDirectories(config);
if (request) await ensurePrivilegedWorkspace(stagingDir);
const workspaceOwnerUid = overrides.workspaceOwnerUid ?? 0;
const stagingOwnerUid = overrides.stagingOwnerUid ?? await directoryOwnerUid(config.dataDir);
if (request) await ensurePrivilegedWorkspace(stagingDir, workspaceOwnerUid);
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
// The download phase intentionally runs beside the live app so users keep
// access while the archive is fetched and staged. SQLite WAL plus the
@@ -546,28 +757,67 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
const database = openDatabase(config);
try {
if (request?.operation === "apply") {
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined;
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string; expectedSha256: string | null } | undefined;
if (staged?.status === "staged" && staged.operation === "apply") {
if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
const root = path.resolve(config.updateWorkspaceDir);
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
await applyStagedUpdate({
sqlite: database.sqlite,
jobId: request.jobId,
version: request.version,
stagedPath: candidate,
currentDir,
currentLink: request.currentLink,
releasesDir: request.releasesDir,
workspaceRoot: root,
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
dataBackupSource: config.dataDir,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
});
console.log(`更新已切换:${request.version}`);
return;
// `download_path` is intentionally NOT required here. The runner NULLs
// that column as soon as it releases a workspace, so it can never be the
// source of truth for a payload that still exists on disk. The job id is
// the stable key; the column survives only as a last-resort candidate in
// `locateStagedWorkspace`.
if (staged.version !== request.version) throw new Error("更新暂存任务无效");
let privateWorkspace: string | undefined;
try {
const source = await locateStagedWorkspace({
jobId: request.jobId,
downloadPath: staged.downloadPath,
stagingRoot: config.stagingDir,
expectedUid: stagingOwnerUid,
});
// Snapshot into the root-only workspace before applying. The web user
// keeps write access to the staging tree, so content that is executed
// by the privileged process must never live there (TOCTOU).
privateWorkspace = await preparePrivateApplyWorkspace({
jobId: request.jobId,
source,
privateRoot: config.updateWorkspaceDir,
expectedUid: workspaceOwnerUid,
expectedSha256: staged.expectedSha256,
});
await applyStagedUpdate({
sqlite: database.sqlite,
jobId: request.jobId,
version: request.version,
stagedPath: privateWorkspace,
workspaceRoot: privateWorkspace,
workspaceOwnerUid,
currentDir,
currentLink: request.currentLink,
releasesDir: request.releasesDir,
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
dataBackupSource: config.dataDir,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
});
// The private copy has been consumed by the release switch and the
// payload is now the live release, so the web-owned source tree is
// redundant. Best-effort cleanup must not fail an applied update.
await rm(source, { recursive: true, force: true }).catch(() => undefined);
console.log(`更新已切换:${request.version}`);
return;
} catch (error) {
// Covers failures raised before `applyStagedUpdate` took ownership of
// the private copy, and the "already committed" case where the failing
// path deliberately skips its own cleanup.
if (privateWorkspace) await rm(privateWorkspace, { recursive: true, force: true }).catch(() => undefined);
// The runner can only report its fixed health-check message. Record the
// real pre-flight cause so the UI and the audit trail show why the
// update was rejected. A terminal row is only reachable through an
// independent guarded UPDATE (`writeJob` refuses to mutate terminal
// rows), which is exactly what this helper issues.
failUpdateJobWithReason(database.sqlite, request.jobId, safeErrorMessage(error), { reason: failureReason(error) });
throw error;
}
}
if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效");
// A direct one-click request starts in queued/apply. Older clients do
+5
View File
@@ -162,6 +162,11 @@ export function loadConfig() {
exportsDir: path.join(dataDir, "exports"),
migrationsDir: path.join(projectRoot, "migrations"),
webDir: path.join(projectRoot, "dist", "web"),
// Coarse per-IP request ceiling applied to every /api/* request. It is a
// backstop against request floods, not a replacement for the stricter
// per-feature limits (login lockout, dangerous-operation re-auth, update
// cooldowns), so the default is deliberately generous.
apiRateLimitPerMinute: integerEnv("TALLYNOTE_RATE_LIMIT_PER_MINUTE", 600),
maxFileBytes: integerEnv("TALLYNOTE_MAX_FILE_MB", 20) * 1024 * 1024,
maxFilesPerRequest: integerEnv("TALLYNOTE_MAX_FILES_PER_REQUEST", 20),
maxRecordBytes: integerEnv("TALLYNOTE_MAX_RECORD_MB", 100) * 1024 * 1024,
+80
View File
@@ -0,0 +1,80 @@
/**
* In-memory, per-key request limiter used as a coarse anti-flood backstop for
* the whole HTTP API.
*
* The semantics are a fixed window per key: the first request of a window
* starts the clock, every later request in the same window increments the
* counter, and an expired window is reset on the next request. This mirrors
* the `login_attempts` window logic already used for login lockouts
* (`server/app.ts`), but it never touches the database: a rate limit decision
* must stay cheap enough to run on every request.
*
* Precise controls (per-IP login lockout, dangerous-operation re-auth) remain
* in place on top of this limiter; it only stops a client from issuing an
* abusive number of requests across all endpoints.
*/
export type RateLimiterOptions = {
/** Maximum number of requests allowed per key inside one window. */
limit: number;
/** Window length in milliseconds. */
windowMs: number;
/** Injectable clock so tests can advance time without waiting. */
now?: () => number;
};
export type RateLimitDecision = {
allowed: boolean;
/** Seconds the caller should wait before retrying; 0 when allowed. */
retryAfterSeconds: number;
};
type Bucket = {
count: number;
windowStart: number;
};
/** Run a full sweep every N checks instead of on every call. */
const SWEEP_INTERVAL_CHECKS = 1000;
export function createRateLimiter(options: RateLimiterOptions) {
const { limit, windowMs } = options;
if (!Number.isInteger(limit) || limit < 1) throw new Error("rate limit 必须是大于等于 1 的整数");
if (!Number.isInteger(windowMs) || windowMs < 1) throw new Error("rate limit 窗口必须是大于等于 1 的整数毫秒数");
const now = options.now ?? Date.now;
const buckets = new Map<string, Bucket>();
let checksSinceSweep = 0;
return {
check(key: string): RateLimitDecision {
const current = now();
let bucket = buckets.get(key);
// A key that is unknown or whose window has already elapsed starts a
// fresh window. This also recycles the single key being hit, so an
// idle client never leaves a stale counter behind.
if (!bucket || current - bucket.windowStart >= windowMs) {
bucket = { count: 0, windowStart: current };
buckets.set(key, bucket);
}
// Bounds long-running memory growth: keys that stopped sending traffic
// are dropped by an amortized periodic sweep rather than on every call.
if (++checksSinceSweep >= SWEEP_INTERVAL_CHECKS) {
checksSinceSweep = 0;
for (const [candidateKey, candidate] of buckets) {
if (current - candidate.windowStart >= windowMs) buckets.delete(candidateKey);
}
}
if (bucket.count >= limit) {
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((bucket.windowStart + windowMs - current) / 1000)) };
}
bucket.count += 1;
return { allowed: true, retryAfterSeconds: 0 };
},
/** Number of tracked keys; used to observe lazy cleanup. */
size(): number {
return buckets.size;
},
};
}
export type RateLimiter = ReturnType<typeof createRateLimiter>;
+150 -43
View File
@@ -1,5 +1,5 @@
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { lstatSync, readdirSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3";
@@ -16,7 +16,6 @@ import {
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
@@ -41,6 +40,20 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
// after the service health check. The runner refreshes its recovery marker as
// a lease while doing long downloads/backups; only an expired lease permits
// the server to reclaim an active row.
/**
* Conflict predicate for "another update is already running".
*
* A row that is `staged` with `operation='download'` is a finished artifact
* waiting for an explicit apply, not a running task: the privileged runner only
* starts working after the apply request is written. It must therefore not
* block a new download. Real in-flight work (queued/downloading/verifying and
* the apply phases) remains protected, which is what keeps the apply path's
* concurrency guard intact.
*
* The SQL fragment expects ACTIVE_UPDATE_STATUSES bound as positional params.
*/
export const ACTIVE_UPDATE_CONFLICT_SQL = `status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND NOT (status='staged' AND operation='download')`;
export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000;
export const QUEUED_UPDATE_TIMEOUT_MS = 25 * 1000;
@@ -211,14 +224,9 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
} catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
}
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml")));
} catch {
// Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime.
}
// Force choosing the full standalone archive so users always get a real, visible streaming download
// Always select the complete production archive. The host Node.js runtime
// is reused, while the application package remains self-contained and
// identical for first installs and in-place updates.
let asset = selectReleaseAsset(metadata, platform, undefined);
let signatureVerified = false;
if (asset) {
@@ -453,6 +461,61 @@ export function currentReleaseVersion(config: AppConfig): string | null {
}
}
/**
* Absolute paths that can hold a job's staging workspace. The web download flow
* always creates `update-<jobId>`; the privileged runner may additionally use a
* `mkdtemp` variant named `update-<jobId>-XXXXXX`.
*
* `update_jobs.download_path` is deliberately NOT used to rebuild these paths:
* it held a bare basename while a download was in flight (rows written by older
* versions still store that basename) and the privileged runner NULLs the column
* after finalizing a row. Rebuilding from it could delete an unrelated staging
* entry that merely shares the basename.
*/
function jobWorkspaceCandidates(stagingDir: string, jobId: string): string[] {
// Job ids are UUIDs; reject anything that could escape the staging root.
if (!jobId || jobId !== path.basename(jobId) || jobId.includes("..")) return [];
const stagingRoot = path.resolve(stagingDir);
const prefix = `update-${jobId}`;
const names = [prefix];
try {
for (const entry of readdirSync(stagingRoot)) {
if (entry.startsWith(`${prefix}-`)) names.push(entry);
}
} catch {
// A missing or unreadable staging directory still leaves the fixed-name
// candidate, which is what the web download path uses.
}
return names.map((name) => path.join(stagingRoot, name));
}
function isDirectoryNotSymlink(target: string): boolean {
try {
const info = lstatSync(target);
return info.isDirectory() && !info.isSymbolicLink();
} catch {
return false;
}
}
/** True while at least one staging workspace for the job still exists. */
export function jobWorkspaceExists(stagingDir: string, jobId: string): boolean {
return jobWorkspaceCandidates(stagingDir, jobId).some(isDirectoryNotSymlink);
}
/**
* Remove every staging workspace owned by a job. Deletion is awaited so callers
* (and tests) observe a settled filesystem when they return.
*/
async function removeJobWorkspaces(stagingDir: string, jobId: string): Promise<void> {
for (const candidate of jobWorkspaceCandidates(stagingDir, jobId)) {
const info = await lstat(candidate).catch(() => null);
// Only real directories are removed; a symlink is never followed.
if (!info?.isDirectory() || info.isSymbolicLink()) continue;
await rm(candidate, { recursive: true, force: true }).catch(() => undefined);
}
}
/**
* Release an update row left behind after its privileged runner lease expired.
* This is deliberately conservative: staged downloads remain available for an
@@ -564,6 +627,36 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
// A stale request/state marker therefore no longer protects an orphaned
// row forever, while a fresh marker remains owned by the runner.
if (row.status === "staged") {
// A staged row that lost its payload (the staging janitor removes
// `update-*` entries after 24h, and a manual cleanup has the same effect)
// can never be applied or completed. Report it instead of leaving a
// permanently actionable row that fails at apply time.
if (!matchingFreshRequest && !matchingFreshState && !jobWorkspaceExists(config.stagingDir, row.id)) {
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET status='failed', error_message=?, completed_at=?, updated_at=?
WHERE id=? AND status='staged' AND updated_at=?
`).run("暂存的更新文件已不存在,请重新下载更新包", now, now, row.id, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: "failure",
before: { status: row.status, operation: row.operation, version: row.version },
after: { status: "failed", version: row.version, reason: "staged_workspace_missing" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
continue;
}
if (row.operation !== "apply" || matchingFreshRequest || matchingFreshState) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
@@ -639,23 +732,39 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
return reconciled;
}
export function cancelUpdateJob(
/**
* Rows an administrator may cancel from the web UI.
*
* `staged` is cancellable only while the row still belongs to the download
* stage. A staged row whose operation is already `apply` has been handed to the
* privileged runner (stop/backup/switch) and must not be interrupted here.
*/
const CANCELLABLE_JOB_SQL = "(status IN ('queued', 'downloading') OR (status='staged' AND operation='download'))";
export function isCancellableUpdateJob(status: UpdateJobStatus, operation: string): boolean {
if (status === "queued" || status === "downloading") return true;
return status === "staged" && operation === "download";
}
export async function cancelUpdateJob(
database: Database.Database,
config: AppConfig,
adminId: string,
requestId: string,
jobId?: string,
): { cancelled: boolean; message?: string } {
): Promise<{ cancelled: boolean; message?: string }> {
type CancelRow = { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null };
const columns = "id, status, operation, version, admin_id AS adminId";
const job = jobId
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=? AND admin_id=?").get(jobId, adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE admin_id=? AND status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get(adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
? database.prepare(`SELECT ${columns} FROM update_jobs WHERE id=? AND admin_id=?`).get(jobId, adminId) as CancelRow | undefined
: database.prepare(`SELECT ${columns} FROM update_jobs WHERE admin_id=? AND ${CANCELLABLE_JOB_SQL} ORDER BY created_at DESC LIMIT 1`).get(adminId) as CancelRow | undefined;
if (!job) return { cancelled: false, message: "当前没有处于等待调度或下载中的更新任务" };
if (job.status !== "queued" && job.status !== "downloading") return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" };
if (!isCancellableUpdateJob(job.status, job.operation)) return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" };
const now = Date.now();
const changed = database.transaction(() => {
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND status IN ('queued', 'downloading')").run(now, now, job.id, adminId);
const result = database.prepare(`UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND ${CANCELLABLE_JOB_SQL}`).run(now, now, job.id, adminId);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId,
@@ -674,10 +783,11 @@ export function cancelUpdateJob(
// The request marker is shared by the privileged runner. Never remove a
// newer/different administrator's request while cancelling this row.
if (requestJobId(config.updateRequestPath) === job.id) forceRemoveRequest(config.updateRequestPath);
if (job.downloadPath) {
const target = path.isAbsolute(job.downloadPath) ? job.downloadPath : path.join(config.stagingDir, job.downloadPath);
import("node:fs/promises").then(({ rm }) => rm(target, { recursive: true, force: true })).catch(() => {});
}
// Locate the workspace by job id. `download_path` is not a reliable source
// (older rows hold a bare archive basename and the runner NULLs the column
// after finalizing), and a basename lookup could delete an unrelated entry.
// The await keeps the caller from racing a still-running download writer.
await removeJobWorkspaces(config.stagingDir, job.id);
return { cancelled: true };
}
return { cancelled: false, message: "取消失败,任务状态可能已改变" };
@@ -688,8 +798,9 @@ export function cancelUpdateJob(
* The root runner only needs to apply (stop/backup/switch/restart) afterwards.
*
* This function runs asynchronously outside the request lifecycle. It updates
* the job row in the database so the frontend can poll progress. On success it
* writes an apply request file so the systemd path unit triggers the runner.
* the job row in the database so the frontend can poll progress. A successful
* download only becomes staged; applying it is a separate, explicit action
* that the administrator submits after reviewing the verification result.
*/
export async function downloadAndStageUpdate(
database: Database.Database,
@@ -711,9 +822,13 @@ export async function downloadAndStageUpdate(
// Claim the job: transition queued -> downloading. If the job was
// cancelled or claimed by another caller, abort immediately.
// `download_path` always holds an absolute workspace path, both while the
// download runs and after the job is staged. Callers must not derive paths
// from it (the privileged runner NULLs it once it finalizes the row), but a
// single semantic keeps the column debuggable.
const claim = database.prepare(
"UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'",
).run(Date.now(), Date.now(), path.basename(archivePath), Date.now(), jobId);
).run(Date.now(), Date.now(), workspace, Date.now(), jobId);
if (claim.changes !== 1) return;
const progressStartedAt = Date.now();
@@ -756,8 +871,10 @@ export async function downloadAndStageUpdate(
await extractSafeArchive(archivePath, payloadDir);
await normalizeReleasePermissions(payloadDir);
const embeddedRuntime = await lstat(path.join(payloadDir, "runtime")).catch(() => null);
if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime");
// Verify payload contains dist directory
const { lstat } = await import("node:fs/promises");
const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) {
throw new Error("发布包缺少 dist 目录");
@@ -765,25 +882,15 @@ export async function downloadAndStageUpdate(
// Transition to staged
const staged = database.prepare(
"UPDATE update_jobs SET status='staged', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')",
"UPDATE update_jobs SET status='staged', operation='download', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')",
).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId);
if (staged.changes !== 1) return; // cancelled
// Write apply request file for the root runner
await writeUpdateRequest(config, {
jobId,
operation: "apply",
version,
metadataUrl,
assetUrl,
assetName,
expectedSha256,
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
stagedPath: workspace,
});
if (staged.changes !== 1) {
// The row was cancelled or claimed elsewhere (status no longer
// verifying/downloading). This process owns the workspace it created, so
// remove it instead of leaking the payload into the staging directory.
await rm(workspace, { recursive: true, force: true }).catch(() => undefined);
return;
}
writeAudit(database, {
requestId: `download:${jobId}`,
+1 -1
View File
@@ -1000,7 +1000,7 @@ export async function normalizeReleasePermissions(rootPath: string): Promise<voi
await walk(target);
} else if (entry.isFile()) {
const relative = path.relative(root, target).split(path.sep).join("/");
const executable = relative.startsWith("bin/") || relative.startsWith("scripts/") || relative.startsWith("runtime/bin/");
const executable = relative.startsWith("bin/") || relative.startsWith("scripts/");
await chmod(target, executable ? 0o755 : 0o644);
} else {
throw new Error("发布包包含不受支持的文件类型");
+2 -1
View File
@@ -6,8 +6,9 @@ User=root
Group=root
WorkingDirectory=/opt/tallynote/current
EnvironmentFile=-/etc/tallynote/tallynote.env
Environment=TALLYNOTE_CONFIG_DIR=/etc/tallynote
ExecStart=/usr/local/libexec/tallynote-update-runner
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
Environment=PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
# The runner consumes queued requests immediately and applies its own bounded
# phase timeouts while keeping full CLI diagnostics in the runner log.
# Archive validation and data backups can exceed systemd's 90s
+16
View File
@@ -2,6 +2,7 @@ TALLYNOTE_HOST=127.0.0.1
TALLYNOTE_PORT=3000
TALLYNOTE_DATA_DIR=/var/lib/tallynote
TALLYNOTE_INSTALL_PREFIX=/opt/tallynote
TALLYNOTE_CONFIG_DIR=/etc/tallynote
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
TALLYNOTE_COOKIE_SECURE=false
TALLYNOTE_ALLOW_INSECURE_HTTP=false
@@ -17,3 +18,18 @@ TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
# Optional: configure a root-managed Ed25519 public key and set
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
# Reverse proxy trust. Leave this empty (or false) when TallyNote is reached
# directly. When the service runs behind a reverse proxy, set the exact number
# of proxy hops that terminate the client connection (a single nginx or caddy
# layer uses 1). Without it every request appears to come from the proxy
# address, so per-IP login lockouts degrade into a single shared global limit
# and the API rate limiter below counts all clients as one. `true` is rejected
# in production because it would let a client spoof its address.
# TALLYNOTE_TRUST_PROXY=1
# Global API rate limit, per client address, in requests per minute. This is a
# coarse anti-flood backstop for /api/* only; the login lockout, dangerous
# operation confirmation and update cooldowns remain stricter and separate.
# Defaults to 600 when unset, which is sufficient for normal browser use.
# TALLYNOTE_RATE_LIMIT_PER_MINUTE=600
+2 -1
View File
@@ -10,7 +10,8 @@ Group=tallynote
WorkingDirectory=/opt/tallynote/current
Environment=NODE_ENV=production
EnvironmentFile=-/etc/tallynote/tallynote.env
Environment=PATH=/opt/tallynote/current/runtime/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=TALLYNOTE_CONFIG_DIR=/etc/tallynote
Environment=PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
ExecStart=/opt/tallynote/current/bin/tallynote
Restart=on-failure
RestartSec=5s
+6 -2
View File
@@ -1,8 +1,12 @@
import { expect, test } from "@playwright/test";
// Keep the expected copy in one place so a product-wide copy refresh cannot
// silently desynchronise this suite from web-next/src/pages/auth/LoginPage.tsx.
const LOGIN_HEADING = "登录 TallyNote 工作台";
test("未登录时显示中文登录入口", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: LOGIN_HEADING, exact: true })).toBeVisible();
await expect(page.locator(".tn-login-header")).toHaveCount(0);
await expect(page.getByLabel("用户名", { exact: true })).toBeVisible();
await expect(page.getByLabel("密码", { exact: true })).toBeVisible();
@@ -17,7 +21,7 @@ for (const viewport of [
test(`未登录入口适配 ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto("/");
await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: LOGIN_HEADING, exact: true })).toBeVisible();
await expect(page.getByLabel("用户名", { exact: true })).toBeVisible();
await expect(page.getByLabel("密码", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
+208
View File
@@ -0,0 +1,208 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildApp } from "../server/app.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { createRateLimiter } from "../server/rate-limit.js";
const configKeys = [
"TALLYNOTE_DATA_DIR",
"TALLYNOTE_PUBLIC_ORIGIN",
"TALLYNOTE_COOKIE_SECURE",
"TALLYNOTE_ALLOW_INSECURE_HTTP",
"TALLYNOTE_RATE_LIMIT_PER_MINUTE",
"TALLYNOTE_TRUST_PROXY",
"NODE_ENV",
"TALLYNOTE_ENV",
];
afterEach(() => { for (const key of configKeys) delete process.env[key]; });
describe("内存滑动窗口限流器", () => {
it("窗口内未超限时放行", () => {
let clock = 1_000;
const limiter = createRateLimiter({ limit: 3, windowMs: 60_000, now: () => clock });
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
clock += 1_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
clock += 1_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
});
it("达到上限后拒绝并给出 Retry-After 秒数", () => {
let clock = 10_000;
const limiter = createRateLimiter({ limit: 2, windowMs: 30_000, now: () => clock });
expect(limiter.check("a").allowed).toBe(true);
expect(limiter.check("a").allowed).toBe(true);
clock += 5_000;
const denied = limiter.check("a");
expect(denied.allowed).toBe(false);
expect(denied.retryAfterSeconds).toBeGreaterThan(0);
// The window started at t=10000 and lasts 30s, so at t=15000 the caller
// must wait the remaining 25 seconds.
expect(denied.retryAfterSeconds).toBe(25);
});
it("窗口过期后计数重置并重新放行", () => {
let clock = 0;
const limiter = createRateLimiter({ limit: 1, windowMs: 1_000, now: () => clock });
expect(limiter.check("a").allowed).toBe(true);
expect(limiter.check("a").allowed).toBe(false);
// One millisecond before the window closes the key is still limited.
clock = 999;
expect(limiter.check("a").allowed).toBe(false);
clock = 1_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
// The reset key starts a brand-new window from the reset moment, so the
// same key is limited again until that new window also elapses.
clock = 1_500;
expect(limiter.check("a").allowed).toBe(false);
clock = 2_000;
expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 });
});
it("不同键互不影响", () => {
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, now: () => 0 });
expect(limiter.check("1.2.3.4").allowed).toBe(true);
expect(limiter.check("1.2.3.4").allowed).toBe(false);
expect(limiter.check("5.6.7.8").allowed).toBe(true);
expect(limiter.check("5.6.7.8").allowed).toBe(false);
expect(limiter.size()).toBe(2);
});
it("惰性清理过期桶,避免长期运行内存增长", () => {
let clock = 0;
const limiter = createRateLimiter({ limit: 10, windowMs: 1_000, now: () => clock });
for (let index = 0; index < 999; index += 1) limiter.check(`stale-${index}`);
expect(limiter.size()).toBe(999);
clock = 5_000;
// The sweep is amortized: only a periodic full pass removes dead keys, so
// the count must drop back to just the key currently receiving traffic.
for (let index = 0; index < 1_000; index += 1) limiter.check("noisy");
expect(limiter.size()).toBe(1);
});
it("拒绝无效的限流参数", () => {
expect(() => createRateLimiter({ limit: 0, windowMs: 1_000 })).toThrow(/limit/);
expect(() => createRateLimiter({ limit: 1.5, windowMs: 1_000 })).toThrow(/limit/);
expect(() => createRateLimiter({ limit: 1, windowMs: 0 })).toThrow(/窗口/);
});
});
describe("全局限流配置", () => {
function validConfigEnv() {
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
}
it("默认每分钟 600 次,并支持显式覆盖", () => {
validConfigEnv();
expect(loadConfig().apiRateLimitPerMinute).toBe(600);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "120";
expect(loadConfig().apiRateLimitPerMinute).toBe(120);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "1";
expect(loadConfig().apiRateLimitPerMinute).toBe(1);
});
it("拒绝非整数或小于 1 的限流值", () => {
validConfigEnv();
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "0";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "-10";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "abc";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "12.5";
expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/);
});
});
describe("全局限流接入 HTTP 层", () => {
const dataDirs: string[] = [];
afterEach(() => {
while (dataDirs.length > 0) rmSync(dataDirs.pop()!, { recursive: true, force: true });
});
async function buildLimitedApp(limit: string, withWeb = false) {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-rate-limit-"));
dataDirs.push(dataDir);
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = limit;
const config = loadConfig();
// By default keep the test independent from the locally generated dist/web
// tree. When a real asset graph is requested the exemption must still hold,
// which proves it is path-based rather than an artefact of a missing webDir.
config.webDir = withWeb ? path.join(dataDir, "web") : path.join(dataDir, "missing-web");
prepareDataDirectories(config);
if (withWeb) {
mkdirSync(path.join(config.webDir, "assets"), { recursive: true });
writeFileSync(path.join(config.webDir, "index.html"), "<!doctype html><title>tallynote-test-index</title>");
writeFileSync(path.join(config.webDir, "assets", "probe.js"), "console.log('tallynote-test-asset');");
}
const database = openDatabase(config);
const app = await buildApp(database, config);
return { app, database };
}
it("超过配置的 /api/* 配额后返回 429 与 Retry-After,非 API 路径不受影响", async () => {
const { app, database } = await buildLimitedApp("2");
try {
// Static/health traffic is exempt: the limiter only owns /api/*.
for (let index = 0; index < 5; index += 1) {
const health = await app.inject({ method: "GET", url: "/health" });
expect(health.statusCode).toBe(200);
}
const first = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(first.statusCode).toBe(200);
const second = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(second.statusCode).toBe(200);
const limited = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(limited.statusCode).toBe(429);
expect(limited.json().error.code).toBe("RATE_LIMITED");
expect(limited.json().error.message).toBe("请求过于频繁,请稍后再试");
expect(limited.json().error.requestId).toBeTruthy();
expect(Number(limited.headers["retry-after"])).toBeGreaterThan(0);
// The limiter must not touch the database: no new table, no writes to
// the login lockout table used by the stricter login protection.
const attempts = database.sqlite.prepare("SELECT COUNT(*) AS count FROM login_attempts").get() as { count: number };
expect(attempts.count).toBe(0);
} finally {
await app.close();
database.sqlite.close();
}
});
it("配额耗尽后静态资源与 SPA 回退仍可访问", async () => {
const { app, database } = await buildLimitedApp("1", true);
try {
// Spend the whole /api/* quota for this client.
const first = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(first.statusCode).toBe(200);
const limited = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(limited.statusCode).toBe(429);
// A limited client must still be able to load the page and its assets,
// otherwise recovery from the limit is impossible without a cache purge.
const index = await app.inject({ method: "GET", url: "/" });
expect(index.statusCode).toBe(200);
expect(index.body).toContain("tallynote-test-index");
const asset = await app.inject({ method: "GET", url: "/assets/probe.js" });
expect(asset.statusCode).toBe(200);
expect(asset.body).toContain("tallynote-test-asset");
// An unknown non-API path falls back to the SPA entry and stays exempt.
const fallback = await app.inject({ method: "GET", url: "/expenses" });
expect(fallback.statusCode).toBe(200);
expect(fallback.body).toContain("tallynote-test-index");
} finally {
await app.close();
database.sqlite.close();
}
});
});
+25 -24
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
@@ -8,6 +8,7 @@ import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { hashPassword } from "../server/security.js";
import { detectPlatform } from "../server/update.js";
import { reconcileOrphanedUpdateJobs } from "../server/update-service.js";
describe("更新 API", () => {
let dataDir: string;
@@ -59,10 +60,10 @@ describe("更新 API", () => {
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.3.1-${detectPlatform().target}-glibc.tar.gz`;
const asset = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
? new Response(`${digest} ${asset}\n`, { status: 200 })
: new Response(JSON.stringify({ tag_name: "v1.3.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: "v9.9.9", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
}
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
@@ -70,7 +71,7 @@ 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.3.1", compatible: true, integrityReady: true, isNewer: true });
expect(checked.json().latest).toMatchObject({ version: "9.9.9", compatible: true, integrityReady: true, isNewer: true });
expect(checked.headers["cache-control"]).toBe("no-store");
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(tooSoon.statusCode).toBe(429);
@@ -82,15 +83,15 @@ describe("更新 API", () => {
const otherChecked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: otherSession.cookies, "x-csrf-token": otherSession.csrf }, payload: {} });
expect(otherChecked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.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: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
expect(request).toMatchObject({ jobId, version: "1.3.1", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
expect(request).toMatchObject({ jobId, version: "9.9.9", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
mockRelease();
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.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: "9.9.9", confirm: true } });
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
@@ -106,10 +107,10 @@ describe("更新 API", () => {
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.1", confirm: true } });
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "9.9.9", confirm: true } });
expect(downloaded.statusCode).toBe(200);
const downloadJobId = downloaded.json().job.id as string;
expect(downloaded.json().job).toMatchObject({ operation: "download", status: expect.any(String), version: "1.3.1" });
expect(downloaded.json().job).toMatchObject({ operation: "download", status: expect.any(String), version: "9.9.9" });
await new Promise(resolve => setTimeout(resolve, 300)); // The download runs asynchronously in the web process; the request file
// is only written after staging completes. Verify the job row exists.
expect(database.sqlite.prepare("SELECT id FROM update_jobs WHERE id=?").get(downloadJobId)).toBeDefined();
@@ -118,21 +119,21 @@ describe("更新 API", () => {
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.3.1", 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.3.1", confirm: true } });
.run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "9.9.9", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" });
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" });
const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string };
expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) });
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.3.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: { jobId: stagedId, version: "9.9.9", confirm: true } });
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
});
it("缺少确认或未启用 systemd 时不接受更新", async () => {
const session = await login();
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.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: "9.9.9" } });
expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig();
@@ -154,7 +155,7 @@ 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.3.1", isNewer: true });
expect(checked.json().latest).toMatchObject({ version: "9.9.9", isNewer: true });
});
it("不会应用已经等于当前版本的暂存更新", async () => {
@@ -210,7 +211,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.3.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: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
@@ -235,7 +236,7 @@ describe("更新 API", () => {
const otherJobId = randomUUID();
const insert = database.sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, ?, 'download', 'queued', '1.3.1', ?, 'https://updates.example/update.tar.gz', ?, ?)
VALUES (?, ?, 'download', 'queued', '9.9.9', ?, 'https://updates.example/update.tar.gz', ?, ?)
`);
insert.run(ownerJobId, ownerId, detectPlatform().target, now, now);
insert.run(otherJobId, otherId, detectPlatform().target, now + 1, now + 1);
@@ -264,7 +265,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.3.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: "9.9.9", confirm: true } });
expect(response.statusCode).toBe(502);
// A failed upstream check must not reserve the per-admin cooldown; an
// operator can retry immediately after fixing the release endpoint.
@@ -289,7 +290,7 @@ describe("更新 API", () => {
const archiveBytes = readFileSync(archivePath);
const digest = createHash("sha256").update(archiveBytes).digest("hex");
const assetName = `tallynote-1.3.1-${detectPlatform().target}-glibc.tar.gz`;
const assetName = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
@@ -300,7 +301,7 @@ describe("更新 API", () => {
return new Response(archiveBytes, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
}
return new Response(JSON.stringify({
tag_name: "v1.3.1",
tag_name: "v9.9.9",
assets: [
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
@@ -312,7 +313,7 @@ describe("更新 API", () => {
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.3.1", confirm: true } });
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "9.9.9", confirm: true } });
expect(downloaded.statusCode).toBe(200);
const downloadJobId = downloaded.json().job.id as string;
@@ -347,7 +348,7 @@ describe("更新 API", () => {
const archiveBytes = readFileSync(archivePath);
const digest = createHash("sha256").update(archiveBytes).digest("hex");
const assetName = `tallynote-1.3.1-${detectPlatform().target}-glibc.tar.gz`;
const assetName = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
// Mock a slow stream
let fetchAborted = false;
@@ -374,7 +375,7 @@ describe("更新 API", () => {
return new Response(stream, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
}
return new Response(JSON.stringify({
tag_name: "v1.3.1",
tag_name: "v9.9.9",
assets: [
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
@@ -385,7 +386,7 @@ describe("更新 API", () => {
const session = await login("update-cancel-inprocess");
await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.1", confirm: true } });
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "9.9.9", confirm: true } });
const downloadJobId = downloaded.json().job.id as string;
// Wait until status becomes downloading
@@ -417,7 +418,7 @@ describe("更新 API", () => {
const session = await login("update-cancel");
mockRelease();
await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.3.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: "9.9.9", confirm: true } });
expect(applied.statusCode).toBe(202);
expect(existsSync(config.updateRequestPath)).toBe(true);
+319
View File
@@ -0,0 +1,319 @@
import { createHash, randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadConfig, prepareDataDirectories, type AppConfig } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { main } from "../server/cli/update.js";
import { createSafeArchive, detectPlatform } from "../server/update.js";
/**
* Link-level coverage for the two-process update hand-off:
* the unprivileged web process stages a verified payload into
* `<dataDir>/staging/update-<jobId>`, then the privileged CLI (`main`) picks it
* up from a staged/apply DB row and switches the release.
*
* Ownership expectations are injected through `UpdateMainOverrides` because the
* suite runs as a non-root developer on macOS. Production defaults stay
* untouched: they never consult `process.getuid()`.
*/
const CURRENT_UID = process.getuid?.() ?? 0;
const NEW_VERSION = "9.9.9";
const METADATA_URL = "https://updates.example/latest";
const TRACKED_ENV = [
"TALLYNOTE_DATA_DIR",
"TALLYNOTE_INSTALL_PREFIX",
"TALLYNOTE_PUBLIC_ORIGIN",
"TALLYNOTE_COOKIE_SECURE",
"TALLYNOTE_UPDATE_STRATEGY",
"TALLYNOTE_UPDATE_METADATA_URL",
"TALLYNOTE_UPDATE_ALLOWED_HOSTS",
"TALLYNOTE_UPDATE_REQUIRE_SIGNATURE",
] as const;
const baselineEnv = new Map<string, string | undefined>(TRACKED_ENV.map((key) => [key, process.env[key]]));
const baselineArgv = [...process.argv];
afterEach(() => {
for (const key of TRACKED_ENV) {
const value = baselineEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
process.argv.splice(0, process.argv.length, ...baselineArgv);
});
type ApplyFixture = {
root: string;
config: AppConfig;
jobId: string;
stagedDir: string;
digest: string;
assetName: string;
};
type FixtureOptions = {
/** Shape of `<stagingDir>/update-<jobId>`: a real staged tree, a symlink
* masquerading as one, or nothing at all. */
stagedWorkspace?: "directory" | "symlink" | "absent";
/** Whether the staged archive that `assertStagedArchiveIntegrity` hashes. */
withArchive?: boolean;
/** Value written to `update_jobs.download_path`. The runner NULLs this column
* when it releases a workspace, so `null` is the post-runner production state. */
downloadPath?: "null" | "stale" | "outside-staging-root";
/** Whether the staged/apply row exists at all. */
withDatabaseRow?: boolean;
};
/** Build the exact on-disk state the web download step leaves behind before a
* privileged apply runs: release layout, staged workspace, staged/apply row and
* the request file the CLI is invoked with. */
async function setupApplyFixture(options: FixtureOptions = {}): Promise<ApplyFixture> {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-apply-staging-"));
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = METADATA_URL;
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
// Installer layout with a live current release so `atomicSwitchRelease` has a
// real previous target to report.
await mkdir(config.releasesDir, { recursive: true, mode: 0o755 });
const previousRelease = path.join(config.releasesDir, config.appVersion);
await mkdir(path.join(previousRelease, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(previousRelease, "dist", "marker"), "old");
await symlink(previousRelease, config.currentLink);
const assetName = `tallynote-${NEW_VERSION}-${detectPlatform().target}.tar.gz`;
const source = path.join(root, "release-source");
await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o700 });
await writeFile(path.join(source, "dist", "marker"), "new");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
const bytes = await readFile(archive);
const digest = createHash("sha256").update(bytes).digest("hex");
const jobId = randomUUID();
const stagedDir = path.join(config.stagingDir, `update-${jobId}`);
const stagedWorkspace = options.stagedWorkspace ?? "directory";
if (stagedWorkspace === "directory") {
await mkdir(path.join(stagedDir, "payload", "dist"), { recursive: true, mode: 0o700 });
await writeFile(path.join(stagedDir, "payload", "dist", "marker"), "new");
if (options.withArchive !== false) await writeFile(path.join(stagedDir, "release.tar.gz"), bytes, { mode: 0o600 });
} else if (stagedWorkspace === "symlink") {
// A symlinked workspace is the classic "swap the staged tree after the web
// process verified it" attack, and must never be followed by root.
const decoy = path.join(root, "decoy-workspace");
await mkdir(path.join(decoy, "payload", "dist"), { recursive: true, mode: 0o700 });
await writeFile(path.join(decoy, "payload", "dist", "marker"), "attacker");
await symlink(decoy, stagedDir);
}
let recordedDownloadPath: string | null = null;
if (options.downloadPath === "stale") recordedDownloadPath = path.join(root, "stale-workspace");
if (options.downloadPath === "outside-staging-root") {
recordedDownloadPath = path.join(root, "outside-workspace");
await mkdir(path.join(recordedDownloadPath, "payload", "dist"), { recursive: true, mode: 0o700 });
}
if (options.withDatabaseRow !== false) {
const database = openDatabase(config);
try {
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_name, asset_url,
expected_sha256, download_path, created_at, updated_at, requested_at)
VALUES (?, 'apply', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(jobId, NEW_VERSION, detectPlatform().target, assetName, `https://updates.example/${assetName}`, digest, recordedDownloadPath, now, now, now);
} finally {
database.sqlite.close();
}
}
await writeFile(config.updateRequestPath, JSON.stringify({
jobId,
operation: "apply",
version: NEW_VERSION,
metadataUrl: config.updateMetadataUrl,
assetUrl: `https://updates.example/${assetName}`,
assetName,
expectedSha256: digest,
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
}), { mode: 0o600 });
return { root, config, jobId, stagedDir, digest, assetName };
}
/** Invoke the privileged entry point the way the runner does: through the
* request file, which is the only path that reaches the staged apply branch. */
async function runMain(fixture: ApplyFixture, overrides: { stagingOwnerUid?: number; workspaceOwnerUid?: number } = {}): Promise<void> {
process.argv.push("--request-file", fixture.config.updateRequestPath);
await main(fixture.config, {
stagingOwnerUid: overrides.stagingOwnerUid ?? CURRENT_UID,
workspaceOwnerUid: overrides.workspaceOwnerUid ?? CURRENT_UID,
});
}
function readJob(config: AppConfig, jobId: string): { status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined {
const database = openDatabase(config);
try {
return database.sqlite.prepare("SELECT status, operation, error_message AS errorMessage, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as
{ status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined;
} finally {
database.sqlite.close();
}
}
/** The audit row written by `failUpdateJobWithReason`, which carries the real
* machine-readable reason the UI renders instead of the runner's health text. */
function readFailureAudit(config: AppConfig, jobId: string): { action: string; outcome: string; afterJson: string } | undefined {
const database = openDatabase(config);
try {
return database.sqlite.prepare("SELECT action, outcome, after_json AS afterJson FROM audit_events WHERE target_id=? ORDER BY id DESC LIMIT 1").get(jobId) as
{ action: string; outcome: string; afterJson: string } | undefined;
} finally {
database.sqlite.close();
}
}
describe("web 暂存 → CLI apply 链路", () => {
it("场景 1:staged 行 + 暂存工作区存在时切换 current 到新 release", async () => {
const fixture = await setupApplyFixture();
try {
await runMain(fixture);
const link = await lstat(fixture.config.currentLink);
expect(link.isSymbolicLink()).toBe(true);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION));
expect((await lstat(path.join(fixture.config.releasesDir, NEW_VERSION))).isDirectory()).toBe(true);
expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new");
// The web-owned staging tree is consumed and the row leaves the staged state.
expect(await lstat(fixture.stagedDir).catch(() => null)).toBeNull();
expect(readJob(fixture.config, fixture.jobId)).toMatchObject({ status: "applying", operation: "apply" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 2:download_path 为 NULL 时仍按 jobId 重建暂存工作区", async () => {
const fixture = await setupApplyFixture({ downloadPath: "null" });
try {
// Precondition: the runner already cleared the transient column.
expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBeNull();
await runMain(fixture);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION));
expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new");
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 2b:download_path 指向已消失的陈旧路径时仍回退到 jobId 候选", async () => {
const fixture = await setupApplyFixture({ downloadPath: "stale" });
try {
expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBe(path.join(fixture.root, "stale-workspace"));
await runMain(fixture);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION));
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 3:候选暂存目录不存在时拒绝并把行置为 failed", async () => {
const fixture = await setupApplyFixture({ stagedWorkspace: "absent" });
try {
await expect(runMain(fixture)).rejects.toThrow(/暂存目录已不存在/);
// No release may be published from a workspace that was never staged.
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
const job = readJob(fixture.config, fixture.jobId);
expect(job?.status).toBe("failed");
expect(job?.errorMessage).toBe("暂存目录已不存在,请重新下载");
expect(readFailureAudit(fixture.config, fixture.jobId)).toMatchObject({ action: "update.failed", outcome: "failure" });
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_missing" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 4a:暂存工作区是符号链接时拒绝执行", async () => {
const fixture = await setupApplyFixture({ stagedWorkspace: "symlink" });
try {
await expect(runMain(fixture)).rejects.toThrow(/更新暂存目录权限无效/);
// The decoy payload must never be promoted to a release.
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed");
expect(readJob(fixture.config, fixture.jobId)?.errorMessage).toBe("更新暂存目录权限无效");
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_insecure" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 4b:记录路径位于 stagingDir 之外时拒绝,即使暂存目录缺失", async () => {
const fixture = await setupApplyFixture({ stagedWorkspace: "absent", downloadPath: "outside-staging-root" });
try {
await expect(runMain(fixture)).rejects.toThrow(/更新暂存路径无效/);
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed");
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_invalid" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 5:暂存区属主与期望 uid 不符时拒绝", async () => {
const fixture = await setupApplyFixture();
try {
await expect(runMain(fixture, { stagingOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新暂存根目录权限无效/);
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
const job = readJob(fixture.config, fixture.jobId);
expect(job?.status).toBe("failed");
expect(job?.errorMessage).toBe("更新暂存根目录权限无效");
expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "apply_precheck_failed" });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("场景 5b:工作区属主与期望 uid 不符时在 preflight 阶段拒绝", async () => {
const fixture = await setupApplyFixture();
try {
await expect(runMain(fixture, { workspaceOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新工作目录必须是 root 拥有且权限为 0700/);
expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull();
expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion));
// The preflight rejection happens before the database is opened, so the
// row is left staged for the runner to finalize. Recorded as observed
// behavior, not asserted as a requirement.
expect(readJob(fixture.config, fixture.jobId)?.status).toBe("staged");
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
});
+30 -26
View File
@@ -40,23 +40,23 @@ describe("更新安全工具", () => {
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
expect(detectPlatform("linux", "x86_64").target).toBe("linux-x64");
const release = {
version: "1.3.1",
version: "9.9.9",
assets: [
{ name: "tallynote-1.3.1-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
{ name: "tallynote-1.3.1-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" },
{ name: "tallynote-9.9.9-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
{ name: "tallynote-9.9.9-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" },
],
};
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))?.name).toContain("linux-x64");
expect(selectReleaseAsset({ version: "1.3.1", assets: [{ name: "tallynote-1.3.1-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined();
expect(selectReleaseAsset({ version: "9.9.9", assets: [{ name: "tallynote-9.9.9-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined();
expect(() => validateHttpsUrl("http://updates.example/x64", { allowedHosts: ["updates.example"] })).toThrow();
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
});
it("优先选择运行时匹配的轻量更新包,并对旧客户端保留完整包回退", () => {
const runtimeHash = runtimeHashFromLockfile("lockfile-v1\n");
const full = { name: "tallynote-1.3.1-linux-x64-glibc.tar.gz", url: "https://updates.example/full" };
const app = { name: `tallynote-1.3.1-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" };
const release = { version: "1.3.1", assets: [full, app] };
const full = { name: "tallynote-9.9.9-linux-x64-glibc.tar.gz", url: "https://updates.example/full" };
const app = { name: `tallynote-9.9.9-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" };
const release = { version: "9.9.9", assets: [full, app] };
expect(applicationUpdateRuntimeHash(app.name)).toBe(runtimeHash);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"), runtimeHash)).toEqual(app);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))).toEqual(full);
@@ -102,12 +102,12 @@ describe("更新安全工具", () => {
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) {
return new Response(JSON.stringify({ tag_name: "v1.3.1", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } });
return new Response(JSON.stringify({ tag_name: "v9.9.9", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } });
}
return new Response(`${digest} app-linux-x64.tar.gz\n`, { status: 200 });
}) as typeof fetch;
const metadata = await fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] });
expect(metadata.version).toBe("1.3.1");
expect(metadata.version).toBe("9.9.9");
expect((await fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"] })).trim()).toContain(digest);
});
@@ -253,22 +253,22 @@ describe("更新安全工具", () => {
const jobId = randomUUID();
database = openDatabase(config);
const now = Date.now();
const assetName = `tallynote-1.3.1-${detectPlatform().target}.tar.gz`;
const assetName = `tallynote-9.9.9-${detectPlatform().target}.tar.gz`;
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url,
expected_sha256, created_at, updated_at, requested_at)
VALUES (?, 'apply', 'queued', '1.3.1', ?, ?, ?, ?, ?, ?)
VALUES (?, 'apply', 'queued', '9.9.9', ?, ?, ?, ?, ?, ?)
`).run(jobId, detectPlatform().target, "https://updates.example/" + assetName, digest, now, now, now);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v1.3.1", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] }));
if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v9.9.9", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] }));
if (url.endsWith("SHA256SUMS")) return new Response(`${digest} ${assetName}\n`);
return new Response(bytes, { headers: { "content-length": String(bytes.length) } });
}) as typeof fetch;
await runUpdate({
metadataUrl: config.updateMetadataUrl,
version: "1.3.1",
version: "9.9.9",
currentVersion: config.appVersion,
currentDir: config.currentLink,
stagingDir: path.join(root, "staging"),
@@ -294,14 +294,14 @@ describe("更新安全工具", () => {
const defaultJobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'applying', '1.3.1', ?, ?, ?, ?)
VALUES (?, 'apply', 'applying', '9.9.9', ?, ?, ?, ?)
`).run(defaultJobId, detectPlatform().target, "https://updates.example/" + assetName, now, now);
finalizeUpdateJob(database.sqlite, defaultJobId, "failed", "");
expect(database.sqlite.prepare("SELECT error_message AS errorMessage FROM update_jobs WHERE id=?").get(defaultJobId)).toEqual({ errorMessage: "新版本健康检查失败,已恢复上一版本" });
const completedJobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'completed', '1.3.1', ?, ?, ?, ?)
VALUES (?, 'apply', 'completed', '9.9.9', ?, ?, ?, ?)
`).run(completedJobId, detectPlatform().target, "https://updates.example/" + assetName, now, now);
finalizeUpdateJob(database.sqlite, completedJobId, "completed");
expect(database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE action='update.completed' AND target_id=?").get(completedJobId)).toEqual({ count: 0 });
@@ -342,10 +342,16 @@ describe("更新安全工具", () => {
const applyingId = randomUUID();
const stagedId = randomUUID();
const stagedApplyId = randomUUID();
insert.run(queuedId, "apply", "queued", "1.3.1", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt);
insert.run(queuedId, "apply", "queued", "9.9.9", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt);
insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt);
insert.run(stagedId, "download", "staged", "1.3.1", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
insert.run(stagedApplyId, "apply", "staged", "1.3.1", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
insert.run(stagedId, "download", "staged", "9.9.9", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
insert.run(stagedApplyId, "apply", "staged", "9.9.9", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
// A staged row is only actionable while its staged payload exists. The
// real download path always creates `update-<id>` before flipping a job
// to `staged`, so create the workspace here too; otherwise the fixture
// tests an impossible state where the row claims an artifact it never had.
await mkdir(path.join(config.stagingDir, `update-${stagedId}`), { recursive: true });
await mkdir(path.join(config.stagingDir, `update-${stagedApplyId}`), { recursive: true });
const now = Date.now();
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });
@@ -381,7 +387,7 @@ describe("更新安全工具", () => {
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'download', 'downloading', '1.3.1', 'linux-x64', ?, ?, ?)
VALUES (?, 'download', 'downloading', '9.9.9', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/download.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "download" }));
const statePath = path.join(config.installPrefix, ".update-state");
@@ -425,7 +431,7 @@ describe("更新安全工具", () => {
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'queued', '1.3.1', 'linux-x64', ?, ?, ?)
VALUES (?, 'apply', 'queued', '9.9.9', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/queued.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "apply" }));
const now = Date.now();
@@ -455,11 +461,9 @@ describe("更新安全工具", () => {
await mkdir(path.join(source, "dist", "server"), { recursive: true });
await mkdir(path.join(source, "bin"), { recursive: true });
await mkdir(path.join(source, "scripts"), { recursive: true });
await mkdir(path.join(source, "runtime", "bin"), { recursive: true });
await writeFile(path.join(source, "dist", "server", "large.js"), Buffer.alloc(2 * 1024 * 1024, 0x41));
await writeFile(path.join(source, "bin", "tallynote"), "#!/bin/sh\n");
await writeFile(path.join(source, "scripts", "runner.sh"), "#!/bin/sh\n");
await writeFile(path.join(source, "runtime", "bin", "node"), "node");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
expect((await stat(archive)).size).toBeLessThan(64 * 1024);
@@ -472,7 +476,7 @@ describe("更新安全工具", () => {
expect((await stat(path.join(destination, "dist", "server", "large.js"))).mode & 0o777).toBe(0o644);
expect((await stat(path.join(destination, "bin", "tallynote"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "scripts", "runner.sh"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "runtime", "bin", "node"))).mode & 0o777).toBe(0o755);
expect(await stat(path.join(destination, "runtime")).catch(() => null)).toBeNull();
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -513,17 +517,17 @@ describe("更新元数据缓存", () => {
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.3.1-${detectPlatform().target}-glibc.tar.gz`;
const platformAsset = `tallynote-9.9.9-${detectPlatform().target}-glibc.tar.gz`;
const sums = `${digest} ${platformAsset}\n`;
const signature = sign(null, Buffer.from(sums), privateKey);
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
? new Response(signature)
: input.toString().endsWith("SHA256SUMS")
? new Response(sums)
: new Response(JSON.stringify({ tag_name: "v1.3.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: "v9.9.9", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
try {
const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ version: "1.3.1", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
expect(result.latest).toMatchObject({ version: "9.9.9", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
} finally {
+28
View File
@@ -417,6 +417,7 @@ remove_prefix() {
log "warning: 保留非符号链接 current:$current"
fi
remove_tree "$releases" 'releases'
remove_managed_node
remove_tree "$PREFIX/.update-work" 'update work'
remove_file_if_owned "$PREFIX/.update-state" 'update state'
if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then
@@ -429,6 +430,33 @@ remove_prefix() {
fi
}
remove_managed_node() {
local node_root="$PREFIX/nodejs" marker
[[ -e "$node_root" || -L "$node_root" ]] || return 0
[[ -d "$node_root" && ! -L "$node_root" ]] || die "Node.js 管理目录不是安全目录:$node_root"
marker="$node_root/.tallynote-managed"
if [[ ! -f "$marker" || "$(sed -n '1p' "$marker" 2>/dev/null)" != tallynote-managed-node-v1 ]]; then
log "保留非 TallyNote 管理的 Node.js 目录:$node_root"
return 0
fi
allowed_owner "$node_root" || die "Node.js 管理目录的所有者不受信任:$node_root"
# Node distributions contain npm/corepack symlinks. They are safe to remove
# because rm never follows symlinks; validate ownership and permissions for
# every node while deliberately permitting those internal links.
local node mode_bits
while IFS= read -r node; do
allowed_owner "$node" || die "Node.js 管理目录节点的所有者不受信任:$node"
[[ -L "$node" ]] && continue
mode_bits=$(stat_mode_bits "$node")
(( (mode_bits & 18) == 0 )) || die "Node.js 管理目录权限过宽:$node"
done < <(find "$node_root" -print)
if (( DRY_RUN )); then
log "dry-run: remove managed Node.js $node_root"
else
rm -rf -- "$node_root"
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'
+10 -3
View File
@@ -1049,14 +1049,21 @@ export default function UpdatePage({
<div>• 升级过程具备原子切换与自愈保护,若健康检查异常将自动回退至当前版本。</div>
</div>
<div className="tn-modal-actions-bar">
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
稍后手动应用
{/* A staged download has no runner attached yet, so the
administrator can still discard it and free the slot. */}
<Button
variant="outline"
onClick={() => void cancelJob()}
loading={cancelling}
disabled={cancelling || actionBusy}
>
取消并清理
</Button>
<Button
theme="primary"
onClick={() => void startApply()}
loading={actionBusy}
disabled={actionBusy}
disabled={actionBusy || cancelling}
icon={<Zap size={16} />}
>
立即应用并重启