release: 1.3.4

This commit is contained in:
Qiufeng
2026-09-11 18:28:03 +08:00
parent 511fc5d785
commit 5afcd98ebd
21 changed files with 403 additions and 187 deletions
+3 -3
View File
@@ -48,9 +48,9 @@ pnpm build:next
## 无 Docker 安装(systemd) ## 无 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 ```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo 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`,正常更新不会覆盖当前或更高版本。 已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--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`。
升级有两种方式: 升级有两种方式:
+12 -3
View File
@@ -1,8 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P) ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
NODE="$ROOT/runtime/bin/node" NODE=${TALLYNOTE_NODE:-}
[[ -x "$NODE" ]] || NODE=$(command -v node || true) node_is_usable() {
[[ -n "$NODE" ]] || { printf 'TallyNote: Node.js runtime not found\n' >&2; exit 127; } 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" "$@" 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 # Production entry point for first-admin setup. The installer keeps the
# EnvironmentFile root-readable only, so parse simple KEY=VALUE assignments # EnvironmentFile root-readable only, so parse simple KEY=VALUE assignments
# without sourcing arbitrary shell code. # 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 export PATH
umask 077 umask 077
@@ -15,6 +15,13 @@ SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
die() { printf 'tallynote admin-init: %s\n' "$*" >&2; exit 1; } 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() { load_environment_file() {
[[ -e "$CONFIG_FILE" ]] || return 0 [[ -e "$CONFIG_FILE" ]] || return 0
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || die '环境文件不是安全的普通文件' [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || die '环境文件不是安全的普通文件'
@@ -105,9 +112,9 @@ main() {
[[ "$argument" == "--check" ]] && check_only=1 [[ "$argument" == "--check" ]] && check_only=1
done done
root=$(resolve_release_root) root=$(resolve_release_root)
node="$root/runtime/bin/node" node=${TALLYNOTE_NODE:-}
[[ -x "$node" ]] || node=$(command -v node || true) node_is_usable "$node" || node=$(command -v node || true)
[[ -n "$node" && -x "$node" ]] || die '找不到 Node.js runtime' node_is_usable "$node" || die '找不到 Node.js 24+'
cli="$root/dist/server/cli/admin-init.js" cli="$root/dist/server/cli/admin-init.js"
[[ -f "$cli" && ! -L "$cli" ]] || die '管理员初始化程序不存在' [[ -f "$cli" && ! -L "$cli" ]] || die '管理员初始化程序不存在'
+7 -4
View File
@@ -1,8 +1,10 @@
# Release、安装与更新 # 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 ./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 安装 ## curl 安装
@@ -83,6 +85,7 @@ tallynote installer: 访问地址:http://127.0.0.1:<端口>
```text ```text
/opt/tallynote/releases/<version>/ # 只读发布代码 /opt/tallynote/releases/<version>/ # 只读发布代码
/opt/tallynote/current -> releases/<version> /opt/tallynote/current -> releases/<version>
/opt/tallynote/nodejs/ # 主机没有 Node.js 24+ 时由安装器管理
/opt/tallynote/.update-work/ # 0700 root:root,root 更新器临时工作区 /opt/tallynote/.update-work/ # 0700 root:root,root 更新器临时工作区
/opt/tallynote/.update-state # root 更新状态标记,异常中断后用于恢复 /opt/tallynote/.update-state # root 更新状态标记,异常中断后用于恢复
/var/lib/tallynote/ # SQLite、附件、暂存和导出 /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 签名。 将环境文件中的 `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 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚: 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 # TallyNote native installer. Installs the latest release by default; use
# --dry-run to preview without changing the host. # --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 export PATH
umask 077 umask 077
@@ -34,6 +34,11 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300}
RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-} RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-}
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl} OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname} 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 # Service network settings are written to the systemd EnvironmentFile on a
# fresh install. Existing values are preserved unless the corresponding # fresh install. Existing values are preserved unless the corresponding
# TALLYNOTE_* variable is explicitly supplied to the installer. # TALLYNOTE_* variable is explicitly supplied to the installer.
@@ -57,6 +62,7 @@ INSTALL_PREVIOUS_TARGET=''
INSTALL_NEW_RELEASE='' INSTALL_NEW_RELEASE=''
INSTALL_WORK_DIR='' INSTALL_WORK_DIR=''
INSTALL_BACKUP_DIR='' INSTALL_BACKUP_DIR=''
INSTALL_UNIT_TMP=''
INSTALL_BACKUP_COMPLETE=0 INSTALL_BACKUP_COMPLETE=0
INSTALL_WAS_ACTIVE=0 INSTALL_WAS_ACTIVE=0
INSTALL_PATH_WAS_ACTIVE=0 INSTALL_PATH_WAS_ACTIVE=0
@@ -65,6 +71,12 @@ INSTALL_WAS_ENABLED=0
INSTALL_PATH_WAS_ENABLED=0 INSTALL_PATH_WAS_ENABLED=0
INSTALL_UPDATE_WAS_ENABLED=0 INSTALL_UPDATE_WAS_ENABLED=0
INSTALL_SYSTEMD_TOUCHED=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 ADMIN_INIT_PATH=/usr/local/sbin/tallynote-admin-init
INSTALL_FIRST_INSTALL=0 INSTALL_FIRST_INSTALL=0
DATA_DIR_TEMP_ROOT=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' [[ "$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' [[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false'
[[ "$SIGNATURE_FORMAT" == ed25519 || "$SIGNATURE_FORMAT" == gpg ]] || die '签名格式必须是 ed25519 或 gpg' [[ "$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 '安装资源限制必须是正整数' [[ "$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 '安装超时配置必须是正整数' [[ "$CONNECT_TIMEOUT" =~ ^[1-9][0-9]*$ && "$MAX_TIME" =~ ^[1-9][0-9]*$ ]] || die '安装超时配置必须是正整数'
@@ -461,6 +474,146 @@ detect_platform() {
export TALLYNOTE_ARCH TALLYNOTE_LIBC 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() { require_https() {
local value=$1 local value=$1
case "$value" in https://*) ;; *) die "release endpoints must use HTTPS: $value" ;; esac case "$value" in https://*) ;; *) die "release endpoints must use HTTPS: $value" ;; esac
@@ -711,7 +864,7 @@ normalize_release_tree() {
fi fi
find "$root" -type d -exec chmod 755 {} + find "$root" -type d -exec chmod 755 {} +
find "$root" -type f -exec chmod 644 {} + 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 [[ -f "$item" && ! -L "$item" ]] || continue
chmod 755 "$item" chmod 755 "$item"
done done
@@ -868,6 +1021,10 @@ stop_existing_services() {
rollback_install_if_needed() { rollback_install_if_needed() {
local result=$? rollback_tmp 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 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 # The failed install may have started units that were inactive before the
# attempt. Stop them before restoring files so systemd never keeps running # 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 if [[ -n "$INSTALL_WORK_DIR" && -d "$INSTALL_WORK_DIR" ]]; then
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
fi 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" return "$result"
} }
@@ -1077,7 +1238,7 @@ validate_existing_env() {
mode_bits=$(stat_mode_bits "$file") mode_bits=$(stat_mode_bits "$file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入' (( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
local key key_count 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=$(env_key_count "$file" "$key")
[[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key" [[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key"
done done
@@ -1085,6 +1246,12 @@ validate_existing_env() {
[[ -z "$value" || "${value%/}" == "${PREFIX%/}" ]] || die '环境文件中的安装目录与本次安装不一致' [[ -z "$value" || "${value%/}" == "${PREFIX%/}" ]] || die '环境文件中的安装目录与本次安装不一致'
value=$(read_env_value "$file" TALLYNOTE_DATA_DIR) value=$(read_env_value "$file" TALLYNOTE_DATA_DIR)
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致' [[ -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) value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false' [[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then
@@ -1161,6 +1328,7 @@ install_release() {
safe_extract "$archive" "$tmp/unpacked" safe_extract "$archive" "$tmp/unpacked"
normalize_release_tree "$tmp/unpacked" normalize_release_tree "$tmp/unpacked"
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root' [[ -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' [[ -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/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/server/cli/admin-init.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units' [[ -f "$tmp/unpacked/systemd/tallynote.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 fi
detect_platform detect_platform
configure_network_interactively 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_host "$INSTALL_HOST"
validate_listen_port "$INSTALL_PORT" validate_listen_port "$INSTALL_PORT"
if (( APPLY && NETWORK_INTERACTIVE )); then if (( APPLY && NETWORK_INTERACTIVE )); then
@@ -1310,6 +1481,11 @@ main() {
for command_name in curl sha256sum tar install sed awk find systemctl; do 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" command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
done 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 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' command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signature verification is enabled'
fi 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' [[ -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 单元、更新辅助程序和卸载器' stage '安装 systemd 单元、更新辅助程序和卸载器'
install -d -m 755 /usr/local/sbin /usr/local/libexec /etc/systemd/system install -d -m 755 /usr/local/sbin /usr/local/libexec /etc/systemd/system
local unit_tmp INSTALL_UNIT_TMP=$(mktemp -d)
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#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$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; 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" > "$INSTALL_UNIT_TMP/tallynote-update.path"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path" sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$INSTALL_UNIT_TMP/tallynote-admin-init"
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 "$INSTALL_UNIT_TMP/tallynote.service" /etc/systemd/system/tallynote.service
install -o root -g root -m 644 "$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 "$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 644 "$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"
install -o root -g root -m 755 "$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"
rm -rf "$unit_tmp" 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 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update 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 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner 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 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 ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
local env_created=0 local env_created=0
if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then 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" chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env" chmod 640 "$CONFIG_DIR/tallynote.env"
env_created=1 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 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_INSTALL_PREFIX "$PREFIX"
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR" 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_STRATEGY systemd
ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL" ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL"
ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS" ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS"
+12 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "tallynote", "name": "tallynote",
"version": "1.3.3", "version": "1.3.4",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.0.6",
@@ -29,30 +29,21 @@
"@fastify/helmet": "^13.0.2", "@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^9.2.1", "@fastify/multipart": "^9.2.1",
"@fastify/static": "^10.1.3", "@fastify/static": "^10.1.3",
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
"@reduxjs/toolkit": "2.12.0",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"echarts": "6.1.0",
"echarts-for-react": "3.0.6",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"fast-xml-parser": "^5.2.5", "fast-xml-parser": "^5.2.5",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"less": "4.4.1",
"lucide-react": "^0.542.0",
"pdf-lib": "^1.17.1", "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", "sharp": "^0.35.4",
"tdesign-react": "1.18.2",
"yauzl": "^3.2.0", "yauzl": "^3.2.0",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
"@reduxjs/toolkit": "2.12.0",
"@playwright/test": "^1.55.0", "@playwright/test": "^1.55.0",
"@types/archiver": "^8.0.0", "@types/archiver": "^8.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
@@ -63,6 +54,15 @@
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"drizzle-kit": "^0.31.4", "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", "tsx": "^4.20.5",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.3", "vite": "^7.1.3",
+33 -33
View File
@@ -23,12 +23,6 @@ importers:
'@fastify/static': '@fastify/static':
specifier: ^10.1.3 specifier: ^10.1.3
version: 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: archiver:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0
@@ -41,12 +35,6 @@ importers:
drizzle-orm: drizzle-orm:
specifier: ^0.45.2 specifier: ^0.45.2
version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) 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: exceljs:
specifier: ^4.4.0 specifier: ^4.4.0
version: 4.4.0 version: 4.4.0
@@ -56,33 +44,12 @@ importers:
fastify: fastify:
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.12.1 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: pdf-lib:
specifier: ^1.17.1 specifier: ^1.17.1
version: 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: sharp:
specifier: ^0.35.4 specifier: ^0.35.4
version: 0.35.4(@types/node@24.13.3) 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: yauzl:
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.4.0 version: 3.4.0
@@ -90,9 +57,15 @@ importers:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.4.3 version: 4.4.3
devDependencies: devDependencies:
'@fontsource-variable/plus-jakarta-sans':
specifier: 5.3.0
version: 5.3.0
'@playwright/test': '@playwright/test':
specifier: ^1.55.0 specifier: ^1.55.0
version: 1.62.1 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': '@types/archiver':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0
@@ -120,6 +93,33 @@ importers:
drizzle-kit: drizzle-kit:
specifier: ^0.31.4 specifier: ^0.31.4
version: 0.31.10 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: tsx:
specifier: ^4.20.5 specifier: ^4.20.5
version: 4.23.12 version: 4.23.12
+6 -7
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
# Build a self-contained release on the target Linux architecture. Native # Build a production release on the target Linux architecture. Native addons
# addons (SQLite, Argon2 and image processing) must be installed on the same # (SQLite, Argon2 and image processing) must be installed on the same
# architecture/libc as the artifact. # 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) ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
VERSION=${1:-} VERSION=${1:-}
OUT_DIR=${2:-$ROOT/release} OUT_DIR=${2:-$ROOT/release}
@@ -28,7 +29,7 @@ cd "$ROOT"
pnpm build pnpm build
stage=$(mktemp -d) stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT 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 # Copy only the production build outputs. In particular, do not carry a
# stale dist/web-next directory from a previous local preview build. # stale dist/web-next directory from a previous local preview build.
cp -a dist/server "$stage/dist/" 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 -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
cp uninstall.sh "$stage/uninstall.sh" 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/" cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
node_path=$(command -v node) chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/uninstall.sh"
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"
# pnpm's default linker creates symlinks. A release archive is deliberately # pnpm's default linker creates symlinks. A release archive is deliberately
# symlink-free so the installer can reject traversal links deterministically. # 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 # always generated; an Ed25519 detached signature is added when a signing key
# is supplied. The script remains separate from the workflow so operators can # is supplied. The script remains separate from the workflow so operators can
# dry-run the exact same asset selection locally without exposing a key. # 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 export PATH
umask 077 umask 077
@@ -205,7 +205,6 @@ fi
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required' command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
full_assets=() full_assets=()
update_assets=()
for file in "$ASSET_DIR"/*.tar.gz; do for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue [[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file") name=$(basename -- "$file")
@@ -214,13 +213,11 @@ for file in "$ASSET_DIR"/*.tar.gz; do
asset_version=${asset_version%%-linux-*} asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name" [[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then
update_assets+=("$file") die "不再发布轻量更新资产:$name;请只保留完整生产包"
else
full_assets+=("$file")
fi fi
full_assets+=("$file")
done done
assets=("${full_assets[@]}") assets=("${full_assets[@]}")
if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found' (( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found' (( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
+31 -17
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH export PATH
umask 077 umask 077
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote} PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/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" REQUEST_FILE="$DATA_DIR/update-request.json"
CURRENT_LINK="$PREFIX/current" CURRENT_LINK="$PREFIX/current"
STATE_FILE="$PREFIX/.update-state" 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; } 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 # 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 # before doing privileged preflight so a partial invocation never leaves a
# heartbeat or lock behind. # heartbeat or lock behind.
@@ -202,9 +225,7 @@ if [[ "$request_operation" == download ]]; then
trap 'exit 143' TERM trap 'exit 143' TERM
trap 'exit 130' INT trap 'exit 130' INT
start_heartbeat start_heartbeat
node_bin="$CURRENT_LINK/runtime/bin/node" node_bin=$(resolve_node) || die 'Node.js 24+ not found'
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
cli="$CURRENT_LINK/dist/server/cli/update.js" cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release' [[ -f "$cli" ]] || die 'update CLI not found in current release'
set +e set +e
@@ -243,8 +264,7 @@ restore_initial_service() {
return "$result" return "$result"
} }
trap restore_initial_service EXIT trap restore_initial_service EXIT
old_node="$CURRENT_LINK/runtime/bin/node" old_node=$(resolve_node) || die 'Node.js 24+ not found'
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
handled=0 handled=0
write_update_state() { write_recovery_state "$1"; } write_update_state() { write_recovery_state "$1"; }
@@ -287,8 +307,7 @@ recover_stale_state() {
return 0 return 0
fi fi
if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then
recovery_node="$CURRENT_LINK/runtime/bin/node" recovery_node=$(resolve_node) || die 'Node.js 24+ not found'
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
for _ in 1 2 3; do for _ in 1 2 3; do
if finalize_state_job "$recovery_node" completed "$state_job"; then if finalize_state_job "$recovery_node" completed "$state_job"; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true 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 # 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 # can still be `applying`; finish it as failed before clearing recovery
# markers so the UI does not poll forever. # markers so the UI does not poll forever.
recovery_node="$CURRENT_LINK/runtime/bin/node" recovery_node=$(resolve_node) || die 'Node.js 24+ not found'
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
if finalize_state_job "$recovery_node" failed "$state_job"; then if finalize_state_job "$recovery_node" failed "$state_job"; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true rm -f -- "$REQUEST_FILE" 2>/dev/null || true
clear_update_state || true clear_update_state || true
@@ -328,8 +346,7 @@ recover_stale_state() {
rm -f -- "$rollback_link" 2>/dev/null || true rm -f -- "$rollback_link" 2>/dev/null || true
return 1 return 1
fi fi
recovery_node="$CURRENT_LINK/runtime/bin/node" recovery_node=$(resolve_node) || die 'Node.js 24+ not found'
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
if ! finalize_state_job "$recovery_node" failed "$state_job"; then if ! finalize_state_job "$recovery_node" failed "$state_job"; then
# If the original queue is still present, retry it from the restored old # 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 # 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 trap cleanup_after_update EXIT
node_bin="$CURRENT_LINK/runtime/bin/node" node_bin=$(resolve_node) || die 'Node.js 24+ not found'
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
cli="$CURRENT_LINK/dist/server/cli/update.js" cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release' [[ -f "$cli" ]] || die 'update CLI not found in current release'
@@ -483,8 +498,7 @@ if (( was_active == 0 )); then
fi fi
write_update_state finalizing || exit 1 write_update_state finalizing || exit 1
final_node="$CURRENT_LINK/runtime/bin/node" final_node=$(resolve_node) || die 'Node.js 24+ not found'
[[ -x "$final_node" ]] || final_node=$(command -v node || true)
if [[ "$job_id" =~ ^[0-9a-f-]{36}$ ]]; then if [[ "$job_id" =~ ^[0-9a-f-]{36}$ ]]; then
finalized=0 finalized=0
for _ in 1 2 3; do for _ in 1 2 3; do
+16 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH export PATH
umask 077 umask 077
@@ -10,9 +10,22 @@ umask 077
# extraction and atomic release switching. # extraction and atomic release switching.
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-${TALLYNOTE_PREFIX:-/opt/tallynote}} PREFIX=${TALLYNOTE_INSTALL_PREFIX:-${TALLYNOTE_PREFIX:-/opt/tallynote}}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/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} REQUEST_FILE=${TALLYNOTE_UPDATE_REQUEST_FILE:-$DATA_DIR/update-request.json}
NODE=${TALLYNOTE_NODE:-} 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; } die() { printf 'tallynote update: %s\n' "$*" >&2; exit 1; }
version_sort_desc() { version_sort_desc() {
if sort -V </dev/null >/dev/null 2>&1; then 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 exec /usr/local/libexec/tallynote-update-runner
fi fi
if [[ -z "$NODE" ]]; then if [[ -z "$NODE" ]]; then
NODE="$PREFIX/current/runtime/bin/node" NODE=$(command -v node || true)
[[ -x "$NODE" ]] || NODE=$(command -v node || true)
fi 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" CLI="$PREFIX/current/dist/server/cli/update.js"
[[ -f "$CLI" ]] || die 'update CLI not found' [[ -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_prefix="$runner_root/prefix"
runner_data="$runner_root/data" runner_data="$runner_root/data"
runner_tools="$runner_root/tools" 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" 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' '{"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' 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' '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:-}" == "-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' '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" 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_script="$runner_root/runner.sh"
runner_path="$runner_tools:/usr/sbin:/usr/bin:/sbin:/bin" 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" chmod 755 "$runner_script"
runner_prefix_physical=$(cd "$runner_prefix" && pwd -P) runner_prefix_physical=$(cd "$runner_prefix" && pwd -P)
runner_data_physical=$(cd "$runner_data" && 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 -- '--request-file' "$runner_root/node.log"
grep -q -- '--finalize-job' "$runner_root/node.log" grep -q -- '--finalize-job' "$runner_root/node.log"
[[ ! -e "$runner_data/update-request.json" ]] [[ ! -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_prefix="$download_runner_root/prefix"
download_runner_data="$download_runner_root/data" download_runner_data="$download_runner_root/data"
download_runner_tools="$download_runner_root/tools" 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" 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 # The request has already been consumed; only the stale download marker is
# left, which is the narrow recovery window covered by this fixture. # left, which is the narrow recovery window covered by this fixture.
download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P) download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P)
download_runner_data_physical=$(cd "$download_runner_data" && 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' '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' 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' '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" 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 "$@" ;; *) /usr/bin/stat "$@" ;;
esac esac
EOF 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" 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" 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_root/node.log" ]]
[[ ! -e "$download_runner_prefix/.update-state" ]] [[ ! -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. # temporary variables still exist; otherwise set -u fails at the end of main.
release_fixture="$tmp/release-fixture" release_fixture="$tmp/release-fixture"
mkdir -p "$release_fixture/dist/server/cli" "$release_fixture/dist/web" "$release_fixture/bin" \ 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' '{"version":"1.0.0"}' > "$release_fixture/package.json"
printf '%s\n' server > "$release_fixture/dist/server/index.js" printf '%s\n' server > "$release_fixture/dist/server/index.js"
printf '%s\n' cli > "$release_fixture/dist/server/cli/admin-init.js" printf '%s\n' 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 production admin wrapper must load a release-relative runtime, change to
# the release root, and forward CLI arguments without requiring pnpm. # the release root, and forward CLI arguments without requiring pnpm.
wrapper_prefix="$tmp/wrapper-prefix" 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" 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" 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 "$wrapper_prefix/releases/1.0.0/runtime/bin/node" chmod 755 "$tmp/wrapper-tools/node"
printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js" 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. # 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 # 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 # real `tallynote` service account or a privileged runuser hand-off; that
# privilege boundary is validated by the production checks themselves. # 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 bash "$root/bin/tallynote-admin-init" --generate
wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P) wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P)
grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log" 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 # A release archive is extracted under umask 077, then explicitly normalized
# so the tallynote system user can traverse and execute the shipped tree. # so the tallynote system user can traverse and execute the shipped tree.
source_tmp="$tmp/source" 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' 'server' > "$source_tmp/dist/server/index.js"
printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh" 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/bin/tallynote"
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh" 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"
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/uninstall.sh" chmod 755 "$source_tmp/uninstall.sh"
archive_tmp="$tmp/release.tar.gz" archive_tmp="$tmp/release.tar.gz"
tar -C "$source_tmp" -czf "$archive_tmp" . tar -C "$source_tmp" -czf "$archive_tmp" .
@@ -612,6 +611,7 @@ bash -c '
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]] [[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]] [[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
[[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]] [[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]]
[[ ! -e "$destination/runtime" ]]
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked" ' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
# A normal public-release install only needs the detached SHA-256 manifest; # A normal public-release install only needs the detached SHA-256 manifest;
+1 -1
View File
@@ -34,7 +34,7 @@ make_fixture() {
done 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/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' '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" 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" chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-admin-init" "$fixture/usr/local/sbin/tallynote-uninstall"
printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db" printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db"
+4 -22
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3"; import type Database from "better-sqlite3";
@@ -10,7 +10,6 @@ import { writeAudit } from "../audit.js";
import { import {
atomicSwitchDirectory, atomicSwitchDirectory,
atomicSwitchRelease, atomicSwitchRelease,
applicationUpdateRuntimeHash,
compareSemver, compareSemver,
createSafeArchive, createSafeArchive,
detectPlatform, detectPlatform,
@@ -20,7 +19,6 @@ import {
isNewerVersion, isNewerVersion,
normalizeReleasePermissions, normalizeReleasePermissions,
parseSemver, parseSemver,
runtimeHashFromLockfile,
selectReleaseAsset, selectReleaseAsset,
sanitizeAssetName, sanitizeAssetName,
validateHttpsUrl, validateHttpsUrl,
@@ -223,16 +221,9 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
if (options.metadataUrl) { if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options); const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(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 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 } : {}) } ? { 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("没有匹配当前平台的更新文件"); if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, { const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [], allowedHosts: options.allowedHosts ?? [],
@@ -339,17 +330,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"); 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"); const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }); await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
if (applicationUpdateRuntimeHash(resolved.asset.name)) { const embeddedRuntime = await lstat(path.join(stagedDir, "runtime")).catch(() => null);
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); }); if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime");
const currentInfo = await lstat(currentRelease).catch(() => null);
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry);
const sourceInfo = await lstat(source).catch(() => null);
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
}
}
await normalizeReleasePermissions(stagedDir); await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null); const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录"); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
+11 -30
View File
@@ -1,5 +1,5 @@
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs"; import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto"; import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3"; import type Database from "better-sqlite3";
@@ -16,7 +16,6 @@ import {
isNewerVersion, isNewerVersion,
normalizeReleasePermissions, normalizeReleasePermissions,
parseSemver, parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName, sanitizeAssetName,
selectReleaseAsset, selectReleaseAsset,
validateHttpsUrl, validateHttpsUrl,
@@ -211,14 +210,9 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
} catch { } catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试"); throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
} }
let runtimeHash: string | undefined; // Always select the complete production archive. The host Node.js runtime
try { // is reused, while the application package remains self-contained and
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml"))); // identical for first installs and in-place updates.
} catch {
// Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime.
}
// Force choosing the full standalone archive so users always get a real, visible streaming download
let asset = selectReleaseAsset(metadata, platform, undefined); let asset = selectReleaseAsset(metadata, platform, undefined);
let signatureVerified = false; let signatureVerified = false;
if (asset) { if (asset) {
@@ -688,8 +682,9 @@ export function cancelUpdateJob(
* The root runner only needs to apply (stop/backup/switch/restart) afterwards. * The root runner only needs to apply (stop/backup/switch/restart) afterwards.
* *
* This function runs asynchronously outside the request lifecycle. It updates * 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 * the job row in the database so the frontend can poll progress. A successful
* writes an apply request file so the systemd path unit triggers the runner. * download only becomes staged; applying it is a separate, explicit action
* that the administrator submits after reviewing the verification result.
*/ */
export async function downloadAndStageUpdate( export async function downloadAndStageUpdate(
database: Database.Database, database: Database.Database,
@@ -756,8 +751,10 @@ export async function downloadAndStageUpdate(
await extractSafeArchive(archivePath, payloadDir); await extractSafeArchive(archivePath, payloadDir);
await normalizeReleasePermissions(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 // Verify payload contains dist directory
const { lstat } = await import("node:fs/promises");
const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null); const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) { if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) {
throw new Error("发布包缺少 dist 目录"); throw new Error("发布包缺少 dist 目录");
@@ -765,26 +762,10 @@ export async function downloadAndStageUpdate(
// Transition to staged // Transition to staged
const staged = database.prepare( const staged = database.prepare(
"UPDATE update_jobs SET status='staged', operation='apply', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')", "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); ).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId);
if (staged.changes !== 1) return; // cancelled if (staged.changes !== 1) return; // cancelled
// Write apply request file for the root runner
await writeUpdateRequest(config, {
jobId,
operation: "apply",
version,
metadataUrl,
assetUrl,
assetName,
expectedSha256,
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
stagedPath: workspace,
});
writeAudit(database, { writeAudit(database, {
requestId: `download:${jobId}`, requestId: `download:${jobId}`,
actorAdminId: adminId, actorAdminId: adminId,
+1 -1
View File
@@ -1000,7 +1000,7 @@ export async function normalizeReleasePermissions(rootPath: string): Promise<voi
await walk(target); await walk(target);
} else if (entry.isFile()) { } else if (entry.isFile()) {
const relative = path.relative(root, target).split(path.sep).join("/"); 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); await chmod(target, executable ? 0o755 : 0o644);
} else { } else {
throw new Error("发布包包含不受支持的文件类型"); throw new Error("发布包包含不受支持的文件类型");
+2 -1
View File
@@ -6,8 +6,9 @@ User=root
Group=root Group=root
WorkingDirectory=/opt/tallynote/current WorkingDirectory=/opt/tallynote/current
EnvironmentFile=-/etc/tallynote/tallynote.env EnvironmentFile=-/etc/tallynote/tallynote.env
Environment=TALLYNOTE_CONFIG_DIR=/etc/tallynote
ExecStart=/usr/local/libexec/tallynote-update-runner 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 # The runner consumes queued requests immediately and applies its own bounded
# phase timeouts while keeping full CLI diagnostics in the runner log. # phase timeouts while keeping full CLI diagnostics in the runner log.
# Archive validation and data backups can exceed systemd's 90s # Archive validation and data backups can exceed systemd's 90s
+1
View File
@@ -2,6 +2,7 @@ TALLYNOTE_HOST=127.0.0.1
TALLYNOTE_PORT=3000 TALLYNOTE_PORT=3000
TALLYNOTE_DATA_DIR=/var/lib/tallynote TALLYNOTE_DATA_DIR=/var/lib/tallynote
TALLYNOTE_INSTALL_PREFIX=/opt/tallynote TALLYNOTE_INSTALL_PREFIX=/opt/tallynote
TALLYNOTE_CONFIG_DIR=/etc/tallynote
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000 TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
TALLYNOTE_COOKIE_SECURE=false TALLYNOTE_COOKIE_SECURE=false
TALLYNOTE_ALLOW_INSECURE_HTTP=false TALLYNOTE_ALLOW_INSECURE_HTTP=false
+2 -1
View File
@@ -10,7 +10,8 @@ Group=tallynote
WorkingDirectory=/opt/tallynote/current WorkingDirectory=/opt/tallynote/current
Environment=NODE_ENV=production Environment=NODE_ENV=production
EnvironmentFile=-/etc/tallynote/tallynote.env 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 ExecStart=/opt/tallynote/current/bin/tallynote
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
+1 -3
View File
@@ -455,11 +455,9 @@ describe("更新安全工具", () => {
await mkdir(path.join(source, "dist", "server"), { recursive: true }); await mkdir(path.join(source, "dist", "server"), { recursive: true });
await mkdir(path.join(source, "bin"), { recursive: true }); await mkdir(path.join(source, "bin"), { recursive: true });
await mkdir(path.join(source, "scripts"), { 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, "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, "bin", "tallynote"), "#!/bin/sh\n");
await writeFile(path.join(source, "scripts", "runner.sh"), "#!/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"); const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive); await createSafeArchive(source, archive);
expect((await stat(archive)).size).toBeLessThan(64 * 1024); expect((await stat(archive)).size).toBeLessThan(64 * 1024);
@@ -472,7 +470,7 @@ describe("更新安全工具", () => {
expect((await stat(path.join(destination, "dist", "server", "large.js"))).mode & 0o777).toBe(0o644); 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, "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, "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 { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
+28
View File
@@ -417,6 +417,7 @@ remove_prefix() {
log "warning: 保留非符号链接 current:$current" log "warning: 保留非符号链接 current:$current"
fi fi
remove_tree "$releases" 'releases' remove_tree "$releases" 'releases'
remove_managed_node
remove_tree "$PREFIX/.update-work" 'update work' remove_tree "$PREFIX/.update-work" 'update work'
remove_file_if_owned "$PREFIX/.update-state" 'update state' remove_file_if_owned "$PREFIX/.update-state" 'update state'
if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then
@@ -429,6 +430,33 @@ remove_prefix() {
fi 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_config() {
remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key' remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key'
remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file' remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file'