Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac10b6fdf | ||
|
|
eeeec54d10 |
@@ -5,6 +5,10 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
|
||||
TALLYNOTE_TRUST_PROXY=false
|
||||
TALLYNOTE_COOKIE_SECURE=false
|
||||
# Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct
|
||||
# access. HTTP on a non-local Origin is opt-in; use HTTPS behind a proxy in
|
||||
# production.
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=false
|
||||
TALLYNOTE_SESSION_IDLE_HOURS=24
|
||||
TALLYNOTE_SESSION_ABSOLUTE_HOURS=168
|
||||
TALLYNOTE_EXPORT_TTL_MINUTES=15
|
||||
|
||||
@@ -57,9 +57,25 @@ pnpm build:next
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
安装命令保持简洁。首次在 SSH/终端中安装时,安装器会交互询问监听方式、端口和公开访问地址:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。选择 `0.0.0.0` 后,请填写真实访问地址,例如 `http://203.0.113.10:3000` 或 `https://tallynote.example.com`;不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。
|
||||
|
||||
安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
|
||||
```
|
||||
|
||||
脚本会从公开仓库的 latest Release 获取当前架构归档和 `SHA256SUMS`,并在安装前始终校验 SHA-256。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。需要固定版本或预览时,仍可使用 `TALLYNOTE_VERSION`、`--version` 或 `--dry-run` 等高级选项。
|
||||
|
||||
安装过程会持续输出带统一前缀的阶段日志,不会在下载、校验或启动服务时静默等待。交互式 SSH/终端中下载还会显示 curl 进度条;非交互式运行(例如 CI)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化):
|
||||
安装过程会持续输出带统一前缀的阶段日志,不会在下载、校验或启动服务时静默等待。交互式 SSH/终端中会先显示网络配置选择,下载时还会显示 curl 进度条;非交互式运行(例如 CI)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化):
|
||||
|
||||
```text
|
||||
tallynote installer: [阶段] 检查运行环境、权限和目标架构
|
||||
@@ -91,7 +107,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`,默认仅监听 `127.0.0.1:3000`。
|
||||
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。
|
||||
|
||||
升级有两种方式:
|
||||
|
||||
|
||||
+9
-1
@@ -38,6 +38,14 @@ GITEA_TOKEN=... \
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式、端口和公开访问地址。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。
|
||||
|
||||
非交互安装命令:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
|
||||
```
|
||||
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。
|
||||
|
||||
安装器会在每个关键阶段输出统一格式的日志,便于在 SSH 或 systemd 安装会话中确认进度;交互式终端下载时还会显示 curl 进度条,CI 或日志重定向时则保持纯文本输出:
|
||||
@@ -101,7 +109,7 @@ Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`ta
|
||||
sudo /usr/local/sbin/tallynote-update --rollback
|
||||
```
|
||||
|
||||
更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。
|
||||
更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`;首次安装时可在交互提示中选择 `0.0.0.0` 和真实的服务器 IP/域名。直连 HTTP 会暴露未加密的会话和数据,只适合受控网络;绑定域名后必须改为 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。自动化安装可使用 `--non-interactive` 或显式网络环境变量。
|
||||
|
||||
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
|
||||
|
||||
|
||||
+300
-3
@@ -34,6 +34,20 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300}
|
||||
RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-}
|
||||
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
|
||||
UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname}
|
||||
# Service network settings are written to the systemd EnvironmentFile on a
|
||||
# fresh install. Existing values are preserved unless the corresponding
|
||||
# TALLYNOTE_* variable is explicitly supplied to the installer.
|
||||
INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1}
|
||||
INSTALL_PORT=${TALLYNOTE_PORT-3000}
|
||||
INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-}
|
||||
INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false}
|
||||
NON_INTERACTIVE=0
|
||||
|
||||
# The production prompt uses the controlling terminal, even when the
|
||||
# installer itself is read from `curl | sudo bash`.
|
||||
PROMPT_INPUT=/dev/tty
|
||||
PROMPT_OUTPUT=/dev/tty
|
||||
PROMPT_REPLY=''
|
||||
|
||||
INSTALL_SWITCHED=0
|
||||
INSTALL_COMMITTED=0
|
||||
@@ -58,13 +72,19 @@ Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL]
|
||||
[--signature-format ed25519|gpg]
|
||||
[--update-public-key-file FILE]
|
||||
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply]
|
||||
[--non-interactive]
|
||||
|
||||
Without arguments, the installer resolves the latest compatible release and
|
||||
installs it. SHA-256 from SHA256SUMS is always required. Detached signature
|
||||
verification is optional by default; enable it with
|
||||
TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use
|
||||
--dry-run to inspect the selected release without downloading or changing the
|
||||
host. --apply is accepted for backwards compatibility.
|
||||
host. For direct IP access, pass TALLYNOTE_HOST=0.0.0.0 and an actual
|
||||
TALLYNOTE_PUBLIC_ORIGIN such as http://203.0.113.10:3000; HTTP also requires
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener
|
||||
and public URL can be selected interactively. Use --non-interactive (or
|
||||
TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for
|
||||
backwards compatibility.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -72,6 +92,107 @@ log() { printf 'tallynote installer: %s\n' "$*"; }
|
||||
stage() { log "[阶段] $*"; }
|
||||
stage_done() { log "[完成] $*"; }
|
||||
|
||||
case "${TALLYNOTE_NON_INTERACTIVE:-false}" in
|
||||
true|1) NON_INTERACTIVE=1 ;;
|
||||
false|0) ;;
|
||||
*) die 'TALLYNOTE_NON_INTERACTIVE 必须是 true 或 false' ;;
|
||||
esac
|
||||
|
||||
prompt_value() {
|
||||
local label=$1 default=${2-} reply
|
||||
if [[ -n "$default" ]]; then
|
||||
printf '%s [%s]: ' "$label" "$default" > "$PROMPT_OUTPUT"
|
||||
else
|
||||
printf '%s: ' "$label" > "$PROMPT_OUTPUT"
|
||||
fi
|
||||
if ! IFS= read -r reply <&9; then
|
||||
die '无法读取终端输入;请使用 --non-interactive 或通过环境变量配置'
|
||||
fi
|
||||
PROMPT_REPLY=${reply:-$default}
|
||||
}
|
||||
|
||||
has_network_environment() {
|
||||
[[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]
|
||||
}
|
||||
|
||||
interactive_network_available() {
|
||||
(( APPLY )) || return 1
|
||||
(( NON_INTERACTIVE == 0 )) || return 1
|
||||
has_network_environment && return 1
|
||||
[[ ! -e "$CONFIG_DIR/tallynote.env" && ! -L "$CONFIG_DIR/tallynote.env" ]] || return 1
|
||||
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
configure_network_interactively() {
|
||||
interactive_network_available || return 0
|
||||
|
||||
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置'
|
||||
|
||||
stage '配置服务网络监听(可直接回车使用默认值)'
|
||||
{
|
||||
printf '\nTallyNote 服务监听配置\n'
|
||||
printf ' 1) 仅本机访问:127.0.0.1(更安全)\n'
|
||||
printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n'
|
||||
} > "$PROMPT_OUTPUT"
|
||||
|
||||
local choice selected_port origin answer
|
||||
while :; do
|
||||
prompt_value '请选择监听方式 1/2' '1'
|
||||
choice=$PROMPT_REPLY
|
||||
case "$choice" in
|
||||
1|2) break ;;
|
||||
*) printf '请输入 1 或 2。\n' > "$PROMPT_OUTPUT" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
while :; do
|
||||
prompt_value '监听端口' "$INSTALL_PORT"
|
||||
selected_port=$PROMPT_REPLY
|
||||
if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then
|
||||
break
|
||||
fi
|
||||
printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT"
|
||||
done
|
||||
|
||||
if [[ "$choice" == 1 ]]; then
|
||||
INSTALL_HOST=127.0.0.1
|
||||
INSTALL_PORT=$selected_port
|
||||
INSTALL_PUBLIC_ORIGIN="http://127.0.0.1:${selected_port}"
|
||||
INSTALL_ALLOW_INSECURE_HTTP=false
|
||||
else
|
||||
INSTALL_HOST=0.0.0.0
|
||||
INSTALL_PORT=$selected_port
|
||||
while :; do
|
||||
prompt_value '实际访问地址(例如 http://203.0.113.10:3000 或 https://tallynote.example.com)' ''
|
||||
origin=$PROMPT_REPLY
|
||||
if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then
|
||||
INSTALL_PUBLIC_ORIGIN=$origin
|
||||
break
|
||||
fi
|
||||
printf '地址无效:请输入不含路径、凭据或通配监听地址的 http:// 或 https:// 地址。\n' > "$PROMPT_OUTPUT"
|
||||
done
|
||||
INSTALL_ALLOW_INSECURE_HTTP=false
|
||||
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* ]]; then
|
||||
{
|
||||
printf '\n警告:直连 HTTP 不加密,登录信息和账目数据可能被窃听。\n'
|
||||
printf '仅在受控局域网或你明确接受风险时继续。\n'
|
||||
} > "$PROMPT_OUTPUT"
|
||||
while :; do
|
||||
prompt_value '确认允许公网 HTTP?输入 yes 继续,其他内容取消' 'no'
|
||||
answer=$PROMPT_REPLY
|
||||
case "$answer" in
|
||||
yes|YES|Yes|y|Y) INSTALL_ALLOW_INSECURE_HTTP=true; break ;;
|
||||
no|NO|No|n|N|'') die '已取消:公网 HTTP 必须明确确认;请改用 HTTPS 或重新运行安装器' ;;
|
||||
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
fi
|
||||
exec 9<&-
|
||||
stage_done "网络配置已选择:${INSTALL_HOST}:${INSTALL_PORT}"
|
||||
}
|
||||
|
||||
[[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false'
|
||||
[[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false'
|
||||
[[ "$SIGNATURE_FORMAT" == ed25519 || "$SIGNATURE_FORMAT" == gpg ]] || die '签名格式必须是 ed25519 或 gpg'
|
||||
@@ -106,6 +227,7 @@ while (($#)); do
|
||||
--keep-releases) KEEP_RELEASES=${2:?missing value for --keep-releases}; shift ;;
|
||||
--allow-downgrade) ALLOW_DOWNGRADE=true ;;
|
||||
--allow-unsigned) ALLOW_UNSIGNED=1; REQUIRE_SIGNATURE=false ;;
|
||||
--non-interactive) NON_INTERACTIVE=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
@@ -621,6 +743,66 @@ validate_env_value() {
|
||||
[[ ${#value} -le 4096 ]] || die "$label 过长"
|
||||
}
|
||||
|
||||
validate_listen_host() {
|
||||
local value=$1 label=${2:-监听地址}
|
||||
validate_env_value "$value" "$label"
|
||||
if [[ "$value" == *:* ]]; then
|
||||
[[ "$value" =~ ^[0-9A-Fa-f:]+$ ]] || die "$label 必须是有效的 IPv6 地址或主机名"
|
||||
elif [[ "$value" =~ ^[0-9.]+$ ]]; then
|
||||
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
local octet
|
||||
IFS='.' read -r -a _host_octets <<< "$value"
|
||||
for octet in "${_host_octets[@]}"; do
|
||||
(( octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
|
||||
done
|
||||
else
|
||||
[[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名"
|
||||
[[ "$value" != *..* && "$value" != *.-* && "$value" != *-.* ]] || die "$label 包含不受支持的主机名"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_listen_port() {
|
||||
local value=$1 label=${2:-监听端口}
|
||||
[[ "$value" =~ ^[1-9][0-9]*$ && "$value" -le 65535 ]] || die "$label 必须是 1-65535 的整数"
|
||||
}
|
||||
|
||||
validate_public_origin() {
|
||||
local value=$1 authority host path_part origin_port suffix
|
||||
case "$value" in
|
||||
http://*|https://*) ;;
|
||||
*) die '公开访问地址必须是 http:// 或 https:// 地址' ;;
|
||||
esac
|
||||
[[ "$value" != *[[:space:]]* && "$value" != *[[:cntrl:]]* && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die '公开访问地址包含不受支持的字符'
|
||||
authority=${value#*://}
|
||||
authority=${authority%%/*}
|
||||
[[ -n "$authority" ]] || die '公开访问地址缺少主机名'
|
||||
if [[ "$authority" == \[*\]* ]]; then
|
||||
host=${authority#\[}; host=${host%%\]*}
|
||||
suffix=${authority#*\]}
|
||||
if [[ -n "$suffix" ]]; then
|
||||
[[ "$suffix" =~ ^:([0-9]+)$ ]] || die '公开访问地址端口无效'
|
||||
origin_port=${BASH_REMATCH[1]}
|
||||
fi
|
||||
else
|
||||
if [[ "$authority" == *:* ]]; then
|
||||
[[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效'
|
||||
host=${BASH_REMATCH[1]}
|
||||
origin_port=${BASH_REMATCH[2]}
|
||||
else
|
||||
host=$authority
|
||||
fi
|
||||
fi
|
||||
[[ -n "$host" ]] || die '公开访问地址缺少主机名'
|
||||
[[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名'
|
||||
[[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die '公开访问地址主机名无效'
|
||||
if [[ -n "$origin_port" ]]; then
|
||||
[[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数'
|
||||
fi
|
||||
path_part=${value#*://}
|
||||
path_part=${path_part#"$authority"}
|
||||
[[ -z "$path_part" || "$path_part" == "/" ]] || die '公开访问地址不能包含路径'
|
||||
}
|
||||
|
||||
validate_semver() {
|
||||
local value=$1 prerelease part
|
||||
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
|
||||
@@ -640,14 +822,14 @@ validate_install_path() {
|
||||
}
|
||||
|
||||
validate_existing_env() {
|
||||
local file=$1 value metadata_host
|
||||
local file=$1 value metadata_host host port origin allow_insecure cookie_secure
|
||||
[[ ! -L "$file" && -f "$file" ]] || die '现有环境文件不是普通文件'
|
||||
[[ "$(stat_uid "$file")" == 0 ]] || die '现有环境文件必须由 root 拥有'
|
||||
local mode_bits
|
||||
mode_bits=$(stat_mode_bits "$file")
|
||||
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
|
||||
local key key_count
|
||||
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
|
||||
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_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
|
||||
@@ -657,6 +839,59 @@ validate_existing_env() {
|
||||
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
|
||||
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
|
||||
if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then
|
||||
host=$(read_env_value "$file" TALLYNOTE_HOST)
|
||||
validate_listen_host "$host" '环境文件中的监听地址'
|
||||
else
|
||||
host=127.0.0.1
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_PORT) )); then
|
||||
port=$(read_env_value "$file" TALLYNOTE_PORT)
|
||||
validate_listen_port "$port" '环境文件中的监听端口'
|
||||
else
|
||||
port=3000
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) )); then
|
||||
allow_insecure=$(read_env_value "$file" TALLYNOTE_ALLOW_INSECURE_HTTP)
|
||||
[[ "$allow_insecure" == true || "$allow_insecure" == false ]] || die '环境文件中的公网 HTTP 开关必须是 true 或 false'
|
||||
else
|
||||
allow_insecure=false
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_COOKIE_SECURE) )); then
|
||||
cookie_secure=$(read_env_value "$file" TALLYNOTE_COOKIE_SECURE)
|
||||
[[ "$cookie_secure" == true || "$cookie_secure" == false ]] || die '环境文件中的安全 Cookie 配置必须是 true 或 false'
|
||||
else
|
||||
cookie_secure=''
|
||||
fi
|
||||
if (( $(env_key_count "$file" TALLYNOTE_PUBLIC_ORIGIN) )); then
|
||||
origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN)
|
||||
validate_env_value "$origin" '环境文件中的公开访问地址'
|
||||
else
|
||||
origin="http://${host}:${port}"
|
||||
fi
|
||||
validate_public_origin "$origin"
|
||||
local origin_host=${origin#*://}
|
||||
if [[ "$origin_host" == \[*\]* ]]; then
|
||||
origin_host=${origin_host#\[}
|
||||
origin_host=${origin_host%%\]*}
|
||||
else
|
||||
origin_host=${origin_host%%:*}
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then
|
||||
case "$origin_host" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then
|
||||
case "$origin_host" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$origin" == https://* && "$cookie_secure" == false ]]; then
|
||||
die '环境文件中的 HTTPS 公开地址必须启用安全 Cookie'
|
||||
fi
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
|
||||
if [[ -n "$value" ]]; then
|
||||
validate_env_value "$value" '环境文件更新源'
|
||||
@@ -735,6 +970,32 @@ main() {
|
||||
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
|
||||
fi
|
||||
detect_platform
|
||||
configure_network_interactively
|
||||
validate_listen_host "$INSTALL_HOST"
|
||||
validate_listen_port "$INSTALL_PORT"
|
||||
if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin'
|
||||
fi
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true || "$INSTALL_ALLOW_INSECURE_HTTP" == false ]] || die 'TALLYNOTE_ALLOW_INSECURE_HTTP 必须是 true 或 false'
|
||||
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
validate_env_value "$INSTALL_PUBLIC_ORIGIN" '公开访问地址'
|
||||
validate_public_origin "$INSTALL_PUBLIC_ORIGIN"
|
||||
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* && "$INSTALL_ALLOW_INSECURE_HTTP" != true ]]; then
|
||||
public_host=${INSTALL_PUBLIC_ORIGIN#http://}
|
||||
if [[ "$public_host" == \[*\]* ]]; then
|
||||
public_host=${public_host#\[}
|
||||
public_host=${public_host%%\]*}
|
||||
else
|
||||
public_host=${public_host%%:*}
|
||||
fi
|
||||
case "$public_host" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*) die '公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
|
||||
esac
|
||||
fi
|
||||
elif [[ "$INSTALL_HOST" != 127.0.0.1 && "$INSTALL_HOST" != localhost && "$INSTALL_HOST" != ::1 ]]; then
|
||||
die '监听非本机地址时必须提供 TALLYNOTE_PUBLIC_ORIGIN(例如 http://服务器IP:3000)'
|
||||
fi
|
||||
[[ "$KEEP_RELEASES" =~ ^[1-9][0-9]*$ ]] || die '--keep-releases must be a positive integer'
|
||||
validate_install_path "$PREFIX" '安装目录'
|
||||
validate_install_path "$DATA_DIR" '数据目录'
|
||||
@@ -876,10 +1137,12 @@ main() {
|
||||
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
|
||||
install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall
|
||||
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
|
||||
local env_created=0
|
||||
if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env"
|
||||
chown root:root "$CONFIG_DIR/tallynote.env"
|
||||
chmod 640 "$CONFIG_DIR/tallynote.env"
|
||||
env_created=1
|
||||
fi
|
||||
ensure_env_key() {
|
||||
local key=$1 value=$2
|
||||
@@ -892,6 +1155,40 @@ main() {
|
||||
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
}
|
||||
set_env_key() {
|
||||
local key=$1 value=$2 escaped tmp
|
||||
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效'
|
||||
validate_env_value "$value" "$key"
|
||||
escaped=${value//\\/\\\\}
|
||||
escaped=${escaped//&/\\&}
|
||||
escaped=${escaped//|/\\|}
|
||||
if grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then
|
||||
sed -i "s|^${key}=.*|${key}=${escaped}|" "$CONFIG_DIR/tallynote.env"
|
||||
else
|
||||
if [[ -s "$CONFIG_DIR/tallynote.env" && "$(tail -c 1 "$CONFIG_DIR/tallynote.env")" != $'\n' ]]; then
|
||||
printf '\n' >> "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
}
|
||||
# A fresh install gets the requested network settings. On upgrades, only
|
||||
# explicitly supplied values change the existing administrator config.
|
||||
if (( env_created )) || [[ -n "${TALLYNOTE_HOST+x}" ]]; then set_env_key TALLYNOTE_HOST "$INSTALL_HOST"; fi
|
||||
if (( env_created )) || [[ -n "${TALLYNOTE_PORT+x}" ]]; then set_env_key TALLYNOTE_PORT "$INSTALL_PORT"; fi
|
||||
if (( env_created )); then
|
||||
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
|
||||
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
|
||||
elif [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" ]]; then
|
||||
local generated_origin_host=$INSTALL_HOST
|
||||
[[ "$generated_origin_host" == *:* && "$generated_origin_host" != \[* ]] && generated_origin_host="[$generated_origin_host]"
|
||||
set_env_key TALLYNOTE_PUBLIC_ORIGIN "http://${generated_origin_host}:${INSTALL_PORT}"
|
||||
fi
|
||||
if [[ "$INSTALL_PUBLIC_ORIGIN" == https://* ]]; then set_env_key TALLYNOTE_COOKIE_SECURE true; fi
|
||||
set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"
|
||||
elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then
|
||||
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
|
||||
fi
|
||||
if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi
|
||||
ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX"
|
||||
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR"
|
||||
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -13,6 +13,10 @@ STATE_FILE="$PREFIX/.update-state"
|
||||
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
|
||||
HOST=${TALLYNOTE_HOST:-127.0.0.1}
|
||||
PORT=${TALLYNOTE_PORT:-3000}
|
||||
HEALTH_HOST=$HOST
|
||||
if [[ "$HEALTH_HOST" == 0.0.0.0 ]]; then HEALTH_HOST=127.0.0.1; fi
|
||||
if [[ "$HEALTH_HOST" == :: ]]; then HEALTH_HOST=::1; fi
|
||||
if [[ "$HEALTH_HOST" == *:* && "$HEALTH_HOST" != \[* ]]; then HEALTH_HOST="[$HEALTH_HOST]"; fi
|
||||
|
||||
die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
|
||||
@@ -222,7 +226,7 @@ write_update_state health-check || exit 1
|
||||
systemctl start "$SERVICE_NAME"
|
||||
healthy=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
|
||||
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HEALTH_HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
||||
@@ -13,6 +13,28 @@ if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.
|
||||
echo 'expected non-HTTPS URL to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
if TALLYNOTE_HOST=0.0.0.0 bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected non-local listener without public origin to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
output=$(TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=true \
|
||||
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
output=$(TALLYNOTE_HOST=::1 TALLYNOTE_PORT=3443 \
|
||||
bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=65536 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 TALLYNOTE_ALLOW_INSECURE_HTTP=true \
|
||||
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected invalid listener port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
|
||||
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP without explicit opt-in to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
tmp=$(mktemp -d)
|
||||
cleanup_tmp() {
|
||||
if [[ -d "$tmp" ]]; then
|
||||
@@ -80,6 +102,182 @@ bash -c '
|
||||
fi
|
||||
' _ "$installer_lib" "$duplicate_env"
|
||||
|
||||
# Existing installations must validate the network settings they preserve on
|
||||
# upgrade, including the direct-IP HTTP combination used by the documented
|
||||
# installer command.
|
||||
network_env="$tmp/network.env"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
|
||||
'TALLYNOTE_ALLOW_INSECURE_HTTP=true' > "$network_env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$network_env"
|
||||
if sed 's/^TALLYNOTE_PORT=.*/TALLYNOTE_PORT=65536/' "$network_env" > "$tmp/invalid-port.env"; then
|
||||
if bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/invalid-port.env" >/dev/null 2>&1; then
|
||||
echo 'expected invalid existing listener port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
|
||||
'TALLYNOTE_ALLOW_INSECURE_HTTP=false' > "$tmp/public-http-without-opt-in.env"
|
||||
if bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/public-http-without-opt-in.env" >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP without opt-in in existing env to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_public_origin "http://[2001:db8::10]:3000"
|
||||
' _ "$installer_lib"
|
||||
printf '%s\n' \
|
||||
'TALLYNOTE_HOST=0.0.0.0' \
|
||||
'TALLYNOTE_PORT=3000' \
|
||||
'TALLYNOTE_PUBLIC_ORIGIN=https://tallynote.example.com' \
|
||||
'TALLYNOTE_COOKIE_SECURE=true' > "$tmp/public-https.env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
PREFIX=/opt/tallynote
|
||||
DATA_DIR=/var/lib/tallynote
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
validate_existing_env "$env_file"
|
||||
' _ "$installer_lib" "$tmp/public-https.env"
|
||||
if bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
validate_public_origin "http://example.test:65536"
|
||||
' _ "$installer_lib" >/dev/null 2>&1; then
|
||||
echo 'expected invalid public origin port to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A fresh interactive install reads from the controlling terminal even when
|
||||
# the installer script itself is piped from curl. The test substitutes files
|
||||
# for that terminal and verifies both listener choices without touching the
|
||||
# host filesystem.
|
||||
interactive_dir="$tmp/interactive"
|
||||
mkdir -p "$interactive_dir/config"
|
||||
printf '\n\n' > "$interactive_dir/local-input"
|
||||
: > "$interactive_dir/local-output"
|
||||
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/local-input"
|
||||
PROMPT_OUTPUT="$dir/local-output"
|
||||
configure_network_interactively
|
||||
[[ "$INSTALL_HOST" == 127.0.0.1 ]]
|
||||
[[ "$INSTALL_PORT" == 3000 ]]
|
||||
[[ "$INSTALL_PUBLIC_ORIGIN" == http://127.0.0.1:3000 ]]
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == false ]]
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
printf '2\n3443\nhttp://203.0.113.10:3443\nyes\n' > "$interactive_dir/public-input"
|
||||
: > "$interactive_dir/public-output"
|
||||
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/public-input"
|
||||
PROMPT_OUTPUT="$dir/public-output"
|
||||
configure_network_interactively
|
||||
[[ "$INSTALL_HOST" == 0.0.0.0 ]]
|
||||
[[ "$INSTALL_PORT" == 3443 ]]
|
||||
[[ "$INSTALL_PUBLIC_ORIGIN" == http://203.0.113.10:3443 ]]
|
||||
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true ]]
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
printf '2\n3000\nhttp://203.0.113.10:3000\nno\n' > "$interactive_dir/refuse-input"
|
||||
: > "$interactive_dir/refuse-output"
|
||||
if env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/refuse-input"
|
||||
PROMPT_OUTPUT="$dir/refuse-output"
|
||||
configure_network_interactively
|
||||
' _ "$installer_lib" "$interactive_dir" >/dev/null 2>&1; then
|
||||
echo 'expected public HTTP confirmation refusal to stop configuration' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Explicit environment variables take precedence over the prompt, including
|
||||
# when a terminal is available.
|
||||
env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
|
||||
TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
|
||||
bash -c '
|
||||
script=$1
|
||||
dir=$2
|
||||
set --
|
||||
source "$script"
|
||||
APPLY=1
|
||||
NON_INTERACTIVE=0
|
||||
CONFIG_DIR="$dir/config"
|
||||
PROMPT_INPUT="$dir/local-input"
|
||||
PROMPT_OUTPUT="$dir/local-output"
|
||||
if interactive_network_available; then
|
||||
echo "expected explicit network environment to skip prompt" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib" "$interactive_dir"
|
||||
|
||||
# A release archive is extracted under umask 077, then explicitly normalized
|
||||
# so the tallynote system user can traverse and execute the shipped tree.
|
||||
source_tmp="$tmp/source"
|
||||
|
||||
+17
-2
@@ -69,7 +69,8 @@ export function loadConfig() {
|
||||
const installPrefix = path.resolve(process.env.TALLYNOTE_INSTALL_PREFIX ?? (updateStrategyRaw === "systemd" ? path.dirname(projectRoot) : projectRoot));
|
||||
const host = process.env.TALLYNOTE_HOST ?? "127.0.0.1";
|
||||
const port = integerEnv("TALLYNOTE_PORT", 3000, 1);
|
||||
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${host}:${port}`;
|
||||
const originHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${originHost}:${port}`;
|
||||
let parsedOrigin: URL;
|
||||
try {
|
||||
parsedOrigin = new URL(publicOrigin);
|
||||
@@ -88,7 +89,14 @@ export function loadConfig() {
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production" || process.env.TALLYNOTE_ENV === "production";
|
||||
const cookieSecure = booleanEnv("TALLYNOTE_COOKIE_SECURE", parsedOrigin.protocol === "https:");
|
||||
// Direct IP access is useful during a first deployment, but it is not
|
||||
// encrypted. Keep this explicitly opt-in so a public install cannot
|
||||
// accidentally expose session cookies over HTTP.
|
||||
const allowInsecureHttp = booleanEnv("TALLYNOTE_ALLOW_INSECURE_HTTP", false);
|
||||
const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (["0.0.0.0", "::"].includes(publicHost)) {
|
||||
throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名");
|
||||
}
|
||||
const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost);
|
||||
const appVersion = (() => {
|
||||
try {
|
||||
@@ -122,6 +130,7 @@ export function loadConfig() {
|
||||
timezone,
|
||||
trustProxy: trustProxyEnv(),
|
||||
cookieSecure,
|
||||
allowInsecureHttp,
|
||||
appVersion,
|
||||
updateMetadataUrl,
|
||||
updateAllowedHosts,
|
||||
@@ -166,7 +175,13 @@ export function loadConfig() {
|
||||
isProduction,
|
||||
};
|
||||
|
||||
if (!localOrigin && (parsedOrigin.protocol !== "https:" || !cookieSecure)) {
|
||||
if (!localOrigin && parsedOrigin.protocol !== "https:" && !allowInsecureHttp) {
|
||||
throw new Error("公网 HTTP 访问必须显式启用 TALLYNOTE_ALLOW_INSECURE_HTTP=true;生产环境建议使用 HTTPS");
|
||||
}
|
||||
if (!localOrigin && parsedOrigin.protocol !== "https:" && cookieSecure) {
|
||||
throw new Error("HTTP public origin 不能启用安全 Cookie");
|
||||
}
|
||||
if (!localOrigin && parsedOrigin.protocol === "https:" && !cookieSecure) {
|
||||
throw new Error("公网部署必须使用 HTTPS 并启用安全 Cookie");
|
||||
}
|
||||
if (parsedOrigin.protocol === "https:" && !cookieSecure) {
|
||||
|
||||
@@ -4,6 +4,7 @@ TALLYNOTE_DATA_DIR=/var/lib/tallynote
|
||||
TALLYNOTE_INSTALL_PREFIX=/opt/tallynote
|
||||
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
|
||||
TALLYNOTE_COOKIE_SECURE=false
|
||||
TALLYNOTE_ALLOW_INSECURE_HTTP=false
|
||||
TALLYNOTE_TIMEZONE=Asia/Shanghai
|
||||
TALLYNOTE_UPDATE_STRATEGY=systemd
|
||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||
|
||||
+22
-1
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { loadConfig, prepareDataDirectories } from "../server/config.js";
|
||||
|
||||
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
|
||||
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
|
||||
|
||||
afterEach(() => { for (const key of keys) delete process.env[key]; });
|
||||
|
||||
@@ -13,11 +13,32 @@ describe("部署安全配置", () => {
|
||||
it("公网 HTTP 或 HTTPS 非安全 Cookie 一律拒绝", () => {
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://example.test";
|
||||
expect(() => loadConfig()).toThrow(/HTTPS/);
|
||||
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
|
||||
expect(loadConfig().allowInsecureHttp).toBe(true);
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
||||
expect(() => loadConfig()).toThrow(/安全 Cookie/);
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "false";
|
||||
expect(() => loadConfig()).toThrow(/安全 Cookie/);
|
||||
});
|
||||
|
||||
it("允许显式配置服务器 IP 的直连 HTTP,并拒绝通配 Origin", () => {
|
||||
process.env.TALLYNOTE_HOST = "0.0.0.0";
|
||||
process.env.TALLYNOTE_PORT = "3000";
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://192.0.2.10:3000";
|
||||
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "false";
|
||||
expect(loadConfig()).toMatchObject({ host: "0.0.0.0", port: 3000, publicOrigin: "http://192.0.2.10:3000", allowInsecureHttp: true });
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://0.0.0.0:3000";
|
||||
expect(() => loadConfig()).toThrow(/通配监听地址/);
|
||||
});
|
||||
|
||||
it("为 IPv6 监听地址生成合法的默认 Origin", () => {
|
||||
process.env.TALLYNOTE_HOST = "::1";
|
||||
process.env.TALLYNOTE_PORT = "3000";
|
||||
expect(loadConfig().publicOrigin).toBe("http://[::1]:3000");
|
||||
});
|
||||
|
||||
it("生产环境不接受任意 trust proxy", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.TALLYNOTE_TRUST_PROXY = "true";
|
||||
|
||||
+14
-14
@@ -59,10 +59,10 @@ describe("更新 API", () => {
|
||||
|
||||
function mockRelease() {
|
||||
const digest = "c".repeat(64);
|
||||
const asset = `tallynote-1.1.4-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const asset = `tallynote-1.1.6-${detectPlatform().target}-glibc.tar.gz`;
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(`${digest} ${asset}\n`, { status: 200 })
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.4", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.6", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
||||
}
|
||||
|
||||
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
|
||||
@@ -70,21 +70,21 @@ describe("更新 API", () => {
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.1.4", compatible: true, integrityReady: true, isNewer: true });
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.1.6", compatible: true, integrityReady: true, isNewer: true });
|
||||
expect(checked.headers["cache-control"]).toBe("no-store");
|
||||
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(tooSoon.statusCode).toBe(429);
|
||||
expect(tooSoon.headers["retry-after"]).toBeDefined();
|
||||
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.4", confirm: true } });
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.6", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
const jobId = applied.json().job.id as string;
|
||||
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
|
||||
expect(request).toMatchObject({ jobId, version: "1.1.4", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||
expect(request).toMatchObject({ jobId, version: "1.1.6", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
|
||||
|
||||
mockRelease();
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.4", confirm: true } });
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.6", 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 } });
|
||||
@@ -98,10 +98,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.1.4", 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: "1.1.6", confirm: true } });
|
||||
expect(downloaded.statusCode).toBe(202);
|
||||
const downloadJobId = downloaded.json().job.id as string;
|
||||
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.4" });
|
||||
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.6" });
|
||||
const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string };
|
||||
expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" });
|
||||
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" });
|
||||
@@ -110,21 +110,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.1.4", 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.1.4", 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, "1.1.6", 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.1.6", 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.1.4", 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: "1.1.6", confirm: true } });
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
||||
});
|
||||
|
||||
it("缺少确认或未启用 systemd 时不接受更新", async () => {
|
||||
const session = await login();
|
||||
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.4" } });
|
||||
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.6" } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
|
||||
const disabledConfig = loadConfig();
|
||||
@@ -137,7 +137,7 @@ describe("更新 API", () => {
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.4", confirm: true } });
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.6", 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);
|
||||
@@ -155,7 +155,7 @@ describe("更新 API", () => {
|
||||
it("应用前重新校验失败时写入失败审计", async () => {
|
||||
const session = await login("update-audit");
|
||||
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
|
||||
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.4", confirm: true } });
|
||||
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.6", confirm: true } });
|
||||
expect(response.statusCode).toBe(502);
|
||||
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
|
||||
expect(audit?.outcome).toBe("failure");
|
||||
|
||||
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
|
||||
prepareDataDirectories(config);
|
||||
const database = openDatabase(config);
|
||||
const digest = "b".repeat(64);
|
||||
const platformAsset = `tallynote-1.1.4-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const platformAsset = `tallynote-1.1.6-${detectPlatform().target}-glibc.tar.gz`;
|
||||
const sums = `${digest} ${platformAsset}\n`;
|
||||
const signature = sign(null, Buffer.from(sums), privateKey);
|
||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
|
||||
? new Response(signature)
|
||||
: input.toString().endsWith("SHA256SUMS")
|
||||
? new Response(sums)
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.4", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||
: new Response(JSON.stringify({ tag_name: "v1.1.6", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
const result = await checkForUpdate(database.sqlite, config);
|
||||
expect(result.latest).toMatchObject({ version: "1.1.4", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||
expect(result.latest).toMatchObject({ version: "1.1.6", 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 {
|
||||
|
||||
Reference in New Issue
Block a user