feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s

This commit is contained in:
Qiufeng
2026-08-29 01:02:29 +08:00
commit 9719429f4a
62 changed files with 29200 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
data
.git
playwright-report
test-results
*.log
+35
View File
@@ -0,0 +1,35 @@
TALLYNOTE_HOST=127.0.0.1
TALLYNOTE_PORT=3000
TALLYNOTE_DATA_DIR=./data
TALLYNOTE_TIMEZONE=Asia/Shanghai
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
TALLYNOTE_TRUST_PROXY=false
TALLYNOTE_COOKIE_SECURE=false
TALLYNOTE_SESSION_IDLE_HOURS=24
TALLYNOTE_SESSION_ABSOLUTE_HOURS=168
TALLYNOTE_EXPORT_TTL_MINUTES=15
TALLYNOTE_MAX_FILE_MB=20
TALLYNOTE_MAX_FILES_PER_REQUEST=20
TALLYNOTE_MAX_RECORD_MB=100
TALLYNOTE_MAX_TOTAL_MB=2048
TALLYNOTE_MAX_CONCURRENT_EXPORTS=2
TALLYNOTE_MAX_EXPORT_RECORDS=5000
TALLYNOTE_MAX_EXPORT_MB=1024
TALLYNOTE_MAX_EXPORT_STORAGE_MB=2048
# One-click updates are disabled for source/dev installs. The systemd
# installer sets these values and enables the privileged updater path unit.
TALLYNOTE_UPDATE_STRATEGY=disabled
TALLYNOTE_INSTALL_PREFIX=./
# The privileged updater derives its private workspace as
# <TALLYNOTE_INSTALL_PREFIX>/.update-work; the installer provisions it as
# 0700 root:root. Do not point it into the application data/staging tree.
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
TALLYNOTE_UPDATE_MAX_MB=512
# One-click/systemd updates require an Ed25519 signature over SHA256SUMS.
# Keep this file root-readable and point to a root-managed public key.
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
+44
View File
@@ -0,0 +1,44 @@
name: TallyNote release
on:
push:
tags:
- "v*.*.*"
# A tag is the immutable input to a release. Publishing is kept in one job so
# SHA256SUMS covers every archive exactly once and the Gitea Release API never
# receives duplicate checksum assets from parallel architecture jobs.
permissions:
contents: write
jobs:
linux-x64:
runs-on: ubuntu-latest
steps:
- name: Checkout tag
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@9.0.6 --activate
- name: Verify tag and test gate
run: |
set -euo pipefail
test "$(node -p 'require("./package.json").version')" = "${GITHUB_REF_NAME#v}"
pnpm install --frozen-lockfile
pnpm check
pnpm test
- name: Build Linux release
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
- name: Create and publish signed Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TALLYNOTE_RELEASE_SIGNING_KEY: ${{ secrets.TALLYNOTE_RELEASE_SIGNING_KEY }}
run: ./scripts/publish-gitea-release.sh "$GITHUB_REF_NAME" ./release
# Linux x86 (i386/i686) is intentionally not published: Node.js 24 and the
# better-sqlite3/argon2/sharp native modules have no maintained 32-bit build.
# Add an ARM64 job only on a runner with native ARM64 support, then let the
# publisher aggregate all archives before signing one SHA256SUMS file.
+16
View File
@@ -0,0 +1,16 @@
node_modules/
dist/
data/
playwright-report/
test-results/
.env
.DS_Store
*.log
release/
release-signing.key
*.key
*.pem
# Keep the canonical architecture source/HTML; visual QA screenshots and the
# superseded v2 experiments are generated artifacts, not release inputs.
artifacts/*visual-check*
artifacts/*v2*
+24
View File
@@ -0,0 +1,24 @@
FROM node:24-bookworm-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:24-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable && useradd --create-home --uid 10001 tallynote
COPY --from=build /app/package.json /app/pnpm-lock.yaml* ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/migrations ./migrations
COPY --from=build /app/server ./server
RUN mkdir -p /data && chown -R tallynote:tallynote /data /app
USER tallynote
EXPOSE 3000
VOLUME ["/data"]
CMD ["node", "dist/server/index.js"]
+103
View File
@@ -0,0 +1,103 @@
# TallyNote
TallyNote 是一个本地优先的采购报销记录网站:记录支付时间、金额、备注、付款凭证和发票,按月整理后导出 Excel 与原始附件。
每笔活动账目至少需要一张付款凭证;发票与“无发票原因”严格二选一。没有发票时,在新增或编辑抽屉勾选“无发票”并填写原因,原因会显示在列表、详情和导出的 Excel“无发票原因”列中。删除最后一张发票时,系统也会在确认弹窗中要求填写原因,并与删除操作原子保存。
导出 ZIP 默认包含 `报销清单.xlsx` 和附件目录。账目列表中的“包含 manifest.json”选项默认关闭;开启后会额外导出附件元数据及 SHA-256 校验值清单。
导出目录示例:
```text
TallyNote_报销资料_xxxxxxxx.zip
├── 报销清单.xlsx
├── 001_20260827_12.34_ab12cd34/
│ ├── 付款凭证/
│ │ └── 付款截图.png
│ └── 发票/
│ └── invoice.pdf
└── manifest.json # 仅勾选“包含 manifest.json”时生成
```
## 本地运行
```bash
pnpm install
pnpm admin:init
pnpm dev
```
生产模式:
```bash
pnpm build
pnpm start
```
首次初始化会要求交互式输入管理员密码。也可以使用 `pnpm admin:init -- --username admin --display-name 管理员 --generate` 生成一次性临时密码。
默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。
## 无 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 位系统上强行安装。
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS` 和 `SHA256SUMS.sig`。安装器默认 dry-run,只有显式 `--apply` 才会下载或写盘;正式安装必须提供独立核对过的 Ed25519 公钥:
```bash
curl --proto '=https' --tlsv1.2 -fsSL \
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
| sudo bash -s -- --apply --version 1.0.0 \
--signing-key /root/tallynote-update.pub \
--update-public-key-file /root/tallynote-update.pub
```
指定版本时,脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 获取归档、`SHA256SUMS` 和签名。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。`--allow-unsigned` 仅供隔离开发机测试,不能用于公网或真实财务数据。
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--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`。
升级有两种方式:
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单、SHA-256 和 Ed25519 签名校验的请求写入队列;root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源、验证签名,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
### 构建发布包
在目标 Linux 架构的 CI runner 上执行(不能在 macOS 上冒充 Linux 架构):
```bash
pnpm install --frozen-lockfile
pnpm release:build 1.0.1 ./release
```
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS` 与 `SHA256SUMS.sig`;当前仓库还没有首个 tag/release 时,后台会明确显示不可用,不会下载未验证文件。CI 需要 `GITEA_TOKEN` 和 `TALLYNOTE_RELEASE_SIGNING_KEY` secrets。
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.0.1` 与 `v1.0.1`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
```bash
git add .
git commit -m "release: 1.0.1"
git tag -a v1.0.1 -m "TallyNote 1.0.1"
git push origin main --follow-tags
```
## Docker
```bash
docker compose up -d --build
docker compose run --rm --no-deps tallynote node dist/server/cli/admin-init.js --username admin --display-name 管理员 --generate
```
只运行一个应用副本,并将 `/data` 作为持久化卷。SQLite、附件和导出文件必须位于同一台主机的本地文件系统;不支持 NFS/NAS 或多个副本共享 SQLite。
## 备份
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256/签名的归档、路径穿越、特殊文件和符号链接;附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
File diff suppressed because one or more lines are too long
+192
View File
@@ -0,0 +1,192 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "TallyNote 本地优先报销账本架构",
"locale": "zh-CN",
"output": "tallynote-architecture.html",
"quality_profile": "showcase",
"viewBox": [1360, 800],
"views": [
{
"id": "request-path",
"label": "主请求路径",
"focus": ["operator", "web", "api", "security", "expense", "db"],
"note": "从管理员在浏览器快速记账,到安全会话和 SQLite 持久化。"
},
{
"id": "attachment-consistency",
"label": "附件一致性",
"focus": ["api", "expense", "attachment", "filesystem", "db", "audit"],
"note": "查看 staging、内容校验、原子晋级与事务回滚如何保持记录和文件一致。"
},
{
"id": "export-recovery",
"label": "导出与恢复",
"focus": ["api", "export", "db", "filesystem", "janitor", "audit"],
"note": "跟踪筛选快照、异步 ZIP 构建、会话绑定下载和重启恢复。"
},
{
"id": "admin-governance",
"label": "管理员治理",
"focus": ["operator", "web", "api", "admin", "security", "audit"],
"note": "查看管理员初始化、停用、重置密码和全局审计边界。"
}
]
},
"components": [
{
"id": "operator",
"type": "external",
"label": "使用者 / 管理员",
"sublabel": "Chrome 浏览器",
"pos": [30, 250],
"size": [150, 80]
},
{
"id": "web",
"type": "frontend",
"label": "React 工作台",
"sublabel": "React 19 + Vite",
"tag": "桌面优先",
"pos": [220, 250],
"size": [190, 80]
},
{
"id": "api",
"type": "backend",
"label": "Fastify REST API",
"sublabel": "/api/* + 静态托管",
"tag": "单进程入口",
"pos": [450, 250],
"size": [190, 80]
},
{
"id": "security",
"type": "security",
"label": "认证与请求防护",
"sublabel": "Argon2id · Cookie · CSRF",
"tag": "HttpOnly / Origin",
"pos": [450, 50],
"size": [190, 80]
},
{
"id": "expense",
"type": "backend",
"label": "账目业务服务",
"sublabel": "筛选 · 状态 · 乐观锁",
"tag": "至少一张凭证",
"pos": [680, 250],
"size": [190, 80]
},
{
"id": "db",
"type": "database",
"label": "SQLite + Drizzle",
"sublabel": "WAL · 外键 · 事务",
"tag": "同机持久化",
"pos": [910, 250],
"size": [190, 80]
},
{
"id": "attachment",
"type": "backend",
"label": "附件流水线",
"sublabel": "staging · 内容识别 · 晋级",
"tag": "20 MB / 文件",
"pos": [680, 430],
"size": [190, 80]
},
{
"id": "filesystem",
"type": "cloud",
"label": "本地数据文件系统",
"sublabel": "files / staging / exports",
"tag": "同一卷 · UUID 路径",
"pos": [910, 430],
"size": [190, 80]
},
{
"id": "export",
"type": "backend",
"label": "异步导出构建器",
"sublabel": "Excel · ZIP · manifest",
"tag": "会话绑定任务",
"pos": [910, 50],
"size": [190, 80]
},
{
"id": "audit",
"type": "backend",
"label": "审计子系统",
"sublabel": "追加写入 · 前后值 · 请求 ID",
"tag": "只读查询",
"pos": [680, 50],
"size": [190, 80]
},
{
"id": "admin",
"type": "backend",
"label": "管理员管理",
"sublabel": "初始化 · 停用 · 重置",
"tag": "最后管理员保护",
"pos": [450, 590],
"size": [190, 80]
},
{
"id": "janitor",
"type": "backend",
"label": "启动清理与恢复",
"sublabel": "孤儿文件 · 过期任务 · 会话",
"tag": "每 60 秒 + 重启",
"pos": [30, 590],
"size": [150, 80]
}
],
"boundaries": [
{
"kind": "region",
"label": "TallyNote 单实例本地运行时(localhost / Docker)",
"wraps": ["web", "api", "security", "expense", "db", "attachment", "filesystem", "export", "audit", "admin", "janitor"],
"pad": 28
},
{
"kind": "security-group",
"label": "受保护 API 边界",
"wraps": ["api", "security", "admin", "expense", "attachment", "export", "audit"],
"pad": 18
}
],
"connections": [
{ "id": "operator-web", "from": "operator", "to": "web", "label": "浏览器交互", "variant": "emphasis", "fromSide": "right", "toSide": "left", "labelAt": [200, 210] },
{ "id": "web-api", "from": "web", "to": "api", "label": "HTTP / REST", "variant": "emphasis", "fromSide": "right", "toSide": "left", "labelAt": [430, 210] },
{ "id": "security-api", "from": "security", "to": "api", "label": "Cookie + CSRF / Origin", "variant": "security", "fromSide": "bottom", "toSide": "top", "labelAt": [900, 195] },
{ "id": "api-expense", "from": "api", "to": "expense", "label": "账目路由", "fromSide": "right", "toSide": "left", "labelAt": [700, 210] },
{ "id": "expense-db", "from": "expense", "to": "db", "label": "SQLite transaction", "variant": "emphasis", "fromSide": "right", "toSide": "left", "labelAt": [890, 210] },
{ "id": "expense-attachment", "from": "expense", "to": "attachment", "label": "multipart 上传", "fromSide": "bottom", "toSide": "top", "labelAt": [880, 380] },
{ "id": "attachment-files", "from": "attachment", "to": "filesystem", "label": "staging → atomic promote", "variant": "emphasis", "fromSide": "right", "toSide": "left", "labelAt": [890, 405] },
{ "id": "api-export", "from": "api", "to": "export", "label": "snapshot → async job", "variant": "dashed", "fromSide": "right", "toSide": "left", "via": [[660, 290], [660, 25], [890, 25], [890, 90]], "labelAt": [775, 12] },
{ "id": "export-db", "from": "export", "to": "db", "label": "快照读取", "fromSide": "bottom", "toSide": "top", "labelAt": [1070, 195] },
{ "id": "export-files", "from": "export", "to": "filesystem", "label": "ZIP / Excel / manifest", "variant": "dashed", "fromSide": "right", "toSide": "bottom", "via": [[1280, 90], [1280, 700], [1005, 700]], "labelAt": [1170, 720] },
{ "id": "expense-audit", "from": "expense", "to": "audit", "label": "操作审计", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "labelAt": [620, 195] },
{ "id": "admin-api", "from": "admin", "to": "api", "label": "管理员管理", "fromSide": "top", "toSide": "bottom", "labelAt": [700, 530] },
{ "id": "janitor-files", "from": "janitor", "to": "filesystem", "label": "孤儿文件 / 过期任务", "variant": "dashed", "fromSide": "bottom", "toSide": "bottom", "via": [[105, 735], [1005, 735]], "labelAt": [555, 715] }
],
"cards": [
{
"dot": "cyan",
"title": "请求与数据",
"items": ["前端通过统一 API 客户端携带会话 Cookie 和 CSRF 令牌", "账目写入、附件元数据和审计在 SQLite 事务内保持一致"]
},
{
"dot": "emerald",
"title": "文件可靠性",
"items": ["附件先落 staging,完成格式、大小和内容校验后原子晋级", "失败路径删除已晋级字节,启动时继续清理孤儿文件"]
},
{
"dot": "amber",
"title": "导出与治理",
"items": ["导出冻结不可变快照,后台生成 Excel、ZIP 和可选 manifest", "管理员变更、记录操作、附件和导出都进入只读审计日志"]
}
]
}
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
NODE="$ROOT/runtime/bin/node"
[[ -x "$NODE" ]] || NODE=$(command -v node || true)
[[ -n "$NODE" ]] || { printf 'TallyNote: Node.js runtime not found\n' >&2; exit 127; }
exec "$NODE" "$ROOT/dist/server/index.js" "$@"
+25
View File
@@ -0,0 +1,25 @@
services:
tallynote:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
TALLYNOTE_HOST: 0.0.0.0
TALLYNOTE_PORT: 3000
TALLYNOTE_DATA_DIR: /data
TALLYNOTE_PUBLIC_ORIGIN: ${TALLYNOTE_PUBLIC_ORIGIN:-http://127.0.0.1:3000}
TALLYNOTE_COOKIE_SECURE: ${TALLYNOTE_COOKIE_SECURE:-false}
TALLYNOTE_TIMEZONE: ${TALLYNOTE_TIMEZONE:-Asia/Shanghai}
volumes:
- tallynote-data:/data
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
deploy:
replicas: 1
volumes:
tallynote-data:
+78
View File
@@ -0,0 +1,78 @@
# Release、安装与更新
TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`、`argon2`、`sharp` 和 Node runtime 都包含原生代码,不能在 macOS 上交叉打包后冒充 Linux。
正式支持: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 及原生依赖没有可维护的正式构建,因此安装器会拒绝它。
## 自动发布
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.0.1`)会触发 `.gitea/workflows/release.yml`:
1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
3. 用 Ed25519 私钥生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和签名。
在仓库的 Actions secrets 配置:
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
- `TALLYNOTE_RELEASE_SIGNING_KEY`:Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
也可以在 Linux 发布机上手动执行:
```bash
pnpm install --frozen-lockfile
pnpm check && pnpm test
pnpm release:build 1.0.1 ./release
GITHUB_REPOSITORY=awaioi/TallyNote \
GITEA_TOKEN=... \
TALLYNOTE_RELEASE_SIGNING_KEY_FILE=/root/secrets/tallynote-release.key \
./scripts/publish-gitea-release.sh v1.0.1 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.0.1-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。
## curl 安装
安装器默认只做 dry-run;只有显式 `--apply` 才会下载或写盘。正式安装必须同时提供 Ed25519 公钥和 `SHA256SUMS.sig`,公钥应通过独立的受信渠道核对指纹。下面示例假设公钥已安全放在服务器 `/root/tallynote-update.pub`:
```bash
curl --proto '=https' --tlsv1.2 -fsSL \
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
| sudo bash -s -- --apply --version 1.0.1 \
--signing-key /root/tallynote-update.pub \
--update-public-key-file /root/tallynote-update.pub
```
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档、`SHA256SUMS` 和 `SHA256SUMS.sig`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
`--allow-unsigned` 只用于隔离的开发/测试主机,不能用于公网或保存真实财务数据的服务器。安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。
安装布局:
```text
/opt/tallynote/releases/<version>/ # 只读发布代码
/opt/tallynote/current -> releases/<version>
/opt/tallynote/.update-work/ # 0700 root:root,root 更新器临时工作区
/opt/tallynote/.update-state # root 更新状态标记,异常中断后用于恢复
/var/lib/tallynote/ # SQLite、附件、暂存和导出
/var/lib/tallynote-backups/ # 更新前数据备份
/etc/tallynote/tallynote.env
```
## 后台一键更新
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL`、`TALLYNOTE_UPDATE_ALLOWED_HOSTS` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且同时通过 SHA-256 与 Ed25519 签名验证的资产;缺少任一项时“更新”按钮保持禁用。
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata、清单和签名,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
```bash
sudo /usr/local/sbin/tallynote-update --rollback
```
更新检查和应用接口带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
业务导出不是备份。停服后复制完整 `/var/lib/tallynote` 数据目录(含数据库、WAL/SHM、附件、暂存、导出和更新任务文件),并限制 SSH、备份和磁盘权限。拥有服务器文件权限的人仍可直接读取底层财务数据。
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./server/db/schema.ts",
out: "./migrations",
dbCredentials: {
url: process.env.TALLYNOTE_DB_PATH ?? "./data/tallynote.db",
},
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f5f7f5" />
<title>TallyNote · 采购报销记录</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Executable
+880
View File
@@ -0,0 +1,880 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# TallyNote native installer. Dry-run by default; pass --apply to mutate the host.
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
export PATH
umask 077
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
REPOSITORY_URL=${TALLYNOTE_REPOSITORY_URL:-https://git.awaioi.com/awaioi/TallyNote}
RELEASE_API_URL=${TALLYNOTE_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest}
RELEASE_BASE_URL=${TALLYNOTE_RELEASE_BASE_URL:-}
VERSION=${TALLYNOTE_VERSION:-latest}
RELEASE_FILE=${TALLYNOTE_RELEASE_FILE:-}
SHA256_URL=${TALLYNOTE_SHA256_URL:-}
SIGNATURE_URL=${TALLYNOTE_SIGNATURE_URL:-}
SIGNING_KEY=${TALLYNOTE_SIGNING_KEY:-}
SIGNATURE_FORMAT=${TALLYNOTE_SIGNATURE_FORMAT:-ed25519}
SHA256_FILE=${TALLYNOTE_SHA256_FILE:-}
UPDATE_PUBLIC_KEY_FILE=${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}
APPLY=0
KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3}
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-true}
ALLOW_DOWNGRADE=${TALLYNOTE_ALLOW_DOWNGRADE:-false}
ALLOW_UNSIGNED=0
MAX_RELEASE_MB=${TALLYNOTE_MAX_RELEASE_MB:-512}
MAX_EXTRACT_MB=${TALLYNOTE_MAX_EXTRACT_MB:-2048}
MAX_ARCHIVE_ENTRIES=${TALLYNOTE_MAX_ARCHIVE_ENTRIES:-100000}
CONNECT_TIMEOUT=${TALLYNOTE_INSTALL_CONNECT_TIMEOUT_SECONDS:-15}
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}
INSTALL_SWITCHED=0
INSTALL_COMMITTED=0
INSTALL_PREVIOUS_TARGET=''
INSTALL_NEW_RELEASE=''
INSTALL_WORK_DIR=''
INSTALL_BACKUP_DIR=''
INSTALL_WAS_ACTIVE=0
INSTALL_PATH_WAS_ACTIVE=0
INSTALL_UPDATE_WAS_ACTIVE=0
DATA_DIR_TEMP_ROOT=0
DATA_DIR_ORIGINAL_OWNER=''
REPOSITORY_URL=${REPOSITORY_URL%/}
RELEASE_API_URL=${RELEASE_API_URL%/}
usage() {
cat <<'EOF'
Usage: install.sh [--apply] [--version VERSION] [--release-base-url HTTPS_URL]
[--release-file FILE] [--sha256-url HTTPS_URL|--sha256-file FILE]
[--signature-url HTTPS_URL] [--signing-key PUBLIC_KEY_FILE]
[--signature-format ed25519|gpg]
[--update-public-key-file FILE]
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--dry-run]
The default is --dry-run. Network downloads and filesystem changes happen only
with --apply. Production installs require a detached signature (Ed25519 over
SHA256SUMS by default; legacy GPG archive signatures are opt-in); --allow-unsigned
is for isolated development hosts only.
EOF
}
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
log() { printf 'tallynote installer: %s\n' "$*"; }
[[ "$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'
[[ "$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 '安装超时配置必须是正整数'
version_sort_desc() {
if sort -V </dev/null >/dev/null 2>&1; then
sort -V -r
return
fi
# BSD sort (macOS) and minimal BusyBox builds may lack -V. The installer
# targets Linux, but keeping a numeric fallback makes dry-runs deterministic
# and avoids deleting a newer 1.10 release before an older 1.9 release.
awk -F'[.-]' '{ printf "%020d.%020d.%020d.%s\t%s\n", $1, $2, $3, ($4 == "" ? "~" : $4), $0 }' \
| sort -r | cut -f2-
}
while (($#)); do
case "$1" in
--apply) APPLY=1 ;;
--dry-run) APPLY=0 ;;
--version) VERSION=${2:?missing value for --version}; shift ;;
--release-base-url) RELEASE_BASE_URL=${2:?missing value for --release-base-url}; shift ;;
--release-file) RELEASE_FILE=${2:?missing value for --release-file}; shift ;;
--sha256-url) SHA256_URL=${2:?missing value for --sha256-url}; shift ;;
--sha256-file) SHA256_FILE=${2:?missing value for --sha256-file}; shift ;;
--signature-url) SIGNATURE_URL=${2:?missing value for --signature-url}; shift ;;
--signing-key) SIGNING_KEY=${2:?missing value for --signing-key}; shift ;;
--signature-format) SIGNATURE_FORMAT=${2:?missing value for --signature-format}; shift ;;
--update-public-key-file) UPDATE_PUBLIC_KEY_FILE=${2:?missing value for --update-public-key-file}; shift ;;
--keep-releases) KEEP_RELEASES=${2:?missing value for --keep-releases}; shift ;;
--allow-downgrade) ALLOW_DOWNGRADE=true ;;
--allow-unsigned) ALLOW_UNSIGNED=1; REQUIRE_SIGNATURE=false ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
shift
done
detect_platform() {
local machine libc os
os=$("$UNAME_BIN" -s)
if [[ "$os" != Linux ]]; then
(( APPLY )) && die "仅支持 Linux 安装(当前系统:$os);可用 --dry-run 预览"
log "dry-run: 当前系统为 ${os},--apply 仅允许 Linux"
fi
machine=$("$UNAME_BIN" -m)
case "$machine" in
x86_64|amd64) TALLYNOTE_ARCH=x64 ;;
aarch64|arm64) TALLYNOTE_ARCH=arm64 ;;
armv7l|armv7|armhf) TALLYNOTE_ARCH=armv7; log 'ARMv7 is experimental; continue only if a matching release exists.' ;;
i?86|x86) die '32-bit x86 (ia32) is unsupported' ;;
*) die "unsupported CPU architecture: $machine" ;;
esac
libc=glibc
if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then libc=musl; fi
TALLYNOTE_LIBC=$libc
export TALLYNOTE_ARCH TALLYNOTE_LIBC
}
require_https() {
local value=$1
case "$value" in https://*) ;; *) die "release endpoints must use HTTPS: $value" ;; esac
[[ "$value" != *[[:cntrl:]]* && "$value" != *[[:space:]]* ]] || die 'release endpoint contains control characters'
[[ "$value" != *'@'* ]] || die 'release endpoints must not contain credentials'
}
url_host() {
local authority host
require_https "$1"
authority=${1#https://}
authority=${authority%%/*}
[[ -n "$authority" && "$authority" != *'@'* ]] || die 'release endpoint host is invalid'
if [[ "$authority" == \[*\]* ]]; then
host=${authority#\[}
host=${host%%\]*}
else
host=${authority%%:*}
fi
[[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die 'release endpoint host is invalid'
if [[ "$authority" != \[*\]* && "$authority" == *:* ]]; then
local port=${authority##*:}
[[ "$port" =~ ^[0-9]{1,5}$ && "$port" -ge 1 && "$port" -le 65535 ]] || die 'release endpoint port is invalid'
fi
printf '%s' "$host" | tr '[:upper:]' '[:lower:]'
}
validate_allowed_hosts() {
local candidate
[[ -z "$RELEASE_ALLOWED_HOSTS" ]] && return 0
IFS=',' read -r -a _allowed_parts <<< "$RELEASE_ALLOWED_HOSTS"
((${#_allowed_parts[@]} > 0)) || die 'release host allowlist is invalid'
for candidate in "${_allowed_parts[@]}"; do
[[ "$candidate" =~ ^[A-Za-z0-9.-]+$ || "$candidate" =~ ^[0-9A-Fa-f:]+$ ]] || die 'release host allowlist contains an invalid host'
done
}
append_allowed_host() {
local host=$1 candidate
[[ -n "$host" ]] || return 0
if [[ -n "$RELEASE_ALLOWED_HOSTS" ]]; then
_allowed_parts=()
IFS=',' read -r -a _allowed_parts <<< "$RELEASE_ALLOWED_HOSTS"
for candidate in "${_allowed_parts[@]}"; do
[[ "$(printf '%s' "$candidate" | tr '[:upper:]' '[:lower:]')" == "$host" ]] && return 0
done
fi
RELEASE_ALLOWED_HOSTS=${RELEASE_ALLOWED_HOSTS:+$RELEASE_ALLOWED_HOSTS,}$host
}
assert_allowed_url() {
local url=$1 host candidate
host=$(url_host "$url")
[[ -n "$RELEASE_ALLOWED_HOSTS" ]] || die 'release host allowlist is empty'
_allowed_parts=()
IFS=',' read -r -a _allowed_parts <<< "$RELEASE_ALLOWED_HOSTS"
for candidate in "${_allowed_parts[@]}"; do
candidate=$(printf '%s' "$candidate" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g')
[[ -n "$candidate" && "$candidate" == "$host" ]] && return 0
done
die "release URL redirected to an untrusted host: $host"
}
download() {
local url=$1 out=$2 max_bytes=${3:-$((MAX_RELEASE_MB * 1024 * 1024))}
local current="$url" headers status location actual origin scheme authority
require_https "$url"
assert_allowed_url "$url"
[[ ! -L "$out" && ! -e "$out" ]] || die "download destination already exists: $out"
for _redirect in 0 1 2 3; do
headers="${out}.headers-${RANDOM}-$$"
status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes" \
--retry 2 --retry-connrefused --output "$out" --dump-header "$headers" \
--write-out '%{http_code}' "$current" 2>/dev/null) || status=000
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
rm -f -- "$headers"
break
fi
if [[ "$status" =~ ^3[0-9][0-9]$ ]]; then
location=$(awk 'BEGIN{IGNORECASE=1} /^Location:/ {sub(/^[^:]*:[[:space:]]*/, ""); gsub(/[\r\n]/, ""); value=$0} END{print value}' "$headers")
rm -f -- "$headers"
[[ -n "$location" ]] || { rm -f -- "$out"; die 'release URL redirect is missing Location'; }
case "$location" in
https://*) current="$location" ;;
/*)
scheme=${current%%://*}
authority=${current#*://}; authority=${authority%%/*}
origin="${scheme}://${authority}"
current="${origin}${location}"
;;
*) current="${current%/*}/$location" ;;
esac
require_https "$current"
assert_allowed_url "$current"
continue
fi
rm -f -- "$headers" "$out"
die "无法下载 release 文件"
done
[[ "$status" =~ ^2[0-9][0-9]$ ]] || { rm -f -- "$out"; die 'release URL 重定向次数超过限制'; }
actual=$(wc -c < "$out" | tr -d '[:space:]')
[[ "$actual" =~ ^[0-9]+$ && "$actual" -le "$max_bytes" ]] || { rm -f -- "$out"; die '下载文件超过大小限制'; }
chmod 600 "$out"
}
resolve_latest_version() {
local payload tag metadata_file
require_https "$RELEASE_API_URL"
assert_allowed_url "$RELEASE_API_URL"
metadata_file=$(mktemp)
rm -f -- "$metadata_file"
download "$RELEASE_API_URL" "$metadata_file" $((2 * 1024 * 1024))
payload=$(cat "$metadata_file")
rm -f -- "$metadata_file"
if command -v jq >/dev/null 2>&1; then
tag=$(printf '%s' "$payload" | jq -r '.tag_name // .tagName // empty' 2>/dev/null || true)
elif command -v python3 >/dev/null 2>&1; then
tag=$(printf '%s' "$payload" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("tag_name") or d.get("tagName") or "")' 2>/dev/null || true)
else
tag=$(printf '%s' "$payload" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
fi
validate_semver "$tag" || die 'release API 未返回有效版本号'
VERSION=${tag#v}
}
release_urls() {
local version_tag="v${VERSION#v}"
if [[ -z "$RELEASE_BASE_URL" ]]; then
RELEASE_BASE_URL="${REPOSITORY_URL}/releases/download/${version_tag}"
elif [[ "$RELEASE_BASE_URL" == *"{version}"* ]]; then
RELEASE_BASE_URL=${RELEASE_BASE_URL//\{version\}/$version_tag}
fi
RELEASE_BASE_URL=${RELEASE_BASE_URL%/}
require_https "$RELEASE_BASE_URL"
append_allowed_host "$(url_host "$RELEASE_BASE_URL")"
}
verify_archive() {
local archive=$1 checksum=$2 signature=$3 key=$4 expected archive_name
[[ -s "$archive" ]] || die 'release archive is empty'
[[ -n "$checksum" ]] || die 'SHA-256 checksum is required (use --sha256-url)'
archive_name=$(basename -- "$archive")
expected=$(awk -v name="$archive_name" 'NF >= 2 { candidate=$2; sub(/^\*/, "", candidate); if (candidate == name || candidate == "./" name) { print $1; exit } }' "$checksum")
[[ -n "$expected" ]] || die "checksum file has no entry for $archive_name"
[[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'checksum file does not contain a SHA-256 digest'
printf '%s %s\n' "$expected" "$archive" | sha256sum -c - >/dev/null || die 'SHA-256 verification failed'
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少 SHA256SUMS.sig;生产安装必须使用签名'
[[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '生产安装必须提供签名公钥(--signing-key FILE)'
[[ "$(stat_uid "$key")" == 0 ]] || die '更新公钥必须由 root 拥有'
[[ "$(wc -c < "$key" | tr -d '[:space:]')" -le 16384 ]] || die '更新公钥文件过大'
local key_bits
key_bits=$(stat_mode_bits "$key")
(( (key_bits & 18) == 0 )) || die '更新公钥不能被组或其他用户写入'
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
command -v gpg >/dev/null 2>&1 || die 'gpg is required for --signature-format gpg'
local gpg_home
gpg_home=$(mktemp -d)
if ! (
set -Eeuo pipefail
trap 'rm -rf -- "$gpg_home"' EXIT
chmod 700 "$gpg_home"
gpg --batch --homedir "$gpg_home" --import "$key" >/dev/null 2>&1
gpg --batch --homedir "$gpg_home" --no-auto-key-retrieve --verify "$signature" "$archive" >/dev/null 2>&1
); then
rm -rf -- "$gpg_home"
die 'release GPG signature verification failed'
fi
rm -rf -- "$gpg_home"
else
"$OPENSSL_BIN" pkey -pubin -in "$key" -noout >/dev/null 2>&1 || die '更新公钥不是有效的 Ed25519 公钥'
if ! "$OPENSSL_BIN" pkeyutl -verify -pubin -inkey "$key" -rawin -in "$checksum" -sigfile "$signature" >/dev/null 2>&1; then
# Accept a base64-encoded detached signature as a convenience for
# operators, while the release workflow emits the safer raw 64 bytes.
local decoded
decoded=$(mktemp)
if ! "$OPENSSL_BIN" base64 -d -A -in "$signature" -out "$decoded" >/dev/null 2>&1 \
|| ! "$OPENSSL_BIN" pkeyutl -verify -pubin -inkey "$key" -rawin -in "$checksum" -sigfile "$decoded" >/dev/null 2>&1; then
rm -f -- "$decoded"
die 'SHA256SUMS 签名校验失败'
fi
rm -f -- "$decoded"
fi
fi
elif [[ -n "$signature" || -n "$key" ]]; then
log 'warning: signature verification disabled by explicit --allow-unsigned'
fi
}
safe_extract() {
local archive=$1 dest=$2 entry listing stats count expanded
local max_archive_bytes=$((MAX_RELEASE_MB * 1024 * 1024))
local max_extract_bytes=$((MAX_EXTRACT_MB * 1024 * 1024))
local archive_bytes
archive_bytes=$(wc -c < "$archive" | tr -d '[:space:]')
[[ "$archive_bytes" =~ ^[0-9]+$ && "$archive_bytes" -le "$max_archive_bytes" ]] || die 'release archive exceeds the compressed size limit'
# Only regular files and directories are accepted. Device nodes, FIFOs,
# sockets, symlinks and hardlinks must never be materialised as root.
listing=$(mktemp)
if ! LC_ALL=C tar -tvzf "$archive" --numeric-owner > "$listing" 2>/dev/null; then
rm -f -- "$listing"
die 'release archive is not a valid tar.gz file'
fi
stats=$(LC_ALL=C awk -v limit="$max_extract_bytes" -v max_entries="$MAX_ARCHIVE_ENTRIES" '
$1 !~ /^[-d]/ { bad=1; exit 3 }
{
entry_size = 0;
for (i = 2; i <= NF; i++) {
if ($i ~ /^[0-9]+$/) entry_size = $i + 0;
if ($i ~ /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/) break;
}
count += 1; size += ($1 ~ /^-/ ? entry_size : 0);
if (count > max_entries || size > limit) exit 2
}
END { if (bad) exit 3; printf "%d %d\n", count, size }
' "$listing") || { rm -f -- "$listing"; die 'release archive contains too many entries or unsupported special files'; }
count=${stats%% *}; expanded=${stats##* }
[[ "$count" =~ ^[0-9]+$ && "$expanded" =~ ^[0-9]+$ ]] || { rm -f -- "$listing"; die 'release archive metadata is invalid'; }
while IFS= read -r entry; do
if [[ "$entry" == /* || "$entry" == ../* || "$entry" == */../* || "$entry" == .. || "$entry" == */.. ]]; then
rm -f -- "$listing"
die "unsafe archive path: $entry"
fi
done < <(LC_ALL=C tar -tzf "$archive")
rm -f -- "$listing"
mkdir -p "$dest"
chmod 700 "$dest"
LC_ALL=C tar -xzf "$archive" -C "$dest" --no-same-owner --no-same-permissions
}
normalize_release_tree() {
local root=$1 item relative
[[ -d "$root" && ! -L "$root" ]] || die 'release extraction directory is invalid'
if find "$root" -type l -print -quit | grep -q .; then
die 'release archive contains a symbolic link'
fi
if find "$root" ! -type d ! -type f ! -type l -print -quit | grep -q .; then
die 'release archive contains an unsupported file type'
fi
find "$root" -type d -exec chmod 755 {} +
find "$root" -type f -exec chmod 644 {} +
for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/*; do
[[ -f "$item" && ! -L "$item" ]] || continue
chmod 755 "$item"
done
}
stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; }
stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; }
stat_mode_bits() {
local mode
mode=$(stat_mode "$1")
[[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1"
printf '%d' "$((8#$mode))"
}
validate_trusted_tool() {
local configured=$1 label=$2 resolved uid mode_bits
[[ -n "$configured" && "$configured" != *[[:space:]]* && "$configured" != *[[:cntrl:]]* ]] || die "$label 路径无效"
resolved=$(command -v "$configured" 2>/dev/null || true)
[[ -n "$resolved" && -x "$resolved" && ! -L "$resolved" ]] || die "$label 必须指向可信可执行文件"
if (( EUID == 0 )); then
uid=$(stat_uid "$resolved")
mode_bits=$(stat_mode_bits "$resolved")
[[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die "$label 必须由 root 拥有且不可被其他用户写入"
fi
}
version_is_newer() {
local candidate=$1 current=$2 ordered candidate_core current_core
[[ "$candidate" != "$current" ]] || return 1
candidate_core=${candidate%%+*}
current_core=${current%%+*}
[[ "$candidate_core" != "$current_core" ]] || return 1
if sort -V </dev/null >/dev/null 2>&1; then
ordered=$(printf '%s\n' "$current" "$candidate" | sort -V | tail -n 1)
[[ "$ordered" == "$candidate" ]]
return
fi
# Linux installs use GNU sort -V; this conservative fallback compares the
# numeric core and treats a stable release as newer than its prerelease.
local c_core=${candidate%%[-+]*} v_core=${current%%[-+]*}
local c_pre='' v_pre=''
[[ "$candidate" == *-* ]] && c_pre=${candidate#*-}
[[ "$current" == *-* ]] && v_pre=${current#*-}
local c_major c_minor c_patch v_major v_minor v_patch
IFS='.' read -r c_major c_minor c_patch <<< "$c_core"
IFS='.' read -r v_major v_minor v_patch <<< "$v_core"
local pair left right
for pair in "$c_major $v_major" "$c_minor $v_minor" "$c_patch $v_patch"; do
read -r left right <<< "$pair"
if (( 10#$left != 10#$right )); then (( 10#$left > 10#$right )); return; fi
done
[[ -z "$c_pre" && -n "$v_pre" ]] && return 0
[[ -n "$c_pre" && -z "$v_pre" ]] && return 1
[[ "$candidate" > "$current" ]]
}
assert_path_chain() {
local target=$1 allowed_uid=${2:-0} current component relative uid mode_bits
[[ "$target" = /* && "$target" != *$'\n'* && "$target" != *$'\r'* ]] || die "路径必须是绝对路径:$target"
relative=${target#/}
current=/
IFS='/' read -r -a _path_parts <<< "$relative"
for component in "${_path_parts[@]}"; do
[[ -n "$component" && "$component" != . && "$component" != .. ]] || continue
current="${current%/}/$component"
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
if [[ -e "$current" ]]; then
[[ -d "$current" ]] || die "路径不是目录:$current"
uid=$(stat_uid "$current")
[[ "$uid" == 0 || "$uid" == "$allowed_uid" ]] || die "路径目录必须由 root 拥有:$current"
mode_bits=$(stat_mode_bits "$current")
# A root-owned sticky directory (for example a hardened /tmp) is fine,
# but ownership is always required before traversing an existing parent.
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
else
mkdir "$current"
chmod 700 "$current"
fi
done
}
ensure_root_directory() {
local directory=$1 mode=${2:-755} uid mode_bits
assert_path_chain "$directory"
[[ -d "$directory" && ! -L "$directory" ]] || die "安装目录无效:$directory"
uid=$(stat_uid "$directory")
[[ "$uid" == 0 ]] || die "安装目录必须由 root 拥有:$directory"
mode_bits=$(stat_mode_bits "$directory")
(( (mode_bits & 18) == 0 )) || die "安装目录不能被组或其他用户写入:$directory"
chmod "$mode" "$directory"
chown root:root "$directory"
}
ensure_data_directory() {
local directory=$1 owner_uid mode_bits
owner_uid=$(id -u tallynote)
# The service owns its private data tree. Permit that one explicit owner
# while keeping every installation/configuration path root-owned.
assert_path_chain "$directory" "$owner_uid"
[[ -d "$directory" && ! -L "$directory" ]] || die "数据目录无效:$directory"
mode_bits=$(stat_mode_bits "$directory")
(( (mode_bits & 18) == 0 )) || die "数据目录不能被组或其他用户写入:$directory"
# A root-owned directory from an earlier manual install is safe to adopt;
# an unrelated non-root owner is not.
local current_uid
current_uid=$(stat_uid "$directory")
[[ "$current_uid" == 0 || "$current_uid" == "$owner_uid" ]] || die "数据目录由不受信用户拥有:$directory"
DATA_DIR_ORIGINAL_OWNER=$(stat -c '%u:%g' "$directory" 2>/dev/null || stat -f '%u:%g' "$directory")
# Temporarily make the parent root-owned while its children are checked and
# repaired. This prevents the service account from swapping a checked child
# for a symlink between the lstat and the privileged chown/chmod calls.
chown root:root "$directory"
chmod 700 "$directory"
DATA_DIR_TEMP_ROOT=1
for child in files staging exports; do
local child_path="$directory/$child"
assert_path_chain "$child_path" "$owner_uid"
[[ -d "$child_path" && ! -L "$child_path" ]] || die "数据子目录无效:$child_path"
chown tallynote:tallynote "$child_path"
chmod 700 "$child_path"
done
chown tallynote:tallynote "$directory"
chmod 700 "$directory"
DATA_DIR_TEMP_ROOT=0
}
stop_existing_services() {
command -v systemctl >/dev/null 2>&1 || return 0
local unit
# Stop the path trigger first so it cannot launch the privileged updater while
# the data tree is being repaired.
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
if systemctl is-active --quiet "$unit"; then
case "$unit" in
tallynote.service) INSTALL_WAS_ACTIVE=1 ;;
tallynote-update.path) INSTALL_PATH_WAS_ACTIVE=1 ;;
tallynote-update.service) INSTALL_UPDATE_WAS_ACTIVE=1 ;;
esac
systemctl stop "$unit" || die "无法停止现有服务:$unit"
fi
done
}
rollback_install_if_needed() {
local result=$? rollback_tmp
if (( INSTALL_SWITCHED == 1 && INSTALL_COMMITTED == 0 )); then
if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then
rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
if [[ ! -e "$rollback_tmp" ]] && ln -s -- "$INSTALL_PREVIOUS_TARGET" "$rollback_tmp" && mv -Tf -- "$rollback_tmp" "$PREFIX/current"; then
:
else
rm -f -- "$rollback_tmp" 2>/dev/null || true
fi
else
rm -f -- "$PREFIX/current" 2>/dev/null || true
fi
if [[ -n "$INSTALL_NEW_RELEASE" && -d "$INSTALL_NEW_RELEASE" ]]; then
rm -rf -- "$INSTALL_NEW_RELEASE" 2>/dev/null || true
fi
fi
if (( DATA_DIR_TEMP_ROOT == 1 )) && [[ -n "$DATA_DIR_ORIGINAL_OWNER" && -d "$DATA_DIR" && ! -L "$DATA_DIR" ]]; then
chown -- "$DATA_DIR_ORIGINAL_OWNER" "$DATA_DIR" 2>/dev/null || true
chmod 700 "$DATA_DIR" 2>/dev/null || true
DATA_DIR_TEMP_ROOT=0
fi
if (( INSTALL_COMMITTED == 0 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
local backup_name target
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote.env update-signing-key.pub; do
case "$backup_name" in
tallynote.env) target="$CONFIG_DIR/tallynote.env" ;;
update-signing-key.pub) target="$CONFIG_DIR/update-signing-key.pub" ;;
*) target="/etc/systemd/system/$backup_name" ;;
esac
[[ ! -L "$target" ]] || continue
if [[ -f "$INSTALL_BACKUP_DIR/$backup_name" ]]; then
cp -a -- "$INSTALL_BACKUP_DIR/$backup_name" "$target" 2>/dev/null || true
else
rm -f -- "$target" 2>/dev/null || true
fi
done
fi
if command -v systemctl >/dev/null 2>&1; then
if (( INSTALL_WAS_ACTIVE == 1 )); then systemctl start tallynote.service 2>/dev/null || true; fi
if (( INSTALL_UPDATE_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.service 2>/dev/null || true; fi
if (( INSTALL_PATH_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.path 2>/dev/null || true; fi
fi
if [[ -n "$INSTALL_WORK_DIR" && -d "$INSTALL_WORK_DIR" ]]; then
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
fi
return "$result"
}
backup_install_files() {
local directory=$1 target name
mkdir -p "$directory"
chmod 700 "$directory"
for name in tallynote.service tallynote-update.service tallynote-update.path; do
target="/etc/systemd/system/$name"
[[ ! -L "$target" ]] || die "现有 systemd 单元不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有 systemd 单元不是普通文件:$target"
cp -a -- "$target" "$directory/$name"
fi
done
for name in tallynote.env update-signing-key.pub; do
target="$CONFIG_DIR/$name"
[[ ! -L "$target" ]] || die "现有配置不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有配置不是普通文件:$target"
cp -a -- "$target" "$directory/$name"
fi
done
}
read_env_value() {
local file=$1 key=$2
sed -n "s/^${key}=//p" "$file" | head -n 1
}
env_key_count() {
local file=$1 key=$2
awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file"
}
validate_env_value() {
local value=$1 label=$2
[[ "$value" != *[[:cntrl:]]* ]] || die "$label 不能包含控制字符"
[[ ${#value} -le 4096 ]] || die "$label 过长"
}
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
[[ "$value" == *-* ]] || return 0
prerelease=${value#*-}
prerelease=${prerelease%%+*}
IFS='.' read -r -a _prerelease_parts <<< "$prerelease"
for part in "${_prerelease_parts[@]}"; do
[[ ! "$part" =~ ^0[0-9]+$ ]] || return 1
done
}
validate_install_path() {
local value=$1 label=$2
[[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径"
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"/../"* && "$value" != */.. && "$value" != *"//"* ]] || die "$label 包含不受支持的路径字符"
}
validate_existing_env() {
local file=$1 value metadata_host
[[ ! -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
key_count=$(env_key_count "$file" "$key")
[[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key"
done
value=$(read_env_value "$file" TALLYNOTE_INSTALL_PREFIX)
[[ -z "$value" || "${value%/}" == "${PREFIX%/}" ]] || die '环境文件中的安装目录与本次安装不一致'
value=$(read_env_value "$file" TALLYNOTE_DATA_DIR)
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
[[ -z "$value" || "$value" == true ]] || die '环境文件禁止关闭发布签名校验'
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
if [[ -n "$value" ]]; then
validate_env_value "$value" '环境文件更新源'
metadata_host=$(url_host "$value")
assert_allowed_url "$value"
[[ -n "$metadata_host" ]] || die '环境文件更新源无效'
fi
}
install_release() {
local archive=$1 version=$2 tmp release_dir current_tmp=''
tmp=$(mktemp -d)
trap 'rm -rf "$tmp" "$current_tmp" 2>/dev/null || true' RETURN
safe_extract "$archive" "$tmp/unpacked"
normalize_release_tree "$tmp/unpacked"
[[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root'
[[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote'
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units'
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" ]] || die 'release archive is missing update support files'
grep -Eq '"version"[[:space:]]*:[[:space:]]*"'"$version"'"([,}]|[[:space:]])' "$tmp/unpacked/package.json" || die 'release package version does not match requested version'
ensure_root_directory "$PREFIX" 755
ensure_root_directory "$PREFIX/releases" 755
release_dir="$PREFIX/releases/$version"
[[ ! -e "$release_dir" ]] || die "release already exists: $release_dir"
if [[ -L "$PREFIX/current" ]]; then
current_target=$(readlink -f -- "$PREFIX/current")
[[ "$current_target" == "$PREFIX/releases/"* && -d "$current_target" ]] || die 'current 符号链接指向安装目录之外'
INSTALL_PREVIOUS_TARGET=$current_target
elif [[ -e "$PREFIX/current" ]]; then
die "$PREFIX/current exists and is not a symlink"
fi
mv "$tmp/unpacked" "$release_dir"
INSTALL_NEW_RELEASE=$release_dir
chown -R root:root "$release_dir"
chmod 755 "$release_dir"
current_tmp="$PREFIX/.current.$$.tmp"
ln -s "$release_dir" "$current_tmp"
mv -Tf "$current_tmp" "$PREFIX/current"
INSTALL_SWITCHED=1
}
prune_releases() {
local current_target current_name version kept=0
current_target=$(readlink -f -- "$PREFIX/current" 2>/dev/null || true)
current_name=$(basename -- "$current_target")
[[ "$current_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] || return 0
mapfile -t versions < <(
find "$PREFIX/releases" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \
| awk '/^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$/' \
| version_sort_desc
)
# KEEP_RELEASES counts the active release. Always retain current even when
# a distro's version sort has unusual prerelease ordering.
for version in "${versions[@]}"; do
if [[ "$version" == "$current_name" ]]; then
kept=$((kept + 1))
continue
fi
if (( kept < KEEP_RELEASES )); then
kept=$((kept + 1))
else
rm -rf -- "$PREFIX/releases/$version"
fi
done
}
main() {
# These variables are useful for isolated tests, but a root install must
# never execute an untrusted PATH entry supplied through sudo's environment.
if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then
validate_trusted_tool "$UNAME_BIN" 'uname'
fi
if (( APPLY )) || [[ -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
fi
detect_platform
[[ "$KEEP_RELEASES" =~ ^[1-9][0-9]*$ ]] || die '--keep-releases must be a positive integer'
validate_install_path "$PREFIX" '安装目录'
validate_install_path "$DATA_DIR" '数据目录'
validate_install_path "$CONFIG_DIR" '配置目录'
validate_env_value "$REPOSITORY_URL" '仓库地址'
validate_env_value "$RELEASE_API_URL" 'Release API 地址'
validate_env_value "$RELEASE_BASE_URL" 'Release 地址'
validate_allowed_hosts
# Bind every network request to the configured release service before any
# redirect is followed. A CDN can be added explicitly through
# TALLYNOTE_RELEASE_ALLOWED_HOSTS when the operator has reviewed it.
append_allowed_host "$(url_host "$RELEASE_API_URL")"
append_allowed_host "$(url_host "$REPOSITORY_URL")"
if [[ "$VERSION" == "latest" ]]; then
if (( ! APPLY )); then
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
log 'version: latest (release lookup happens with --apply)'
log 'dry-run: pass --version VERSION to preview an exact artifact'
return 0
fi
resolve_latest_version
fi
validate_semver "$VERSION" || die 'version must be a semantic version (for example 1.2.3)'
VERSION=${VERSION#v}
if [[ -L "$PREFIX/current" ]]; then
current_target=$(readlink -f -- "$PREFIX/current" 2>/dev/null || true)
current_version=$(basename -- "$current_target")
if validate_semver "$current_version" >/dev/null 2>&1 && [[ "$ALLOW_DOWNGRADE" != true ]] && ! version_is_newer "$VERSION" "$current_version"; then
die "拒绝安装不高于当前版本的 release:当前 $current_version,候选 $VERSION(如确需降级请使用 --allow-downgrade)"
fi
fi
release_urls
local artifact archive checksum signature artifact_url work release_dir
artifact=${RELEASE_FILE:+$(basename -- "$RELEASE_FILE")}
artifact=${artifact:-tallynote-${VERSION}-linux-${TALLYNOTE_ARCH}-${TALLYNOTE_LIBC}.tar.gz}
[[ "$artifact" =~ ^[A-Za-z0-9][A-Za-z0-9._+\-]*\.(tar\.gz|tgz|tar)$ ]] || die 'release 文件名无效'
artifact_url="$RELEASE_BASE_URL/$artifact"
log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}"
log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR"
if (( ! APPLY )); then log 'dry-run: pass --apply to download, verify, extract, and configure systemd'; return 0; fi
[[ "$REQUIRE_SIGNATURE" == true || "$ALLOW_UNSIGNED" -eq 1 ]] || die '生产安装必须校验发布签名;仅隔离开发环境可使用 --allow-unsigned'
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行 --apply'
[[ $EUID -eq 0 ]] || die '--apply must run as root'
for command_name in curl sha256sum tar install sed awk find systemctl; do
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
done
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
work=$(mktemp -d)
INSTALL_WORK_DIR=$work
INSTALL_BACKUP_DIR="$work/original"
trap rollback_install_if_needed EXIT
archive="$work/$artifact"
if [[ -n "$RELEASE_FILE" && -f "$RELEASE_FILE" && ! -L "$RELEASE_FILE" ]]; then
cp -- "$RELEASE_FILE" "$archive"
chmod 600 "$archive"
[[ "$(wc -c < "$archive" | tr -d '[:space:]')" -le $((MAX_RELEASE_MB * 1024 * 1024)) ]] || die '本地 release 文件超过大小限制'
else
[[ -z "$RELEASE_FILE" ]] || die '本地 release 文件不存在或是符号链接'
download "$artifact_url" "$archive"
fi
checksum="$work/SHA256SUMS"
SHA256_URL=${SHA256_URL:-$RELEASE_BASE_URL/SHA256SUMS}
if [[ -n "$SHA256_FILE" && -f "$SHA256_FILE" && ! -L "$SHA256_FILE" ]]; then
cp -- "$SHA256_FILE" "$checksum"
chmod 600 "$checksum"
[[ "$(wc -c < "$checksum" | tr -d '[:space:]')" -le $((2 * 1024 * 1024)) ]] || die '本地 SHA256SUMS 文件过大'
else
[[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接'
download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024))
fi
signature=''
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc}
signature="$work/$artifact.asc"
else
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/SHA256SUMS.sig}
signature="$work/SHA256SUMS.sig"
fi
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
elif [[ -n "$SIGNATURE_URL" ]]; then
signature="$work/SHA256SUMS.sig"
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
fi
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
id tallynote >/dev/null 2>&1 || useradd --system --user-group --home-dir "$DATA_DIR" --shell /usr/sbin/nologin tallynote
backup_install_files "$INSTALL_BACKUP_DIR"
stop_existing_services
ensure_root_directory "$PREFIX" 755
ensure_root_directory "$PREFIX/releases" 755
ensure_root_directory "$PREFIX/.update-work" 700
ensure_root_directory "$CONFIG_DIR" 755
ensure_data_directory "$DATA_DIR"
if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then
validate_existing_env "$CONFIG_DIR/tallynote.env"
fi
install_release "$archive" "$VERSION"
release_dir="$PREFIX/releases/$VERSION"
[[ -f "$release_dir/systemd/tallynote.service" && -f "$release_dir/systemd/tallynote-update.service" && -f "$release_dir/systemd/tallynote-update.path" ]] || die 'release package is missing systemd unit files'
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" ]] || die 'release package is missing update support files'
install -d -m 755 /usr/local/libexec /etc/systemd/system
local unit_tmp
unit_tmp=$(mktemp -d)
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service"
sed "s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service
install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service
install -o root -g root -m 644 "$unit_tmp/tallynote-update.path" /etc/systemd/system/tallynote-update.path
rm -rf "$unit_tmp"
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
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"
fi
ensure_env_key() {
local key=$1 value=$2
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效'
validate_env_value "$value" "$key"
if ! grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then
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
}
ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX"
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR"
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL"
ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS"
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
# The bootstrap verification key is also the key used by the privileged
# updater unless the operator already configured a separate one.
UPDATE_PUBLIC_KEY_FILE=${UPDATE_PUBLIC_KEY_FILE:-$SIGNING_KEY}
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
validate_install_path "$UPDATE_PUBLIC_KEY_FILE" '更新公钥路径'
[[ -f "$UPDATE_PUBLIC_KEY_FILE" && ! -L "$UPDATE_PUBLIC_KEY_FILE" ]] || die 'update public key file is invalid'
[[ "$(stat_uid "$UPDATE_PUBLIC_KEY_FILE")" == 0 ]] || die 'update public key file must be root-owned'
install -o root -g tallynote -m 640 "$UPDATE_PUBLIC_KEY_FILE" "$CONFIG_DIR/update-signing-key.pub"
if grep -qE '^TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=' "$CONFIG_DIR/tallynote.env"; then
sed -i "s#^TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=.*#TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$CONFIG_DIR/update-signing-key.pub#" "$CONFIG_DIR/tallynote.env"
else
printf 'TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=%s\n' "$CONFIG_DIR/update-signing-key.pub" >> "$CONFIG_DIR/tallynote.env"
fi
fi
chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env"
systemctl daemon-reload
systemctl enable --now tallynote.service tallynote-update.path
prune_releases
INSTALL_COMMITTED=1
trap - EXIT
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
INSTALL_WORK_DIR=''
log 'installed; inspect with systemctl status tallynote.service'
}
main "$@"
+128
View File
@@ -0,0 +1,128 @@
CREATE TABLE IF NOT EXISTS admins (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
username_norm TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
must_change_password INTEGER NOT NULL DEFAULT 1 CHECK(must_change_password IN (0,1)),
auth_version INTEGER NOT NULL DEFAULT 1 CHECK(auth_version >= 1),
version INTEGER NOT NULL DEFAULT 1 CHECK(version >= 1),
created_at INTEGER NOT NULL,
created_by TEXT REFERENCES admins(id) ON DELETE RESTRICT,
password_changed_at INTEGER,
last_login_at INTEGER,
disabled_at INTEGER,
disabled_by TEXT REFERENCES admins(id) ON DELETE RESTRICT
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS admins_username_norm_uq ON admins(username_norm);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
admin_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
csrf_hash TEXT NOT NULL,
auth_version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
idle_expires_at INTEGER NOT NULL,
absolute_expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS sessions_admin_idx ON sessions(admin_id);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(idle_expires_at);
CREATE TABLE IF NOT EXISTS expenses (
id TEXT PRIMARY KEY,
paid_at INTEGER NOT NULL,
amount_cents INTEGER NOT NULL CHECK(amount_cents > 0 AND amount_cents <= 999999999999),
note TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'unreimbursed' CHECK(status IN ('unreimbursed','reimbursed')),
version INTEGER NOT NULL DEFAULT 1 CHECK(version >= 1),
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL REFERENCES admins(id) ON DELETE RESTRICT,
updated_at INTEGER NOT NULL,
updated_by TEXT NOT NULL REFERENCES admins(id) ON DELETE RESTRICT,
reimbursed_at INTEGER,
reimbursed_by TEXT REFERENCES admins(id) ON DELETE RESTRICT,
deleted_at INTEGER,
deleted_by TEXT REFERENCES admins(id) ON DELETE RESTRICT
) STRICT;
CREATE INDEX IF NOT EXISTS expenses_list_idx ON expenses(deleted_at, status, paid_at DESC);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
expense_id TEXT NOT NULL REFERENCES expenses(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('payment_proof','invoice')),
storage_path TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL CHECK(size_bytes > 0),
sha256 TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL REFERENCES admins(id) ON DELETE RESTRICT
) STRICT;
CREATE INDEX IF NOT EXISTS attachments_expense_idx ON attachments(expense_id);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at INTEGER NOT NULL,
request_id TEXT NOT NULL,
actor_admin_id TEXT,
actor_username TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
outcome TEXT NOT NULL CHECK(outcome IN ('success','denied','failure')),
before_json TEXT CHECK(before_json IS NULL OR json_valid(before_json)),
after_json TEXT CHECK(after_json IS NULL OR json_valid(after_json)),
metadata_json TEXT CHECK(metadata_json IS NULL OR json_valid(metadata_json))
) STRICT;
CREATE INDEX IF NOT EXISTS audit_time_idx ON audit_events(occurred_at DESC);
CREATE INDEX IF NOT EXISTS audit_target_idx ON audit_events(target_type, target_id, occurred_at DESC);
CREATE TRIGGER IF NOT EXISTS audit_events_no_update BEFORE UPDATE ON audit_events
BEGIN SELECT RAISE(ABORT, 'audit_events are append-only'); END;
CREATE TRIGGER IF NOT EXISTS audit_events_no_delete BEFORE DELETE ON audit_events
BEGIN SELECT RAISE(ABORT, 'audit_events are append-only'); END;
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS export_jobs (
id TEXT PRIMARY KEY,
admin_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
session_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('queued','building','ready','failed','expired')),
selection_json TEXT NOT NULL CHECK(json_valid(selection_json)),
snapshot_json TEXT NOT NULL CHECK(json_valid(snapshot_json)),
file_path TEXT,
file_name TEXT NOT NULL,
size_bytes INTEGER,
sha256 TEXT,
error_message TEXT,
created_at INTEGER NOT NULL,
ready_at INTEGER,
expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS exports_expiry_idx ON export_jobs(expires_at);
CREATE INDEX IF NOT EXISTS exports_session_idx ON export_jobs(session_hash);
CREATE TABLE IF NOT EXISTS login_attempts (
key_hash TEXT PRIMARY KEY,
window_start INTEGER NOT NULL,
failures INTEGER NOT NULL,
blocked_until INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS file_deletions (
id TEXT PRIMARY KEY,
storage_path TEXT NOT NULL,
reason TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','complete','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at INTEGER NOT NULL,
completed_at INTEGER
) STRICT;
CREATE INDEX IF NOT EXISTS file_deletions_status_idx ON file_deletions(status);
@@ -0,0 +1 @@
ALTER TABLE expenses ADD COLUMN invoice_missing_reason TEXT;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS update_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL CHECK(status IN ('queued','downloading','verifying','staged','backing_up','applying','completed','failed','cancelled')),
version TEXT NOT NULL,
platform TEXT NOT NULL,
release_url TEXT,
asset_name TEXT,
asset_url TEXT NOT NULL,
expected_sha256 TEXT,
actual_sha256 TEXT,
download_path TEXT,
backup_path TEXT,
size_bytes INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
completed_at INTEGER
) STRICT;
CREATE INDEX IF NOT EXISTS update_jobs_status_idx ON update_jobs(status, created_at);
+7
View File
@@ -0,0 +1,7 @@
ALTER TABLE update_jobs ADD COLUMN admin_id TEXT REFERENCES admins(id) ON DELETE SET NULL;
ALTER TABLE update_jobs ADD COLUMN session_hash TEXT;
ALTER TABLE update_jobs ADD COLUMN request_id TEXT;
ALTER TABLE update_jobs ADD COLUMN requested_at INTEGER;
ALTER TABLE update_jobs ADD COLUMN started_at INTEGER;
CREATE INDEX IF NOT EXISTS update_jobs_admin_idx ON update_jobs(admin_id, created_at);
CREATE INDEX IF NOT EXISTS update_jobs_session_idx ON update_jobs(session_hash);
+64
View File
@@ -0,0 +1,64 @@
{
"name": "tallynote",
"version": "1.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "concurrently -k -n server,web -c cyan,magenta \"tsx watch server/index.ts\" \"vite\"",
"build": "tsc -p tsconfig.server.json && vite build",
"start": "node dist/server/index.js",
"admin:init": "tsx server/cli/admin-init.ts",
"release:build": "bash scripts/build-release.sh",
"release:publish": "bash scripts/publish-gitea-release.sh",
"db:generate": "drizzle-kit generate",
"check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^9.2.1",
"@fastify/static": "^10.1.3",
"archiver": "^8.0.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.2.0",
"drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0",
"fast-xml-parser": "^5.2.5",
"fastify": "^5.4.0",
"lucide-react": "^0.542.0",
"pdf-lib": "^1.17.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sharp": "^0.35.4",
"yauzl": "^3.2.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/archiver": "^8.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/yauzl": "^2.10.3",
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1",
"drizzle-kit": "^0.31.4",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vitest": "^3.2.4"
},
"pnpm": {
"overrides": {
"uuid": ">=11.1.1"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
use: {
baseURL: "http://127.0.0.1:3400",
trace: "retain-on-failure",
...devices["Desktop Chrome"],
},
webServer: {
command: "node dist/server/index.js",
url: "http://127.0.0.1:3400/health",
reuseExistingServer: false,
timeout: 120_000,
env: {
NODE_ENV: "production",
TALLYNOTE_HOST: "127.0.0.1",
TALLYNOTE_PORT: "3400",
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3400",
TALLYNOTE_COOKIE_SECURE: "false",
TALLYNOTE_DATA_DIR: "/tmp/tallynote-e2e",
},
},
});
+4576
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Build a self-contained release on the target Linux architecture. Native
# addons (SQLite, Argon2 and image processing) must be installed on the same
# architecture/libc as the artifact.
ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
VERSION=${1:-}
OUT_DIR=${2:-$ROOT/release}
[[ "$(uname -s)" == "Linux" ]] || { printf 'release builds must run on Linux; detected %s\n' "$(uname -s)" >&2; exit 2; }
if [[ -z "$VERSION" ]]; then
VERSION=$(node -p 'require("./package.json").version')
fi
VERSION=${VERSION#v}
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || { printf 'invalid version: %s\n' "$VERSION" >&2; exit 2; }
case "$(uname -m)" in
x86_64|amd64) ARCH=x64 ;;
aarch64|arm64) ARCH=arm64 ;;
armv7l|armv7|armhf) ARCH=armv7 ;;
*) printf 'unsupported architecture: %s\n' "$(uname -m)" >&2; exit 2 ;;
esac
LIBC=glibc
if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then LIBC=musl; fi
cd "$ROOT"
pnpm build
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
cp -a dist/. "$stage/dist/"
cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
node_path=$(command -v node)
cp -L "$node_path" "$stage/runtime/bin/node"
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node"
# pnpm's default linker creates symlinks. A release archive is deliberately
# symlink-free so the installer can reject traversal links deterministically.
(cd "$stage" && pnpm install --prod --node-linker=hoisted --frozen-lockfile)
find "$stage" -type l -delete
mkdir -p "$OUT_DIR"
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
# Keep the sidecar useful when a caller builds more than one architecture into
# the same directory. The publishing script recomputes this list immediately
# before signing, so stale or hand-edited entries can never reach a Release.
(cd "$OUT_DIR" && sha256sum ./*.tar.gz | sed 's#^\./##' | LC_ALL=C sort > SHA256SUMS)
printf 'built %s\n' "$archive"
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Publish one immutable, signed release to a Gitea-compatible API. The script
# is intentionally separate from the workflow so operators can dry-run the
# exact same asset selection locally without ever exposing a signing key.
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
export PATH
umask 077
TAG=''
ASSET_DIR='release'
GITHUB_SERVER=${GITHUB_SERVER_URL:-https://git.awaioi.com}
GITHUB_SERVER=${GITHUB_SERVER%/}
API_ROOT=${GITEA_API_URL:-$GITHUB_SERVER/api/v1}
REPOSITORY=${GITHUB_REPOSITORY:-awaioi/TallyNote}
TOKEN=${GITEA_TOKEN:-${GITHUB_TOKEN:-}}
SIGNING_KEY_FILE=${TALLYNOTE_RELEASE_SIGNING_KEY_FILE:-}
SIGNING_KEY_VALUE=${TALLYNOTE_RELEASE_SIGNING_KEY:-}
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
CURL_BIN=${TALLYNOTE_CURL_BIN:-curl}
DRY_RUN=0
AUTH_CONFIG=''
SUMS_TMP=''
SIG_TMP=''
usage() {
cat <<'EOF'
Usage: publish-gitea-release.sh TAG [ASSET_DIR] [--dry-run]
Required in publish mode:
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
or TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
EOF
}
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
log() { printf 'release publisher: %s\n' "$*"; }
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
prerelease=${value#*-}
[[ "$value" == *-* ]] || return 0
prerelease=${prerelease%%+*}
IFS='.' read -r -a _prerelease_parts <<< "$prerelease"
for part in "${_prerelease_parts[@]}"; do
[[ ! "$part" =~ ^0[0-9]+$ ]] || return 1
done
}
validate_api_root() {
local value=$1 authority host port path_part
[[ "$value" == https://* && "$value" != *[[:cntrl:]]* && "$value" != *[[:space:]]* ]] || die 'GITEA_API_URL must be a clean HTTPS URL'
[[ "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die 'GITEA_API_URL must not contain credentials, query, or fragment'
authority=${value#https://}
authority=${authority%%/*}
[[ -n "$authority" ]] || die 'GITEA_API_URL host is invalid'
if [[ "$authority" == \[*\]* ]]; then
host=${authority#\[}; host=${host%%\]*}
else
host=${authority%%:*}
fi
[[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die 'GITEA_API_URL host is invalid'
if [[ "$authority" != \[*\]* && "$authority" == *:* ]]; then
port=${authority##*:}
[[ "$port" =~ ^[0-9]{1,5}$ && "$port" -ge 1 && "$port" -le 65535 ]] || die 'GITEA_API_URL port is invalid'
fi
path_part=${value#https://"$authority"}
[[ -z "$path_part" || "$path_part" == /* ]] || die 'GITEA_API_URL path is invalid'
[[ "$path_part" != *'//'* ]] || die 'GITEA_API_URL path is invalid'
}
assert_sidecar_target() {
local target=$1
[[ ! -L "$target" ]] || die "sidecar target must not be a symbolic link: $target"
[[ ! -e "$target" || -f "$target" ]] || die "sidecar target must be a regular file: $target"
}
validate_signing_key_file() {
local file=$1 uid mode
[[ -f "$file" && ! -L "$file" ]] || die 'signing key file is invalid'
uid=$(stat -c '%u' "$file" 2>/dev/null || stat -f '%u' "$file")
mode=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file")
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]] || die 'signing key file must be owned by the publishing user'
[[ "$mode" =~ ^[0-7]+$ && $((8#$mode & 18)) -eq 0 ]] || die 'signing key file is readable or writable by group/other users'
}
write_auth_config() {
local escaped
[[ "$TOKEN" != *[[:cntrl:]]* && ${#TOKEN} -le 4096 ]] || die 'Gitea token contains invalid characters'
escaped=${TOKEN//\\/\\\\}
escaped=${escaped//\"/\\\"}
AUTH_CONFIG=$(mktemp)
chmod 600 "$AUTH_CONFIG"
printf 'header = "Authorization: token %s"\nheader = "Accept: application/json"\n' "$escaped" > "$AUTH_CONFIG"
}
while (($#)); do
case "$1" in
--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
*)
if [[ -z "$TAG" ]]; then TAG=$1
elif [[ "$ASSET_DIR" == release ]]; then ASSET_DIR=$1
else die "unknown option: $1"; fi
;;
esac
shift
done
validate_semver "$TAG" || die 'TAG must be a semantic version such as v1.0.0'
TAG="v${TAG#v}"
[[ "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || die 'GITHUB_REPOSITORY must be owner/repository'
API_ROOT=${API_ROOT%/}
validate_api_root "$API_ROOT"
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
assets=()
for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file")
[[ "$name" =~ ^tallynote-[A-Za-z0-9][A-Za-z0-9.+-]*-linux-(x64|arm64|armv7)-[A-Za-z0-9._-]+\.tar\.gz$ ]] || die "invalid release asset name: $name"
asset_version=${name#tallynote-}
asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
assets+=("$file")
done
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
assert_sidecar_target "$SUMS_FILE"
assert_sidecar_target "$SIG_FILE"
SUMS_TMP=$(mktemp "$ASSET_DIR/.SHA256SUMS.XXXXXX")
{
(cd "$ASSET_DIR" && for file in ./*.tar.gz; do sha256sum "$file"; done)
} | sed 's#^\./##' | LC_ALL=C sort > "$SUMS_TMP"
chmod 600 "$SUMS_TMP"
mv -f -- "$SUMS_TMP" "$SUMS_FILE"
SUMS_TMP=''
temporary_key=''
temporary_key_owned=0
release_json=''
cleanup() {
if [[ "$temporary_key_owned" -eq 1 && -n "$temporary_key" ]]; then rm -f -- "$temporary_key"; fi
if [[ -n "$release_json" ]]; then rm -f -- "$release_json"; fi
if [[ -n "$AUTH_CONFIG" ]]; then rm -f -- "$AUTH_CONFIG"; fi
if [[ -n "$SUMS_TMP" ]]; then rm -f -- "$SUMS_TMP"; fi
if [[ -n "$SIG_TMP" ]]; then rm -f -- "$SIG_TMP"; fi
}
trap cleanup EXIT
if [[ -n "$SIGNING_KEY_FILE" ]]; then
validate_signing_key_file "$SIGNING_KEY_FILE"
temporary_key=$SIGNING_KEY_FILE
elif [[ -n "$SIGNING_KEY_VALUE" ]]; then
temporary_key=$(mktemp)
temporary_key_owned=1
chmod 600 "$temporary_key"
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
unset SIGNING_KEY_VALUE
else
[[ "$DRY_RUN" -eq 1 ]] || die 'TALLYNOTE_RELEASE_SIGNING_KEY_FILE or TALLYNOTE_RELEASE_SIGNING_KEY is required'
fi
if [[ -n "$temporary_key" ]]; then
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
SIG_TMP=$(mktemp "$ASSET_DIR/.SHA256SUMS.sig.XXXXXX")
"$OPENSSL_BIN" pkeyutl -sign -rawin -inkey "$temporary_key" -in "$SUMS_FILE" -out "$SIG_TMP" >/dev/null 2>&1 || die 'could not create Ed25519 signature'
chmod 600 "$SIG_TMP"
mv -f -- "$SIG_TMP" "$SIG_FILE"
SIG_TMP=''
fi
log "tag: $TAG"
log "assets: ${#assets[@]} archive(s), SHA256SUMS${temporary_key:+, SHA256SUMS.sig}"
if (( DRY_RUN )); then
log 'dry-run: no API request was sent'
exit 0
fi
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
[[ -s "$SIG_FILE" ]] || die 'signature was not generated'
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
api_curl() {
"$CURL_BIN" --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
}
api_curl_status() {
# Status probes must keep 404/409 bodies so the caller can distinguish a
# missing release from a transport failure without putting the token in argv.
"$CURL_BIN" --proto '=https' --tlsv1.2 --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
}
repo_path="${REPOSITORY}"
release_json=$(mktemp)
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取 Gitea Release'
if [[ "$status" == 200 ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
elif [[ "$status" == 404 ]]; then
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "TallyNote $TAG" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
create_status=$(api_curl_status -H 'Content-Type: application/json' -d "$body" -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases") || die '无法创建 Gitea Release'
if [[ "$create_status" == 2* ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
elif [[ "$create_status" == 409 || "$create_status" == 422 ]]; then
# Another runner may have created the tag between our GET and POST. Reuse
# that release instead of producing a duplicate or failing the workflow.
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取并发创建的 Gitea Release'
[[ "$status" == 200 ]] || die "Gitea Release 创建冲突(HTTP $create_status)"
release_id=$(jq -r '.id // empty' "$release_json")
else
die "无法创建 Gitea Release(HTTP $create_status)"
fi
else
die "Gitea Release 查询失败(HTTP $status)"
fi
[[ "$release_id" =~ ^[0-9]+$ ]] || die 'Gitea 未返回有效 Release ID'
assets_endpoint="$API_ROOT/repos/$repo_path/releases/$release_id/assets"
# Remove same-name assets so rerunning a tag build is deterministic. The
# release itself and all unrelated assets remain untouched.
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
while IFS=$'\t' read -r existing_id existing_name; do
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
for candidate in "${assets[@]}" "$SUMS_FILE" "$SIG_FILE"; do
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
done
done < <(jq -r '.[]? | [(.id|tostring), .name] | @tsv' <<< "$existing")
upload_asset() {
local file=$1 name
name=$(basename -- "$file")
# Asset names are restricted to URL-safe characters above.
api_curl -F "attachment=@$file;filename=$name" "$assets_endpoint?name=$name" >/dev/null \
|| die "无法上传资产:$name"
}
for file in "${assets[@]}"; do upload_asset "$file"; done
upload_asset "$SUMS_FILE"
upload_asset "$SIG_FILE"
log "published $TAG to $REPOSITORY"
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
umask 077
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
REQUEST_FILE="$DATA_DIR/update-request.json"
CURRENT_LINK="$PREFIX/current"
STATE_FILE="$PREFIX/.update-state"
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
HOST=${TALLYNOTE_HOST:-127.0.0.1}
PORT=${TALLYNOTE_PORT:-3000}
die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
[[ -f "$REQUEST_FILE" || -f "$STATE_FILE" ]] || exit 0
[[ -L "$CURRENT_LINK" ]] || die 'current release link is missing'
old_target=$(readlink -f -- "$CURRENT_LINK")
[[ "$old_target" == "$PREFIX/releases/"* && -d "$old_target" ]] || die 'current release target is invalid'
was_active=0
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
restore_initial_service() {
local result=$?
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
return "$result"
}
trap restore_initial_service EXIT
systemctl stop "$SERVICE_NAME"
job_id=''
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
fi
old_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
switched=0
handled=0
write_update_state() {
local phase=$1 temporary
temporary="$PREFIX/.update-state-$$-${RANDOM}.tmp"
[[ ! -e "$temporary" && ! -L "$temporary" ]] || return 1
printf 'job_id=%s\nold_target=%s\nphase=%s\n' "$job_id" "$old_target" "$phase" > "$temporary"
chmod 600 "$temporary"
mv -Tf -- "$temporary" "$STATE_FILE"
}
clear_update_state() {
[[ ! -L "$STATE_FILE" ]] || return 1
rm -f -- "$STATE_FILE"
}
finalize_state_job() {
local node=$1 status=$2 state_job=$3
[[ "$state_job" =~ ^[0-9a-f-]{36}$ && -n "$node" ]] || return 1
[[ -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 1
"$node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$state_job" --finalize-status "$status" --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
}
recover_stale_state() {
local state_job state_old state_phase current_target recovery_node rollback_link state_mode state_uid
[[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] || die 'update state file is invalid'
state_uid=$(stat -c '%u' "$STATE_FILE" 2>/dev/null || stat -f '%u' "$STATE_FILE")
state_mode=$(stat -c '%a' "$STATE_FILE" 2>/dev/null || stat -f '%Lp' "$STATE_FILE")
[[ "$state_uid" == 0 && "$state_mode" =~ ^[0-7]+$ && $((8#$state_mode & 077)) -eq 0 ]] || die 'update state file permissions are invalid'
state_job=$(sed -n 's/^job_id=//p' "$STATE_FILE" | head -n 1)
state_old=$(sed -n 's/^old_target=//p' "$STATE_FILE" | head -n 1)
state_phase=$(sed -n 's/^phase=//p' "$STATE_FILE" | head -n 1)
[[ "$state_job" =~ ^[0-9a-f-]{36}$ ]] || die 'update state job id is invalid'
[[ "$state_old" == "$PREFIX/releases/"* && -d "$state_old" && ! -L "$state_old" ]] || die 'update state target is invalid'
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
for _ in 1 2 3; do
if finalize_state_job "$recovery_node" completed "$state_job"; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
clear_update_state || true
return 10
fi
sleep 1
done
return 1
fi
if [[ "$current_target" != "$state_old" ]]; then
rollback_link="$PREFIX/.current-recovery-$$-${RANDOM}.tmp"
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
ln -s -- "$state_old" "$rollback_link" || return 1
if ! mv -Tf -- "$rollback_link" "$CURRENT_LINK"; then
rm -f -- "$rollback_link" 2>/dev/null || true
return 1
fi
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
if ! finalize_state_job "$recovery_node" failed "$state_job"; then
# If the original queue is still present, retry it from the restored old
# release; a crash before the CLI wrote its job row is recoverable this
# way. Without a queue there is no safe operation to replay.
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
clear_update_state || true
return 0
fi
return 1
fi
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
clear_update_state || true
return 11
fi
clear_update_state || true
return 0
}
if [[ -e "$STATE_FILE" ]]; then
recovery_result=0
set +e
recover_stale_state
recovery_result=$?
set -e
case "$recovery_result" in
10) if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi; exit 0 ;;
11) if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi; exit 1 ;;
0) : ;;
*) if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi; exit 1 ;;
esac
fi
[[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]] || exit 0
rollback_current() {
local current_target rollback_link
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
[[ "$current_target" == "$old_target" ]] && return 0
rollback_link="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
ln -s -- "$old_target" "$rollback_link" || return 1
if ! mv -Tf -- "$rollback_link" "$CURRENT_LINK"; then
rm -f -- "$rollback_link" 2>/dev/null || true
return 1
fi
switched=0
}
finalize_failed_job() {
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 0
"$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
}
finalize_completed_job() {
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
[[ -n "$final_node" ]] || return 1
"$final_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status completed >/dev/null 2>&1
}
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
cleanup_after_update() {
local result=$? rollback_ok=1
if (( result != 0 && handled == 0 )); then
if ! rollback_current; then rollback_ok=0; fi
if (( rollback_ok == 1 && switched == 0 )); then
if finalize_failed_job; then
rm -f -- "$REQUEST_FILE"
clear_update_state || true
fi
fi
fi
if (( was_active )); then
systemctl start "$SERVICE_NAME" || true
else
systemctl stop "$SERVICE_NAME" || true
fi
return "$result"
}
trap cleanup_after_update EXIT
write_update_state running || exit 1
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
set +e
"$node_bin" "$cli" --request-file "$REQUEST_FILE" --defer-completion
update_result=$?
set -e
if (( update_result != 0 )); then
exit "$update_result"
fi
if [[ "$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)" != "$old_target" ]]; then
switched=1
fi
write_update_state health-check || exit 1
systemctl start "$SERVICE_NAME"
healthy=0
for _ in $(seq 1 30); do
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
sleep 1
done
if (( healthy == 0 )); then
systemctl stop "$SERVICE_NAME" || true
rollback_current || die '无法恢复上一版本链接'
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
if ! finalize_failed_job; then
exit 1
fi
rm -f -- "$REQUEST_FILE"
clear_update_state || true
handled=1
trap - EXIT
exit 1
fi
# Preserve an operator's intentionally stopped service after validating the
# new release in a temporary start.
if (( was_active == 0 )); then
systemctl stop "$SERVICE_NAME"
fi
write_update_state finalizing || exit 1
final_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$final_node" ]] || final_node=$(command -v node || true)
if [[ "$job_id" =~ ^[0-9a-f-]{36}$ ]]; then
finalized=0
for _ in 1 2 3; do
if finalize_completed_job; then finalized=1; break; fi
sleep 1
done
(( finalized == 1 )) || exit 1
fi
rm -f -- "$REQUEST_FILE"
clear_update_state || true
handled=1
trap - EXIT
exit 0
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
umask 077
# Manual updater for operators without using the web control. The same
# verified TypeScript updater used by the systemd queue performs download,
# extraction and atomic release switching.
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-${TALLYNOTE_PREFIX:-/opt/tallynote}}
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
REQUEST_FILE=${TALLYNOTE_UPDATE_REQUEST_FILE:-$DATA_DIR/update-request.json}
NODE=${TALLYNOTE_NODE:-}
die() { printf 'tallynote update: %s\n' "$*" >&2; exit 1; }
version_sort_desc() {
if sort -V </dev/null >/dev/null 2>&1; then
sort -V -r
return
fi
awk -F'[.-]' '{ printf "%020d.%020d.%020d.%s\t%s\n", $1, $2, $3, ($4 == "" ? "~" : $4), $0 }' \
| sort -r | cut -f2-
}
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
if [[ "${1:-}" == "--rollback" ]]; then
current="$PREFIX/current"
[[ -L "$current" ]] || die 'current release is not a symlink'
current_target=$(readlink -f -- "$current")
current_name=$(basename -- "$current_target")
[[ "$current_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] || die 'current release version is invalid'
mapfile -t releases < <(
find "$PREFIX/releases" -mindepth 1 -maxdepth 1 -type d -printf '%p\n' \
| awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$/' \
| version_sort_desc
)
previous=''
found_current=0
for release in "${releases[@]}"; do
release_target=$(readlink -f -- "$release")
if [[ "$release_target" == "$current_target" ]]; then
found_current=1
continue
fi
if (( found_current )); then
previous=$release
break
fi
done
[[ -n "$previous" && -d "$previous" ]] || die 'no previous release available'
was_active=0
if systemctl is-active --quiet tallynote.service; then was_active=1; fi
if (( was_active )); then systemctl stop tallynote.service; fi
tmp="$PREFIX/.current-rollback-$$-${RANDOM}"
[[ ! -e "$tmp" && ! -L "$tmp" ]] || die 'rollback temporary path already exists'
ln -s -- "$previous" "$tmp"
mv -Tf -- "$tmp" "$current"
if (( was_active )); then systemctl start tallynote.service; fi
printf 'rolled back to %s\n' "$(basename -- "$previous")"
exit 0
fi
[[ -f "$REQUEST_FILE" ]] || die "no queued update request at $REQUEST_FILE"
[[ -L "$PREFIX/current" ]] || die 'current release is not a symlink'
# Prefer the systemd runner, which performs the post-switch health check and
# rollback. The fallback remains useful in development installations where the
# privileged helper has not been installed yet.
if [[ -x /usr/local/libexec/tallynote-update-runner ]]; then
exec /usr/local/libexec/tallynote-update-runner
fi
if [[ -z "$NODE" ]]; then
NODE="$PREFIX/current/runtime/bin/node"
[[ -x "$NODE" ]] || NODE=$(command -v node || true)
fi
[[ -n "$NODE" ]] || die 'node runtime not found'
CLI="$PREFIX/current/dist/server/cli/update.js"
[[ -f "$CLI" ]] || die 'update CLI not found'
was_active=0
if systemctl is-active --quiet tallynote.service; then was_active=1; fi
if (( was_active )); then systemctl stop tallynote.service; fi
set +e
"$NODE" "$CLI" --request-file "$REQUEST_FILE"
result=$?
set -e
if (( result != 0 )); then
rm -f -- "$REQUEST_FILE"
if (( was_active )); then systemctl start tallynote.service || true; fi
die 'update failed; the previous release remains active'
fi
if (( was_active )); then systemctl start tallynote.service || { rm -f -- "$REQUEST_FILE"; die 'updated service failed to start'; }; fi
rm -f -- "$REQUEST_FILE"
printf 'update completed; inspect the update page for details\n'
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
set -Eeuo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
grep -q 'dry-run' <<<"$output"
output=$(bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
grep -q 'release: 1.2.3' <<<"$output"
if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.test/releases >/dev/null 2>&1; then
echo 'expected non-HTTPS URL to fail' >&2
exit 1
fi
tmp=$(mktemp -d)
cleanup_tmp() {
if [[ -d "$tmp" ]]; then
rm -r "$tmp" 2>/dev/null || true
fi
}
trap cleanup_tmp EXIT
cat >"$tmp/uname" <<'EOF'
#!/usr/bin/env bash
printf 'i686\n'
EOF
chmod +x "$tmp/uname"
if TALLYNOTE_UNAME_BIN="$tmp/uname" bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
echo 'expected ia32 to fail' >&2
exit 1
fi
if [[ "$(uname -s)" != Linux ]]; then
if bash "$root/scripts/build-release.sh" 1.0.0 /tmp/tallynote-installer-release-test >/dev/null 2>&1; then
echo 'expected non-Linux release build to fail on this host' >&2
exit 1
fi
fi
# Exercise installer helpers without mutating the host. Removing the final
# main invocation lets this subprocess source the exact production code.
installer_lib="$tmp/install-lib.sh"
sed '$d' "$root/install.sh" > "$installer_lib"
bash -c '
script=$1
mode_dir=$2
owner_parent=$3
set --
source "$script"
mkdir -p "$mode_dir"
chmod 700 "$mode_dir"
[[ "$(stat_mode_bits "$mode_dir")" == 448 ]]
mkdir -p "$owner_parent"
if (assert_path_chain "$owner_parent/child") >/dev/null 2>&1; then
echo "expected non-root path parent to fail" >&2
exit 1
fi
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
# when the first value looks valid (systemd uses the later value).
duplicate_env="$tmp/duplicate.env"
printf '%s\n' 'TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true' 'TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false' > "$duplicate_env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
if (validate_existing_env "$env_file") >/dev/null 2>&1; then
echo "expected duplicate environment assignment to fail" >&2
exit 1
fi
' _ "$installer_lib" "$duplicate_env"
# A release archive is extracted under umask 077, then explicitly normalized
# so the tallynote system user can traverse and execute the shipped tree.
source_tmp="$tmp/source"
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
archive_tmp="$tmp/release.tar.gz"
tar -C "$source_tmp" -czf "$archive_tmp" .
bash -c '
script=$1
archive=$2
destination=$3
set --
source "$script"
safe_extract "$archive" "$destination"
normalize_release_tree "$destination"
[[ "$(stat_mode "$destination/dist")" == 755 ]]
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
# Newline/control characters in release configuration must never become extra
# systemd EnvironmentFile assignments.
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
echo 'expected control characters in release URL to fail' >&2
exit 1
fi
# The publisher is safe to exercise on every host in dry-run mode. When an
# OpenSSL build supports Ed25519, also verify the exact detached signature.
publisher_tmp=$(mktemp -d)
printf 'test-release' > "$publisher_tmp/tallynote-1.0.0-linux-x64-glibc.tar.gz"
if "$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" --dry-run >/dev/null 2>&1; then
test -s "$publisher_tmp/SHA256SUMS"
else
echo 'publisher dry-run failed' >&2
exit 1
fi
openssl_test_bin=${TALLYNOTE_OPENSSL_BIN:-$(command -v openssl || true)}
if [[ -n "$openssl_test_bin" ]] && "$openssl_test_bin" genpkey -algorithm ED25519 -out "$publisher_tmp/key" >/dev/null 2>&1; then
TALLYNOTE_RELEASE_SIGNING_KEY_FILE="$publisher_tmp/key" TALLYNOTE_OPENSSL_BIN="$openssl_test_bin" \
"$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" --dry-run >/dev/null 2>&1
"$openssl_test_bin" pkey -in "$publisher_tmp/key" -pubout -out "$publisher_tmp/pub" >/dev/null 2>&1
"$openssl_test_bin" pkeyutl -verify -pubin -inkey "$publisher_tmp/pub" -rawin \
-in "$publisher_tmp/SHA256SUMS" -sigfile "$publisher_tmp/SHA256SUMS.sig" >/dev/null 2>&1
# Exercise the 404 -> create -> assets -> upload flow with a local curl
# shim. The shim records argv and verifies the secret only arrives through
# the temporary curl config file, never as a process argument.
if command -v jq >/dev/null 2>&1; then
fake_curl="$publisher_tmp/fake-curl"
fake_trace="$publisher_tmp/curl-args"
fake_config_seen="$publisher_tmp/curl-config-seen"
cat > "$fake_curl" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
out=''; format=''; method='GET'; url=''; previous=''; config=''
for arg in "$@"; do
case "$previous" in
out) out=$arg; previous=''; continue ;;
format) format=$arg; previous=''; continue ;;
method) method=$arg; previous=''; continue ;;
config) config=$arg; previous=''; continue ;;
esac
case "$arg" in
-o) previous=out ;;
-w) previous=format ;;
-X) previous=method ;;
--config) previous=config ;;
-d*|-F*) method=POST ;;
http://*|https://*) url=$arg ;;
esac
done
printf '%s\n' "$*" >> "$TALLYNOTE_FAKE_CURL_TRACE"
[[ "$*" != *"$TALLYNOTE_FAKE_TOKEN"* ]] || { echo 'token leaked in curl argv' >&2; exit 91; }
[[ -n "$config" && -s "$config" ]] || { echo 'curl auth config missing' >&2; exit 92; }
grep -q "Authorization: token $TALLYNOTE_FAKE_TOKEN" "$config"
printf '%s\n' seen > "$TALLYNOTE_FAKE_CURL_CONFIG_SEEN"
code=200; body='{}'
if [[ "$url" == */releases/tags/* ]]; then
if [[ ! -f "$TALLYNOTE_FAKE_RELEASE_CREATED" ]]; then code=404; body='{}'; else code=200; body='{"id":42}'; fi
elif [[ "$url" == */releases && "$method" == POST ]]; then
printf '%s' created > "$TALLYNOTE_FAKE_RELEASE_CREATED"
code=201; body='{"id":42}'
elif [[ "$url" == */assets && "$method" == GET ]]; then
code=200; body='[]'
elif [[ "$url" == */assets\?name=* ]]; then
code=201; body='{"id":1}'
elif [[ "$method" == DELETE ]]; then
code=204; body=''
fi
if [[ -n "$out" ]]; then
printf '%s' "$body" > "$out"
else
printf '%s' "$body"
fi
if [[ "$format" == '%{http_code}' ]]; then
printf '%s' "$code"
fi
EOF
chmod 700 "$fake_curl"
TALLYNOTE_FAKE_CURL_TRACE="$fake_trace" TALLYNOTE_FAKE_CURL_CONFIG_SEEN="$fake_config_seen" \
TALLYNOTE_FAKE_RELEASE_CREATED="$publisher_tmp/release-created" TALLYNOTE_FAKE_TOKEN='secret-token' \
TALLYNOTE_CURL_BIN="$fake_curl" GITEA_API_URL='https://gitea.example/api/v1' \
GITHUB_REPOSITORY='awaioi/TallyNote' GITEA_TOKEN='secret-token' \
TALLYNOTE_RELEASE_SIGNING_KEY_FILE="$publisher_tmp/key" \
TALLYNOTE_OPENSSL_BIN="$openssl_test_bin" \
"$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" >/dev/null
if grep -q 'secret-token' "$fake_trace"; then
echo 'token leaked in curl argv' >&2
exit 1
fi
test -s "$fake_config_seen"
fi
fi
if [[ -d "$publisher_tmp" ]]; then
rm -r "$publisher_tmp" 2>/dev/null || true
fi
printf '%s\n' 'installer shell tests passed'
+1654
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import type Database from "better-sqlite3";
export type AuditInput = {
requestId: string;
actorAdminId?: string | null;
actorUsername?: string | null;
action: string;
targetType: string;
targetId?: string | null;
outcome?: "success" | "denied" | "failure";
before?: unknown;
after?: unknown;
metadata?: unknown;
};
function json(value: unknown): string | null {
return value === undefined ? null : JSON.stringify(value);
}
export function writeAudit(sqlite: Database.Database, input: AuditInput): void {
sqlite.prepare(`
INSERT INTO audit_events (
occurred_at, request_id, actor_admin_id, actor_username, action,
target_type, target_id, outcome, before_json, after_json, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
Date.now(),
input.requestId,
input.actorAdminId ?? null,
input.actorUsername ?? null,
input.action,
input.targetType,
input.targetId ?? null,
input.outcome ?? "success",
json(input.before),
json(input.after),
json(input.metadata),
);
}
+97
View File
@@ -0,0 +1,97 @@
import { stdin as input, stdout as output } from "node:process";
import { mkdirSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { openDatabase } from "../db/index.js";
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js";
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword } from "../security.js";
import { writeAudit } from "../audit.js";
function arg(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
async function readSecret(prompt: string): Promise<string> {
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
output.write(prompt);
return await new Promise<string>((resolve, reject) => {
let value = "";
const wasRaw = Boolean(input.isRaw);
const onData = (chunk: Buffer) => {
const text = chunk.toString("utf8");
if (text === "\u0003") {
cleanup();
reject(new Error("已取消"));
} else if (text === "\r" || text === "\n") {
cleanup();
output.write("\n");
resolve(value);
} else if (text === "\u007f") {
value = value.slice(0, -1);
} else if (!text.includes("\u001b")) {
value += text;
}
};
const cleanup = () => {
input.off("data", onData);
input.setRawMode?.(wasRaw);
input.pause();
};
input.resume();
input.setRawMode?.(true);
input.on("data", onData);
});
}
async function main() {
const config = loadConfig();
prepareDataDirectories(config);
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
const release = acquireInstanceLock(config);
const database = openDatabase(config);
try {
const existing = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
const username = arg("--username") ?? (await readSecret("用户名: "));
const displayName = arg("--display-name") ?? (await readSecret("显示名称: "));
const generate = process.argv.includes("--generate");
let password = generate ? temporaryPassword() : await readSecret("密码(至少 12 个字符): ");
if (!generate) {
const confirmation = await readSecret("再次输入密码: ");
if (password !== confirmation) throw new Error("两次密码输入不一致");
}
const policyError = validateNewPassword(password);
if (policyError) throw new Error(policyError);
const normalized = normalizeUsername(username);
if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符");
const passwordHash = await hashPassword(password);
const id = randomUUID();
const now = Date.now();
database.sqlite.transaction(() => {
const current = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (current.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?)
`).run(id, username.normalize("NFKC").trim(), normalized, displayName.trim(), passwordHash, now);
writeAudit(database.sqlite, {
requestId: `cli:${randomUUID()}`,
actorUsername: "cli",
action: "admin.initialized",
targetType: "admin",
targetId: id,
after: { username: normalized, displayName: displayName.trim(), status: "active" },
});
})();
console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。");
} finally {
database.sqlite.close();
release();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+418
View File
@@ -0,0 +1,418 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3";
import { z } from "zod";
import { acquireInstanceLock, loadConfig, prepareDataDirectories, type AppConfig } from "../config.js";
import { openDatabase } from "../db/index.js";
import { writeAudit } from "../audit.js";
import {
atomicSwitchDirectory,
atomicSwitchRelease,
compareSemver,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
extractSafeArchive,
fetchReleaseMetadata,
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
selectReleaseAsset,
sanitizeAssetName,
validateHttpsUrl,
type ReleaseAsset,
type ReleaseMetadata,
type UrlPolicy,
} from "../update.js";
import { attachSidecarHash } from "../update-service.js";
import type { UpdateJobStatus } from "../../shared/contracts.js";
const updateRequestFileSchema = z.object({
jobId: z.string().uuid(),
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
metadataUrl: z.string().url(),
assetUrl: z.string().url(),
assetName: z.string().min(1).max(200),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/i),
requestedAt: z.number().int().positive(),
currentLink: z.string().min(1),
releasesDir: z.string().min(1),
dataDir: z.string().min(1),
}).strict();
export type UpdateRequestFile = z.infer<typeof updateRequestFileSchema>;
/** Validate the hand-off from the unprivileged web process. URL and path
* fields are treated as untrusted data even though the file is local: the
* privileged runner must bind them to its own configuration before using it.
*/
export function validateUpdateRequest(requestValue: unknown, config: AppConfig): UpdateRequestFile {
const request = updateRequestFileSchema.parse(requestValue);
if (path.resolve(request.dataDir) !== path.resolve(config.dataDir)
|| path.resolve(request.currentLink) !== path.resolve(config.currentLink)
|| path.resolve(request.releasesDir) !== path.resolve(config.releasesDir)) {
throw new Error("更新请求目录与服务配置不一致");
}
const configuredMetadataUrl = validateHttpsUrl(config.updateMetadataUrl, {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
}).toString();
const requestedMetadataUrl = validateHttpsUrl(request.metadataUrl, {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
}).toString();
if (requestedMetadataUrl !== configuredMetadataUrl) throw new Error("更新请求源与服务配置不一致");
const requestAge = Date.now() - request.requestedAt;
if (requestAge > 24 * 60 * 60 * 1000 || requestAge < -5 * 60 * 1000) throw new Error("更新请求已过期");
return request;
}
export type UpdateRunOptions = UrlPolicy & {
sqlite?: Database.Database;
metadataUrl?: string | undefined;
assetUrl?: string | undefined;
assetName?: string | undefined;
version?: string | undefined;
expectedSha256?: string | undefined;
currentVersion?: string | undefined;
currentDir: string;
stagingDir: string;
backupArchivePath?: string | undefined;
dataBackupArchivePath?: string | undefined;
dataBackupSource?: string | undefined;
backupDir?: string | undefined;
releasesDir?: string | undefined;
currentLink?: string | undefined;
adminId?: string | undefined;
sessionHash?: string | undefined;
requestId?: string | undefined;
deferCompletion?: boolean | undefined;
maxBytes?: number | undefined;
dataBackupMaxBytes?: number | undefined;
fetchImpl?: typeof fetch;
platform?: ReturnType<typeof detectPlatform> | undefined;
jobId?: string | undefined;
publicKey?: string | undefined;
requireSignature?: boolean | undefined;
};
export type UpdateRunResult = {
jobId: string;
version: string;
asset: ReleaseAsset;
archivePath: string;
backupArchivePath?: string;
backupDir?: string;
};
function safeErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "更新失败";
const message = error.message;
if (message.length > 200 || /https?:\/\//i.test(message) || /authorization|token|secret|password|cookie|apikey/i.test(message)) return "更新失败";
return message || "更新失败";
}
function normalizedSha256(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().replace(/^sha256:/i, "").toLowerCase();
if (!/^[a-f0-9]{64}$/.test(normalized)) throw new Error("SHA-256 校验值无效");
return normalized;
}
function writeJob(sqlite: Database.Database | undefined, jobId: string, values: {
status: UpdateJobStatus;
version: string;
platform: string;
releaseUrl?: string | undefined;
assetName?: string | undefined;
assetUrl: string;
expectedSha256?: string | undefined;
actualSha256?: string | undefined;
downloadPath?: string | undefined;
backupPath?: string | undefined;
sizeBytes?: number | undefined;
errorMessage?: string | undefined;
completedAt?: number | undefined;
adminId?: string | undefined;
sessionHash?: string | undefined;
requestId?: string | undefined;
requestedAt?: number | undefined;
startedAt?: number | undefined;
}): void {
if (!sqlite) return;
const now = Date.now();
sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at,
status, version, platform, release_url, asset_name, asset_url,
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
created_at, updated_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
request_id=COALESCE(excluded.request_id, update_jobs.request_id),
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
status=excluded.status, version=excluded.version, platform=excluded.platform,
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
asset_url=excluded.asset_url,
expected_sha256=COALESCE(excluded.expected_sha256, update_jobs.expected_sha256),
actual_sha256=COALESCE(excluded.actual_sha256, update_jobs.actual_sha256),
download_path=COALESCE(excluded.download_path, update_jobs.download_path),
backup_path=COALESCE(excluded.backup_path, update_jobs.backup_path),
size_bytes=COALESCE(excluded.size_bytes, update_jobs.size_bytes),
error_message=COALESCE(excluded.error_message, update_jobs.error_message),
updated_at=excluded.updated_at,
completed_at=COALESCE(excluded.completed_at, update_jobs.completed_at)
`).run(
jobId,
values.adminId ?? null,
values.sessionHash ?? null,
values.requestId ?? null,
values.requestedAt ?? null,
values.startedAt ?? null,
values.status,
values.version,
values.platform,
values.releaseUrl ?? null,
values.assetName ?? null,
values.assetUrl,
values.expectedSha256 ?? null,
values.actualSha256 ?? null,
values.downloadPath ?? null,
values.backupPath ?? null,
values.sizeBytes ?? null,
values.errorMessage ?? null,
now,
now,
values.completedAt ?? null,
);
}
function updateJob(sqlite: Database.Database | undefined, jobId: string, values: Parameters<typeof writeJob>[2]): void {
writeJob(sqlite, jobId, values);
}
function clearTransientJobPath(sqlite: Database.Database | undefined, jobId: string): void {
if (!sqlite) return;
sqlite.prepare("UPDATE update_jobs SET download_path=NULL, updated_at=? WHERE id=?").run(Date.now(), jobId);
}
async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<typeof detectPlatform>): Promise<{ release?: ReleaseMetadata; asset: ReleaseAsset; version: string; releaseUrl?: string }> {
if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(metadataUrl, options);
let asset = options.assetUrl && !options.requireSignature
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
: selectReleaseAsset(release, platform);
if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [],
baseUrl: metadataUrl.toString(),
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
publicKey: options.publicKey,
requireSignature: options.requireSignature,
});
asset = integrity.asset;
if (options.requireSignature && !integrity.signatureVerified) throw new Error("更新发布签名校验失败");
if (options.version && compareSemver(options.version, release.version) !== 0) throw new Error("更新版本与发布信息不一致");
return { release, asset: { ...asset, name: sanitizeAssetName(asset.name) }, version: release.version, releaseUrl: metadataUrl.toString() };
}
if (!options.assetUrl || !options.version) throw new Error("必须提供 metadata URL,或同时提供更新文件地址和版本号");
const assetUrl = validateHttpsUrl(options.assetUrl, options);
parseSemver(options.version);
return { asset: { name: sanitizeAssetName(options.assetName ?? path.basename(assetUrl.pathname)), url: assetUrl.toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }, version: options.version };
}
async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
const resolved = path.resolve(directory);
await mkdir(resolved, { recursive: true, mode: 0o700 });
const info = await lstat(resolved).catch(() => null);
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0 || uid !== 0) {
throw new Error("更新工作目录必须是 root 拥有且权限为 0700");
}
return resolved;
}
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
const platform = options.platform ?? detectPlatform();
const jobId = options.jobId ?? randomUUID();
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
try {
resolved = await resolveRelease(options, platform);
const suppliedSha256 = normalizedSha256(options.expectedSha256);
const expectedSha256 = normalizedSha256(options.requireSignature && options.metadataUrl ? resolved.asset.sha256 : suppliedSha256 ?? resolved.asset.sha256);
if (options.requireSignature && options.metadataUrl && suppliedSha256 && suppliedSha256 !== expectedSha256) throw new Error("更新校验值与发布信息不一致");
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
writeJob(options.sqlite, jobId, {
status: "queued", version: resolved.version, platform: platform.target,
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
requestId: options.requestId, requestedAt: Date.now(),
});
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
const workspace = await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
try {
updateJob(options.sqlite, jobId, { status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
updateJob(options.sqlite, jobId, { status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
updateJob(options.sqlite, jobId, { status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath });
let backupArchivePath: string | undefined;
if (options.dataBackupArchivePath && options.dataBackupSource) {
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: options.dataBackupArchivePath });
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, {
maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024,
});
}
if (options.backupArchivePath) {
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
const backupSource = await realpath(options.currentDir).catch(() => options.currentDir);
await createSafeArchive(backupSource, options.backupArchivePath, {
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
});
backupArchivePath = options.backupArchivePath;
}
updateJob(options.sqlite, jobId, { status: "applying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
const switchedBackup = options.releasesDir && options.currentLink
? (await atomicSwitchRelease(stagedDir, options.currentLink, options.releasesDir, resolved.version)).previousTarget
: await atomicSwitchDirectory(stagedDir, options.currentDir, options.backupDir);
const completedAt = Date.now();
updateJob(options.sqlite, jobId, { status: options.deferCompletion ? "applying" : "completed", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: switchedBackup ?? backupArchivePath, ...(options.deferCompletion ? {} : { completedAt }) });
return { jobId, version: resolved.version, asset: resolved.asset, archivePath, ...(backupArchivePath ? { backupArchivePath } : {}), ...(switchedBackup ? { backupDir: switchedBackup } : {}) };
} finally {
await rm(workspace, { recursive: true, force: true });
clearTransientJobPath(options.sqlite, jobId);
}
} catch (error) {
const fallbackVersion = resolved?.version ?? options.version ?? "0.0.0";
const fallbackAsset = resolved?.asset ?? { name: options.assetName ?? "unknown", url: options.assetUrl ?? "https://invalid.invalid/unknown" };
updateJob(options.sqlite, jobId, { status: "failed", version: fallbackVersion, platform: platform.target, releaseUrl: resolved?.releaseUrl, assetName: fallbackAsset.name, assetUrl: fallbackAsset.url, expectedSha256: options.expectedSha256 ?? fallbackAsset.sha256, errorMessage: safeErrorMessage(error) });
throw new Error(safeErrorMessage(error));
}
}
export function finalizeUpdateJob(
sqlite: Database.Database,
jobId: string,
status: "completed" | "failed",
message?: string,
): void {
const row = sqlite.prepare(`
SELECT id, status, version, platform, admin_id AS adminId,
request_id AS requestId, session_hash AS sessionHash
FROM update_jobs WHERE id=?
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
if (!row) throw new Error("更新任务不存在");
if (row.status !== "applying" && row.status !== "completed" && row.status !== "failed") throw new Error("更新任务状态不允许完成");
const now = Date.now();
const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null;
sqlite.transaction(() => {
sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=?").run(status, safeFailureMessage, now, now, jobId);
writeAudit(sqlite, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: status === "completed" ? "update.completed" : "update.failed",
targetType: "update",
targetId: jobId,
outcome: status === "completed" ? "success" : "failure",
after: { status, version: row.version, platform: row.platform, ...(status === "failed" ? { reason: "health_check_failed" } : {}) },
});
})();
}
function arg(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
export async function main(config: AppConfig = loadConfig()): Promise<void> {
const finalizeJobId = arg("--finalize-job");
if (finalizeJobId) {
const finalStatus = arg("--finalize-status");
if (finalStatus !== "completed" && finalStatus !== "failed") throw new Error("更新完成状态无效");
prepareDataDirectories(config);
const database = openDatabase(config);
try {
finalizeUpdateJob(database.sqlite, finalizeJobId, finalStatus, arg("--message"));
} finally {
database.sqlite.close();
}
return;
}
const requestPath = arg("--request-file");
const metadataUrl = arg("--metadata-url");
const assetUrl = arg("--asset-url");
const version = arg("--version");
let request: UpdateRequestFile | undefined;
if (requestPath) {
if (path.resolve(requestPath) !== path.resolve(config.updateRequestPath)) throw new Error("更新请求文件路径无效");
try {
const requestInfo = await lstat(requestPath);
if (!requestInfo.isFile() || requestInfo.isSymbolicLink() || (requestInfo.mode & 0o077) !== 0) throw new Error("权限");
request = validateUpdateRequest(JSON.parse(await readFile(requestPath, "utf8")), config);
} catch { throw new Error("更新请求文件无效"); }
}
const effectiveMetadataUrl = request ? config.updateMetadataUrl : metadataUrl;
const effectiveAssetUrl = request ? undefined : assetUrl;
const effectiveVersion = request?.version ?? version;
const deferCompletion = request ? process.argv.includes("--defer-completion") : false;
const currentDir = request?.currentLink ?? arg("--current-dir") ?? config.projectRoot;
const stagingDir = arg("--staging-dir") ?? (request ? config.updateWorkspaceDir : config.stagingDir);
const backupArchive = arg("--backup-archive") ?? (request ? path.join(config.dataDir, "backups", `update-${request.jobId}.tar.gz`) : undefined);
const dataBackupArchive = arg("--data-backup") ?? (request ? path.join(path.dirname(config.dataDir), "tallynote-backups", `data-${request.jobId}.tar.gz`) : undefined);
const allowedHosts = process.argv.flatMap((value, index) => value === "--allow-host" && process.argv[index + 1] ? [process.argv[index + 1]!] : []);
prepareDataDirectories(config);
if (request) await ensurePrivilegedWorkspace(stagingDir);
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
const release = acquireInstanceLock(config);
const database = openDatabase(config);
try {
const result = await runUpdate({
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
...(effectiveVersion ? { version: effectiveVersion } : {}),
...((request ? undefined : arg("--sha256")) ? { expectedSha256: arg("--sha256") } : {}),
currentDir,
stagingDir,
...(request ? { currentLink: request.currentLink, releasesDir: request.releasesDir } : {}),
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive, dataBackupSource: config.dataDir } : {}),
...((arg("--backup-dir")) ? { backupDir: arg("--backup-dir") } : {}),
allowedHosts: allowedHosts.length ? allowedHosts : config.updateAllowedHosts,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
currentVersion: config.appVersion,
...(deferCompletion ? { deferCompletion: true } : {}),
...(request ? { jobId: request.jobId } : {}),
publicKey: config.updatePublicKey,
requireSignature: request ? true : config.updateRequireSignature,
sqlite: database.sqlite,
});
console.log(`更新完成:${result.version}`);
} finally {
database.sqlite.close();
release();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(safeErrorMessage(error));
process.exitCode = 1;
});
}
+252
View File
@@ -0,0 +1,252 @@
import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, writeSync } from "node:fs";
import path from "node:path";
function integerEnv(name: string, fallback: number, minimum = 1): number {
const raw = process.env[name];
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} 必须是大于等于 ${minimum} 的整数`);
return value;
}
function nonNegativeIntegerEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} 必须是大于等于 0 的整数`);
return value;
}
function booleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined) return fallback;
if (raw === "true") return true;
if (raw === "false") return false;
throw new Error(`${name} 必须是 true 或 false`);
}
function trustProxyEnv(): boolean | number {
const raw = process.env.TALLYNOTE_TRUST_PROXY;
if (raw === undefined || raw === "false") return false;
if (raw === "true") return true;
if (/^[0-9]+$/.test(raw)) {
const hops = Number(raw);
if (Number.isSafeInteger(hops) && hops >= 0 && hops <= 10) return hops;
}
throw new Error("TALLYNOTE_TRUST_PROXY 必须是 false、true 或 0-10 的代理跳数");
}
function csvEnv(name: string): string[] {
return (process.env[name] ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function updatePublicKeyEnv(): string | undefined {
const inline = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY?.trim();
const file = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY_FILE?.trim();
if (inline && file) throw new Error("TALLYNOTE_UPDATE_PUBLIC_KEY 与 TALLYNOTE_UPDATE_PUBLIC_KEY_FILE 只能配置一个");
if (file) {
try {
const info = lstatSync(file);
if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 || (info.mode & 0o022) !== 0) throw new Error("更新公钥文件无效");
return readFileSync(file, "utf8").trim();
} catch (error) {
if (error instanceof Error && error.message === "更新公钥文件无效") throw error;
throw new Error("更新公钥文件不可读取");
}
}
return inline || undefined;
}
export type AppConfig = ReturnType<typeof loadConfig>;
export function loadConfig() {
const projectRoot = path.resolve(process.cwd());
const dataDir = path.resolve(process.env.TALLYNOTE_DATA_DIR ?? path.join(projectRoot, "data"));
const updateStrategyRaw = process.env.TALLYNOTE_UPDATE_STRATEGY?.trim().toLowerCase() || "disabled";
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}`;
let parsedOrigin: URL;
try {
parsedOrigin = new URL(publicOrigin);
} catch {
throw new Error("TALLYNOTE_PUBLIC_ORIGIN 必须是有效的 HTTP(S) 地址");
}
if (!["http:", "https:"].includes(parsedOrigin.protocol) || parsedOrigin.username || parsedOrigin.password || parsedOrigin.search || parsedOrigin.hash || (parsedOrigin.pathname !== "/" && parsedOrigin.pathname !== "")) {
throw new Error("TALLYNOTE_PUBLIC_ORIGIN 必须是没有路径或凭据的 HTTP(S) 地址");
}
const timezone = process.env.TALLYNOTE_TIMEZONE ?? "Asia/Shanghai";
try {
new Intl.DateTimeFormat("zh-CN", { timeZone: timezone }).format();
} catch {
throw new Error(`无效时区:${timezone}`);
}
const isProduction = process.env.NODE_ENV === "production" || process.env.TALLYNOTE_ENV === "production";
const cookieSecure = booleanEnv("TALLYNOTE_COOKIE_SECURE", parsedOrigin.protocol === "https:");
const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase();
const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost);
const appVersion = (() => {
try {
const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")) as { version?: unknown };
return typeof packageJson.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(packageJson.version) ? packageJson.version : "0.0.0";
} catch {
return "0.0.0";
}
})();
// The default points at the project's public Gitea repository. Operators
// can override it for a fork or an internal release feed.
const updateMetadataUrl = process.env.TALLYNOTE_UPDATE_METADATA_URL?.trim()
|| "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest";
const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS");
const updatePublicKey = updatePublicKeyEnv();
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", updateStrategyRaw === "systemd");
if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) {
throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd");
}
if (updateStrategyRaw === "systemd" && updateAllowedHosts.length === 0) {
throw new Error("systemd 一键更新必须配置 TALLYNOTE_UPDATE_ALLOWED_HOSTS");
}
const config = {
projectRoot,
host,
port,
publicOrigin: parsedOrigin.origin,
timezone,
trustProxy: trustProxyEnv(),
cookieSecure,
appVersion,
updateMetadataUrl,
updateAllowedHosts,
updatePublicKey,
updateRequireSignature,
updateStrategy: updateStrategyRaw as "disabled" | "systemd",
updateHelperPath: process.env.TALLYNOTE_UPDATE_HELPER_PATH?.trim() || path.join(projectRoot, "dist", "server", "cli", "update.js"),
updateRequestPath: path.join(dataDir, "update-request.json"),
installPrefix,
currentLink: path.join(installPrefix, "current"),
releasesDir: path.join(installPrefix, "releases"),
// The privileged updater must never create its root-owned workspace below
// the application-owned data tree. The installer provisions this directory
// as 0700 root:root; development/test callers may override --staging-dir.
updateWorkspaceDir: path.join(installPrefix, ".update-work"),
updateMaxBytes: integerEnv("TALLYNOTE_UPDATE_MAX_MB", 512) * 1024 * 1024,
// Update checks hit an external release endpoint. Keep a short local
// cooldown so an authenticated account cannot turn the endpoint into an
// outbound request flood; set to 0 only for controlled test environments.
updateCheckCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS", 60) * 1000,
updateApplyCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS", 15) * 1000,
isLocalOrigin: localOrigin,
dataDir,
dbPath: path.join(dataDir, "tallynote.db"),
filesDir: path.join(dataDir, "files"),
stagingDir: path.join(dataDir, "staging"),
exportsDir: path.join(dataDir, "exports"),
migrationsDir: path.join(projectRoot, "migrations"),
webDir: path.join(projectRoot, "dist", "web"),
maxFileBytes: integerEnv("TALLYNOTE_MAX_FILE_MB", 20) * 1024 * 1024,
maxFilesPerRequest: integerEnv("TALLYNOTE_MAX_FILES_PER_REQUEST", 20),
maxRecordBytes: integerEnv("TALLYNOTE_MAX_RECORD_MB", 100) * 1024 * 1024,
maxTotalBytes: integerEnv("TALLYNOTE_MAX_TOTAL_MB", 2048) * 1024 * 1024,
maxConcurrentExports: integerEnv("TALLYNOTE_MAX_CONCURRENT_EXPORTS", 2),
maxExportRecords: integerEnv("TALLYNOTE_MAX_EXPORT_RECORDS", 5000),
maxExportBytes: integerEnv("TALLYNOTE_MAX_EXPORT_MB", 1024) * 1024 * 1024,
maxExportStorageBytes: integerEnv("TALLYNOTE_MAX_EXPORT_STORAGE_MB", 2048) * 1024 * 1024,
sessionIdleMs: integerEnv("TALLYNOTE_SESSION_IDLE_HOURS", 24) * 60 * 60 * 1000,
sessionAbsoluteMs: integerEnv("TALLYNOTE_SESSION_ABSOLUTE_HOURS", 168) * 60 * 60 * 1000,
exportTtlMs: integerEnv("TALLYNOTE_EXPORT_TTL_MINUTES", 15) * 60 * 1000,
isProduction,
};
if (!localOrigin && (parsedOrigin.protocol !== "https:" || !cookieSecure)) {
throw new Error("公网部署必须使用 HTTPS 并启用安全 Cookie");
}
if (parsedOrigin.protocol === "https:" && !cookieSecure) {
throw new Error("HTTPS public origin 不能关闭安全 Cookie");
}
if (config.isProduction && config.trustProxy === true) {
throw new Error("生产环境不能使用 TALLYNOTE_TRUST_PROXY=true,请填写明确的代理跳数(例如 1)");
}
return config;
}
function secureDirectory(directory: string): void {
const info = lstatSync(directory);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`数据目录不能是符号链接:${directory}`);
chmodSync(directory, 0o700);
}
function secureFile(filePath: string): void {
if (!existsSync(filePath)) return;
const info = lstatSync(filePath);
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`数据文件不能是符号链接:${filePath}`);
chmodSync(filePath, 0o600);
}
export function prepareDataDirectories(config: AppConfig): void {
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
secureDirectory(config.dataDir);
for (const directory of [config.filesDir, config.stagingDir, config.exportsDir]) {
mkdirSync(directory, { recursive: true, mode: 0o700 });
secureDirectory(directory);
}
for (const filePath of [config.dbPath, `${config.dbPath}-wal`, `${config.dbPath}-shm`, config.updateRequestPath]) secureFile(filePath);
const rootDevice = statSync(realpathSync(config.dataDir)).dev;
for (const directory of [config.filesDir, config.stagingDir, config.exportsDir]) {
if (statSync(realpathSync(directory)).dev !== rootDevice) {
throw new Error("数据库、附件、暂存区和导出目录必须位于同一文件系统");
}
}
}
export function acquireInstanceLock(config: AppConfig): () => void {
const lockPath = path.join(config.dataDir, ".instance.lock");
const owner = JSON.stringify({ pid: process.pid, createdAt: Date.now() });
let fd: number;
try {
fd = openSync(lockPath, "wx", 0o600);
writeSync(fd, owner);
fsyncSync(fd);
closeSync(fd);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
let ownerPid: number | undefined;
try {
ownerPid = (JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: number }).pid;
} catch {
throw new Error("检测到另一个 TallyNote 进程正在初始化数据目录");
}
if (ownerPid && ownerPid !== process.pid) {
try {
process.kill(ownerPid, 0);
throw new Error("检测到另一个 TallyNote 进程正在使用该数据目录");
} catch (probeError) {
if ((probeError as NodeJS.ErrnoException).code !== "ESRCH") throw probeError;
}
}
try {
unlinkSync(lockPath);
} catch (unlinkError) {
throw new Error(`无法接管数据目录锁:${String(unlinkError)}`);
}
fd = openSync(lockPath, "wx", 0o600);
writeSync(fd, owner);
fsyncSync(fd);
closeSync(fd);
}
let released = false;
return () => {
if (released) return;
released = true;
try {
const current = JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: number };
if (current.pid === process.pid) unlinkSync(lockPath);
} catch {
// A stale lock is recovered on next startup.
}
};
}
+46
View File
@@ -0,0 +1,46 @@
import Database from "better-sqlite3";
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { readdirSync, readFileSync } from "node:fs";
import { chmodSync, existsSync } from "node:fs";
import path from "node:path";
import type { AppConfig } from "../config.js";
import * as schema from "./schema.js";
export type DatabaseContext = {
sqlite: Database.Database;
db: BetterSQLite3Database<typeof schema>;
};
function migrate(sqlite: Database.Database, migrationsDir: string): void {
sqlite.exec("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT");
const applied = new Set(
(sqlite.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((row) => row.name),
);
const files = readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")).sort();
const apply = sqlite.transaction((name: string, sqlText: string) => {
sqlite.exec(sqlText);
sqlite.prepare("INSERT INTO schema_migrations(name, applied_at) VALUES (?, ?)").run(name, Date.now());
});
for (const name of files) {
if (!applied.has(name)) apply(name, readFileSync(path.join(migrationsDir, name), "utf8"));
}
}
export function openDatabase(config: AppConfig): DatabaseContext {
const sqlite = new Database(config.dbPath);
sqlite.pragma("foreign_keys = ON");
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("synchronous = FULL");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("temp_store = MEMORY");
migrate(sqlite, config.migrationsDir);
// SQLite creates the database and journal files after the initial directory
// preparation. Enforce private permissions again after opening so a broad
// process umask can never expose financial data to other local users.
for (const filePath of [config.dbPath, `${config.dbPath}-wal`, `${config.dbPath}-shm`]) {
if (existsSync(filePath)) chmodSync(filePath, 0o600);
}
const foreignKeys = sqlite.pragma("foreign_keys", { simple: true });
if (foreignKeys !== 1) throw new Error("SQLite 外键未启用");
return { sqlite, db: drizzle(sqlite, { schema }) };
}
+160
View File
@@ -0,0 +1,160 @@
import { sql } from "drizzle-orm";
import { blob, check, index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const admins = sqliteTable("admins", {
id: text("id").primaryKey(),
username: text("username").notNull(),
usernameNorm: text("username_norm").notNull(),
displayName: text("display_name").notNull(),
passwordHash: text("password_hash").notNull(),
status: text("status", { enum: ["active", "disabled"] }).notNull().default("active"),
mustChangePassword: integer("must_change_password", { mode: "boolean" }).notNull().default(true),
authVersion: integer("auth_version").notNull().default(1),
version: integer("version").notNull().default(1),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by"),
passwordChangedAt: integer("password_changed_at"),
lastLoginAt: integer("last_login_at"),
disabledAt: integer("disabled_at"),
disabledBy: text("disabled_by"),
}, (table) => [
uniqueIndex("admins_username_norm_uq").on(table.usernameNorm),
check("admins_status_ck", sql`${table.status} in ('active','disabled')`),
check("admins_versions_ck", sql`${table.version} >= 1 and ${table.authVersion} >= 1`),
]);
export const sessions = sqliteTable("sessions", {
tokenHash: text("token_hash").primaryKey(),
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
csrfHash: text("csrf_hash").notNull(),
authVersion: integer("auth_version").notNull(),
createdAt: integer("created_at").notNull(),
lastSeenAt: integer("last_seen_at").notNull(),
idleExpiresAt: integer("idle_expires_at").notNull(),
absoluteExpiresAt: integer("absolute_expires_at").notNull(),
}, (table) => [index("sessions_admin_idx").on(table.adminId), index("sessions_expiry_idx").on(table.idleExpiresAt)]);
export const expenses = sqliteTable("expenses", {
id: text("id").primaryKey(),
paidAt: integer("paid_at").notNull(),
amountCents: integer("amount_cents").notNull(),
note: text("note").notNull().default(""),
invoiceMissingReason: text("invoice_missing_reason"),
status: text("status", { enum: ["unreimbursed", "reimbursed"] }).notNull().default("unreimbursed"),
version: integer("version").notNull().default(1),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
updatedAt: integer("updated_at").notNull(),
updatedBy: text("updated_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
reimbursedAt: integer("reimbursed_at"),
reimbursedBy: text("reimbursed_by").references(() => admins.id, { onDelete: "restrict" }),
deletedAt: integer("deleted_at"),
deletedBy: text("deleted_by").references(() => admins.id, { onDelete: "restrict" }),
}, (table) => [
index("expenses_list_idx").on(table.deletedAt, table.status, table.paidAt),
check("expenses_amount_ck", sql`${table.amountCents} > 0 and ${table.amountCents} <= 999999999999`),
check("expenses_status_ck", sql`${table.status} in ('unreimbursed','reimbursed')`),
check("expenses_version_ck", sql`${table.version} >= 1`),
]);
export const attachments = sqliteTable("attachments", {
id: text("id").primaryKey(),
expenseId: text("expense_id").notNull().references(() => expenses.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["payment_proof", "invoice"] }).notNull(),
storagePath: text("storage_path").notNull(),
originalName: text("original_name").notNull(),
mimeType: text("mime_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
sha256: text("sha256").notNull(),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
}, (table) => [
uniqueIndex("attachments_path_uq").on(table.storagePath),
index("attachments_expense_idx").on(table.expenseId),
check("attachments_kind_ck", sql`${table.kind} in ('payment_proof','invoice')`),
check("attachments_size_ck", sql`${table.sizeBytes} > 0`),
]);
export const auditEvents = sqliteTable("audit_events", {
id: integer("id").primaryKey({ autoIncrement: true }),
occurredAt: integer("occurred_at").notNull(),
requestId: text("request_id").notNull(),
actorAdminId: text("actor_admin_id"),
actorUsername: text("actor_username"),
action: text("action").notNull(),
targetType: text("target_type").notNull(),
targetId: text("target_id"),
outcome: text("outcome", { enum: ["success", "denied", "failure"] }).notNull(),
beforeJson: text("before_json"),
afterJson: text("after_json"),
metadataJson: text("metadata_json"),
}, (table) => [index("audit_time_idx").on(table.occurredAt), index("audit_target_idx").on(table.targetType, table.targetId)]);
export const systemSettings = sqliteTable("system_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const exportJobs = sqliteTable("export_jobs", {
id: text("id").primaryKey(),
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
sessionHash: text("session_hash").notNull(),
status: text("status", { enum: ["queued", "building", "ready", "failed", "expired"] }).notNull(),
selectionJson: text("selection_json").notNull(),
snapshotJson: text("snapshot_json").notNull(),
filePath: text("file_path"),
fileName: text("file_name").notNull(),
sizeBytes: integer("size_bytes"),
sha256: text("sha256"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
readyAt: integer("ready_at"),
expiresAt: integer("expires_at").notNull(),
}, (table) => [index("exports_expiry_idx").on(table.expiresAt), index("exports_session_idx").on(table.sessionHash)]);
export const loginAttempts = sqliteTable("login_attempts", {
keyHash: text("key_hash").primaryKey(),
windowStart: integer("window_start").notNull(),
failures: integer("failures").notNull(),
blockedUntil: integer("blocked_until"),
});
export const fileDeletions = sqliteTable("file_deletions", {
id: text("id").primaryKey(),
storagePath: text("storage_path").notNull(),
reason: text("reason").notNull(),
status: text("status", { enum: ["pending", "complete", "failed"] }).notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
lastError: text("last_error"),
createdAt: integer("created_at").notNull(),
completedAt: integer("completed_at"),
}, (table) => [index("file_deletions_status_idx").on(table.status)]);
export const updateJobs = sqliteTable("update_jobs", {
id: text("id").primaryKey(),
adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }),
sessionHash: text("session_hash"),
requestId: text("request_id"),
status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(),
version: text("version").notNull(),
platform: text("platform").notNull(),
releaseUrl: text("release_url"),
assetName: text("asset_name"),
assetUrl: text("asset_url").notNull(),
expectedSha256: text("expected_sha256"),
actualSha256: text("actual_sha256"),
downloadPath: text("download_path"),
backupPath: text("backup_path"),
sizeBytes: integer("size_bytes"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
requestedAt: integer("requested_at"),
startedAt: integer("started_at"),
updatedAt: integer("updated_at").notNull(),
completedAt: integer("completed_at"),
}, (table) => [
index("update_jobs_status_idx").on(table.status, table.createdAt),
index("update_jobs_admin_idx").on(table.adminId, table.createdAt),
index("update_jobs_session_idx").on(table.sessionHash),
]);
+27
View File
@@ -0,0 +1,27 @@
import type { FastifyRequest } from "fastify";
export class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message);
}
}
export function errorPayload(request: FastifyRequest, error: AppError) {
return {
error: {
code: error.code,
message: error.message,
requestId: request.id,
...(error.details === undefined ? {} : { details: error.details }),
},
};
}
export function notFound(message = "没有找到对应内容"): never {
throw new AppError(404, "NOT_FOUND", message);
}
+276
View File
@@ -0,0 +1,276 @@
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, createWriteStream } from "node:fs";
import { open, readdir, rename, rm, stat, unlink } from "node:fs/promises";
import path from "node:path";
import { ZipArchive } from "archiver";
import type Database from "better-sqlite3";
import ExcelJS from "exceljs";
import type { AppConfig } from "./config.js";
import { readStorageFile, safeStoragePath, sanitizeOriginalName } from "./files.js";
export type ExportAttachment = {
id: string;
kind: "payment_proof" | "invoice";
originalName: string;
mimeType: string;
storagePath: string;
sizeBytes: number;
sha256: string;
};
export type ExportExpense = {
id: string;
paidAt: number;
amountCents: number;
note: string;
invoiceMissingReason: string | null;
status: "unreimbursed" | "reimbursed";
attachments: ExportAttachment[];
};
export type ExportSnapshot = { expenses: ExportExpense[]; includeManifest?: boolean };
// The application is intentionally single-instance, but a queued export can
// still be triggered twice by a retry or two browser tabs. Keep one builder
// per job so both calls cannot write the same .part file concurrently.
const activeExportBuilds = new Set<string>();
export function safeExcelText(value: string): string {
const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").slice(0, 32_767);
return /^[\s\u0000-\u001f]*[=+\-@]/.test(cleaned) ? `'${cleaned}` : cleaned;
}
function dateParts(timestamp: number, timezone: string): { display: string; compact: string } {
const formatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const pieces = Object.fromEntries(formatter.formatToParts(timestamp).map((part) => [part.type, part.value]));
return {
display: `${pieces.year}-${pieces.month}-${pieces.day} ${pieces.hour}:${pieces.minute}`,
compact: `${pieces.year}${pieces.month}${pieces.day}`,
};
}
function uniqueAttachmentName(attachment: ExportAttachment, seen: Set<string>): string {
const parsed = path.parse(sanitizeOriginalName(attachment.originalName));
const fallbackExtension = path.extname(attachment.storagePath);
const extension = (parsed.ext || fallbackExtension).slice(0, 16);
const base = (parsed.name || attachment.kind).slice(0, 100);
if ([base, extension].some((part) => part.includes("/") || part.includes("\\") || part === "." || part === "..")) {
throw new Error("附件文件名包含非法路径片段");
}
let candidate = `${base}${extension}`;
let counter = 2;
while (seen.has(candidate.toLocaleLowerCase("und"))) candidate = `${base}_${counter++}${extension}`;
seen.add(candidate.toLocaleLowerCase("und"));
return candidate;
}
async function workbookBuffer(snapshot: ExportSnapshot, config: AppConfig): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = "TallyNote";
workbook.created = new Date();
const sheet = workbook.addWorksheet("报销清单", { views: [{ state: "frozen", ySplit: 1 }] });
sheet.columns = [
{ header: "序号", key: "sequence", width: 8 },
{ header: "支付时间", key: "paidAt", width: 22 },
{ header: "金额(元)", key: "amount", width: 16 },
{ header: "备注", key: "note", width: 44 },
{ header: "状态", key: "status", width: 14 },
{ header: "记录 ID", key: "id", width: 38 },
{ header: "付款凭证", key: "proofs", width: 38 },
{ header: "发票", key: "invoices", width: 38 },
{ header: "无发票原因", key: "invoiceMissingReason", width: 44 },
];
sheet.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
sheet.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4D43" } };
sheet.getRow(1).height = 24;
snapshot.expenses.forEach((expense, index) => {
const row = sheet.addRow({
sequence: index + 1,
paidAt: dateParts(expense.paidAt, config.timezone).display,
amount: expense.amountCents / 100,
note: safeExcelText(expense.note),
status: expense.status === "reimbursed" ? "已报销" : "未报销",
id: expense.id,
proofs: safeExcelText(expense.attachments.filter((item) => item.kind === "payment_proof").map((item) => item.originalName).join(";")),
invoices: safeExcelText(expense.attachments.filter((item) => item.kind === "invoice").map((item) => item.originalName).join(";")),
invoiceMissingReason: safeExcelText(expense.invoiceMissingReason || ""),
});
row.getCell("amount").numFmt = '¥#,##0.00';
row.alignment = { vertical: "top", wrapText: true };
});
const totalRow = sheet.addRow({
sequence: "合计",
amount: snapshot.expenses.reduce((sum, expense) => sum + expense.amountCents, 0) / 100,
});
totalRow.font = { bold: true };
totalRow.getCell("amount").numFmt = '¥#,##0.00';
sheet.autoFilter = { from: "A1", to: "I1" };
return Buffer.from(await workbook.xlsx.writeBuffer());
}
export async function buildExportJob(sqlite: Database.Database, config: AppConfig, jobId: string): Promise<void> {
if (activeExportBuilds.has(jobId)) return;
activeExportBuilds.add(jobId);
try {
await buildExportJobOnce(sqlite, config, jobId);
} finally {
activeExportBuilds.delete(jobId);
}
}
async function buildExportJobOnce(sqlite: Database.Database, config: AppConfig, jobId: string): Promise<void> {
const job = sqlite.prepare("SELECT snapshot_json AS snapshotJson FROM export_jobs WHERE id=? AND status IN ('queued','building')").get(jobId) as { snapshotJson: string } | undefined;
if (!job) return;
sqlite.prepare("UPDATE export_jobs SET status='building', error_message=NULL WHERE id=?").run(jobId);
// Use a fresh O_EXCL path for every build. A deterministic `.part` path can
// be pre-created as a symlink by another local process and then followed by
// createWriteStream. The final rename remains atomic and replaces only the
// destination entry itself.
const partialPath = path.join(config.exportsDir, `${jobId}.zip.part-${randomUUID()}`);
const finalPath = path.join(config.exportsDir, `${jobId}.zip`);
try {
const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot;
const output = createWriteStream(partialPath, { flags: "wx", mode: 0o600 });
const archive = new ZipArchive({ zlib: { level: 6 } });
const completed = new Promise<void>((resolve, reject) => {
output.on("close", resolve);
output.on("error", reject);
archive.on("warning", reject);
archive.on("error", reject);
});
archive.pipe(output);
archive.append(await workbookBuffer(snapshot, config), { name: "报销清单.xlsx" });
if (snapshot.includeManifest === true) {
const manifest = {
generatedAt: new Date().toISOString(),
records: snapshot.expenses.map((expense) => ({
id: expense.id,
paidAt: dateParts(expense.paidAt, config.timezone).display,
amountCents: expense.amountCents,
invoiceMissingReason: expense.invoiceMissingReason || null,
attachments: expense.attachments.map((attachment) => ({
id: attachment.id,
kind: attachment.kind,
originalName: attachment.originalName,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
sha256: attachment.sha256,
})),
})),
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
}
for (const [index, expense] of snapshot.expenses.entries()) {
const date = dateParts(expense.paidAt, config.timezone).compact;
const folder = `${String(index + 1).padStart(3, "0")}_${date}_${(expense.amountCents / 100).toFixed(2)}_${expense.id.slice(0, 8)}`;
const seen = new Set<string>();
for (const attachment of expense.attachments) {
const group = attachment.kind === "payment_proof" ? "付款凭证" : "发票";
const fileName = uniqueAttachmentName(attachment, seen);
const absolute = safeStoragePath(config.filesDir, attachment.storagePath);
const bytes = await readStorageFile(config, attachment.storagePath);
const digest = createHash("sha256").update(bytes).digest("hex");
if (bytes.length !== attachment.sizeBytes || digest !== attachment.sha256) {
throw new Error(`附件校验失败:${attachment.id}`);
}
archive.append(bytes, { name: `${folder}/${group}/${fileName}` });
}
}
await archive.finalize();
await completed;
await rename(partialPath, finalPath);
// Open without following symlinks and keep the descriptor for the digest
// and size read. This closes the check/use gap around the published file.
const handle = await open(finalPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
let bytes: Buffer;
let info;
try {
info = await handle.stat();
if (!info.isFile()) throw new Error("导出文件类型无效");
bytes = await handle.readFile();
} finally {
await handle.close();
}
const published = sqlite.prepare(`
UPDATE export_jobs SET status='ready', file_path=?, size_bytes=?, sha256=?, ready_at=?
WHERE id=? AND status='building'
`).run(path.basename(finalPath), info.size, createHash("sha256").update(bytes).digest("hex"), Date.now(), jobId);
if (published.changes !== 1) await rm(finalPath, { force: true });
} catch (error) {
await rm(partialPath, { force: true });
await rm(finalPath, { force: true });
// Never expose filesystem paths, attachment IDs, or raw OS errors through
// the export status API. Keep a small allowlist of actionable messages.
const raw = error instanceof Error ? error.message : "";
const safe = raw.startsWith("附件校验失败") || raw.includes("ENOENT")
? "导出失败:附件文件缺失或校验不通过"
: "导出失败:服务器无法生成导出文件";
sqlite.prepare("UPDATE export_jobs SET status='failed', error_message=? WHERE id=? AND status='building'").run(safe, jobId);
}
}
export function insertExportJob(
sqlite: Database.Database,
config: AppConfig,
input: { adminId: string; sessionHash: string; selection: unknown; snapshot: ExportSnapshot },
): string {
const id = randomUUID();
const now = Date.now();
sqlite.prepare(`
INSERT INTO export_jobs (
id, admin_id, session_hash, status, selection_json, snapshot_json,
file_name, created_at, expires_at
) VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?)
`).run(
id,
input.adminId,
input.sessionHash,
JSON.stringify(input.selection),
JSON.stringify(input.snapshot),
`TallyNote_报销资料_${id.slice(0, 8)}.zip`,
now,
now + config.exportTtlMs,
);
return id;
}
export async function resumeExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const jobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>;
for (const job of jobs) await buildExportJob(sqlite, config, job.id);
}
export async function expireExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const rows = sqlite.prepare("SELECT id, file_path AS filePath FROM export_jobs WHERE status != 'expired' AND expires_at <= ?").all(Date.now()) as Array<{ id: string; filePath: string | null }>;
for (const row of rows) {
if (row.filePath) {
await unlink(safeStoragePath(config.exportsDir, row.filePath)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
sqlite.prepare("UPDATE export_jobs SET status='expired', file_path=NULL WHERE id=?").run(row.id);
}
}
export async function cleanupOrphanedExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const referenced = new Set((sqlite.prepare("SELECT file_path AS filePath FROM export_jobs WHERE status='ready' AND file_path IS NOT NULL AND expires_at > ?").all(Date.now()) as Array<{ filePath: string }>).map((row) => row.filePath));
const activeJobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>;
for (const job of activeJobs) {
referenced.add(`${job.id}.zip`);
}
const cutoff = Date.now() - 10 * 60 * 1000;
for (const entry of await readdir(config.exportsDir, { withFileTypes: true })) {
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const target = path.join(config.exportsDir, entry.name);
const info = await stat(target).catch(() => null);
const belongsToActiveBuild = activeJobs.some((job) => entry.name.startsWith(`${job.id}.zip.part-`));
if (info && info.mtimeMs < cutoff && !referenced.has(entry.name) && !belongsToActiveBuild) await rm(target, { force: true });
}
}
+252
View File
@@ -0,0 +1,252 @@
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs";
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
import path from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { MultipartFile } from "@fastify/multipart";
import type Database from "better-sqlite3";
import { XMLParser, XMLValidator } from "fast-xml-parser";
import { PDFDocument } from "pdf-lib";
import sharp from "sharp";
import yauzl from "yauzl";
import type { AttachmentKind } from "../shared/contracts.js";
import type { AppConfig } from "./config.js";
import { AppError } from "./errors.js";
export type StagedFile = {
id: string;
originalName: string;
stagingPath: string;
sizeBytes: number;
sha256: string;
mimeType: string;
extension: string;
kind: AttachmentKind;
};
const imageTypes = new Map([
["jpeg", { mimeType: "image/jpeg", extension: "jpg" }],
["png", { mimeType: "image/png", extension: "png" }],
["webp", { mimeType: "image/webp", extension: "webp" }],
]);
export function sanitizeOriginalName(value: string): string {
const normalized = path.basename(value.normalize("NFKC").replaceAll("\\", "/")).replace(/[\u0000-\u001f\u007f]/g, "").trim();
return (normalized || "未命名文件").slice(0, 200);
}
function detectBasic(buffer: Buffer): "jpeg" | "png" | "webp" | "pdf" | "ofd" | "xml" | null {
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "jpeg";
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "webp";
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") return "pdf";
if (buffer[0] === 0x50 && buffer[1] === 0x4b) return "ofd";
const prefix = buffer.subarray(0, 256).toString("utf8").trimStart();
if (prefix.startsWith("<?xml") || prefix.startsWith("<")) return "xml";
return null;
}
async function validateOfd(buffer: Buffer): Promise<void> {
await new Promise<void>((resolve, reject) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) return reject(error ?? new Error("无法读取 OFD"));
let entries = 0;
let total = 0;
let hasRoot = false;
let settled = false;
const fail = (reason: Error) => {
if (settled) return;
settled = true;
zip.close();
reject(reason);
};
zip.on("entry", (entry) => {
entries += 1;
total += entry.uncompressedSize;
const name = entry.fileName.replaceAll("\\", "/");
if (name === "OFD.xml") hasRoot = true;
if (entries > 2000 || total > 200 * 1024 * 1024 || name.startsWith("/") || name.split("/").includes("..")) {
fail(new Error("OFD 结构超出安全限制"));
return;
}
zip.readEntry();
});
zip.on("end", () => {
if (settled) return;
settled = true;
hasRoot ? resolve() : reject(new Error("缺少 OFD.xml"));
});
zip.on("error", fail);
zip.readEntry();
});
});
}
async function validateContent(buffer: Buffer, kind: AttachmentKind): Promise<{ mimeType: string; extension: string }> {
const detected = detectBasic(buffer);
if (!detected) throw new AppError(415, "UNSUPPORTED_MEDIA_TYPE", "无法识别文件格式");
if (imageTypes.has(detected)) {
const metadata = await sharp(buffer, { failOn: "error", limitInputPixels: 40_000_000 }).metadata();
if (!metadata.width || !metadata.height || !metadata.format || !imageTypes.has(metadata.format)) {
throw new AppError(415, "INVALID_IMAGE", "图片内容无效");
}
return imageTypes.get(metadata.format)!;
}
if (kind === "payment_proof") {
throw new AppError(415, "PAYMENT_PROOF_MUST_BE_IMAGE", "付款凭证仅支持 JPEG、PNG 或 WebP 图片");
}
if (detected === "pdf") {
// Inspect the binary token stream case-insensitively. PDF names are
// case-sensitive in theory, but rejecting common active-content aliases
// avoids browser/plugin execution surprises across viewers.
const pdfTokens = buffer.toString("latin1");
const suspicious = /\/(?:JavaScript|JS|Launch|EmbeddedFile|OpenAction|AA)\b/i.test(pdfTokens);
if (suspicious) throw new AppError(415, "UNSAFE_PDF", "PDF 包含不受支持的活动内容");
const document = await PDFDocument.load(buffer, { ignoreEncryption: false, throwOnInvalidObject: true });
if (document.getPageCount() < 1 || document.getPageCount() > 2000) throw new Error("PDF 页数无效");
return { mimeType: "application/pdf", extension: "pdf" };
}
if (detected === "ofd") {
await validateOfd(buffer);
return { mimeType: "application/ofd", extension: "ofd" };
}
const xml = buffer.toString("utf8");
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new AppError(415, "UNSAFE_XML", "XML 不允许 DTD 或实体声明");
if (XMLValidator.validate(xml) !== true) throw new AppError(415, "INVALID_XML", "XML 内容无效");
new XMLParser({ processEntities: false, ignoreAttributes: false }).parse(xml);
return { mimeType: "application/xml", extension: "xml" };
}
export async function stageMultipartFile(config: AppConfig, part: MultipartFile, kind: AttachmentKind): Promise<StagedFile> {
const id = randomUUID();
const stagingPath = path.join(config.stagingDir, `${id}.part`);
let sizeBytes = 0;
const hash = createHash("sha256");
const meter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
sizeBytes += chunk.length;
if (sizeBytes > config.maxFileBytes) return callback(new AppError(413, "FILE_TOO_LARGE", "单个文件超过大小限制"));
hash.update(chunk);
callback(null, chunk);
},
});
try {
await pipeline(part.file, meter, createWriteStream(stagingPath, { flags: "wx", mode: 0o600 }));
if (part.file.truncated || sizeBytes === 0) throw new AppError(413, "FILE_TOO_LARGE", "文件为空或超过大小限制");
const buffer = await readFile(stagingPath);
const type = await validateContent(buffer, kind);
return {
id,
originalName: sanitizeOriginalName(part.filename),
stagingPath,
sizeBytes,
sha256: hash.digest("hex"),
mimeType: type.mimeType,
extension: type.extension,
kind,
};
} catch (error) {
await rm(stagingPath, { force: true });
if (error instanceof AppError) throw error;
throw new AppError(415, "INVALID_FILE", "文件内容校验失败");
}
}
export async function promoteStagedFile(config: AppConfig, file: StagedFile): Promise<string> {
const relative = path.join(file.id.slice(0, 2), `${file.id}.${file.extension}`);
const destination = safeStoragePath(config.filesDir, relative);
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
await chmod(path.dirname(destination), 0o700);
await rename(file.stagingPath, destination);
const directory = await open(path.dirname(destination), "r");
await directory.sync();
await directory.close();
return relative;
}
export function safeStoragePath(root: string, relative: string): string {
if (path.isAbsolute(relative)) throw new AppError(500, "INVALID_STORAGE_PATH", "附件路径无效");
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(root, relative);
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new AppError(500, "INVALID_STORAGE_PATH", "附件路径无效");
return resolved;
}
export async function discardStaged(files: StagedFile[]): Promise<void> {
await Promise.all(files.map((file) => rm(file.stagingPath, { force: true })));
}
export async function processFileDeletions(sqlite: Database.Database, config: AppConfig): Promise<void> {
const rows = sqlite.prepare(`
SELECT id, storage_path AS storagePath FROM file_deletions
WHERE status IN ('pending','failed') AND attempts < 10 ORDER BY created_at LIMIT 100
`).all() as Array<{ id: string; storagePath: string }>;
for (const row of rows) {
try {
await unlink(safeStoragePath(config.filesDir, row.storagePath)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
sqlite.prepare("UPDATE file_deletions SET status='complete', attempts=attempts+1, last_error=NULL, completed_at=? WHERE id=?").run(Date.now(), row.id);
} catch (error) {
sqlite.prepare("UPDATE file_deletions SET status='failed', attempts=attempts+1, last_error=? WHERE id=?").run(String(error).slice(0, 500), row.id);
}
}
}
export async function cleanupStaging(config: AppConfig): Promise<void> {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const entry of await readdir(config.stagingDir, { withFileTypes: true })) {
const target = path.join(config.stagingDir, entry.name);
const info = await stat(target).catch(() => null);
if (info && info.mtimeMs < cutoff) await rm(target, { recursive: true, force: true });
}
}
export async function cleanupOrphanedFiles(sqlite: Database.Database, config: AppConfig): Promise<void> {
const referenced = new Set((sqlite.prepare("SELECT storage_path AS storagePath FROM attachments").all() as Array<{ storagePath: string }>).map((row) => row.storagePath));
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const walk = async (directory: string, prefix: string): Promise<void> => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const relative = path.join(prefix, entry.name);
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
await walk(target, relative);
continue;
}
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const info = await stat(target).catch(() => null);
if (info && info.mtimeMs < cutoff && !referenced.has(relative)) await rm(target, { force: true });
}
};
await walk(config.filesDir, "");
}
export async function fileReadStream(config: AppConfig, storagePath: string) {
return safeReadStream(config.filesDir, storagePath);
}
/** Open a private file by descriptor and keep the no-follow guarantee through
* the subsequent read. Used for both attachment and export downloads. */
export async function safeReadStream(root: string, relativePath: string) {
const handle = await open(safeStoragePath(root, relativePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
try {
const info = await handle.stat();
if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
return handle.createReadStream({ autoClose: true });
} catch (error) {
await handle.close().catch(() => undefined);
throw error;
}
}
export async function readStorageFile(config: AppConfig, storagePath: string): Promise<Buffer> {
const handle = await open(safeStoragePath(config.filesDir, storagePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
try {
const info = await handle.stat();
if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
return await handle.readFile();
} finally {
await handle.close();
}
}
+54
View File
@@ -0,0 +1,54 @@
import { cleanupOrphanedFiles, cleanupStaging, processFileDeletions } from "./files.js";
import { loadConfig, prepareDataDirectories, acquireInstanceLock } from "./config.js";
import { openDatabase } from "./db/index.js";
import { buildApp } from "./app.js";
import { cleanupOrphanedExports, expireExports, resumeExports } from "./exporter.js";
const config = loadConfig();
prepareDataDirectories(config);
const releaseLock = acquireInstanceLock(config);
const database = openDatabase(config);
async function start() {
await cleanupStaging(config);
await cleanupOrphanedFiles(database.sqlite, config);
database.sqlite.prepare("DELETE FROM login_attempts WHERE window_start < ? AND (blocked_until IS NULL OR blocked_until < ?)").run(Date.now() - 24 * 60 * 60 * 1000, Date.now());
database.sqlite.prepare("DELETE FROM sessions WHERE idle_expires_at <= ? OR absolute_expires_at <= ?").run(Date.now(), Date.now());
await processFileDeletions(database.sqlite, config);
await expireExports(database.sqlite, config);
await cleanupOrphanedExports(database.sqlite, config);
await resumeExports(database.sqlite, config);
const app = await buildApp(database, config);
const janitor = setInterval(() => {
void cleanupStaging(config);
void cleanupOrphanedFiles(database.sqlite, config);
database.sqlite.prepare("DELETE FROM login_attempts WHERE window_start < ? AND (blocked_until IS NULL OR blocked_until < ?)").run(Date.now() - 24 * 60 * 60 * 1000, Date.now());
database.sqlite.prepare("DELETE FROM sessions WHERE idle_expires_at <= ? OR absolute_expires_at <= ?").run(Date.now(), Date.now());
void processFileDeletions(database.sqlite, config);
void expireExports(database.sqlite, config);
void cleanupOrphanedExports(database.sqlite, config);
}, 60_000);
const shutdown = async () => {
clearInterval(janitor);
await app.close().catch(() => undefined);
database.sqlite.close();
releaseLock();
};
process.once("SIGINT", () => void shutdown().finally(() => process.exit(0)));
process.once("SIGTERM", () => void shutdown().finally(() => process.exit(0)));
try {
await app.listen({ host: config.host, port: config.port });
} catch (error) {
clearInterval(janitor);
await app.close().catch(() => undefined);
throw error;
}
app.log.info(`TallyNote running at ${config.publicOrigin}`);
}
start().catch((error) => {
console.error(error);
database.sqlite.close();
releaseLock();
process.exitCode = 1;
});
+52
View File
@@ -0,0 +1,52 @@
import argon2 from "argon2";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
const ARGON_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65_536,
timeCost: 3,
parallelism: 1,
hashLength: 32,
} as const;
export function normalizeUsername(username: string): string {
return username.normalize("NFKC").trim().toLocaleLowerCase("und");
}
export function validateNewPassword(password: string): string | null {
const length = [...password].length;
if (length < 12 || length > 128 || Buffer.byteLength(password, "utf8") > 512) {
return "密码长度需要为 12–128 个字符";
}
return null;
}
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON_OPTIONS);
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
export function randomToken(bytes = 32): string {
return randomBytes(bytes).toString("base64url");
}
export function sha256(value: string | Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
export function constantTimeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
export function temporaryPassword(): string {
return `${randomToken(15)}A7!`;
}
+327
View File
@@ -0,0 +1,327 @@
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3";
import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
detectPlatform,
fetchReleaseBytes,
fetchReleaseMetadata,
fetchReleaseText,
isNewerVersion,
parseSemver,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
type ReleaseAsset,
type ReleaseMetadata,
} from "./update.js";
import type { UpdateJobStatus } from "../shared/contracts.js";
export const UPDATE_CACHE_KEY = "update.release.v1";
export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
"queued",
"downloading",
"verifying",
"staged",
"backing_up",
"applying",
];
export type CachedRelease = {
checkedAt: number;
metadataUrl: string;
version: string;
tagName?: string;
publishedAt?: string;
platform: string;
signatureVerified?: boolean;
asset?: {
name: string;
url: string;
size?: number;
sha256?: string;
};
};
export type UpdateCheckResult = {
configured: boolean;
currentVersion: string;
platform: ReturnType<typeof detectPlatform>;
checkedAt: number;
latest: {
version: string;
tagName?: string;
publishedAt?: string;
compatible: boolean;
integrityReady: boolean;
signatureReady: boolean;
isNewer: boolean;
assetName?: string;
assetSize?: number;
} | null;
};
export type UpdateRequest = {
jobId: string;
version: string;
metadataUrl: string;
assetUrl: string;
assetName: string;
expectedSha256: string;
requestedAt: number;
// These paths are derived from the server config and are included so the
// privileged runner does not need to infer a working directory from input.
currentLink: string;
releasesDir: string;
dataDir: string;
};
function setting(database: Database.Database, key: string): string | undefined {
return (database.prepare("SELECT value FROM system_settings WHERE key=?").get(key) as { value: string } | undefined)?.value;
}
function saveSetting(database: Database.Database, key: string, value: unknown): void {
database.prepare(`
INSERT INTO system_settings(key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
`).run(key, JSON.stringify(value), Date.now());
}
function sha256FromSums(text: string, assetName: string): string | undefined {
const wanted = sanitizeAssetName(assetName);
for (const line of text.split(/\r?\n/)) {
const match = /^\s*([a-f0-9]{64})\s+[* ]?(.+?)\s*$/.exec(line);
if (!match) continue;
const name = match[2]!.replaceAll("\\", "/").split("/").pop() ?? "";
if (name === wanted) return match[1]!.toLowerCase();
}
return undefined;
}
/** Verify an Ed25519 detached signature over the exact SHA256SUMS bytes.
* The signature sidecar is accepted as either base64 or a 64-byte hex value.
*/
export function verifyReleaseSignature(payload: string, encodedSignature: string | Uint8Array, publicKey: string): boolean {
try {
const signature = (() => {
if (encodedSignature instanceof Uint8Array) {
const bytes = Buffer.from(encodedSignature);
if (bytes.length === 64) return bytes;
encodedSignature = bytes.toString("utf8");
}
const compact = encodedSignature.trim().replace(/\s+/g, "");
return /^[a-f0-9]{128}$/i.test(compact)
? Buffer.from(compact, "hex")
: Buffer.from(compact, "base64");
})();
if (signature.length !== 64) return false;
return verifySignature(null, Buffer.from(payload, "utf8"), createPublicKey(publicKey), signature);
} catch {
return false;
}
}
function signatureAssetFor(metadata: ReleaseMetadata, sums: ReleaseAsset): ReleaseAsset | undefined {
const sumsName = sums.name.toLowerCase();
return metadata.assets.find((candidate) => {
const name = candidate.name.toLowerCase();
return name === `${sumsName}.sig` || name === `${sumsName}.asc`;
});
}
export async function attachSidecarHash(
metadata: ReleaseMetadata,
asset: ReleaseAsset,
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; publicKey?: string | undefined; requireSignature?: boolean | undefined },
): Promise<{ asset: ReleaseAsset; signatureVerified: boolean }> {
let signatureVerified = false;
if (asset.sha256 && (!options.publicKey || !options.requireSignature)) return { asset, signatureVerified };
const sums = metadata.assets.find((candidate) => /^(?:sha256sums?|checksums?)(?:\.txt)?$/i.test(path.basename(candidate.name)));
if (!sums) return { asset, signatureVerified };
try {
const content = await fetchReleaseText(sums.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: Math.min(options.maxBytes, 2 * 1024 * 1024) });
const sha256 = sha256FromSums(content, asset.name);
if (options.publicKey) {
const signatureAsset = signatureAssetFor(metadata, sums);
if (signatureAsset) {
const signature = await fetchReleaseBytes(signatureAsset.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: 64 * 1024 });
signatureVerified = verifyReleaseSignature(content, signature, options.publicKey);
}
}
return { asset: sha256 ? { ...asset, sha256 } : asset, signatureVerified };
} catch {
// A missing/unreadable sidecar makes the update unavailable; it must not
// turn into an unverified download.
return { asset, signatureVerified };
}
}
function policy(config: AppConfig) {
return {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
maxRedirects: 3,
} as const;
}
function safeMetadataUrl(config: AppConfig): string {
try {
return validateHttpsUrl(config.updateMetadataUrl, policy(config)).toString();
} catch {
throw new AppError(503, "UPDATE_NOT_CONFIGURED", "更新源地址配置无效");
}
}
export async function checkForUpdate(database: Database.Database, config: AppConfig): Promise<UpdateCheckResult> {
const platform = detectPlatform();
const checkedAt = Date.now();
if (config.updateStrategy === "disabled" || !config.updateMetadataUrl) {
return { configured: false, currentVersion: config.appVersion, platform, checkedAt, latest: null };
}
const metadataUrl = safeMetadataUrl(config);
let metadata: ReleaseMetadata;
try {
metadata = await fetchReleaseMetadata(metadataUrl, policy(config));
} catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
}
let asset = selectReleaseAsset(metadata, platform);
let signatureVerified = false;
if (asset) {
const integrity = await attachSidecarHash(metadata, asset, {
allowedHosts: config.updateAllowedHosts,
baseUrl: metadataUrl,
maxBytes: config.updateMaxBytes,
publicKey: config.updatePublicKey,
requireSignature: config.updateRequireSignature,
});
asset = integrity.asset;
signatureVerified = integrity.signatureVerified;
}
const safeVersion = metadata.version;
const cached: CachedRelease = {
checkedAt,
metadataUrl,
version: safeVersion,
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
platform: platform.target,
signatureVerified,
...(asset ? {
asset: {
name: sanitizeAssetName(asset.name),
url: validateHttpsUrl(asset.url, policy(config)).toString(),
...(asset.size === undefined ? {} : { size: asset.size }),
...(asset.sha256 ? { sha256: asset.sha256 } : {}),
},
} : {}),
};
saveSetting(database, UPDATE_CACHE_KEY, cached);
return {
configured: true,
currentVersion: config.appVersion,
platform,
checkedAt,
latest: {
version: safeVersion,
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
compatible: Boolean(asset),
integrityReady: Boolean(asset?.sha256 && (!config.updateRequireSignature || signatureVerified)),
signatureReady: !config.updateRequireSignature || signatureVerified,
isNewer: isNewerVersion(config.appVersion, safeVersion),
...(asset ? { assetName: asset.name, ...(asset.size === undefined ? {} : { assetSize: asset.size }) } : {}),
},
};
}
export function readCachedRelease(database: Database.Database, config: AppConfig): CachedRelease | null {
const raw = setting(database, UPDATE_CACHE_KEY);
if (!raw) return null;
try {
const value = JSON.parse(raw) as CachedRelease;
if (!value || typeof value !== "object" || typeof value.version !== "string" || typeof value.metadataUrl !== "string" || typeof value.platform !== "string") return null;
parseSemver(value.version);
const metadataUrl = validateHttpsUrl(value.metadataUrl, policy(config)).toString();
if (value.signatureVerified !== undefined && typeof value.signatureVerified !== "boolean") return null;
if (value.asset) {
if (typeof value.asset.name !== "string" || typeof value.asset.url !== "string") return null;
sanitizeAssetName(value.asset.name);
validateHttpsUrl(value.asset.url, policy(config));
if (value.asset.sha256 !== undefined && !/^[a-f0-9]{64}$/i.test(value.asset.sha256)) return null;
}
return { ...value, metadataUrl };
} catch {
return null;
}
}
export function publicCheckFromCache(database: Database.Database, config: AppConfig): UpdateCheckResult {
const platform = detectPlatform();
const cached = readCachedRelease(database, config);
if (!cached || cached.platform !== platform.target) {
const compatible = Boolean(cached && cached.platform === platform.target && cached.asset);
return { configured: config.updateStrategy !== "disabled", currentVersion: config.appVersion, platform, checkedAt: cached?.checkedAt ?? 0, latest: cached ? {
version: cached.version,
...(cached.tagName ? { tagName: cached.tagName } : {}),
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
compatible,
integrityReady: compatible && Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
isNewer: isNewerVersion(config.appVersion, cached.version),
...(cached.asset ? { assetName: cached.asset.name, ...(cached.asset.size === undefined ? {} : { assetSize: cached.asset.size }) } : {}),
} : null };
}
return {
configured: config.updateStrategy !== "disabled",
currentVersion: config.appVersion,
platform,
checkedAt: cached.checkedAt,
latest: {
version: cached.version,
...(cached.tagName ? { tagName: cached.tagName } : {}),
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
compatible: Boolean(cached.asset),
integrityReady: Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
isNewer: isNewerVersion(config.appVersion, cached.version),
...(cached.asset ? { assetName: cached.asset.name, ...(cached.asset.size === undefined ? {} : { assetSize: cached.asset.size }) } : {}),
},
};
}
export async function writeUpdateRequest(config: AppConfig, request: UpdateRequest): Promise<void> {
const parent = path.dirname(config.updateRequestPath);
await mkdir(parent, { recursive: true, mode: 0o700 });
const temporary = `${config.updateRequestPath}.tmp-${randomUUID()}`;
await writeFile(temporary, JSON.stringify(request), { encoding: "utf8", mode: 0o600, flag: "wx" });
try {
await chmod(temporary, 0o600);
await rename(temporary, config.updateRequestPath);
} catch (error) {
await import("node:fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => undefined);
throw error;
}
}
export function publicUpdateJob(row: Record<string, unknown> | undefined): Record<string, unknown> | null {
if (!row) return null;
const hasError = typeof row.errorMessage === "string" && row.errorMessage.length > 0;
return {
id: row.id,
status: row.status,
version: row.version,
platform: row.platform,
assetName: row.assetName ?? null,
sizeBytes: row.sizeBytes ?? null,
// Do not expose filesystem paths, command output, or upstream response
// text through the authenticated status endpoint. Detailed diagnostics
// remain in the server journal for operators.
errorMessage: hasError ? "更新失败,请查看服务器日志或重试" : null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
completedAt: row.completedAt ?? null,
};
}
+992
View File
@@ -0,0 +1,992 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import { chmod, mkdir, open, readdir, rename, lstat, readlink, symlink, rm } from "node:fs/promises";
import path from "node:path";
import { Readable, Transform } from "node:stream";
import { finished, pipeline } from "node:stream/promises";
import { createGzip, createGunzip } from "node:zlib";
import yauzl from "yauzl";
/** A small semver implementation so update checks do not depend on a runtime package. */
export type SemVer = {
major: number;
minor: number;
patch: number;
prerelease: string[];
build: string[];
};
export type UpdatePlatform = {
os: string;
arch: string;
target: string;
aliases: string[];
platform: string;
architecture: string;
};
export type ReleaseAsset = {
name: string;
url: string;
sha256?: string;
size?: number;
};
export type ReleaseMetadata = {
version: string;
tagName?: string;
publishedAt?: string;
assets: ReleaseAsset[];
};
export type UrlPolicy = {
/** Host names or HTTPS URLs which are allowed for requests. */
allowedHosts?: readonly string[] | undefined;
/** When allowedHosts is omitted, requests are constrained to this URL's host. */
baseUrl?: string | URL | undefined;
maxRedirects?: number | undefined;
};
function invalidVersion(): never {
throw new Error("更新版本号无效");
}
export function parseSemver(value: string): SemVer {
const input = value.trim().replace(/^v/i, "");
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(input);
if (!match) return invalidVersion();
const prerelease = match[4] ? match[4].split(".") : [];
const build = match[5] ? match[5].split(".") : [];
if (prerelease.some((part) => /^0\d+$/.test(part))) return invalidVersion();
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (![major, minor, patch].every((part) => Number.isSafeInteger(part))) return invalidVersion();
return { major, minor, patch, prerelease, build };
}
export function compareSemver(left: string | SemVer, right: string | SemVer): number {
const a = typeof left === "string" ? parseSemver(left) : left;
const b = typeof right === "string" ? parseSemver(right) : right;
for (const key of ["major", "minor", "patch"] as const) {
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
}
if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1;
if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1;
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i += 1) {
const x = a.prerelease[i];
const y = b.prerelease[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
if (x === y) continue;
const xn = /^\d+$/.test(x);
const yn = /^\d+$/.test(y);
if (xn && yn) {
if (x.length !== y.length) return x.length > y.length ? 1 : -1;
return x > y ? 1 : -1;
}
if (xn !== yn) return xn ? -1 : 1;
return x > y ? 1 : -1;
}
return 0;
}
export function isNewerVersion(current: string, candidate: string): boolean {
return compareSemver(candidate, current) > 0;
}
export function detectPlatform(platform = process.platform, architecture = process.arch): UpdatePlatform {
const os = platform === "win32" ? "windows" : platform;
const arch = ({ amd64: "x64", x86_64: "x64", aarch64: "arm64" } as Record<string, string>)[architecture] ?? architecture;
const target = `${os}-${arch}`;
return {
os,
arch,
target,
aliases: [target, `${os}_${arch}`, `${platform}-${architecture}`, `${platform}_${architecture}`, os, platform],
platform: os,
architecture: arch,
};
}
function hostFromEntry(entry: string): string {
try {
const parsed = new URL(entry.includes("://") ? entry : `https://${entry}`);
if (entry.includes("://") && parsed.protocol !== "https:") throw new Error("scheme");
return parsed.hostname.toLowerCase();
} catch {
throw new Error("更新地址白名单无效");
}
}
export function validateHttpsUrl(value: string | URL, policy: UrlPolicy = {}): URL {
let parsed: URL;
try {
parsed = new URL(value.toString());
} catch {
throw new Error("更新地址无效");
}
if (parsed.protocol !== "https:") throw new Error("更新地址必须使用 HTTPS");
if (parsed.username || parsed.password) throw new Error("更新地址不允许携带凭据");
const configured = policy.allowedHosts?.map(hostFromEntry);
const allowed = configured && configured.length > 0
? configured
: policy.baseUrl
? [hostFromEntry(policy.baseUrl.toString())]
: [parsed.hostname.toLowerCase()];
if (!allowed.includes(parsed.hostname.toLowerCase())) throw new Error("更新地址主机不在允许列表中");
return parsed;
}
function metadataError(): Error {
return new Error("更新发布信息不可用");
}
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
/** Read a fetch body without ever buffering more than the caller's bound. */
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise<Buffer> {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("响应大小限制无效");
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const declared = Number(contentLength);
if (Number.isFinite(declared) && declared > maxBytes) throw new Error(tooLargeMessage);
}
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
try {
for (;;) {
const result = await reader.read();
if (result.done) break;
const chunk = Buffer.from(result.value);
if (chunk.length > maxBytes - total) {
await reader.cancel().catch(() => undefined);
throw new Error(tooLargeMessage);
}
total += chunk.length;
chunks.push(chunk);
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, total);
}
export async function fetchReleaseMetadata(
metadataUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<ReleaseMetadata> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(metadataUrl, options);
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual", headers: { accept: "application/json" } });
} catch {
throw metadataError();
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw metadataError();
const location = response.headers.get("location");
if (!location) throw metadataError();
current = validateHttpsUrl(new URL(location, current), options.baseUrl ? options : { ...options, baseUrl: current });
}
if (response.status < 200 || response.status >= 300) throw metadataError();
let payload: unknown;
try {
const maxBytes = Math.min(options.maxBytes ?? DEFAULT_METADATA_MAX_BYTES, DEFAULT_METADATA_MAX_BYTES);
const body = await readBoundedResponse(response, maxBytes, "更新发布信息过大");
payload = JSON.parse(body.toString("utf8"));
} catch { throw metadataError(); }
if (!payload || typeof payload !== "object") throw metadataError();
const item = payload as Record<string, unknown>;
const rawVersion = typeof item.version === "string" ? item.version : typeof item.tag_name === "string" ? item.tag_name : typeof item.tagName === "string" ? item.tagName : undefined;
if (!rawVersion) throw metadataError();
const version = parseSemver(rawVersion);
if (typeof item.tag_name === "string") {
try {
if (compareSemver(version, item.tag_name) !== 0) throw metadataError();
} catch {
throw metadataError();
}
}
const assetsRaw = Array.isArray(item.assets) ? item.assets : [];
const assets: ReleaseAsset[] = [];
for (const raw of assetsRaw) {
if (!raw || typeof raw !== "object") continue;
const asset = raw as Record<string, unknown>;
const name = typeof asset.name === "string" ? asset.name : undefined;
const url = typeof asset.url === "string" ? asset.url : typeof asset.browser_download_url === "string" ? asset.browser_download_url : undefined;
if (!name || !url) continue;
let sha256: string | undefined;
const digest = typeof asset.sha256 === "string" ? asset.sha256 : typeof asset.digest === "string" ? asset.digest : undefined;
if (digest) {
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
}
assets.push({ name, url: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
}
return {
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
assets,
};
}
/** Fetch a small text sidecar (for example SHA256SUMS) with the same
* redirect, HTTPS and host policy used for release metadata. */
export async function fetchReleaseText(
textUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<string> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(textUrl, options);
const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl
? options
: { ...options, baseUrl: current };
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
} catch {
throw new Error("更新校验文件下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw new Error("更新校验文件下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新校验文件下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300) throw new Error("更新校验文件下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 1024 * 1024;
if (declared > maxBytes) throw new Error("更新校验文件过大");
try {
return (await readBoundedResponse(response, maxBytes, "更新校验文件过大")).toString("utf8");
} catch (error) {
if (error instanceof Error && error.message === "更新校验文件过大") throw error;
throw new Error("更新校验文件下载失败");
}
}
/** Fetch a bounded binary sidecar (for example an Ed25519 detached
* signature). Text decoding would corrupt arbitrary signature bytes, so keep
* this separate from fetchReleaseText. */
export async function fetchReleaseBytes(
bytesUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<Buffer> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(bytesUrl, options);
const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl
? options
: { ...options, baseUrl: current };
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
} catch {
throw new Error("更新签名下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw new Error("更新签名下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新签名下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300) throw new Error("更新签名下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 64 * 1024;
if (declared > maxBytes) throw new Error("更新签名文件过大");
try {
return await readBoundedResponse(response, maxBytes, "更新签名文件过大");
} catch (error) {
if (error instanceof Error && error.message === "更新签名文件过大") throw error;
throw new Error("更新签名下载失败");
}
}
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): ReleaseAsset | undefined {
const platformCandidates = release.assets.filter((asset) => {
const name = asset.name.toLowerCase();
return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase()));
});
const candidates = platformCandidates.length > 0
? platformCandidates
: (() => {
// A generic single-platform archive is useful for small private feeds,
// but never let an explicitly named foreign architecture through.
if (release.assets.length !== 1) return [];
const name = release.assets[0]!.name.toLowerCase();
const knownArchitecture = /(?:^|[-_.])(x64|amd64|x86_64|arm64|aarch64|armv7|armhf|i386|i686|ia32)(?:[-_.]|$)/.test(name);
return name.includes(platform.os.toLowerCase()) && !knownArchitecture ? [release.assets[0]!] : [];
})();
candidates.sort((a, b) => {
const target = platform.target.toLowerCase();
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
});
return candidates[0];
}
export function sanitizeAssetName(value: string): string {
const normalized = value.normalize("NFKC").replaceAll("\\", "/");
const name = path.posix.basename(normalized);
if (!name || name === "." || name === ".." || name !== normalized || name.includes("\0") || name.length > 200 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error("更新文件名无效");
return name;
}
export async function sha256File(filePath: string): Promise<string> {
const hash = createHash("sha256");
await pipeline(createReadStream(filePath), new Transform({ transform(chunk, _encoding, callback) { hash.update(chunk); callback(null, chunk); } }), new Transform({ transform(_chunk, _encoding, callback) { callback(); } }));
return hash.digest("hex");
}
export async function verifySha256(filePath: string, expected: string): Promise<boolean> {
const normalized = expected.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(normalized)) throw new Error("SHA-256 校验值无效");
return (await sha256File(filePath)) === normalized;
}
export async function downloadReleaseAsset(
url: string | URL,
destination: string,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<{ size: number; sha256: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(url, options);
const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl
? options
: { ...options, baseUrl: current };
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
} catch {
throw new Error("更新文件下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw new Error("更新文件下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新文件下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300 || !response.body) throw new Error("更新文件下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
if (declared > maxBytes) throw new Error("更新文件超过大小限制");
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
const temporary = `${destination}.part-${randomUUID()}`;
let size = 0;
const hash = createHash("sha256");
const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) {
size += chunk.length;
if (size > maxBytes) return callback(new Error("更新文件超过大小限制"));
hash.update(chunk);
callback(null, chunk);
} });
try {
await pipeline(Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), meter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
const fd = await open(temporary, "r");
await fd.sync();
await fd.close();
await rename(temporary, destination);
} catch (error) {
await import("node:fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => undefined);
throw error instanceof Error && error.message.startsWith("更新文件") ? error : new Error("更新文件下载失败");
}
return { size, sha256: hash.digest("hex") };
}
function tarField(value: string, length: number): Buffer {
const output = Buffer.alloc(length, 0);
Buffer.from(value, "utf8").copy(output, 0, 0, length);
return output;
}
function tarOctal(value: number, length: number): Buffer {
const text = value.toString(8).padStart(length - 1, "0").slice(-(length - 1));
return Buffer.from(`${text}\0`, "ascii");
}
function tarHeader(name: string, size: number, mode: number, directory: boolean): Buffer {
let nameField = name;
let prefixField = "";
if (Buffer.byteLength(name) > 100) {
const slash = name.lastIndexOf("/");
if (slash <= 0 || Buffer.byteLength(name.slice(0, slash)) > 155 || Buffer.byteLength(name.slice(slash + 1)) > 100) throw new Error("归档路径过长");
prefixField = name.slice(0, slash);
nameField = name.slice(slash + 1);
}
const header = Buffer.alloc(512, 0);
tarField(nameField, 100).copy(header, 0);
tarOctal(mode & 0o777, 8).copy(header, 100);
tarOctal(0, 8).copy(header, 108);
tarOctal(0, 8).copy(header, 116);
tarOctal(size, 12).copy(header, 124);
tarOctal(Math.floor(Date.now() / 1000), 12).copy(header, 136);
Buffer.from(" ", "ascii").copy(header, 148);
header[156] = directory ? 0x35 : 0x30;
tarField("ustar\0", 6).copy(header, 257);
tarField("00", 2).copy(header, 263);
tarField(prefixField, 155).copy(header, 345);
let checksum = 0;
for (const byte of header) checksum += byte;
tarOctal(checksum, 8).copy(header, 148);
return header;
}
export type SafeArchiveOptions = {
maxEntries?: number;
maxBytes?: number;
};
async function writeArchiveChunk(stream: Transform, chunk: Buffer): Promise<void> {
if (stream.write(chunk)) return;
await new Promise<void>((resolve, reject) => {
const onDrain = () => { cleanup(); resolve(); };
const onError = (error: Error) => { cleanup(); reject(error); };
const cleanup = () => {
stream.off("drain", onDrain);
stream.off("error", onError);
};
stream.once("drain", onDrain);
stream.once("error", onError);
});
}
export async function createSafeArchive(sourceDir: string, archivePath: string, options: SafeArchiveOptions = {}): Promise<void> {
const root = path.resolve(sourceDir);
const archiveResolved = path.resolve(archivePath);
if (archiveResolved === root || archiveResolved.startsWith(`${root}${path.sep}`)) throw new Error("归档目标不能位于源目录内");
const maxEntries = options.maxEntries ?? 100_000;
const maxBytes = options.maxBytes ?? 2 * 1024 * 1024 * 1024;
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("归档限制无效");
let entries = 0;
let total = 0;
const archiveParent = path.resolve(path.dirname(archiveResolved));
const archiveInfo = await lstat(archiveResolved).catch(() => null);
if (archiveInfo?.isSymbolicLink() || (archiveInfo && !archiveInfo.isFile())) throw new Error("归档目标文件无效");
await mkdir(archiveParent, { recursive: true, mode: 0o700 });
await assertPrivateDirectory(archiveParent);
const temporary = `${archiveResolved}.part-${randomUUID()}`;
let gzip: Transform | undefined;
let output: ReturnType<typeof createWriteStream> | undefined;
let renamed = false;
const walk = async (directory: string, prefix: string): Promise<void> => {
const directoryEntries = await readdir(directory, { withFileTypes: true });
directoryEntries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of directoryEntries) {
const target = path.join(directory, entry.name);
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const info = await lstat(target);
if (info.isSymbolicLink()) throw new Error("归档不允许符号链接");
entries += 1;
if (entries > maxEntries) throw new Error("归档条目过多");
if (info.isDirectory()) {
await assertPrivateDirectory(target);
await writeArchiveChunk(gzip!, tarHeader(`${relative}/`, 0, 0o700, true));
await walk(target, relative);
} else if (info.isFile()) {
const handle = await open(target, "r");
try {
const current = await handle.stat();
if (!current.isFile() || !Number.isSafeInteger(current.size) || current.size < 0) throw new Error("归档源文件无效");
if (current.size > maxBytes - total) throw new Error("归档超过大小限制");
total += current.size;
await writeArchiveChunk(gzip!, tarHeader(relative, current.size, 0o600, false));
let position = 0;
while (position < current.size) {
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, current.size - position));
const result = await handle.read(chunk, 0, chunk.length, position);
if (result.bytesRead <= 0) throw new Error("归档源文件读取失败");
position += result.bytesRead;
await writeArchiveChunk(gzip!, chunk.subarray(0, result.bytesRead));
}
const remainder = current.size % 512;
if (remainder) await writeArchiveChunk(gzip!, Buffer.alloc(512 - remainder));
} finally {
await handle.close().catch(() => undefined);
}
} else {
throw new Error("归档包含不受支持的文件类型");
}
}
};
const info = await lstat(root);
if (!info.isDirectory()) throw new Error("归档源目录无效");
await assertPrivateDirectory(root);
try {
output = createWriteStream(temporary, { flags: "wx", mode: 0o600 });
gzip = createGzip({ level: 6 });
gzip.pipe(output);
await walk(root, "");
await writeArchiveChunk(gzip, Buffer.alloc(1024));
gzip.end();
await finished(output);
const handle = await open(temporary, "r");
await handle.sync();
await handle.close();
await chmod(temporary, 0o600);
await rename(temporary, archiveResolved);
renamed = true;
} finally {
if (gzip && !gzip.destroyed) gzip.destroy();
if (output && !output.destroyed) output.destroy();
if (!renamed) await rm(temporary, { force: true }).catch(() => undefined);
}
}
function safeArchiveEntry(entryName: string): string {
const name = entryName.replaceAll("\\", "/");
if (!name || name.startsWith("/") || /^[A-Za-z]:\//.test(name) || name.includes("\0")) throw new Error("归档包含不安全路径");
const normalized = path.posix.normalize(name);
// GNU/BSD tar commonly emits a harmless `./` root directory entry.
if (normalized === "." || normalized === "./") return "";
if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) throw new Error("归档包含不安全路径");
return normalized.replace(/\/$/, "");
}
async function assertPrivateDirectory(directory: string): Promise<void> {
const info = await lstat(directory).catch(() => null);
if (!info || info.isSymbolicLink() || !info.isDirectory()) throw new Error("归档目标目录无效");
// A sticky world-writable parent such as /tmp is acceptable for a freshly
// created mkdtemp workspace. Non-sticky group/other writable directories
// are not: a local user could replace a path between validation and use.
if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) throw new Error("归档目标目录权限过宽");
}
async function ensureArchiveParent(root: string, target: string): Promise<void> {
await assertPrivateDirectory(root);
const relative = path.relative(root, path.dirname(target));
let current = root;
for (const component of relative ? relative.split(path.sep) : []) {
current = path.join(current, component);
const info = await lstat(current).catch(() => null);
if (info?.isSymbolicLink() || (info && !info.isDirectory())) throw new Error("归档目标目录无效");
if (!info) {
await mkdir(current, { mode: 0o700 });
await chmod(current, 0o700);
} else {
await assertPrivateDirectory(current);
}
}
}
async function extractSafeZip(archivePath: string, destinationDir: string, options: { maxEntries?: number; maxBytes?: number }): Promise<void> {
const maxEntries = options.maxEntries ?? 100_000;
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
const root = path.resolve(destinationDir);
const rootInfo = await lstat(root).catch(() => null);
if (rootInfo?.isSymbolicLink() || (rootInfo && !rootInfo.isDirectory())) throw new Error("归档目标目录无效");
await mkdir(root, { recursive: true, mode: 0o700 });
await new Promise<void>((resolve, reject) => {
yauzl.open(archivePath, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) return reject(new Error("归档结构无效"));
let entries = 0;
let total = 0;
let settled = false;
const fail = (reason: unknown) => { if (!settled) { settled = true; zip.close(); reject(reason instanceof Error ? reason : new Error("归档结构无效")); } };
zip.on("error", fail);
zip.on("entry", (entry) => {
if (settled) return;
entries += 1;
if (entries > maxEntries) return fail(new Error("归档条目过多"));
let name: string;
try { name = safeArchiveEntry(entry.fileName); } catch (reason) { return fail(reason); }
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
if ((mode & 0o170000) === 0o120000) return fail(new Error("归档不允许符号链接"));
const target = path.resolve(root, name);
if (name && !target.startsWith(`${root}${path.sep}`)) return fail(new Error("归档包含不安全路径"));
const directory = entry.fileName.endsWith("/") || (mode & 0o170000) === 0o040000;
if (directory) {
if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize !== 0) return fail(new Error("归档目录条目结构无效"));
const prepare = name ? ensureArchiveParent(root, target) : Promise.resolve();
prepare.then(async () => {
const existing = await lstat(target).catch(() => null);
if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) throw new Error("归档目标目录无效");
if (!existing) await mkdir(target, { mode: 0o700 });
zip.readEntry();
}).catch(fail);
return;
}
if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize < 0 || entry.uncompressedSize > maxBytes - total) return fail(new Error("归档超过大小限制"));
total += entry.uncompressedSize;
if (!name) return fail(new Error("归档文件名无效"));
ensureArchiveParent(root, target).then(() => new Promise<void>((resolveEntry, rejectEntry) => {
zip.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) return rejectEntry(new Error("归档结构无效"));
pipeline(stream, createWriteStream(target, { mode: 0o600, flags: "wx" })).then(resolveEntry).catch(rejectEntry);
});
})).then(() => { zip.readEntry(); }).catch(fail);
});
zip.readEntry();
zip.once("end", () => { if (!settled) { settled = true; resolve(); } });
});
});
}
/**
* Read an archive incrementally. The previous implementation read the whole
* gzip and then called gunzipSync, which let a tiny gzip bomb allocate an
* unbounded amount of memory before the expanded-size limit was checked.
*/
class ArchiveStreamReader {
private readonly iterator: AsyncIterator<Buffer | Uint8Array>;
private buffered = Buffer.alloc(0) as Buffer<ArrayBufferLike>;
private done = false;
constructor(private readonly stream: Readable) {
this.iterator = stream[Symbol.asyncIterator]();
}
private async fill(minimum: number): Promise<void> {
while (!this.done && this.buffered.length < minimum) {
const next = await this.iterator.next();
if (next.done) {
this.done = true;
break;
}
const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value);
if (chunk.length === 0) continue;
this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]);
}
}
async read(length: number): Promise<Buffer | null> {
if (!Number.isSafeInteger(length) || length < 0) throw new Error("归档读取长度无效");
if (length === 0) return Buffer.alloc(0);
await this.fill(length);
if (this.buffered.length === 0 && this.done) return null;
if (this.buffered.length < length) throw new Error("归档结构无效");
const result = this.buffered.subarray(0, length);
this.buffered = this.buffered.subarray(length);
return result;
}
async discard(length: number): Promise<void> {
let remaining = length;
while (remaining > 0) {
const chunk = await this.read(Math.min(remaining, 64 * 1024));
if (!chunk) throw new Error("归档结构无效");
remaining -= chunk.length;
}
}
async copyToFile(length: number, target: string): Promise<void> {
const handle = await open(target, "wx", 0o600);
let complete = false;
try {
let remaining = length;
while (remaining > 0) {
const chunk = await this.read(Math.min(remaining, 64 * 1024));
if (!chunk) throw new Error("归档结构无效");
let written = 0;
while (written < chunk.length) {
const result = await handle.write(chunk, written, chunk.length - written);
if (result.bytesWritten <= 0) throw new Error("归档写入失败");
written += result.bytesWritten;
}
remaining -= chunk.length;
}
await handle.sync();
complete = true;
} finally {
await handle.close().catch(() => undefined);
if (!complete) await rm(target, { force: true }).catch(() => undefined);
}
}
}
function parsePaxPath(payload: Buffer): string | undefined {
let offset = 0;
let pathValue: string | undefined;
while (offset < payload.length) {
const space = payload.indexOf(0x20, offset);
if (space <= offset) throw new Error("归档扩展头无效");
const lengthText = payload.subarray(offset, space).toString("ascii");
if (!/^\d+$/.test(lengthText)) throw new Error("归档扩展头无效");
const length = Number(lengthText);
if (!Number.isSafeInteger(length) || length <= space - offset + 2 || offset + length > payload.length) throw new Error("归档扩展头无效");
const record = payload.subarray(offset, offset + length);
if (record[record.length - 1] !== 0x0a) throw new Error("归档扩展头无效");
const equals = record.indexOf(0x3d, space - offset + 1);
if (equals < 0) throw new Error("归档扩展头无效");
const key = record.subarray(space - offset + 1, equals).toString("utf8");
if (key === "path") pathValue = record.subarray(equals + 1, record.length - 1).toString("utf8");
offset += length;
}
return pathValue;
}
async function readTarMetadata(reader: ArchiveStreamReader, size: number, maxBytes: number): Promise<Buffer> {
// Extended headers only carry names and metadata. A small hard cap keeps a
// malformed header from turning into another allocation vector.
if (size > Math.min(maxBytes, 4 * 1024 * 1024)) throw new Error("归档扩展头过大");
const payload = await reader.read(size);
if (!payload) throw new Error("归档结构无效");
await reader.discard((512 - (size % 512)) % 512);
return payload;
}
async function extractSafeTar(stream: Readable, destinationDir: string, options: { maxEntries?: number; maxBytes?: number }): Promise<void> {
const maxEntries = options.maxEntries ?? 100_000;
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
const root = path.resolve(destinationDir);
const rootInfo = await lstat(root).catch(() => null);
if (rootInfo?.isSymbolicLink() || (rootInfo && !rootInfo.isDirectory())) throw new Error("归档目标目录无效");
await mkdir(root, { recursive: true, mode: 0o700 });
const reader = new ArchiveStreamReader(stream);
let entries = 0;
let total = 0;
let globalPath: string | undefined;
let pendingPath: string | undefined;
let terminated = false;
try {
while (true) {
const header = await reader.read(512);
if (!header) throw new Error("归档结构无效");
if (header.every((value) => value === 0)) {
terminated = true;
break;
}
const storedChecksum = Number.parseInt(header.subarray(148, 156).toString("ascii").replace(/[\0 ]/g, ""), 8);
let checksum = 0;
for (let index = 0; index < header.length; index += 1) checksum += index >= 148 && index < 156 ? 0x20 : header[index]!;
if (!Number.isFinite(storedChecksum) || checksum !== storedChecksum) throw new Error("归档校验失败");
entries += 1;
if (entries > maxEntries) throw new Error("归档条目过多");
const sizeText = header.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim();
const size = sizeText ? Number.parseInt(sizeText, 8) : 0;
if (!Number.isSafeInteger(size) || size < 0) throw new Error("归档结构无效");
const type = header[156];
if (type === 0x78 || type === 0x67 || type === 0x4c || type === 0x4b) {
total += size;
if (!Number.isSafeInteger(total) || total > maxBytes) throw new Error("归档超过大小限制");
const payload = await readTarMetadata(reader, size, maxBytes);
if (type === 0x4c) {
const end = payload.indexOf(0);
pendingPath = payload.subarray(0, end < 0 ? payload.length : end).toString("utf8");
} else if (type === 0x4b) {
// Hard/symbolic links are intentionally unsupported. Reject the
// GNU long-link record instead of carrying it into a later entry.
throw new Error("归档不允许链接");
} else {
const extendedPath = parsePaxPath(payload);
if (type === 0x67) globalPath = extendedPath;
else if (extendedPath !== undefined) pendingPath = extendedPath;
}
continue;
}
const namePart = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
const prefixPart = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
const rawName = pendingPath ?? globalPath ?? (prefixPart ? `${prefixPart}/${namePart}` : namePart);
pendingPath = undefined;
const name = safeArchiveEntry(rawName);
const target = path.resolve(root, name);
if (name && !target.startsWith(`${root}${path.sep}`)) throw new Error("归档包含不安全路径");
if (type === 0x35) {
if (size !== 0) throw new Error("归档目录条目结构无效");
if (name) await ensureArchiveParent(root, target);
const existing = await lstat(target).catch(() => null);
if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) throw new Error("归档目标目录无效");
if (!existing) await mkdir(target, { mode: 0o700 });
} else if (type === 0x30 || type === 0) {
if (!name) throw new Error("归档文件名无效");
await ensureArchiveParent(root, target);
const parent = await lstat(path.dirname(target));
if (!parent.isDirectory()) throw new Error("归档目标目录无效");
if (size > maxBytes - total) throw new Error("归档超过大小限制");
total += size;
await reader.copyToFile(size, target);
} else {
throw new Error("归档包含不受支持的文件类型");
}
await reader.discard((512 - (size % 512)) % 512);
}
} finally {
stream.destroy();
}
if (!terminated) throw new Error("归档结构无效");
}
async function readArchivePrefix(archivePath: string, length: number): Promise<Buffer> {
const handle = await open(archivePath, "r");
try {
const buffer = Buffer.alloc(length);
const result = await handle.read(buffer, 0, length, 0);
return buffer.subarray(0, result.bytesRead);
} finally {
await handle.close().catch(() => undefined);
}
}
/** Make a staged release readable by the unprivileged systemd service. */
export async function normalizeReleasePermissions(rootPath: string): Promise<void> {
const root = path.resolve(rootPath);
const rootInfo = await lstat(root).catch(() => null);
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) throw new Error("发布目录无效");
const walk = async (directory: string): Promise<void> => {
await chmod(directory, 0o755);
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const target = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error("发布包不允许符号链接");
if (entry.isDirectory()) {
await walk(target);
} else if (entry.isFile()) {
const relative = path.relative(root, target).split(path.sep).join("/");
const executable = relative.startsWith("bin/") || relative.startsWith("scripts/") || relative.startsWith("runtime/bin/");
await chmod(target, executable ? 0o755 : 0o644);
} else {
throw new Error("发布包包含不受支持的文件类型");
}
}
};
await walk(root);
}
export async function extractSafeArchive(archivePath: string, destinationDir: string, options: { maxEntries?: number; maxBytes?: number } = {}): Promise<void> {
const archiveInfo = await lstat(archivePath).catch(() => null);
if (!archiveInfo?.isFile() || archiveInfo.isSymbolicLink()) throw new Error("归档文件无效");
const destinationInfo = await lstat(destinationDir).catch(() => null);
const prefix = await readArchivePrefix(archivePath, 512);
try {
if (prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))) {
await extractSafeZip(archivePath, destinationDir, options);
return;
}
if (prefix.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))) {
await extractSafeTar(createReadStream(archivePath).pipe(createGunzip()), destinationDir, options);
return;
}
if (prefix.subarray(257, 262).toString("ascii") !== "ustar") throw new Error("归档格式无效");
await extractSafeTar(createReadStream(archivePath), destinationDir, options);
} catch (error) {
// The updater normally uses a disposable workspace. Keep the public helper
// equally tidy when it created the destination itself.
if (!destinationInfo) await rm(destinationDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
export const archiveDirectory = createSafeArchive;
export const backupDirectory = createSafeArchive;
export async function atomicSwitchDirectory(stagedDir: string, currentDir: string, backupDir?: string): Promise<string | undefined> {
const staged = path.resolve(stagedDir);
const current = path.resolve(currentDir);
if (staged === current) throw new Error("更新目录无效");
const stagedInfo = await lstat(staged).catch(() => null);
if (!stagedInfo?.isDirectory() || stagedInfo.isSymbolicLink()) throw new Error("更新暂存目录无效");
const currentInfo = await lstat(current).catch(() => null);
if (currentInfo && (!currentInfo.isDirectory() || currentInfo.isSymbolicLink())) throw new Error("当前安装目录无效");
const backup = backupDir ? path.resolve(backupDir) : path.join(path.dirname(current), `.backup-${Date.now()}-${randomUUID()}`);
if (await lstat(backup).catch(() => null)) throw new Error("备份目录已存在");
const backupParent = path.resolve(path.dirname(backup));
await mkdir(backupParent, { recursive: true, mode: 0o700 });
await assertPrivateDirectory(backupParent);
await assertPrivateDirectory(path.dirname(current));
if (currentInfo) await rename(current, backup);
try {
await rename(staged, current);
} catch (error) {
if (currentInfo) {
try {
await rename(backup, current);
} catch {
throw new Error("更新目录切换失败,旧版本恢复失败");
}
}
throw new Error("更新目录切换失败");
}
return currentInfo ? backup : undefined;
}
/** Atomically publish a release in the installer layout (`current` symlink).
* The old release is intentionally retained for rollback; only the link is
* replaced, so the active data directory is never moved or overwritten. */
export async function atomicSwitchRelease(
stagedDir: string,
currentLink: string,
releasesDir: string,
version: string,
): Promise<{ previousTarget?: string; publishedTarget: string }> {
const staged = path.resolve(stagedDir);
const link = path.resolve(currentLink);
const releases = path.resolve(releasesDir);
const parsed = parseSemver(version);
const normalizedVersion = `${parsed.major}.${parsed.minor}.${parsed.patch}${parsed.prerelease.length ? `-${parsed.prerelease.join(".")}` : ""}${parsed.build.length ? `+${parsed.build.join(".")}` : ""}`;
const target = path.join(releases, normalizedVersion);
if (!target.startsWith(`${releases}${path.sep}`)) throw new Error("更新版本目录无效");
const stagedInfo = await lstat(staged).catch(() => null);
if (!stagedInfo?.isDirectory() || stagedInfo.isSymbolicLink()) throw new Error("更新暂存目录无效");
const releasesInfo = await lstat(releases).catch(() => null);
if (releasesInfo?.isSymbolicLink() || (releasesInfo && !releasesInfo.isDirectory())) throw new Error("发布目录无效");
await mkdir(releases, { recursive: true, mode: 0o755 });
await assertPrivateDirectory(releases);
await assertPrivateDirectory(path.dirname(releases));
if (await lstat(target).catch(() => null)) throw new Error("该版本已经安装");
const currentInfo = await lstat(link).catch(() => null);
if (currentInfo && !currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
await assertPrivateDirectory(path.dirname(link));
let previousTarget: string | undefined;
if (currentInfo?.isSymbolicLink()) {
const raw = await readlink(link);
const resolvedPrevious = path.resolve(path.dirname(link), raw);
if (!resolvedPrevious.startsWith(`${releases}${path.sep}`)) throw new Error("当前发布链接无效");
previousTarget = path.relative(path.dirname(link), resolvedPrevious) || ".";
}
await rename(staged, target);
const temporaryLink = path.join(path.dirname(link), `.current-${process.pid}-${randomUUID()}.tmp`);
let linkCommitted = false;
try {
await symlink(target, temporaryLink);
await rename(temporaryLink, link);
linkCommitted = true;
const parent = await open(path.dirname(link), "r");
try {
await parent.sync();
} finally {
await parent.close();
}
} catch (error) {
await rm(temporaryLink, { force: true }).catch(() => undefined);
if (linkCommitted) {
// The link may already be visible when the directory fsync fails. Put
// the old link back before removing the new target; otherwise a crash
// recovery path could leave `current` dangling.
try {
if (previousTarget) {
const rollbackLink = path.join(path.dirname(link), `.current-rollback-${process.pid}-${randomUUID()}.tmp`);
const previousAbsolute = path.resolve(path.dirname(link), previousTarget);
await symlink(previousAbsolute, rollbackLink);
await rename(rollbackLink, link);
} else {
await rm(link, { force: true });
}
} catch {
// Never delete a target which may still be referenced by `current`.
throw new Error("更新目录切换失败,旧版本恢复失败");
}
}
await rm(target, { recursive: true, force: true }).catch(() => undefined);
throw error instanceof Error && error.message === "当前发布链接无效" ? error : new Error("更新目录切换失败");
}
return { ...(previousTarget ? { previousTarget } : {}), publishedTarget: target };
}
// Compatibility aliases for callers that prefer verb-oriented names.
export const getReleaseMetadata = fetchReleaseMetadata;
export const compareVersions = compareSemver;
export const getCurrentPlatform = detectPlatform;
export const downloadFile = downloadReleaseAsset;
export const verifyFileSha256 = verifySha256;
export const safeExtractArchive = extractSafeArchive;
export const switchDirectoryAtomically = atomicSwitchDirectory;
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod";
export const expenseStatusSchema = z.enum(["unreimbursed", "reimbursed"]);
export type ExpenseStatus = z.infer<typeof expenseStatusSchema>;
export const attachmentKindSchema = z.enum(["payment_proof", "invoice"]);
export type AttachmentKind = z.infer<typeof attachmentKindSchema>;
export const MAX_AMOUNT_CENTS = 999_999_999_999;
const expenseFieldsSchema = z.object({
paidAt: z.string().datetime({ offset: true }),
amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/).refine((value) => {
try { amountToCents(value); return true; } catch { return false; }
}, "金额超出允许范围"),
note: z.string().trim().max(2000).default(""),
}).strict();
const invoiceMissingReasonField = z.string().trim().max(500).nullable().optional();
export const expenseInputSchema = expenseFieldsSchema.extend({
invoiceMissingReason: invoiceMissingReasonField.default(null),
}).strict();
export const expenseUpdateSchema = expenseFieldsSchema.extend({
invoiceMissingReason: invoiceMissingReasonField,
version: z.number().int().positive(),
}).strict();
export const statusUpdateSchema = z.object({
status: expenseStatusSchema,
version: z.number().int().positive(),
}).strict();
export const versionSchema = z.object({
version: z.number().int().positive(),
}).strict();
export const attachmentDeleteSchema = versionSchema.extend({
// Only needed when removing the final invoice. The server preserves an
// existing reason when this field is omitted.
invoiceMissingReason: invoiceMissingReasonField,
}).strict();
export const permanentDeleteSchema = versionSchema.extend({
password: z.string().min(1).max(512),
});
export const loginSchema = z.object({
username: z.string().trim().min(1).max(128),
password: z.string().min(1).max(512),
}).strict();
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1).max(512),
newPassword: z.string().min(12).max(128),
}).strict();
export const createAdminSchema = z.object({
username: z.string().trim().min(3).max(64),
displayName: z.string().trim().min(1).max(80),
}).strict();
export const adminStatusSchema = z.object({
status: z.enum(["active", "disabled"]),
version: z.number().int().positive(),
}).strict();
export const exportRequestSchema = z.union([
z.object({
ids: z.array(z.string().uuid()).min(1).max(5000).refine((ids) => new Set(ids).size === ids.length, "记录不能重复"),
includeManifest: z.boolean().default(false),
}).strict(),
z.object({
month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/),
status: expenseStatusSchema,
query: z.string().max(200).default(""),
missingInvoice: z.boolean().default(false),
includeManifest: z.boolean().default(false),
}).strict(),
]);
export const updateJobStatusSchema = z.enum([
"queued",
"downloading",
"verifying",
"staged",
"backing_up",
"applying",
"completed",
"failed",
"cancelled",
]);
export type UpdateJobStatus = z.infer<typeof updateJobStatusSchema>;
/** The browser never supplies release URLs or filesystem paths. */
export const updateApplySchema = z.object({
version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/),
confirm: z.literal(true),
}).strict();
export type ApiError = {
error: {
code: string;
message: string;
requestId: string;
details?: unknown;
};
};
export function amountToCents(value: string): number {
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(value);
if (!match) throw new Error("INVALID_AMOUNT");
const cents = Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
if (!Number.isSafeInteger(cents) || cents <= 0 || cents > MAX_AMOUNT_CENTS) throw new Error("INVALID_AMOUNT");
return cents;
}
export function centsToAmount(cents: number): string {
return (cents / 100).toFixed(2);
}
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Watch for TallyNote release update requests
[Path]
PathExists=/var/lib/tallynote/update-request.json
PathChanged=/var/lib/tallynote/update-request.json
Unit=tallynote-update.service
[Install]
WantedBy=multi-user.target
+33
View File
@@ -0,0 +1,33 @@
[Unit]
Description=TallyNote privileged release updater
After=network-online.target
Wants=network-online.target
ConditionPathExists=/var/lib/tallynote/update-request.json
[Service]
Type=oneshot
User=root
Group=root
WorkingDirectory=/opt/tallynote/current
EnvironmentFile=-/etc/tallynote/tallynote.env
ExecStart=/usr/local/libexec/tallynote-update-runner
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
PrivateTmp=true
PrivateDevices=true
ProtectHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
UMask=0077
ReadWritePaths=/opt/tallynote /var/lib/tallynote /var/lib/tallynote-backups
+15
View File
@@ -0,0 +1,15 @@
TALLYNOTE_HOST=127.0.0.1
TALLYNOTE_PORT=3000
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_TIMEZONE=Asia/Shanghai
TALLYNOTE_UPDATE_STRATEGY=systemd
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
# Configure a root-managed Ed25519 public key before enabling one-click updates.
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
+38
View File
@@ -0,0 +1,38 @@
[Unit]
Description=TallyNote expense records
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=tallynote
Group=tallynote
WorkingDirectory=/opt/tallynote/current
Environment=NODE_ENV=production
EnvironmentFile=-/etc/tallynote/tallynote.env
Environment=PATH=/opt/tallynote/current/runtime/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/opt/tallynote/current/bin/tallynote
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
InaccessiblePaths=/opt/tallynote/.update-work
ProtectHome=true
PrivateDevices=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
UMask=0077
ReadWritePaths=/var/lib/tallynote
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
+393
View File
@@ -0,0 +1,393 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { buildApp } from "../server/app.js";
import { hashPassword } from "../server/security.js";
const tinyPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
function multipart(parts: Array<{ name: string; value?: string; filename?: string; contentType?: string; data?: Buffer }>): { body: Buffer; contentType: string } {
const boundary = `----tallynote-${randomUUID()}`;
const chunks: Buffer[] = [];
for (const part of parts) {
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"${part.filename ? `; filename="${part.filename}"` : ""}${part.filename ? `\r\nContent-Type: ${part.contentType || "application/octet-stream"}` : ""}\r\n\r\n`));
chunks.push(part.data ?? Buffer.from(part.value ?? ""));
chunks.push(Buffer.from("\r\n"));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` };
}
describe("TallyNote API", () => {
let dataDir: string;
let app: Awaited<ReturnType<typeof buildApp>>;
let database: ReturnType<typeof openDatabase>;
let config: ReturnType<typeof loadConfig>;
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-api-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3999";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
app = await buildApp(database, config);
});
afterEach(async () => {
await app.close();
database.sqlite.close();
rmSync(dataDir, { recursive: true, force: true });
});
async function seedAdmin() {
const id = randomUUID();
const password = "ApiTestPassword!2026";
const now = Date.now();
const passwordHash = await hashPassword(password);
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 0, 1, 1, ?)
`).run(id, "api-admin", "api-admin", "API 测试管理员", passwordHash, now);
return { id, password };
}
async function login() {
const admin = await seedAdmin();
const response = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: { origin: config.publicOrigin },
payload: { username: "api-admin", password: admin.password },
});
expect(response.statusCode).toBe(200);
const rawCookies = response.headers["set-cookie"];
const cookies = (Array.isArray(rawCookies) ? rawCookies : [rawCookies ?? ""]).map((cookie) => cookie.split(";", 1)[0]).join("; ");
const csrf = /(?:^|; )tally_csrf=([^;]+)/.exec(cookies)?.[1];
expect(csrf).toBeTruthy();
return { admin, cookies, csrf: csrf! };
}
it("未初始化时健康检查为 false,且错误包含 requestId", async () => {
const health = await app.inject({ method: "GET", url: "/health" });
expect(health.statusCode).toBe(200);
expect(health.json()).toEqual({ status: "ok", initialized: false });
expect(health.headers["content-security-policy"]).toContain("frame-ancestors 'none'");
expect(health.headers["x-frame-options"]).toBe("DENY");
const missing = await app.inject({ method: "GET", url: "/api/nope" });
expect(missing.statusCode).toBe(404);
expect(missing.json().error.requestId).toBeTruthy();
});
it("拒绝没有 Origin 的写请求", async () => {
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe("ORIGIN_FORBIDDEN");
});
it("将非法 JSON、伪造请求 ID 处理为结构化 400", async () => {
const response = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: { origin: config.publicOrigin, "content-type": "application/json", "x-request-id": "attacker" },
payload: "{",
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVALID_JSON");
expect(response.json().error.requestId).not.toBe("attacker");
expect(response.json().error.requestId).toMatch(/^[0-9a-f-]{36}$/);
});
it("登录入口使用小 body limit,避免未认证大 JSON 消耗内存", async () => {
const response = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: { origin: config.publicOrigin, "content-type": "application/json" },
payload: { username: "x", password: "x", padding: "x".repeat(20_000) },
});
expect(response.statusCode).toBe(413);
expect(response.json().error.code).toBe("REQUEST_TOO_LARGE");
});
it("下发服务器时区,并禁止当前管理员重置自己", async () => {
const session = await login();
const status = await app.inject({ method: "GET", url: "/api/auth/status" });
expect(status.json()).toEqual({ initialized: true, timezone: config.timezone });
const reset = await app.inject({
method: "POST",
url: `/api/admins/${session.admin.id}/reset-password`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: 1 },
});
expect(reset.statusCode).toBe(409);
expect(reset.json().error.code).toBe("SELF_RESET_FORBIDDEN");
});
it("重复设置相同报销状态是幂等操作", async () => {
const session = await login();
const expenseId = randomUUID();
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by,
updated_at, updated_by, reimbursed_at, reimbursed_by)
VALUES (?, ?, 1234, '幂等测试', 'reimbursed', 1, ?, ?, ?, ?, ?, ?)
`).run(expenseId, now, now, session.admin.id, now, session.admin.id, now - 1000, session.admin.id);
const response = await app.inject({
method: "POST",
url: `/api/expenses/${expenseId}/status`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { status: "reimbursed", version: 1 },
});
expect(response.statusCode).toBe(200);
expect(response.json().expense.version).toBe(1);
const row = database.sqlite.prepare("SELECT version, reimbursed_at AS reimbursedAt FROM expenses WHERE id=?").get(expenseId) as { version: number; reimbursedAt: number };
expect(row).toEqual({ version: 1, reimbursedAt: now - 1000 });
});
it("新建账目拒绝未知 multipart 字段", async () => {
const session = await login();
const boundary = "----tallynote-test-boundary";
const payload = [
`--${boundary}`,
'Content-Disposition: form-data; name="unexpected"',
"",
"value",
`--${boundary}--`,
"",
].join("\r\n");
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: {
origin: config.publicOrigin,
cookie: session.cookies,
"x-csrf-token": session.csrf,
"content-type": `multipart/form-data; boundary=${boundary}`,
},
payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("UNKNOWN_FIELD");
});
it("附件记录存在但文件缺失时返回 410", async () => {
const session = await login();
const expenseId = randomUUID();
const attachmentId = randomUUID();
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by,
updated_at, updated_by)
VALUES (?, ?, 100, '缺失附件测试', 'unreimbursed', 1, ?, ?, ?, ?)
`).run(expenseId, now, now, session.admin.id, now, session.admin.id);
database.sqlite.prepare(`
INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type,
size_bytes, sha256, created_at, created_by)
VALUES (?, ?, 'payment_proof', 'aa/missing.png', 'missing.png', 'image/png', 10, ?, ?, ?)
`).run(attachmentId, expenseId, "0".repeat(64), now, session.admin.id);
const response = await app.inject({
method: "GET",
url: `/api/attachments/${attachmentId}/content`,
headers: { cookie: session.cookies },
});
expect(response.statusCode).toBe(410);
expect(response.json().error.code).toBe("ATTACHMENT_MISSING");
});
it("无发票时必须填写原因,并在账目中保存", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "note", value: "无票测试" },
{ name: "invoiceMissingReason", value: "商家无法开具发票" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(response.statusCode).toBe(201);
const expense = response.json().expense;
expect(expense.invoiceCount).toBe(0);
expect(expense.invoiceMissingReason).toBe("商家无法开具发票");
});
it("无发票且未填写原因时拒绝新建", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVOICE_OR_REASON_REQUIRED");
});
it("有发票时拒绝同时填写无发票原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "invoiceMissingReason", value: "供应商无法开票" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const response = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVOICE_REASON_WITH_INVOICE");
});
it("编辑无票账目时可更新原因,但不能清空为无原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense;
const rejected = await app.inject({
method: "PATCH",
url: `/api/expenses/${expense.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { paidAt: "2026-08-27T12:00:00.000Z", amount: "12.34", note: "无票测试", invoiceMissingReason: null, version: expense.version },
});
expect(rejected.statusCode).toBe(400);
expect(rejected.json().error.code).toBe("INVOICE_OR_REASON_REQUIRED");
const updated = await app.inject({
method: "PATCH",
url: `/api/expenses/${expense.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { paidAt: "2026-08-27T12:00:00.000Z", amount: "12.34", note: "无票测试", invoiceMissingReason: "供应商仅提供收据", version: expense.version },
});
expect(updated.statusCode).toBe(200);
expect(updated.json().expense.invoiceMissingReason).toBe("供应商仅提供收据");
});
it("已有发票时编辑拒绝填写无发票原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense;
const response = await app.inject({
method: "PATCH",
url: `/api/expenses/${expense.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { paidAt: "2026-08-27T12:00:00.000Z", amount: "12.34", note: "保留发票", invoiceMissingReason: "不应填写", version: expense.version },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("INVOICE_REASON_WITH_INVOICE");
});
it("删除最后一张发票时要求并原子保存无发票原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "note", value: "删除发票测试" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense as { id: string; version: number; invoiceCount: number; attachments: Array<{ id: string; kind: string }> };
const invoice = expense.attachments.find((item) => item.kind === "invoice");
expect(invoice).toBeTruthy();
const rejected = await app.inject({
method: "DELETE",
url: `/api/attachments/${invoice!.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: expense.version },
});
expect(rejected.statusCode).toBe(409);
expect(rejected.json().error.code).toBe("INVOICE_OR_REASON_REQUIRED");
const deleted = await app.inject({
method: "DELETE",
url: `/api/attachments/${invoice!.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: expense.version, invoiceMissingReason: "供应商仅提供收据,无法补开发票" },
});
expect(deleted.statusCode).toBe(200);
const updated = deleted.json().expense;
expect(updated.invoiceCount).toBe(0);
expect(updated.invoiceMissingReason).toBe("供应商仅提供收据,无法补开发票");
expect(updated.version).toBe(expense.version + 1);
expect(updated.attachments.some((item: { id: string }) => item.id === invoice!.id)).toBe(false);
});
it("发票字节丢失时仍可删除附件元数据并保存原因", async () => {
const session = await login();
const form = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "8.00" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": form.contentType },
payload: form.body,
});
expect(created.statusCode).toBe(201);
const expense = created.json().expense as { id: string; version: number; attachments: Array<{ id: string; kind: string }> };
const invoice = expense.attachments.find((item) => item.kind === "invoice")!;
const stored = database.sqlite.prepare("SELECT storage_path AS storagePath FROM attachments WHERE id=?").get(invoice.id) as { storagePath: string };
rmSync(path.join(config.filesDir, stored.storagePath), { force: true });
const deleted = await app.inject({
method: "DELETE",
url: `/api/attachments/${invoice.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
payload: { version: expense.version, invoiceMissingReason: "原始发票文件已丢失,无法重新取得" },
});
expect(deleted.statusCode).toBe(200);
expect(deleted.json().expense.invoiceCount).toBe(0);
expect(deleted.json().expense.invoiceMissingReason).toBe("原始发票文件已丢失,无法重新取得");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { amountToCents, centsToAmount, exportRequestSchema } from "../shared/contracts.js";
import { zonedMonthBounds } from "../server/app.js";
import { safeExcelText } from "../server/exporter.js";
import { safeStoragePath, sanitizeOriginalName } from "../server/files.js";
describe("金额", () => {
it("按分精确转换并格式化", () => {
expect(amountToCents("12.3")).toBe(1230);
expect(amountToCents("0.01")).toBe(1);
expect(centsToAmount(1234)).toBe("12.34");
expect(() => amountToCents("12.345")).toThrow();
expect(() => amountToCents("0")).toThrow();
});
});
describe("时区月份", () => {
it("按 Asia/Shanghai 返回 UTC 月份边界", () => {
const [start, end] = zonedMonthBounds("2026-08", "Asia/Shanghai");
expect(new Date(start).toISOString()).toBe("2026-07-31T16:00:00.000Z");
expect(new Date(end).toISOString()).toBe("2026-08-31T16:00:00.000Z");
});
});
describe("导出选项", () => {
it("默认不包含 manifest.json,并支持显式开启", () => {
expect(exportRequestSchema.parse({ ids: ["00000000-0000-4000-8000-000000000001"] }).includeManifest).toBe(false);
expect(exportRequestSchema.parse({ month: "2026-08", status: "unreimbursed" }).includeManifest).toBe(false);
expect(exportRequestSchema.parse({ ids: ["00000000-0000-4000-8000-000000000001"], includeManifest: true }).includeManifest).toBe(true);
});
});
describe("文件和导出安全", () => {
it("不让用户文件名参与路径", () => {
expect(sanitizeOriginalName("../../秘密\u0000.png")).toBe("秘密.png");
expect(safeStoragePath("/tmp/tallynote-files", "ab/example.png")).toBe("/tmp/tallynote-files/ab/example.png");
expect(() => safeStoragePath("/tmp/tallynote-files", "../outside")).toThrow();
});
it("阻止 Excel 公式注入", () => {
expect(safeExcelText("=HYPERLINK(\"https://example.com\")")).toBe("'=HYPERLINK(\"https://example.com\")");
expect(safeExcelText("普通备注")).toBe("普通备注");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, symlink, unlink as unlinkFile, writeFile } from "node:fs/promises";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildApp } from "../server/app.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { hashPassword } from "../server/security.js";
const proof = Buffer.from("download-proof");
describe("下载审计", () => {
let dataDir: string;
let config: ReturnType<typeof loadConfig>;
let database: ReturnType<typeof openDatabase>;
let app: Awaited<ReturnType<typeof buildApp>>;
let cookies = "";
let csrf = "";
let attachmentId = "";
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-download-audit-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3993";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
config = loadConfig(); prepareDataDirectories(config); database = openDatabase(config); app = await buildApp(database, config);
const adminId = randomUUID();
database.sqlite.prepare("INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, must_change_password, auth_version, version, created_at) VALUES (?, 'download-admin', 'download-admin', '下载管理员', ?, 'active', 0, 1, 1, ?)").run(adminId, await hashPassword("DownloadPassword!2026"), Date.now());
const login = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin: config.publicOrigin }, payload: { username: "download-admin", password: "DownloadPassword!2026" } });
const raw = login.headers["set-cookie"];
cookies = (Array.isArray(raw) ? raw : [raw ?? ""]).map((value) => value.split(";", 1)[0]).join("; ");
csrf = /(?:^|; )tally_csrf=([^;]+)/.exec(cookies)?.[1] ?? "";
const expenseId = randomUUID(); attachmentId = randomUUID(); const storagePath = "dd/proof.bin"; const now = Date.now();
database.sqlite.prepare("INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, 100, '下载审计', 'unreimbursed', 1, ?, ?, ?, ?)").run(expenseId, now, now, adminId, now, adminId);
await mkdir(path.join(config.filesDir, "dd"), { recursive: true }); await writeFile(path.join(config.filesDir, storagePath), proof, { mode: 0o600 });
database.sqlite.prepare("INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, 'payment_proof', ?, 'proof.png', 'image/png', ?, ?, ?, ?)").run(attachmentId, expenseId, storagePath, proof.length, createHash("sha256").update(proof).digest("hex"), now, adminId);
});
afterEach(async () => { await app.close(); database.sqlite.close(); rmSync(dataDir, { recursive: true, force: true }); for (const key of ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE"]) delete process.env[key]; });
it("读取附件后记录 preview 审计事件", async () => {
const response = await app.inject({ method: "GET", url: `/api/attachments/${attachmentId}/content`, headers: { cookie: cookies } });
expect(response.statusCode).toBe(200);
const event = database.sqlite.prepare("SELECT action, outcome FROM audit_events WHERE action='expense.attachment_previewed' ORDER BY id DESC LIMIT 1").get() as { action: string; outcome: string };
expect(event).toEqual({ action: "expense.attachment_previewed", outcome: "success" });
});
it("附件路径是符号链接时拒绝读取", async () => {
const outside = path.join(dataDir, "outside-secret.txt");
await writeFile(outside, "must-not-leak");
const target = path.join(config.filesDir, "dd", "proof.bin");
await unlinkFile(target);
await symlink(outside, target);
const response = await app.inject({ method: "GET", url: `/api/attachments/${attachmentId}/content`, headers: { cookie: cookies } });
expect(response.statusCode).toBe(410);
expect(response.body).not.toContain("must-not-leak");
});
});
+9
View File
@@ -0,0 +1,9 @@
import { expect, test } from "@playwright/test";
test("未登录时显示中文登录入口", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("TallyNote")).toBeVisible();
await expect(page.getByLabel("用户名")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import yauzl from "yauzl";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { buildExportJob, insertExportJob, type ExportSnapshot } from "../server/exporter.js";
const proofBytes = Buffer.from("export-proof-bytes");
function zipEntries(buffer: Buffer): Promise<Map<string, Buffer>> {
return new Promise((resolve, reject) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) {
reject(error ?? new Error("无法读取导出 ZIP"));
return;
}
const entries = new Map<string, Buffer>();
let settled = false;
const fail = (reason: Error) => {
if (settled) return;
settled = true;
zip.close();
reject(reason);
};
zip.on("error", fail);
zip.on("end", () => {
if (settled) return;
settled = true;
resolve(entries);
});
zip.on("entry", (entry) => {
zip.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) {
fail(streamError ?? new Error("无法读取 ZIP 条目"));
return;
}
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on("error", fail);
stream.on("end", () => {
entries.set(entry.fileName, Buffer.concat(chunks));
if (!settled) zip.readEntry();
});
});
});
zip.readEntry();
});
});
}
describe("导出 ZIP 产物", () => {
let dataDir: string;
let config: ReturnType<typeof loadConfig>;
let database: ReturnType<typeof openDatabase>;
let adminId: string;
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-export-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
adminId = randomUUID();
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 0, 1, 1, ?)
`).run(adminId, "export-admin", "export-admin", "导出测试管理员", "not-a-password-hash", Date.now());
});
afterEach(async () => {
database.sqlite.close();
await rm(dataDir, { recursive: true, force: true });
});
async function createJob(includeManifest: boolean): Promise<{ jobId: string; expenseId: string; reason: string }> {
const expenseId = randomUUID();
const attachmentId = randomUUID();
const paidAt = Date.parse("2026-08-27T04:00:00.000Z");
const reason = "供应商仅提供收据,无法补开发票";
const storagePath = "aa/payment.png";
await mkdir(path.join(config.filesDir, "aa"), { recursive: true });
await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 });
const sha256 = createHash("sha256").update(proofBytes).digest("hex");
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, invoice_missing_reason, status, version,
created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, 'unreimbursed', 1, ?, ?, ?, ?)
`).run(expenseId, paidAt, 1234, "导出无发票测试", reason, Date.now(), adminId, Date.now(), adminId);
database.sqlite.prepare(`
INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type,
size_bytes, sha256, created_at, created_by)
VALUES (?, ?, 'payment_proof', ?, ?, 'image/png', ?, ?, ?, ?)
`).run(attachmentId, expenseId, storagePath, "付款截图.png", proofBytes.length, sha256, Date.now(), adminId);
const snapshot: ExportSnapshot = {
includeManifest,
expenses: [{
id: expenseId,
paidAt,
amountCents: 1234,
note: "导出无发票测试",
invoiceMissingReason: reason,
status: "unreimbursed",
attachments: [{
id: attachmentId,
kind: "payment_proof",
originalName: "付款截图.png",
mimeType: "image/png",
storagePath,
sizeBytes: proofBytes.length,
sha256,
}],
}],
};
const jobId = insertExportJob(database.sqlite, config, {
adminId,
sessionHash: "session-hash",
selection: { ids: [expenseId], includeManifest },
snapshot,
});
await buildExportJob(database.sqlite, config, jobId);
return { jobId, expenseId, reason };
}
it("Excel 包含无发票原因列和合计,默认不生成 manifest", async () => {
const { jobId, reason } = await createJob(false);
const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string };
expect(job.status).toBe("ready");
const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath)));
expect([...archive.keys()]).toContain("报销清单.xlsx");
expect(archive.has("manifest.json")).toBe(false);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(archive.get("报销清单.xlsx")!);
const sheet = workbook.getWorksheet("报销清单")!;
expect(sheet.getCell("I1").value).toBe("无发票原因");
expect(sheet.getCell("I2").value).toBe(reason);
expect(sheet.getCell("C2").value).toBe(12.34);
expect(sheet.getCell("C3").value).toBe(12.34);
expect([...archive.keys()].some((name) => name.endsWith("/付款凭证/付款截图.png"))).toBe(true);
});
it("开启 manifest 时包含原因和附件元数据,重复构建不会破坏 ZIP", async () => {
const { jobId, expenseId, reason } = await createJob(true);
await Promise.all([buildExportJob(database.sqlite, config, jobId), buildExportJob(database.sqlite, config, jobId)]);
const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string };
expect(job.status).toBe("ready");
const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath)));
const manifest = JSON.parse(archive.get("manifest.json")!.toString("utf8")) as { records: Array<{ id: string; invoiceMissingReason: string; attachments: Array<{ originalName: string }> }> };
expect(manifest.records).toHaveLength(1);
expect(manifest.records[0]).toMatchObject({ id: expenseId, invoiceMissingReason: reason });
expect(manifest.records[0]!.attachments[0]!.originalName).toBe("付款截图.png");
});
it("导出错误不泄露本地路径或内部附件标识", async () => {
const expenseId = randomUUID();
const missingId = randomUUID();
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, 100, '审计下载', 'unreimbursed', 1, ?, ?, ?, ?)
`).run(expenseId, now, now, adminId, now, adminId);
const storagePath = "bb/proof.png";
await mkdir(path.join(config.filesDir, "bb"), { recursive: true });
await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 });
const digest = createHash("sha256").update(proofBytes).digest("hex");
database.sqlite.prepare(`
INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by)
VALUES (?, ?, 'payment_proof', ?, 'proof.png', 'image/png', ?, ?, ?, ?)
`).run(randomUUID(), expenseId, storagePath, proofBytes.length, digest, now, adminId);
const brokenSnapshot: ExportSnapshot = {
includeManifest: false,
expenses: [{ id: missingId, paidAt: now, amountCents: 100, note: "broken", invoiceMissingReason: null, status: "unreimbursed", attachments: [{ id: randomUUID(), kind: "payment_proof", originalName: "missing.png", mimeType: "image/png", storagePath: "cc/does-not-exist.png", sizeBytes: 12, sha256: "d".repeat(64) }] }],
};
database.sqlite.prepare("INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, 100, 'broken', 'unreimbursed', 1, ?, ?, ?, ?)").run(missingId, now, now, adminId, now, adminId);
const brokenJob = insertExportJob(database.sqlite, config, { adminId, sessionHash: "audit-session", selection: { ids: [missingId] }, snapshot: brokenSnapshot });
await buildExportJob(database.sqlite, config, brokenJob);
const failed = database.sqlite.prepare("SELECT error_message AS errorMessage FROM export_jobs WHERE id=?").get(brokenJob) as { errorMessage: string };
expect(failed.errorMessage).toBe("导出失败:附件文件缺失或校验不通过");
expect(failed.errorMessage).not.toContain("does-not-exist");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
describe("数据库迁移", () => {
it("从 0000 旧库升级时保留记录并幂等应用新字段", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-migration-"));
const previousDataDir = process.env.TALLYNOTE_DATA_DIR;
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3997";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
let migrated: ReturnType<typeof openDatabase> | undefined;
try {
const config = loadConfig();
prepareDataDirectories(config);
const legacy = new Database(config.dbPath);
legacy.exec(readFileSync(path.join(config.migrationsDir, "0000_initial.sql"), "utf8"));
legacy.exec("CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT");
legacy.prepare("INSERT INTO schema_migrations(name, applied_at) VALUES ('0000_initial.sql', ?)").run(Date.now());
legacy.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES ('legacy-admin', 'legacy', 'legacy', '旧管理员', 'hash', 'active', 0, 1, 1, ?)
`).run(Date.now());
legacy.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by,
updated_at, updated_by)
VALUES ('00000000-0000-4000-8000-000000000099', ?, 100, '旧账目', 'unreimbursed', 1, ?, 'legacy-admin', ?, 'legacy-admin')
`).run(Date.now(), Date.now(), Date.now());
legacy.close();
migrated = openDatabase(config);
const columns = migrated.sqlite.prepare("PRAGMA table_info(expenses)").all() as Array<{ name: string }>;
expect(columns.some((column) => column.name === "invoice_missing_reason")).toBe(true);
expect(migrated.sqlite.prepare("SELECT name FROM schema_migrations ORDER BY name").all()).toEqual([
{ name: "0000_initial.sql" },
{ name: "0001_invoice_missing_reason.sql" },
{ name: "0002_update_jobs.sql" },
{ name: "0003_update_job_ownership.sql" },
]);
const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>;
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at"]));
expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null });
migrated.sqlite.close();
migrated = openDatabase(config);
expect(migrated.sqlite.prepare("SELECT COUNT(*) AS count FROM schema_migrations WHERE name='0001_invoice_missing_reason.sql'").get()).toEqual({ count: 1 });
} finally {
migrated?.sqlite.close();
if (previousDataDir === undefined) delete process.env.TALLYNOTE_DATA_DIR;
else process.env.TALLYNOTE_DATA_DIR = previousDataDir;
rmSync(dataDir, { recursive: true, force: true });
}
});
});
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "vitest";
import { chmodSync, mkdirSync, symlinkSync, writeFileSync, statSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
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"];
afterEach(() => { for (const key of keys) delete process.env[key]; });
describe("部署安全配置", () => {
it("公网 HTTP 或 HTTPS 非安全 Cookie 一律拒绝", () => {
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://example.test";
expect(() => loadConfig()).toThrow(/HTTPS/);
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
expect(() => loadConfig()).toThrow(/安全 Cookie/);
});
it("生产环境不接受任意 trust proxy", () => {
process.env.NODE_ENV = "production";
process.env.TALLYNOTE_TRUST_PROXY = "true";
expect(() => loadConfig()).toThrow(/代理跳数/);
process.env.TALLYNOTE_TRUST_PROXY = "1";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
expect(loadConfig().trustProxy).toBe(1);
});
it("systemd 更新必须绑定主机白名单并默认要求签名", () => {
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
process.env.TALLYNOTE_COOKIE_SECURE = "true";
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
const config = loadConfig();
expect(config.updateRequireSignature).toBe(true);
});
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-permissions-"));
try {
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3994";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
const config = loadConfig();
mkdirSync(config.filesDir, { recursive: true });
mkdirSync(config.stagingDir, { recursive: true });
mkdirSync(config.exportsDir, { recursive: true });
writeFileSync(config.dbPath, "placeholder");
chmodSync(config.dataDir, 0o777); chmodSync(config.filesDir, 0o777); chmodSync(config.dbPath, 0o666);
prepareDataDirectories(config);
expect(statSync(config.dataDir).mode & 0o777).toBe(0o700);
expect(statSync(config.filesDir).mode & 0o777).toBe(0o700);
expect(statSync(config.dbPath).mode & 0o777).toBe(0o600);
const linked = path.join(dataDir, "linked");
symlinkSync(config.filesDir, linked);
process.env.TALLYNOTE_DATA_DIR = linked;
expect(() => prepareDataDirectories(loadConfig())).toThrow(/符号链接/);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
});
+134
View File
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chmodSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { buildApp } from "../server/app.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { hashPassword } from "../server/security.js";
import { detectPlatform } from "../server/update.js";
describe("更新 API", () => {
let dataDir: string;
let config: ReturnType<typeof loadConfig>;
let database: ReturnType<typeof openDatabase>;
let app: Awaited<ReturnType<typeof buildApp>>;
const originalFetch = globalThis.fetch;
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-update-api-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3995";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
// This API fixture focuses on queue ownership; the signature path is
// covered by update.test.ts with a generated Ed25519 key.
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
app = await buildApp(database, config);
});
afterEach(async () => {
globalThis.fetch = originalFetch;
await app.close();
database.sqlite.close();
rmSync(dataDir, { recursive: true, force: true });
for (const key of ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"]) delete process.env[key];
});
async function login(username = "update-admin") {
const adminId = randomUUID();
const password = "UpdateApiPassword!2026";
const passwordHash = await hashPassword(password);
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 0, 1, 1, ?)
`).run(adminId, username, username, `更新测试管理员-${username}`, passwordHash, Date.now());
const response = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin: config.publicOrigin }, payload: { username, password } });
const raw = response.headers["set-cookie"];
const cookies = (Array.isArray(raw) ? raw : [raw ?? ""]).map((value) => value.split(";", 1)[0]).join("; ");
const csrf = /(?:^|; )tally_csrf=([^;]+)/.exec(cookies)?.[1] ?? "";
return { cookies, csrf };
}
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
? new Response(`${digest} ${asset}\n`, { status: 200 })
: new Response(JSON.stringify({ tag_name: "v1.1.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
}
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
const session = await login();
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.0", compatible: true, integrityReady: true, isNewer: true });
expect(checked.headers["cache-control"]).toBe("no-store");
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(tooSoon.statusCode).toBe(429);
expect(tooSoon.headers["retry-after"]).toBeDefined();
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
expect(request).toMatchObject({ jobId, 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.0", confirm: true } });
expect(duplicate.statusCode).toBe(409);
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(status.json().job).toMatchObject({ id: jobId, status: "queued" });
const audit = database.sqlite.prepare("SELECT action FROM audit_events WHERE action LIKE 'update.%' ORDER BY id").all() as Array<{ action: string }>;
expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"]));
});
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.0" } });
expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig();
expect(disabledConfig.updateStrategy).toBe("disabled");
});
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
const owner = await login("update-owner");
const other = await login("update-other");
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.0", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
const hiddenStatus = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: other.cookies } });
expect(hiddenStatus.statusCode).toBe(200);
expect(hiddenStatus.json().job).toBeNull();
const hiddenDetail = await app.inject({ method: "GET", url: `/api/update/jobs/${jobId}`, headers: { cookie: other.cookies } });
expect(hiddenDetail.statusCode).toBe(404);
const ownDetail = await app.inject({ method: "GET", url: `/api/update/jobs/${jobId}`, headers: { cookie: owner.cookies } });
expect(ownDetail.statusCode).toBe(200);
expect(ownDetail.json().job.errorMessage).toBe("更新失败,请查看服务器日志或重试");
});
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.0", confirm: true } });
expect(response.statusCode).toBe(502);
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
expect(audit?.outcome).toBe("failure");
});
});
+294
View File
@@ -0,0 +1,294 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import {
atomicSwitchRelease,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
fetchReleaseMetadata,
fetchReleaseText,
extractSafeArchive,
isNewerVersion,
normalizeReleasePermissions,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
} from "../server/update.js";
import { runUpdate } from "../server/cli/update.js";
import { validateUpdateRequest } from "../server/cli/update.js";
import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
for (const key of envKeys) delete process.env[key];
});
describe("更新安全工具", () => {
it("严格比较 SemVer、平台和 HTTPS 白名单", () => {
expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true);
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
expect(detectPlatform("linux", "x86_64").target).toBe("linux-x64");
const release = {
version: "1.2.0",
assets: [
{ name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" },
{ name: "tallynote-1.2.0-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" },
],
};
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))?.name).toContain("linux-x64");
expect(selectReleaseAsset({ version: "1.2.0", assets: [{ name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined();
expect(() => validateHttpsUrl("http://updates.example/x64", { allowedHosts: ["updates.example"] })).toThrow();
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
});
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
const signature = sign(null, Buffer.from(payload), privateKey).toString("base64");
const pem = publicKey.export({ type: "spki", format: "pem" }).toString();
expect(verifyReleaseSignature(payload, signature, pem)).toBe(true);
expect(verifyReleaseSignature(payload, sign(null, Buffer.from(payload), privateKey), pem)).toBe(true);
expect(verifyReleaseSignature(payload + "tampered", signature, pem)).toBe(false);
});
it("拒绝把队列文件重定向到另一更新源", () => {
process.env.TALLYNOTE_DATA_DIR = "/tmp/tallynote-request-test";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3997";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
const config = loadConfig();
const base = {
jobId: "00000000-0000-4000-8000-000000000001",
version: "1.1.0",
assetUrl: "https://updates.example/app.tar.gz",
assetName: "app.tar.gz",
expectedSha256: "a".repeat(64),
requestedAt: Date.now(),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
};
expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://evil.example/latest" }, config)).toThrow(/请求源|主机/);
expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://updates.example/latest", requestedAt: Date.now() - 2 * 24 * 60 * 60 * 1000 }, config)).toThrow(/过期/);
});
it("读取 metadata 和 SHA256 sidecar 时限制重定向主机", async () => {
const digest = "a".repeat(64);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) {
return new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } });
}
return new Response(`${digest} app-linux-x64.tar.gz\n`, { status: 200 });
}) as typeof fetch;
const metadata = await fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] });
expect(metadata.version).toBe("1.2.0");
expect((await fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"] })).trim()).toContain(digest);
});
it("对没有 Content-Length 的 metadata 和 sidecar 响应执行流式大小限制", async () => {
const oversized = "x".repeat(2 * 1024 * 1024 + 1);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
return url.endsWith("/latest")
? new Response(oversized, { status: 200 })
: new Response(oversized, { status: 200 });
}) as typeof fetch;
await expect(fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] })).rejects.toThrow("更新发布信息不可用");
await expect(fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"], maxBytes: 1024 })).rejects.toThrow("更新校验文件过大");
});
it("不会把 SHA256SUMS.sig 误当成摘要清单", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-sidecar-order-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "e".repeat(64);
const assetName = `tallynote-1.2.1-${detectPlatform().target}-glibc.tar.gz`;
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
? new Response("not-a-digest")
: input.toString().endsWith("SHA256SUMS")
? new Response(`${digest} ${assetName}\n`)
: new Response(JSON.stringify({ tag_name: "v1.2.1", assets: [{ name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: assetName, browser_download_url: `https://updates.example/${assetName}` }] })));
try {
const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ compatible: true, integrityReady: true });
} finally {
database.sqlite.close();
await rm(dataDir, { recursive: true, force: true });
}
});
it("下载流限制大小并返回摘要", async () => {
const bytes = Buffer.from("release-bytes");
const destinationRoot = await mkdtemp(path.join(tmpdir(), "tallynote-update-download-"));
try {
globalThis.fetch = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch;
const result = await downloadReleaseAsset("https://updates.example/release.tar.gz", path.join(destinationRoot, "release.tar.gz"), { allowedHosts: ["updates.example"], maxBytes: 1024 });
expect(result.size).toBe(bytes.length);
expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex"));
} finally {
await rm(destinationRoot, { recursive: true, force: true });
}
});
it("原子切换 current 符号链接并保留旧版本", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-switch-"));
try {
const releases = path.join(root, "releases");
const current = path.join(root, "current");
const old = path.join(releases, "1.0.0");
const staged = path.join(root, "staged");
await mkdir(path.join(old, "dist"), { recursive: true });
await writeFile(path.join(old, "dist", "marker"), "old");
await mkdir(path.join(staged, "dist"), { recursive: true });
await writeFile(path.join(staged, "dist", "marker"), "new");
await symlink(old, current);
const result = await atomicSwitchRelease(staged, current, releases, "1.1.0");
expect(await readlink(current)).toBe(path.join(releases, "1.1.0"));
expect(result.previousTarget).toBe(path.relative(root, old));
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("runUpdate 校验摘要、解包并原子替换目录", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-run-"));
try {
const source = path.join(root, "source");
const current = path.join(root, "current");
const staging = path.join(root, "staging");
const backup = path.join(root, "backups", "old.tar.gz");
await mkdir(path.join(source, "dist"), { recursive: true });
await writeFile(path.join(source, "dist", "marker"), "new");
await mkdir(path.join(current, "dist"), { recursive: true });
await writeFile(path.join(current, "dist", "marker"), "old");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
const bytes = await readFile(archive);
const digest = createHash("sha256").update(bytes).digest("hex");
const fetchImpl = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch;
const result = await runUpdate({
assetUrl: "https://updates.example/release.tar.gz",
assetName: "release.tar.gz",
version: "1.1.0",
expectedSha256: digest,
currentVersion: "1.0.0",
currentDir: current,
stagingDir: staging,
backupArchivePath: backup,
allowedHosts: ["updates.example"],
fetchImpl,
});
expect(result.version).toBe("1.1.0");
expect(await readFile(path.join(current, "dist", "marker"), "utf8")).toBe("new");
expect((await stat(backup)).size).toBeGreaterThan(0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-"));
try {
const source = path.join(root, "source");
const destination = path.join(root, "destination");
await mkdir(path.join(source, "dist", "server"), { recursive: true });
await mkdir(path.join(source, "bin"), { recursive: true });
await mkdir(path.join(source, "scripts"), { recursive: true });
await mkdir(path.join(source, "runtime", "bin"), { recursive: true });
await writeFile(path.join(source, "dist", "server", "large.js"), Buffer.alloc(2 * 1024 * 1024, 0x41));
await writeFile(path.join(source, "bin", "tallynote"), "#!/bin/sh\n");
await writeFile(path.join(source, "scripts", "runner.sh"), "#!/bin/sh\n");
await writeFile(path.join(source, "runtime", "bin", "node"), "node");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
expect((await stat(archive)).size).toBeLessThan(64 * 1024);
await expect(extractSafeArchive(archive, destination, { maxBytes: 1024 * 1024 })).rejects.toThrow(/大小限制/);
expect(await stat(destination).catch(() => null)).toBeNull();
await extractSafeArchive(archive, destination, { maxBytes: 4 * 1024 * 1024 });
await normalizeReleasePermissions(destination);
expect((await stat(path.join(destination, "dist"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "dist", "server", "large.js"))).mode & 0o777).toBe(0o644);
expect((await stat(path.join(destination, "bin", "tallynote"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "scripts", "runner.sh"))).mode & 0o777).toBe(0o755);
expect((await stat(path.join(destination, "runtime", "bin", "node"))).mode & 0o777).toBe(0o755);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("流式创建备份遵守大小上限并清理失败的临时文件", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-archive-"));
try {
const source = path.join(root, "source");
const archive = path.join(root, "backup.tar.gz");
await mkdir(source, { recursive: true });
await writeFile(path.join(source, "large.bin"), Buffer.alloc(128 * 1024, 0x42));
await expect(createSafeArchive(source, archive, { maxBytes: 1024 })).rejects.toThrow(/大小限制/);
expect(await stat(archive).catch(() => null)).toBeNull();
expect((await readdir(root)).filter((name) => name.includes(".part-")).length).toBe(0);
await createSafeArchive(source, archive, { maxBytes: 256 * 1024 });
expect((await stat(archive)).size).toBeGreaterThan(0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("更新元数据缓存", () => {
it("选择当前平台资产并要求 SHA256 sidecar", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-cache-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
process.env.TALLYNOTE_UPDATE_PUBLIC_KEY = publicPem;
const config = loadConfig();
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`;
const sums = `${digest} ${platformAsset}\n`;
const signature = sign(null, Buffer.from(sums), privateKey);
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
? new Response(signature)
: input.toString().endsWith("SHA256SUMS")
? new Response(sums)
: new Response(JSON.stringify({ tag_name: "v1.1.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
try {
const result = await checkForUpdate(database.sqlite, config);
expect(result.latest).toMatchObject({ version: "1.1.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
} finally {
database.sqlite.close();
await rm(dataDir, { recursive: true, force: true });
}
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2023",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": ".",
"types": ["node"],
"sourceMap": true
},
"include": ["server/**/*.ts", "shared/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["web/src/**/*.ts", "web/src/**/*.tsx", "shared/**/*.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const apiPort = Number(process.env.TALLYNOTE_PORT ?? 3000);
export default defineConfig({
root: "web",
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
proxy: {
"/api": `http://127.0.0.1:${apiPort}`,
"/health": `http://127.0.0.1:${apiPort}`,
},
},
build: {
outDir: "../dist/web",
emptyOutDir: true,
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
pool: "forks",
fileParallelism: false,
},
});
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TallyNote 账目台</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
+892
View File
@@ -0,0 +1,892 @@
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { createRoot } from "react-dom/client";
import {
AlertCircle,
Archive,
ArrowDownToLine,
CheckCircle2,
Check,
ChevronLeft,
ChevronRight,
CircleDollarSign,
ClipboardList,
Copy,
Eye,
FileDown,
FileText,
Image as ImageIcon,
Loader2,
LogOut,
Menu,
Plus,
RotateCcw,
RefreshCw,
Search,
Settings,
ShieldCheck,
Server,
Trash2,
Upload,
Users,
X,
} from "lucide-react";
import "./styles.css";
type Admin = {
id: string;
username: string;
displayName: string;
status: string;
mustChangePassword: boolean;
version: number;
lastLoginAt?: number | null;
};
type Attachment = {
id: string;
kind: "payment_proof" | "invoice";
originalName: string;
mimeType: string;
sizeBytes: number;
previewable: boolean;
};
type Expense = {
id: string;
paidAt: number;
amountCents: number;
note: string;
invoiceMissingReason: string | null;
status: "unreimbursed" | "reimbursed";
version: number;
paymentProofCount: number;
invoiceCount: number;
deletedAt?: number | null;
attachments?: Attachment[];
createdByName?: string;
updatedByName?: string;
};
type TimelineEvent = {
id: number;
occurredAt: number;
actorUsername?: string | null;
action: string;
};
type Notice = { id: number; kind: "success" | "error" | "info"; message: string };
type UpdateJob = {
id: string;
status: "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
version: string;
platform: string;
assetName?: string | null;
sizeBytes?: number | null;
errorMessage?: string | null;
createdAt: number;
updatedAt: number;
completedAt?: number | null;
};
type UpdateInfo = {
configured: boolean;
strategy: "disabled" | "systemd";
currentVersion: string;
platform: { target: string; os: string; arch: string };
checkedAt: number;
latest: {
version: string;
tagName?: string;
publishedAt?: string;
compatible: boolean;
integrityReady: boolean;
signatureReady: boolean;
isNewer: boolean;
assetName?: string;
assetSize?: number;
} | null;
job: UpdateJob | null;
};
class ApiError extends Error {
constructor(public readonly status: number, message: string, public readonly code?: string, public readonly details?: any) {
super(message);
}
}
let appTimezone = "Asia/Shanghai";
const money = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
const dateText = (ms: number) => new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: appTimezone }).format(new Date(ms));
const dateInputValue = (date: Date) => {
const parts = new Intl.DateTimeFormat("en-CA", { timeZone: appTimezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}-${values.month}-${values.day}T${values.hour}:${values.minute}`;
};
const dateFromInput = (value: string) => {
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/.exec(value.trim());
if (!match) throw new Error("请选择有效的支付日期和时间");
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const calendarProbe = new Date(0);
calendarProbe.setUTCFullYear(year, month - 1, day);
calendarProbe.setUTCHours(0, 0, 0, 0);
if (month < 1 || month > 12 || day < 1 || day > 31 || calendarProbe.getUTCFullYear() !== year || calendarProbe.getUTCMonth() !== month - 1 || calendarProbe.getUTCDate() !== day || hour < 0 || hour > 23 || minute < 0 || minute > 59) throw new Error("支付时间无效");
const wall = new Date(0);
wall.setUTCFullYear(year, month - 1, day);
wall.setUTCHours(hour, minute, 0, 0);
const utc = wall.getTime();
const parts = new Intl.DateTimeFormat("en-CA", { timeZone: appTimezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(new Date(utc));
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
const observed = Date.UTC(Number(values.year), Number(values.month) - 1, Number(values.day), Number(values.hour), Number(values.minute));
return new Date(utc + (utc - observed)).toISOString();
};
const monthNow = () => {
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: appTimezone, year: "numeric", month: "2-digit" }).formatToParts(new Date()).map((part) => [part.type, part.value]));
return `${parts.year}-${parts.month}`;
};
const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
const readCookie = (name: string) => document.cookie.split("; ").find((value) => value.startsWith(`${name}=`))?.split("=")[1] ?? "";
async function api<T = any>(url: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (["POST", "PUT", "PATCH", "DELETE"].includes((init.method || "GET").toUpperCase())) {
const raw = readCookie("tally_csrf");
try { headers.set("X-CSRF-Token", decodeURIComponent(raw)); } catch { headers.set("X-CSRF-Token", raw); }
}
let response: Response;
try {
response = await fetch(url, { credentials: "include", ...init, headers });
} catch {
throw new ApiError(0, "网络连接失败,请确认服务仍在运行");
}
if (response.status === 204) return undefined as T;
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const code = data?.error?.code;
if (response.status === 401 && code === "AUTH_REQUIRED" && !["/api/auth/login", "/api/auth/session", "/api/auth/change-password"].includes(url)) {
window.dispatchEvent(new CustomEvent("tallynote-auth-expired", { detail: data?.error?.message || "登录已失效,请重新登录" }));
}
throw new ApiError(response.status, data?.error?.message || `请求失败(${response.status})`, code, data?.error?.details);
}
return data;
}
function Button({ children, kind = "default", ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { kind?: "default" | "primary" | "danger" | "ghost" }) {
return <button className={`btn btn-${kind}`} {...props}>{children}</button>;
}
function TooltipLayer() {
const activeRef = useRef<HTMLElement | null>(null);
const [tooltip, setTooltip] = useState<{ id: string; label: string; left: number; top: number; placement: "above" | "below" } | null>(null);
const update = useCallback((element: HTMLElement) => {
const label = element.getAttribute("title");
if (!label) return;
const rect = element.getBoundingClientRect();
const placement = rect.top > 56 ? "above" : "below";
setTooltip({ id: "tallynote-tooltip", label, left: rect.left + rect.width / 2, top: placement === "above" ? rect.top : rect.bottom, placement });
}, []);
useEffect(() => {
const show = (event: Event) => {
const target = (event.target as Element | null)?.closest<HTMLElement>("[title]");
if (!target) return;
activeRef.current = target;
update(target);
};
const hide = (event: Event) => {
const target = activeRef.current;
const related = (event as MouseEvent).relatedTarget as Node | null;
if (target && related && target.contains(related)) return;
activeRef.current = null;
setTooltip(null);
};
const onViewportChange = () => { if (activeRef.current) update(activeRef.current); };
document.addEventListener("pointerover", show);
document.addEventListener("pointerout", hide);
document.addEventListener("focusin", show);
document.addEventListener("focusout", hide);
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
return () => {
document.removeEventListener("pointerover", show);
document.removeEventListener("pointerout", hide);
document.removeEventListener("focusin", show);
document.removeEventListener("focusout", hide);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
};
}, [update]);
if (!tooltip) return null;
const style: React.CSSProperties = tooltip.placement === "above"
? { left: tooltip.left, top: tooltip.top, transform: "translate(-50%, calc(-100% - 8px))" }
: { left: tooltip.left, top: tooltip.top, transform: "translate(-50%, 8px)" };
return createPortal(<span id={tooltip.id} className="tooltip" role="tooltip" style={style}>{tooltip.label}</span>, document.body);
}
let overlayLockCount = 0;
let previousBodyOverflow = "";
let previousBodyPaddingRight = "";
function useOverlayScrollLock() {
useLayoutEffect(() => {
const body = document.body;
if (overlayLockCount === 0) {
previousBodyOverflow = body.style.overflow;
previousBodyPaddingRight = body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
const existingPaddingRight = Number.parseFloat(window.getComputedStyle(body).paddingRight) || 0;
body.style.paddingRight = `${existingPaddingRight + scrollbarWidth}px`;
}
body.style.overflow = "hidden";
}
overlayLockCount += 1;
return () => {
overlayLockCount = Math.max(0, overlayLockCount - 1);
if (overlayLockCount === 0) {
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
}
};
}, []);
}
function NoticeRegion({ notices, dismiss }: { notices: Notice[]; dismiss: (id: number) => void }) {
return <div className="notice-region" aria-live="polite" aria-atomic="true">{notices.map((notice) => <div key={notice.id} className={`notice notice-${notice.kind}`} role={notice.kind === "error" ? "alert" : "status"}><span>{notice.kind === "error" ? <AlertCircle size={16} /> : <Check size={16} />}{notice.message}</span><button className="icon-btn compact" onClick={() => dismiss(notice.id)} aria-label="关闭通知" title="关闭"><X size={15} /></button></div>)}</div>;
}
function Login({ onDone, notice }: { onDone: (admin: Admin) => void; notice?: string }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true); setError("");
try { const result = await api<{ admin: Admin }>("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); onDone(result.admin); }
catch (error) { setError((error as Error).message); }
finally { setBusy(false); }
};
return <div className="auth-shell"><div className="auth-panel"><div className="brand-mark"><CircleDollarSign size={28} /><span>TallyNote</span></div><p className="muted">账目与凭证工作台</p>{notice && <div className="info" role="status"><AlertCircle size={16} />{notice}</div>}<form onSubmit={submit} className="stack"><label>用户名<input value={username} onChange={(event) => setUsername(event.target.value)} autoFocus autoComplete="username" required /></label><label>密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required /></label>{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}<Button kind="primary" disabled={busy} type="submit">{busy ? <Loader2 className="spin" size={16} /> : "登录"}</Button></form></div></div>;
}
function ChangePassword({ admin, onDone }: { admin: Admin; onDone: (admin: Admin) => void }) {
const [currentPassword, setCurrent] = useState("");
const [newPassword, setNew] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (newPassword !== confirm) { setError("两次输入的新密码不一致"); return; }
setBusy(true); setError("");
try { const result = await api<{ admin: Admin }>("/api/auth/change-password", { method: "POST", body: JSON.stringify({ currentPassword, newPassword }) }); onDone(result.admin); }
catch (error) { setError((error as Error).message); }
finally { setBusy(false); }
};
return <div className="auth-shell"><div className="auth-panel"><div className="brand-mark"><ShieldCheck size={28} /><span>首次登录保护</span></div><p className="muted">管理员 <strong>{admin.displayName}</strong> 需要设置新密码(至少 12 位)。</p><form onSubmit={submit} className="stack"><label>当前密码<input type="password" value={currentPassword} onChange={(event) => setCurrent(event.target.value)} autoComplete="current-password" required /></label><label>新密码<input type="password" minLength={12} value={newPassword} onChange={(event) => setNew(event.target.value)} autoComplete="new-password" required /></label><label>确认新密码<input type="password" value={confirm} onChange={(event) => setConfirm(event.target.value)} autoComplete="new-password" required /></label>{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}<Button kind="primary" disabled={busy} type="submit">{busy ? <Loader2 className="spin" size={16} /> : "更新密码"}</Button></form></div></div>;
}
function Modal({ title, onClose, children, footer, initialFocus = "close" }: { title: string; onClose: () => void; children: React.ReactNode; footer?: React.ReactNode; initialFocus?: "close" | "content" }) {
useOverlayScrollLock();
const titleId = useId();
const firstRef = useRef<HTMLButtonElement>(null);
const modalRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
if (initialFocus === "close") firstRef.current?.focus(); else modalRef.current?.querySelector<HTMLElement>("input, textarea, select, [role=button]")?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onCloseRef.current(); return; }
if (event.key !== "Tab" || !modalRef.current) return;
const focusable = Array.from(modalRef.current.querySelectorAll<HTMLElement>("button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\")]"));
if (!focusable.length) return;
const first = focusable[0]!; const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown, true);
return () => { document.removeEventListener("keydown", onKeyDown, true); previous?.focus(); };
}, [initialFocus]);
return createPortal(<div className="modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><section ref={modalRef} className="modal" role="dialog" aria-modal="true" aria-labelledby={titleId}><div className="modal-head"><h2 id={titleId}>{title}</h2><button ref={firstRef} className="icon-btn" onClick={onClose} aria-label="关闭" title="关闭"><X size={18} /></button></div><div className="modal-body">{children}</div>{footer && <div className="modal-footer">{footer}</div>}</section></div>, document.body);
}
function ConfirmDialog({ title, message, confirmLabel = "确认", danger = false, busy = false, onClose, onConfirm }: { title: string; message: React.ReactNode; confirmLabel?: string; danger?: boolean; busy?: boolean; onClose: () => void; onConfirm: () => void }) {
return <Modal title={title} onClose={busy ? () => undefined : onClose} footer={<><Button onClick={onClose} disabled={busy}>取消</Button><Button kind={danger ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? <Loader2 className="spin" size={16} /> : confirmLabel}</Button></>}><p className="modal-message">{message}</p></Modal>;
}
function AttachmentDeleteDialog({ attachment, requiresReason, reason, error, busy, onReasonChange, onClose, onConfirm }: {
attachment: Attachment;
requiresReason: boolean;
reason: string;
error?: string;
busy: boolean;
onReasonChange: (value: string) => void;
onClose: () => void;
onConfirm: (reason: string) => void;
}) {
const reasonId = useId();
const message = attachment.kind === "payment_proof"
? "将删除这张付款凭证。账目至少需要保留一张付款凭证。"
: requiresReason
? "这是最后一张发票。删除后必须保留无发票原因,原因会与删除操作一起保存。"
: "将删除这张发票。当前已填写的无发票原因会按原样保留(如有)。";
return <Modal title="删除附件?" initialFocus={requiresReason ? "content" : "close"} onClose={busy ? () => undefined : onClose} footer={<><Button onClick={onClose} disabled={busy}>取消</Button><Button kind="danger" onClick={() => onConfirm(reason)} disabled={busy || (requiresReason && !reason.trim())}>{busy ? <Loader2 className="spin" size={16} /> : "删除附件"}</Button></>}>
<p className="modal-message">{message}</p>
{requiresReason && <label className="invoice-reason delete-invoice-reason" htmlFor={reasonId}>无发票原因 <span aria-hidden="true" className="required">*</span><textarea id={reasonId} aria-label="无发票原因" rows={3} maxLength={500} value={reason} onChange={(event) => onReasonChange(event.target.value)} placeholder="例如:商家无法开具发票" required aria-required="true" aria-describedby={`${reasonId}-hint`} /><span id={`${reasonId}-hint`} className="field-hint">删除最后一张发票后,这个原因会显示在账目和导出清单中。</span></label>}
{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}
</Modal>;
}
function SecretDialog({ password, title, onClose }: { password: string; title: string; onClose: () => void }) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const copy = async () => {
if (!navigator.clipboard?.writeText) { setCopyState("failed"); return; }
try { await navigator.clipboard.writeText(password); setCopyState("copied"); }
catch { setCopyState("failed"); }
};
return <Modal title={title} onClose={onClose} footer={<Button kind="primary" onClick={onClose}>完成</Button>}><div className="secret-box"><code>{password}</code><button className="icon-btn" onClick={copy} aria-label="复制临时密码" title="复制临时密码"><Copy size={16} /></button></div>{copyState === "copied" && <div className="copy-feedback copy-success" role="status">临时密码已复制</div>}{copyState === "failed" && <div className="copy-feedback copy-failed" role="alert">复制失败,请手动选中上方密码复制</div>}<p className="field-hint">请立即安全转交。该账号首次登录时必须修改密码。</p></Modal>;
}
function PasswordDialog({ title, onClose, onConfirm, busy, error }: { title: string; onClose: () => void; onConfirm: (password: string) => void; busy: boolean; error?: string }) {
const [password, setPassword] = useState("");
return <Modal title={title} initialFocus="content" onClose={busy ? () => undefined : onClose} footer={<><Button onClick={onClose} disabled={busy}>取消</Button><Button kind="danger" onClick={() => onConfirm(password)} disabled={busy || !password}>{busy ? <Loader2 className="spin" size={16} /> : "永久删除"}</Button></>}><p className="modal-message">业务记录和附件字节将永久删除,但完整审计历史仍会保留。请输入当前登录密码确认。</p>{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}<label>当前密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" /></label></Modal>;
}
function Drawer({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
useOverlayScrollLock();
const titleId = useId();
const drawerRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
drawerRef.current?.querySelector<HTMLElement>("input, textarea, button")?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if ((event.target as Element | null)?.closest(".modal")) return;
if (event.key === "Escape") { event.preventDefault(); onCloseRef.current(); return; }
if (event.key !== "Tab" || !drawerRef.current) return;
const focusable = Array.from(drawerRef.current.querySelectorAll<HTMLElement>("button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\")]"));
if (!focusable.length) return;
const first = focusable[0]!; const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown, true);
return () => { document.removeEventListener("keydown", onKeyDown, true); previous?.focus(); };
}, []);
return createPortal(<div className="drawer-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><aside ref={drawerRef} className="drawer" role="dialog" aria-modal="true" aria-labelledby={titleId}><div className="drawer-head"><h2 id={titleId}>{title}</h2><button className="icon-btn" onClick={onClose} aria-label="关闭" title="关闭"><X size={18} /></button></div>{children}</aside></div>, document.body);
}
function FilePick({ label, kind, files, setFiles, required, maxFiles = 20 }: { label: string; kind: "payment_proof" | "invoice"; files: File[]; setFiles: (files: File[]) => void; required?: boolean; maxFiles?: number }) {
const id = useId();
const accept = kind === "payment_proof" ? "image/jpeg,image/png,image/webp" : ".pdf,.ofd,.xml,application/pdf,application/ofd,application/xml";
const choose = (event: React.ChangeEvent<HTMLInputElement>) => {
const incoming = Array.from(event.target.files || []);
const merged = [...files, ...incoming].filter((file, index, all) => all.findIndex((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified) === index);
setFiles(merged.slice(0, maxFiles));
event.target.value = "";
};
return <div className="file-pick"><span className="field-label">{label}{required && <span className="required"> *</span>}</span><div className="file-input"><Upload size={16} />选择文件<input id={id} type="file" aria-label={label} aria-required={required || undefined} accept={accept} multiple disabled={maxFiles <= 0} onChange={choose} /></div>{files.length > 0 && <ul className="file-list">{files.map((file) => <li key={`${file.name}-${file.lastModified}-${file.size}`}><span title={file.name}>{file.name} <small>{formatBytes(file.size)}</small></span><button type="button" className="icon-btn compact" onClick={() => setFiles(files.filter((item) => item !== file))} aria-label={`移除文件 ${file.name}`} title="移除"><X size={14} /></button></li>)}</ul>}<span className="field-hint">单次保存最多 20 个附件,单文件大小由服务器限制。</span></div>;
}
function ExpenseDrawer({ expense, onClose, onSaved, notify }: { expense?: Expense | null; onClose: () => void; onSaved: () => void; notify: (message: string, kind?: Notice["kind"]) => void }) {
const initialAmount = useRef(expense ? (expense.amountCents / 100).toFixed(2) : "");
const initialNote = useRef(expense?.note || "");
const initialInvoiceMissingReason = useRef(expense?.invoiceMissingReason || "");
const [amount, setAmount] = useState(initialAmount.current);
const [note, setNote] = useState(initialNote.current);
const initialPaidAt = useRef(expense ? dateInputValue(new Date(expense.paidAt)) : dateInputValue(new Date()));
const [paidAt, setPaidAt] = useState(initialPaidAt.current);
const [proofs, setProofs] = useState<File[]>([]);
const [invoices, setInvoices] = useState<File[]>([]);
const [invoiceMissing, setInvoiceMissing] = useState(Boolean(initialInvoiceMissingReason.current));
const [invoiceMissingReason, setInvoiceMissingReason] = useState(initialInvoiceMissingReason.current);
const [serverInvoiceCount, setServerInvoiceCount] = useState(expense?.invoiceCount ?? 0);
const [version, setVersion] = useState(expense?.version ?? 1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [conflict, setConflict] = useState<Expense | null>(null);
const [discardPrompt, setDiscardPrompt] = useState(false);
const invoiceReasonId = useId();
const hasInvoice = Boolean(serverInvoiceCount + invoices.length);
const hasExistingInvoice = serverInvoiceCount > 0;
const setSelectedInvoices = (files: File[]) => {
setInvoices(files);
if (files.length > 0) {
setInvoiceMissing(false);
setInvoiceMissingReason("");
}
};
const dirty = Boolean(proofs.length || invoices.length || amount !== initialAmount.current || note !== initialNote.current || paidAt !== initialPaidAt.current || invoiceMissing !== Boolean(initialInvoiceMissingReason.current) || invoiceMissingReason !== initialInvoiceMissingReason.current);
const requestClose = () => { if (busy) return; if (dirty) setDiscardPrompt(true); else onClose(); };
const applyServer = (server: Expense, keepDraft: boolean) => {
setVersion(server.version);
setServerInvoiceCount(server.invoiceCount);
if (server.invoiceCount > 0) {
// A server-side invoice always wins the mutually exclusive policy,
// including when the user chose to keep an unsaved draft after a conflict.
setInvoiceMissing(false);
setInvoiceMissingReason("");
}
if (!keepDraft) {
const serverPaidAt = dateInputValue(new Date(server.paidAt));
const serverAmount = (server.amountCents / 100).toFixed(2);
const serverInvoiceMissingReason = server.invoiceMissingReason || "";
setPaidAt(serverPaidAt); setAmount(serverAmount); setNote(server.note);
if (server.invoiceCount === 0) {
setInvoiceMissing(Boolean(serverInvoiceMissingReason));
setInvoiceMissingReason(serverInvoiceMissingReason);
}
initialPaidAt.current = serverPaidAt; initialAmount.current = serverAmount; initialNote.current = server.note; initialInvoiceMissingReason.current = serverInvoiceMissingReason;
setProofs([]); setInvoices([]);
}
setConflict(null); setError(keepDraft ? "当前内容已保留,请再次保存以覆盖服务器版本。" : "");
};
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!expense && proofs.length === 0) { setError("至少选择一张付款凭证"); return; }
if (!/^(?:0|[1-9][0-9]*)(?:[.][0-9]{1,2})?$/.test(amount) || Number(amount) <= 0 || !Number.isFinite(Number(amount))) { setError("金额必须为大于零且最多两位小数"); return; }
const normalizedInvoiceMissingReason = invoiceMissing ? invoiceMissingReason.trim() : "";
const hasInvoiceAfterSave = Boolean(serverInvoiceCount + invoices.length);
if (!hasInvoiceAfterSave && !normalizedInvoiceMissingReason) { setError("请上传发票,或勾选“无发票”并填写原因"); return; }
setBusy(true); setError("");
try {
const normalizedPaidAt = dateFromInput(paidAt);
const proofFiles = proofs;
const invoiceFiles = invoices;
const uploadAttachments = async (kind: "payment_proof" | "invoice", files: File[], startingVersion: number) => {
if (!files.length || !expense) return startingVersion;
const form = new FormData();
form.append("version", String(startingVersion));
files.forEach((file) => form.append(kind === "payment_proof" ? "paymentProofs" : "invoices", file));
const result = await api<{ expense: Expense }>(`/api/expenses/${expense.id}/attachments?kind=${kind}`, { method: "POST", body: form });
setVersion(result.expense.version);
setServerInvoiceCount(result.expense.invoiceCount);
if (result.expense.invoiceCount > 0) {
setInvoiceMissing(false);
setInvoiceMissingReason("");
} else {
setInvoiceMissing(Boolean(result.expense.invoiceMissingReason));
setInvoiceMissingReason(result.expense.invoiceMissingReason || "");
}
return result.expense.version;
};
if (expense) {
let currentVersion = version;
const submittedInvoiceReason = hasInvoiceAfterSave ? null : (normalizedInvoiceMissingReason || null);
if (invoiceFiles.length && !hasExistingInvoice) { currentVersion = await uploadAttachments("invoice", invoiceFiles, currentVersion); setInvoices([]); }
const updated = await api<{ expense: Expense }>(`/api/expenses/${expense.id}`, { method: "PATCH", body: JSON.stringify({ paidAt: normalizedPaidAt, amount, note, invoiceMissingReason: submittedInvoiceReason, version: currentVersion }) });
currentVersion = updated.expense.version;
setVersion(currentVersion);
setServerInvoiceCount(updated.expense.invoiceCount);
if (invoiceFiles.length && hasExistingInvoice) { currentVersion = await uploadAttachments("invoice", invoiceFiles, currentVersion); setInvoices([]); }
if (proofFiles.length) { currentVersion = await uploadAttachments("payment_proof", proofFiles, currentVersion); setProofs([]); }
} else {
const form = new FormData(); form.append("paidAt", normalizedPaidAt); form.append("amount", amount); form.append("note", note); form.append("invoiceMissingReason", normalizedInvoiceMissingReason); proofFiles.forEach((file) => form.append("paymentProofs", file)); invoiceFiles.forEach((file) => form.append("invoices", file)); await api("/api/expenses", { method: "POST", body: form });
}
notify(expense ? "账目已更新" : "账目已保存", "success"); onSaved(); onClose();
} catch (caught) {
const errorValue = caught as ApiError;
if (errorValue instanceof ApiError && errorValue.status === 409 && errorValue.details?.current) setConflict(errorValue.details.current as Expense);
else setError((caught as Error).message);
} finally { setBusy(false); }
};
return <>
<Drawer title={expense ? "编辑账目" : "新增账目"} onClose={requestClose}><form noValidate className="stack drawer-form" onSubmit={submit}>
<label>支付时间<input type="datetime-local" value={paidAt} onChange={(event) => setPaidAt(event.target.value)} step={60} required /><span className="field-hint">选择日期和时间(精确到分钟,按应用时区保存)</span></label>
<label>金额(元)<input inputMode="decimal" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0.00" pattern="(?:0|[1-9][0-9]*)(?:[.][0-9]{1,2})?" title="请输入大于零且最多两位小数的金额" required /></label>
<label>备注<textarea rows={4} maxLength={2000} value={note} onChange={(event) => setNote(event.target.value)} placeholder="可选" /></label>
<FilePick label="付款凭证(至少 1 张)" kind="payment_proof" files={proofs} setFiles={setProofs} required={!expense} maxFiles={Math.max(0, 20 - invoices.length)} />
<div className="invoice-policy"><FilePick label="发票(可选)" kind="invoice" files={invoices} setFiles={setSelectedInvoices} maxFiles={Math.max(0, 20 - proofs.length)} /><label className="check invoice-missing-toggle"><input type="checkbox" checked={invoiceMissing} disabled={hasInvoice} onChange={(event) => { setInvoiceMissing(event.target.checked); if (!event.target.checked) setInvoiceMissingReason(""); }} />无发票</label>{hasInvoice && <span className="field-hint invoice-policy-hint">已上传发票,无需填写无发票原因。</span>}{invoiceMissing && !hasInvoice && <label className="invoice-reason" htmlFor={invoiceReasonId}>无发票原因 <span aria-hidden="true" className="required">*</span><textarea id={invoiceReasonId} aria-label="无发票原因" rows={3} maxLength={500} value={invoiceMissingReason} onChange={(event) => setInvoiceMissingReason(event.target.value)} placeholder="例如:商家无法开具发票" required aria-required="true" /></label>}{!hasExistingInvoice && !invoices.length && !invoiceMissing && <span className="field-hint invoice-policy-hint">请上传发票,或勾选“无发票”并填写原因。</span>}</div>
{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}
<div className="drawer-actions"><Button type="button" onClick={requestClose}>取消</Button><Button kind="primary" disabled={busy} type="submit">{busy ? <Loader2 className="spin" size={16} /> : <><Check size={16} />保存</>}</Button></div>
</form></Drawer>
{discardPrompt && <ConfirmDialog title="放弃未保存内容?" message="当前表单有未保存的修改,关闭后这些内容会丢失。" confirmLabel="放弃并关闭" danger onClose={() => setDiscardPrompt(false)} onConfirm={onClose} />}
{conflict && <Modal title="记录已被更新" onClose={() => setConflict(null)} footer={<><Button onClick={() => applyServer(conflict, false)}>加载服务器版本</Button><Button kind="primary" onClick={() => applyServer(conflict, true)}>保留当前内容</Button></>}><p className="modal-message">另一位管理员刚刚修改了这笔账目。请选择如何处理,系统不会静默覆盖。</p><div className="conflict-summary"><span>服务器金额</span><strong>{money(conflict.amountCents)}</strong><span>服务器版本</span><strong>v{conflict.version}</strong></div></Modal>}
</>;
}
function AttachmentPreview({ attachment, onClose }: { attachment: Attachment; onClose: () => void }) {
const source = `/api/attachments/${attachment.id}/content`;
return <Modal title={attachment.originalName} onClose={onClose} footer={<><a className="btn" href={`${source}?download=1`}>下载附件</a><Button onClick={onClose}>关闭</Button></>}><div className="preview-frame">{attachment.mimeType.startsWith("image/") ? <img src={source} alt={attachment.originalName} /> : <iframe src={source} title={attachment.originalName} />}</div></Modal>;
}
function ExpenseDetail({ expense, onClose, onUpdated, onRequestEdit, notify }: { expense: Expense; onClose: () => void; onUpdated: () => void; onRequestEdit: (expense: Expense) => void; notify: (message: string, kind?: Notice["kind"]) => void }) {
const [detail, setDetail] = useState<Expense>(expense);
const [timeline, setTimeline] = useState<TimelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmAction, setConfirmAction] = useState<"status" | "trash" | null>(null);
const [removeTarget, setRemoveTarget] = useState<Attachment | null>(null);
const [removeReason, setRemoveReason] = useState("");
const [removeError, setRemoveError] = useState("");
const [preview, setPreview] = useState<Attachment | null>(null);
const loadDetail = useCallback(async () => {
setLoading(true); setError("");
try { const result = await api<{ expense: Expense; timeline: TimelineEvent[] }>(`/api/expenses/${expense.id}`); setDetail(result.expense); setTimeline(result.timeline); }
catch (caught) { setError((caught as Error).message); }
finally { setLoading(false); }
}, [expense.id]);
useEffect(() => { void loadDetail(); }, [loadDetail]);
const updateStatus = async () => {
setBusy(true);
try { const next = detail.status === "reimbursed" ? "unreimbursed" : "reimbursed"; const result = await api<{ expense: Expense }>(`/api/expenses/${detail.id}/status`, { method: "POST", body: JSON.stringify({ status: next, version: detail.version }) }); setDetail(result.expense); await loadDetail(); setConfirmAction(null); notify(next === "reimbursed" ? "已标记为已报销" : "已改回未报销", "success"); onUpdated(); }
catch (caught) { setError((caught as Error).message); setConfirmAction(null); }
finally { setBusy(false); }
};
const trash = async () => {
setBusy(true);
try { await api(`/api/expenses/${detail.id}`, { method: "DELETE", body: JSON.stringify({ version: detail.version }) }); setConfirmAction(null); notify("账目已移入回收站", "success"); onClose(); onUpdated(); }
catch (caught) { setError((caught as Error).message); setConfirmAction(null); }
finally { setBusy(false); }
};
const openRemoveDialog = (attachment: Attachment) => {
setRemoveError("");
setRemoveReason("");
setRemoveTarget(attachment);
};
const removeAttachment = async (reason: string) => {
if (!removeTarget) return;
const requiresReason = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim();
const normalizedReason = reason.trim();
if (requiresReason && !normalizedReason) {
setRemoveError("请填写无发票原因");
return;
}
setBusy(true);
try {
const payload: { version: number; invoiceMissingReason?: string } = { version: detail.version };
if (requiresReason) payload.invoiceMissingReason = normalizedReason;
const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(payload) });
setDetail(result.expense);
setRemoveTarget(null);
setRemoveReason("");
setRemoveError("");
notify("附件已删除", "success");
await loadDetail();
onUpdated();
}
catch (caught) { setRemoveError((caught as Error).message); }
finally { setBusy(false); }
};
return <>
<Drawer title="账目详情" onClose={onClose}><div className="detail">
{loading ? <div className="inline-loading"><Loader2 className="spin" size={18} />加载详情…</div> : error ? <div className="detail-error"><div className="error" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={loadDetail}>重试</button></div><Button onClick={onClose}>关闭</Button></div> : <>
<div className="detail-top"><div className="detail-amount">{money(detail.amountCents)}</div><Button onClick={() => onRequestEdit(detail)}><Settings size={15} />编辑</Button></div>
<div className="detail-row"><span>支付时间</span><strong>{dateText(detail.paidAt)}</strong></div><div className="detail-row"><span>发票</span>{detail.invoiceCount > 0 ? <strong>{detail.invoiceCount} 张</strong> : detail.invoiceMissingReason ? <span><span className="missing">无发票</span><span className="note-text">:{detail.invoiceMissingReason}</span></span> : <span className="missing">未说明</span>}</div><div className="detail-row"><span>状态</span><Status status={detail.status} /></div><div className="detail-row"><span>备注</span><span className="note-text">{detail.note || "无"}</span></div>
<div className="section-title">附件 <span className="muted">{(detail.attachments || []).length}</span></div><div className="attachments">{(detail.attachments || []).map((attachment) => <div className="attachment" key={attachment.id}><span className="attachment-name"><span className="attachment-icon">{attachment.mimeType.startsWith("image/") ? <ImageIcon size={16} /> : <FileText size={16} />}</span><span title={attachment.originalName}>{attachment.originalName}</span><small>{formatBytes(attachment.sizeBytes)}</small></span><span className="attachment-actions">{attachment.previewable && <button className="icon-btn compact" onClick={() => setPreview(attachment)} aria-label={`预览 ${attachment.originalName}`} title="预览"><Search size={15} /></button>}<a className="icon-btn compact" href={`/api/attachments/${attachment.id}/content?download=1`} aria-label={`下载 ${attachment.originalName}`} title="下载"><ArrowDownToLine size={15} /></a><button className="icon-btn compact danger-icon" onClick={() => openRemoveDialog(attachment)} disabled={busy} aria-label={`删除 ${attachment.originalName}`} title="删除附件"><Trash2 size={14} /></button></span></div>)}</div>
{timeline.length > 0 && <><div className="section-title">操作记录</div><div className="timeline">{timeline.slice(0, 12).map((item) => <div className="timeline-item" key={item.id}><span>{dateText(item.occurredAt)}</span><strong>{item.action}</strong><small>{item.actorUsername || "系统"}</small></div>)}</div></>}
<div className="drawer-actions"><Button onClick={() => setConfirmAction("status")} disabled={busy}>{detail.status === "reimbursed" ? "标记未报销" : "标记已报销"}</Button><Button kind="danger" onClick={() => setConfirmAction("trash")} disabled={busy}><Trash2 size={15} />移入回收站</Button></div>
</>}
</div></Drawer>
{confirmAction === "status" && <ConfirmDialog title={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} message={detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"} confirmLabel="确认变更" busy={busy} onClose={() => setConfirmAction(null)} onConfirm={updateStatus} />}
{confirmAction === "trash" && <ConfirmDialog title="移入回收站?" message="账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。" confirmLabel="移入回收站" danger busy={busy} onClose={() => setConfirmAction(null)} onConfirm={trash} />}
{removeTarget && <AttachmentDeleteDialog attachment={removeTarget} requiresReason={removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim()} reason={removeReason} error={removeError} busy={busy} onReasonChange={setRemoveReason} onClose={() => { if (!busy) { setRemoveTarget(null); setRemoveError(""); setRemoveReason(""); } }} onConfirm={removeAttachment} />}
{preview && <AttachmentPreview attachment={preview} onClose={() => setPreview(null)} />}
</>;
}
function Status({ status }: { status: string }) { return <span className={`status status-${status}`}>{status === "reimbursed" ? "已报销" : "未报销"}</span>; }
function Expenses({ notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const [month, setMonth] = useState(monthNow());
const [status, setStatus] = useState<"unreimbursed" | "reimbursed">("unreimbursed");
const [query, setQuery] = useState("");
const [missingInvoice, setMissing] = useState(false);
const [items, setItems] = useState<Expense[]>([]);
const [summary, setSummary] = useState({ count: 0, amountCents: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null);
const [selected, setSelected] = useState<Expense | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState<string | null>(null);
const [includeManifest, setIncludeManifest] = useState(false);
const [trashTarget, setTrashTarget] = useState<Expense | null>(null);
const [trashing, setTrashing] = useState(false);
const loadSequence = useRef(0);
const load = async (queryOverride = query) => {
const sequence = ++loadSequence.current;
setLoading(true); setError("");
try { const result = await api<{ items: Expense[]; summary: typeof summary }>(`/api/expenses?month=${month}&status=${status}&query=${encodeURIComponent(queryOverride)}&missingInvoice=${missingInvoice}`); if (sequence === loadSequence.current) { setItems(result.items); setSummary(result.summary); setSelectedIds((current) => { const visible = new Set(result.items.map((item) => item.id)); return new Set([...current].filter((id) => visible.has(id))); }); } }
catch (caught) { if (sequence === loadSequence.current) setError((caught as Error).message); }
finally { if (sequence === loadSequence.current) setLoading(false); }
};
useEffect(() => { setSelectedIds(new Set()); void load(); }, [month, status, missingInvoice]);
const shiftMonth = (delta: number) => { const [yearText, monthText] = month.split("-"); const year = Number(yearText || new Date().getFullYear()); const currentMonth = Number(monthText || new Date().getMonth() + 1); const next = new Date(year, currentMonth - 1 + delta, 1); setMonth(`${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`); };
const selectedTotal = useMemo(() => items.filter((item) => selectedIds.has(item.id)).reduce((sum, item) => sum + item.amountCents, 0), [items, selectedIds]);
const exportAll = async () => {
try { const selection = selectedIds.size ? { ids: [...selectedIds], includeManifest } : { month, status, query, missingInvoice, includeManifest }; const result = await api<{ job: { id: string } }>("/api/exports", { method: "POST", body: JSON.stringify(selection) }); setExporting(result.job.id); notify(includeManifest ? "导出任务已创建(含 manifest.json)" : "导出任务已创建", "info"); }
catch (caught) { notify((caught as Error).message, "error"); }
};
const moveToTrash = async () => {
if (!trashTarget) return;
setTrashing(true);
try {
await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) });
setSelectedIds((current) => { const next = new Set(current); next.delete(trashTarget.id); return next; });
setTrashTarget(null);
notify("账目已移入回收站,可在回收站恢复", "success");
await load();
} catch (caught) {
notify((caught as Error).message, "error");
} finally {
setTrashing(false);
}
};
useEffect(() => {
if (!exporting) return undefined;
const timer = window.setInterval(async () => {
try { const result = await api<{ job: { status: string; errorMessage?: string } }>(`/api/exports/${exporting}`); if (result.job.status === "ready") { window.clearInterval(timer); window.location.href = `/api/exports/${exporting}/download`; setExporting(null); } else if (result.job.status === "failed" || result.job.status === "expired") { window.clearInterval(timer); notify(result.job.errorMessage || "导出失败", "error"); setExporting(null); } }
catch { window.clearInterval(timer); notify("无法查询导出状态", "error"); setExporting(null); }
}, 1000);
return () => window.clearInterval(timer);
}, [exporting, notify]);
const handleMonth = (event: React.FormEvent<HTMLInputElement>) => { const value = event.currentTarget.value; if (/^\d{4}-\d{2}$/.test(value)) setMonth(value); };
const refresh = () => { void load(); };
return <div className="page"><div className="page-head"><div><div className="eyebrow">账目台 / {month}</div><h1>账目列表</h1></div><div className="head-actions"><label className="export-option" title="额外附带附件元数据和 SHA-256 校验值"><input type="checkbox" checked={includeManifest} onChange={(event) => setIncludeManifest(event.target.checked)} />包含 manifest.json</label><Button onClick={exportAll} disabled={!!exporting || (!selectedIds.size && !items.length)}><FileDown size={16} />{exporting ? "导出中…" : selectedIds.size ? `导出所选(${selectedIds.size})` : "导出筛选结果"}</Button><Button kind="primary" onClick={() => setDrawer("new")}><Plus size={16} />新增账目</Button></div></div>
<div className="toolbar"><button className="icon-btn" onClick={() => shiftMonth(-1)} aria-label="上个月" title="上个月"><ChevronLeft size={18} /></button><input className="month-input" type="month" value={month} onChange={handleMonth} onInput={handleMonth} aria-label="账目月份" /><button className="icon-btn" onClick={() => shiftMonth(1)} aria-label="下个月" title="下个月"><ChevronRight size={18} /></button><div className="segmented" role="group" aria-label="报销状态"><button className={status === "unreimbursed" ? "active" : ""} onClick={() => setStatus("unreimbursed")} aria-pressed={status === "unreimbursed"}>未报销</button><button className={status === "reimbursed" ? "active" : ""} onClick={() => setStatus("reimbursed")} aria-pressed={status === "reimbursed"}>已报销</button></div><form className="search" onSubmit={(event) => { event.preventDefault(); void load(); }}><Search size={16} /><input aria-label="搜索备注" placeholder="搜索备注后按 Enter" value={query} onChange={(event) => setQuery(event.target.value)} />{query && <button type="button" className="search-clear" onClick={() => { setQuery(""); void load(""); }} aria-label="清除搜索"><X size={14} /></button>}</form><label className="check"><input type="checkbox" checked={missingInvoice} onChange={(event) => setMissing(event.target.checked)} />缺发票</label></div>
{selectedIds.size > 0 && <div className="selection-bar"><span>已选 {selectedIds.size} 笔</span><strong>{money(selectedTotal)}</strong><button className="text-button" onClick={() => setSelectedIds(new Set())}>清除选择</button></div>}
<div className="summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong></div>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}
<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>账目加载失败</p><Button onClick={() => void load()}>重试</Button></div> : items.length === 0 ? <div className="empty"><ClipboardList size={28} /><p>暂无符合条件的账目</p><Button kind="primary" onClick={() => setDrawer("new")}><Plus size={15} />新增第一笔</Button></div> : <table><thead><tr><th className="select-col"><input type="checkbox" aria-label="选择全部当前记录" checked={items.length > 0 && items.every((item) => selectedIds.has(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? new Set(items.map((item) => item.id)) : new Set())} /></th><th>支付时间</th><th>金额</th><th>备注</th><th>凭证</th><th>发票</th><th>状态</th><th>操作</th></tr></thead><tbody>{items.map((item) => { const invoiceLabel = item.invoiceMissingReason ? `无发票:${item.invoiceMissingReason}` : "缺发票"; return <tr key={item.id} tabIndex={0} aria-label={`查看 ${item.note || "账目"}`} onClick={() => { setSelected(item); setDrawer("detail"); }} onKeyDown={(event) => { if ((event.key === "Enter" || event.key === " ") && !(event.target as HTMLElement).closest("button,input,a,select,textarea")) { event.preventDefault(); setSelected(item); setDrawer("detail"); } }}><td className="select-col" onClick={(event) => event.stopPropagation()}><input type="checkbox" aria-label={`选择 ${dateText(item.paidAt)} ${money(item.amountCents)}`} checked={selectedIds.has(item.id)} onChange={() => setSelectedIds((current) => { const next = new Set(current); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next; })} /></td><td>{dateText(item.paidAt)}</td><td className="amount">{money(item.amountCents)}</td><td className="note-cell">{item.note || <span className="muted">无备注</span>}</td><td>{item.paymentProofCount}</td><td>{item.invoiceCount === 0 ? <span className="missing missing-label" title={invoiceLabel} aria-label={invoiceLabel}>{invoiceLabel}</span> : item.invoiceCount}</td><td><Status status={item.status} /></td><td><div className="row-actions"><button className="row-action" onClick={(event) => { event.stopPropagation(); setSelected(item); setDrawer("detail"); }} aria-label={`查看 ${item.note || "账目"}`} title="查看详情"><FileText size={15} /></button><button className="row-action" onClick={(event) => { event.stopPropagation(); setSelected(item); setDrawer("edit"); }} aria-label={`编辑 ${item.note || "账目"}`} title="编辑"><Settings size={15} /></button><button className="row-action row-action-danger" onClick={(event) => { event.stopPropagation(); setTrashTarget(item); }} aria-label={`将 ${item.note || "账目"} 移入回收站`} title="移入回收站"><Trash2 size={15} /></button></div></td></tr>; })}</tbody></table>}</div>
{drawer === "new" && <ExpenseDrawer onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail expense={selected} onClose={() => setDrawer(null)} onUpdated={refresh} onRequestEdit={(expense) => { setSelected(expense); setDrawer("edit"); }} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}
{trashTarget && <ConfirmDialog title="移入回收站?" message={<>将“{trashTarget.note || `${dateText(trashTarget.paidAt)}的账目`}”移入回收站。它会从普通列表和导出结果中隐藏,附件会保留,可随时恢复。</>} confirmLabel="移入回收站" danger busy={trashing} onClose={() => setTrashTarget(null)} onConfirm={moveToTrash} />}
</div>;
}
function Trash({ notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const [items, setItems] = useState<Expense[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [purgeTarget, setPurgeTarget] = useState<Expense | null>(null); const [purgeError, setPurgeError] = useState("");
const load = async () => { setLoading(true); setError(""); try { setItems((await api<{ items: Expense[] }>("/api/trash")).items); } catch (caught) { setError((caught as Error).message); } finally { setLoading(false); } };
useEffect(() => { void load(); }, []);
const restore = async (item: Expense) => { setBusy(true); try { await api(`/api/trash/${item.id}/restore`, { method: "POST", body: JSON.stringify({ version: item.version }) }); notify("账目已恢复", "success"); await load(); } catch (caught) { setError((caught as Error).message); } finally { setBusy(false); } };
const purge = async (password: string) => { if (!purgeTarget) return; setBusy(true); setPurgeError(""); try { await api(`/api/trash/${purgeTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: purgeTarget.version, password }) }); setPurgeTarget(null); notify("账目已永久删除,审计历史仍保留", "success"); await load(); } catch (caught) { setPurgeError((caught as Error).message); } finally { setBusy(false); } };
return <div className="page"><div className="page-head"><div><div className="eyebrow">管理</div><h1>回收站</h1></div><Button onClick={load} disabled={loading}><RotateCcw size={15} />刷新</Button></div>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={load}>重试</button></div>}<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>回收站加载失败</p><Button onClick={load}>重试</Button></div> : items.length === 0 ? <div className="empty"><Trash2 size={28} /><p>回收站为空</p></div> : <table><thead><tr><th>删除时间</th><th>金额</th><th>备注</th><th>状态</th><th>操作</th></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td>{item.deletedAt ? dateText(item.deletedAt) : "-"}</td><td className="amount">{money(item.amountCents)}</td><td className="note-cell">{item.note || "无备注"}</td><td><Status status={item.status} /></td><td className="actions"><Button onClick={() => restore(item)} disabled={busy}><RotateCcw size={14} />恢复</Button><Button kind="danger" onClick={() => { setPurgeError(""); setPurgeTarget(item); }} disabled={busy}><Trash2 size={14} />永久删除</Button></td></tr>)}</tbody></table>}</div>{purgeTarget && <PasswordDialog title="永久删除账目" busy={busy} error={purgeError} onClose={() => { setPurgeError(""); setPurgeTarget(null); }} onConfirm={purge} />}</div>;
}
function Admins({ notify, currentAdmin }: { notify: (message: string, kind?: Notice["kind"]) => void; currentAdmin: Admin }) {
const [items, setItems] = useState<Admin[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showCreate, setShowCreate] = useState(false); const [createDiscardPrompt, setCreateDiscardPrompt] = useState(false); const [form, setForm] = useState({ username: "", displayName: "" }); const [formError, setFormError] = useState(""); const [busy, setBusy] = useState(false); const [action, setAction] = useState<{ type: "toggle" | "reset"; admin: Admin } | null>(null); const [secret, setSecret] = useState<{ title: string; value: string } | null>(null);
const load = async () => { setLoading(true); setError(""); try { setItems((await api<{ items: Admin[] }>("/api/admins")).items); } catch (caught) { setError((caught as Error).message); } finally { setLoading(false); } };
useEffect(() => { void load(); }, []);
const closeCreate = () => { if (busy) return; if (form.username || form.displayName) setCreateDiscardPrompt(true); else setShowCreate(false); };
const create = async (event: React.FormEvent) => { event.preventDefault(); setBusy(true); setFormError(""); try { const result = await api<{ temporaryPassword: string }>("/api/admins", { method: "POST", body: JSON.stringify(form) }); setShowCreate(false); setForm({ username: "", displayName: "" }); setSecret({ title: "管理员已创建", value: result.temporaryPassword }); notify("管理员已创建", "success"); await load(); } catch (caught) { setFormError((caught as Error).message); } finally { setBusy(false); } };
const toggle = async () => { if (!action) return; setBusy(true); try { const next = action.admin.status === "active" ? "disabled" : "active"; await api(`/api/admins/${action.admin.id}/status`, { method: "PUT", body: JSON.stringify({ status: next, version: action.admin.version }) }); setAction(null); notify(next === "active" ? "管理员已启用" : "管理员已停用", "success"); await load(); } catch (caught) { setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
const reset = async () => { if (!action) return; setBusy(true); try { const result = await api<{ temporaryPassword: string }>(`/api/admins/${action.admin.id}/reset-password`, { method: "POST", body: JSON.stringify({ version: action.admin.version }) }); setAction(null); setSecret({ title: "临时密码已生成", value: result.temporaryPassword }); notify("密码已重置,现有会话已失效", "success"); await load(); } catch (caught) { setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
return <div className="page"><div className="page-head"><div><div className="eyebrow">系统</div><h1>管理员</h1></div><div className="head-actions"><Button onClick={load} disabled={loading}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => { setFormError(""); setCreateDiscardPrompt(false); setForm({ username: "", displayName: "" }); setShowCreate(true); }}><Plus size={16} />新增管理员</Button></div></div>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={load}>重试</button></div>}<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>管理员列表加载失败</p><Button onClick={load}>重试</Button></div> : items.length === 0 ? <div className="empty"><Users size={28} /><p>暂无管理员</p></div> : <table><thead><tr><th>用户名</th><th>显示名</th><th>状态</th><th>版本</th><th>最近登录</th><th>操作</th></tr></thead><tbody>{items.map((admin) => <tr key={admin.id}><td>{admin.username}</td><td>{admin.displayName}</td><td><span className={`status ${admin.status === "active" ? "status-reimbursed" : "status-disabled"}`}>{admin.status === "active" ? "有效" : "已停用"}</span></td><td>v{admin.version}</td><td>{admin.lastLoginAt ? dateText(admin.lastLoginAt) : "从未登录"}</td><td className="actions"><Button onClick={() => setAction({ type: "reset", admin })} disabled={admin.id === currentAdmin.id} title={admin.id === currentAdmin.id ? "请使用修改密码功能" : "重置密码"}>重置密码</Button><Button onClick={() => setAction({ type: "toggle", admin })} disabled={admin.id === currentAdmin.id} title={admin.id === currentAdmin.id ? "不能停用当前登录账号" : undefined}>{admin.status === "active" ? "停用" : "启用"}</Button></td></tr>)}</tbody></table>}</div>{showCreate && <Drawer title="新增管理员" onClose={closeCreate}><form className="stack drawer-form" onSubmit={create}><label>用户名<input value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} minLength={3} maxLength={64} autoComplete="off" required /></label><label>显示名<input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} maxLength={80} required /></label>{formError && <div className="error" role="alert"><AlertCircle size={16} />{formError}</div>}<div className="drawer-actions"><Button type="button" onClick={closeCreate}>取消</Button><Button kind="primary" type="submit" disabled={busy}>{busy ? <Loader2 className="spin" size={16} /> : "创建并生成临时密码"}</Button></div></form></Drawer>}{createDiscardPrompt && <ConfirmDialog title="放弃未保存内容?" message="当前管理员表单有未保存的修改,关闭后这些内容会丢失。" confirmLabel="放弃并关闭" danger onClose={() => setCreateDiscardPrompt(false)} onConfirm={() => { setCreateDiscardPrompt(false); setShowCreate(false); setForm({ username: "", displayName: "" }); }} />}{action?.type === "toggle" && <ConfirmDialog title={action.admin.status === "active" ? "停用管理员?" : "启用管理员?"} message={action.admin.status === "active" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"} confirmLabel={action.admin.status === "active" ? "停用" : "启用"} danger={action.admin.status === "active"} busy={busy} onClose={() => setAction(null)} onConfirm={toggle} />}{action?.type === "reset" && <ConfirmDialog title="重置管理员密码?" message={<>将生成一次性临时密码,并立即使“{action.admin.displayName}”的现有会话失效。</>} confirmLabel="重置密码" danger busy={busy} onClose={() => setAction(null)} onConfirm={reset} />}{secret && <SecretDialog title={secret.title} password={secret.value} onClose={() => setSecret(null)} />}</div>;
}
const updateStatusLabels: Record<UpdateJob["status"], string> = {
queued: "等待系统服务",
downloading: "下载中",
verifying: "校验文件",
staged: "准备完成",
backing_up: "备份数据",
applying: "切换并检查服务",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [error, setError] = useState("");
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
const [reloadReady, setReloadReady] = useState(false);
const announcedJob = useRef<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
setInfo(await api<UpdateInfo>("/api/update/status"));
} catch (caught) {
setError((caught as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
const job = info?.job;
if (!job || !["queued", "downloading", "verifying", "staged", "backing_up", "applying"].includes(job.status)) return;
let disposed = false;
const poll = async () => {
try {
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
if (disposed) return;
setInfo((current) => current ? { ...current, job: result.job } : current);
if (result.job.status === "completed" && announcedJob.current !== result.job.id) {
announcedJob.current = result.job.id;
setReloadReady(true);
notify("更新完成,请重新加载页面", "success");
}
} catch (caught) {
if (!disposed) setError((caught as Error).message);
}
};
void poll();
const timer = window.setInterval(() => { void poll(); }, 1200);
return () => { disposed = true; window.clearInterval(timer); };
}, [info?.job?.id, info?.job?.status, notify]);
const check = async () => {
setChecking(true); setError("");
try {
const result = await api<Omit<UpdateInfo, "job"> & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" });
setInfo((current) => ({ ...result, job: result.job ?? current?.job ?? null }));
notify(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
} catch (caught) {
setError((caught as Error).message);
} finally { setChecking(false); }
};
const apply = async () => {
if (!confirmVersion) return;
setApplying(true); setError("");
try {
const result = await api<{ job: UpdateJob }>("/api/update/apply", { method: "POST", body: JSON.stringify({ version: confirmVersion, confirm: true }) });
setConfirmVersion(null);
setInfo((current) => current ? { ...current, job: result.job } : current);
notify("更新请求已提交,服务会短暂重启", "info");
} catch (caught) {
setError((caught as Error).message);
} finally { setApplying(false); }
};
const latest = info?.latest;
const job = info?.job;
const hasActiveJob = Boolean(job && ["queued", "downloading", "verifying", "staged", "backing_up", "applying"].includes(job.status));
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && (!job || job.status === "failed" || job.version !== latest.version));
return <div className="page update-page">
<div className="page-head"><div><div className="eyebrow">系统</div><h1>系统更新</h1></div><div className="head-actions"><Button onClick={() => void load()} disabled={loading || checking}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => void check()} disabled={loading || checking || hasActiveJob}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}
{loading ? <div className="update-loading"><Loader2 className="spin" size={22} />正在读取版本信息</div> : info && <>
<div className="update-overview">
<section className="update-card"><div className="update-card-icon"><Server size={20} /></div><div><span className="update-label">当前版本</span><strong className="update-version">v{info.currentVersion}</strong><span className="field-hint">运行平台:{info.platform.target}</span></div></section>
<section className="update-card"><div className="update-card-icon"><ShieldCheck size={20} /></div><div><span className="update-label">更新方式</span><strong>{info.strategy === "systemd" ? "systemd 一键更新" : "命令行更新"}</strong><span className="field-hint">{info.strategy === "systemd" ? "数据目录不会被替换" : "当前安装未启用后台更新"}</span></div></section>
</div>
{latest ? <section className="update-release"><div className="update-release-head"><div><span className="update-label">最新 Release</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <span className="field-hint">发布时间:{dateText(Date.parse(latest.publishedAt))}</span>}</div><span className={`update-badge ${latest.isNewer ? "update-badge-new" : "update-badge-current"}`}>{latest.isNewer ? "有新版本" : "已是最新"}</span></div><div className="update-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : latest.signatureReady ? "缺少 SHA-256" : "缺少发布签名"}</strong></div>{latest.assetSize !== undefined && <div><span>文件大小</span><strong>{formatBytes(latest.assetSize)}</strong></div>}</div>{latest.isNewer && !latest.compatible && <div className="info"><AlertCircle size={16} />当前平台没有可安装的 release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="error"><AlertCircle size={16} />发布文件必须同时提供 SHA-256 和受信任的 Ed25519 签名,当前已禁用更新。</div>}<div className="update-actions">{canApply && <Button kind="primary" onClick={() => setConfirmVersion(latest.version)} disabled={hasActiveJob}><DownloadIcon /><span>更新到 v{latest.version}</span></Button>}{reloadReady && <Button kind="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></section> : <div className="update-empty"><RefreshCw size={24} /><p>点击“检查更新”获取最新 Release。</p></div>}
{job && <section className="update-job"><div className="update-job-head"><div><span className="update-label">最近任务</span><strong>v{job.version}</strong></div><span className={`update-job-status update-job-${job.status}`}>{updateStatusLabels[job.status]}</span></div>{hasActiveJob && <div className="update-progress" aria-label={updateStatusLabels[job.status]}><span style={{ width: `${job.status === "queued" ? 8 : job.status === "downloading" ? 28 : job.status === "verifying" ? 48 : job.status === "staged" ? 65 : job.status === "backing_up" ? 80 : 92}%` }} /></div>}{job.status === "queued" && <p className="field-hint">等待 root 权限的 systemd 更新服务接管,页面会自动刷新状态。</p>}{job.status === "failed" && job.errorMessage && <div className="error"><AlertCircle size={16} />{job.errorMessage}</div>}{job.status === "completed" && <div className="info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</section>}
</>}
{confirmVersion && <ConfirmDialog title="确认更新系统?" message={<>将更新到 <strong>v{confirmVersion}</strong>。服务会短暂停止并重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。</>} confirmLabel="开始更新" busy={applying} onClose={() => setConfirmVersion(null)} onConfirm={() => void apply()} />}
</div>;
}
function DownloadIcon() { return <ArrowDownToLine size={16} />; }
function Audit({ notify: _notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const pageSize = 100;
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [action, setAction] = useState("");
const [targetType, setTargetType] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const load = async (append = false) => {
if (append) setLoadingMore(true); else setLoading(true);
setError("");
try {
const nextOffset = append ? offset : 0;
const params = new URLSearchParams({ limit: String(pageSize), offset: String(nextOffset) });
if (action.trim()) params.set("action", action.trim());
if (targetType) params.set("targetType", targetType);
const result = await api<{ items: any[] }>(`/api/audit?${params}`);
setItems((current) => append ? [...current, ...result.items] : result.items);
setOffset(nextOffset + result.items.length);
setHasMore(result.items.length === pageSize);
} catch (caught) {
setError((caught as Error).message);
} finally {
if (append) setLoadingMore(false); else setLoading(false);
}
};
useEffect(() => { void load(); }, []);
return <div className="page"><div className="page-head"><div><div className="eyebrow">系统</div><h1>审计日志</h1></div><Button onClick={() => void load()} disabled={loading}><RotateCcw size={15} />刷新</Button></div><form className="audit-filters" onSubmit={(event) => { event.preventDefault(); void load(); }}><label>动作<input value={action} onChange={(event) => setAction(event.target.value)} placeholder="例如 expense.created" /></label><label>目标<select value={targetType} onChange={(event) => setTargetType(event.target.value)}><option value="">全部目标</option><option value="expense">账目</option><option value="admin">管理员</option><option value="export">导出</option><option value="session">会话</option></select></label><Button kind="primary" type="submit"><Search size={15} />筛选</Button></form>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>审计日志加载失败</p><Button onClick={() => void load()}>重试</Button></div> : items.length === 0 ? <div className="empty"><Archive size={28} /><p>暂无审计记录</p></div> : <><table><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>目标</th><th>结果</th></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td>{dateText(item.occurredAt)}</td><td>{item.actorUsername || "系统"}</td><td><code>{item.action}</code></td><td>{item.targetType}{item.targetId ? ` / ${item.targetId.slice(0, 8)}` : ""}</td><td><span className={`outcome outcome-${item.outcome || "success"}`}>{item.outcome || "success"}</span></td></tr>)}</tbody></table>{hasMore && <div className="table-more"><Button onClick={() => void load(true)} disabled={loadingMore}>{loadingMore ? <Loader2 className="spin" size={15} /> : <RotateCcw size={15} />}加载更早记录</Button></div>}</>}</div></div>;
}
function App() {
const [admin, setAdmin] = useState<Admin | null>(null); const [boot, setBoot] = useState(true); const [page, setPage] = useState("expenses"); const [mobileNav, setMobileNav] = useState(false); const [notices, setNotices] = useState<Notice[]>([]); const [authExpiredNotice, setAuthExpiredNotice] = useState(""); const noticeId = useRef(0); const menuButtonRef = useRef<HTMLButtonElement>(null); const navRef = useRef<HTMLElement>(null); const wasMobileNavOpen = useRef(false);
const notify = useCallback((message: string, kind: Notice["kind"] = "info") => { const id = ++noticeId.current; setNotices((current) => [...current, { id, message, kind }]); window.setTimeout(() => setNotices((current) => current.filter((notice) => notice.id !== id)), 5000); }, []);
useEffect(() => {
const onExpired = (event: Event) => { setAdmin(null); setPage("expenses"); setMobileNav(false); setAuthExpiredNotice((event as CustomEvent<string>).detail || "登录已失效,请重新登录"); };
window.addEventListener("tallynote-auth-expired", onExpired);
return () => window.removeEventListener("tallynote-auth-expired", onExpired);
}, []);
useEffect(() => {
api<{ timezone?: string }>("/api/auth/status").then((result) => { if (result.timezone) appTimezone = result.timezone; }).catch(() => undefined).finally(() => {
api<{ admin: Admin }>("/api/auth/session").then((result) => setAdmin(result.admin)).catch(() => undefined).finally(() => setBoot(false));
});
}, []);
useEffect(() => {
const nav = navRef.current;
if (!nav) return;
const media = window.matchMedia("(max-width: 900px)");
const syncAccessibility = () => {
const isMobile = media.matches;
if (isMobile && !mobileNav) {
nav.setAttribute("aria-hidden", "true");
nav.setAttribute("inert", "");
} else {
nav.removeAttribute("aria-hidden");
nav.removeAttribute("inert");
}
if (isMobile && mobileNav) {
nav.querySelector<HTMLButtonElement>(".nav-item")?.focus();
} else if (isMobile && wasMobileNavOpen.current) {
menuButtonRef.current?.focus();
}
wasMobileNavOpen.current = mobileNav;
};
syncAccessibility();
media.addEventListener?.("change", syncAccessibility);
return () => media.removeEventListener?.("change", syncAccessibility);
}, [admin, mobileNav]);
useEffect(() => {
if (!mobileNav) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setMobileNav(false);
return;
}
if (event.key !== "Tab" || !navRef.current || !window.matchMedia("(max-width: 900px)").matches) return;
const focusable = Array.from(navRef.current.querySelectorAll<HTMLButtonElement>("button:not([disabled])"));
if (!focusable.length) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [mobileNav]);
if (boot) return <div className="loading-screen" role="status" aria-live="polite"><Loader2 className="spin" /><span>正在加载 TallyNote…</span></div>;
if (!admin) return <Login notice={authExpiredNotice} onDone={(next) => { setAuthExpiredNotice(""); setAdmin(next); }} />;
if (admin.mustChangePassword) return <ChangePassword admin={admin} onDone={setAdmin} />;
const nav = [{ id: "expenses", label: "账目", icon: ClipboardList }, { id: "trash", label: "回收站", icon: Trash2 }, { id: "admins", label: "管理员", icon: Users }, { id: "audit", label: "审计日志", icon: Archive }, { id: "update", label: "系统更新", icon: RefreshCw }];
const logout = async () => { await api("/api/auth/logout", { method: "POST" }).catch(() => undefined); setAdmin(null); };
return <div className="app-shell"><NoticeRegion notices={notices} dismiss={(id) => setNotices((current) => current.filter((notice) => notice.id !== id))} /><header className="topbar"><button ref={menuButtonRef} className="icon-btn mobile-only" onClick={() => setMobileNav((open) => !open)} aria-label={mobileNav ? "关闭导航" : "打开导航"} aria-expanded={mobileNav} aria-controls="main-navigation" title={mobileNav ? "关闭导航" : "打开导航"}>{mobileNav ? <X size={18} /> : <Menu size={18} />}</button><div className="brand-mark"><CircleDollarSign size={22} /><span>TallyNote</span></div><div className="topbar-right"><span className="user-chip">{admin.displayName}</span><button className="icon-btn" onClick={logout} aria-label="退出登录" title="退出登录"><LogOut size={17} /></button></div></header><div className="body-shell">{mobileNav && <button className="mobile-nav-backdrop" aria-label="关闭导航" onClick={() => setMobileNav(false)} /> }<aside ref={navRef} id="main-navigation" className={`sidebar ${mobileNav ? "open" : ""}`} aria-label="主导航">{nav.map((item) => { const Icon = item.icon; return <button key={item.id} className={`nav-item ${page === item.id ? "active" : ""}`} onClick={() => { setPage(item.id); setMobileNav(false); }} aria-current={page === item.id ? "page" : undefined}><Icon size={17} />{item.label}</button>; })}</aside><main key={page} className="page-transition">{page === "expenses" && <Expenses notify={notify} />}{page === "trash" && <Trash notify={notify} />}{page === "admins" && <Admins notify={notify} currentAdmin={admin} />}{page === "audit" && <Audit notify={notify} />}{page === "update" && <Update notify={notify} />}</main></div></div>;
}
const rootElement = document.getElementById("root") as (HTMLElement & { __tallynoteRoot?: ReturnType<typeof createRoot> }) | null;
if (!rootElement) throw new Error("缺少应用挂载节点");
// Vite can re-evaluate this module during HMR; retain the root on the DOM
// node so development reloads do not create a second React root.
const appRoot = rootElement.__tallynoteRoot ?? createRoot(rootElement);
rootElement.__tallynoteRoot = appRoot;
appRoot.render(<React.StrictMode><><TooltipLayer /><App /></></React.StrictMode>);
+268
View File
@@ -0,0 +1,268 @@
:root {
font-family: Inter, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1f2937;
background: #f5f7fa;
line-height: 1.5;
font-size: 14px;
}
* { box-sizing: border-box; }
body { margin: 0; background: #f5f7fa; overflow-x: clip; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input[type="checkbox"]:focus-visible, a:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
.loading-screen { height: 100vh; display: grid; place-items: center; color: #2563eb; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.auth-shell { min-height: 100vh; display: grid; place-items: center; background: #edf1f5; padding: 16px; }
.auth-panel { width: min(400px, 100%); background: #fff; border: 1px solid #dfe4ea; border-radius: 8px; padding: 32px; box-shadow: 0 12px 30px #1f293710; }
.brand-mark { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 22px; color: #1d4ed8; }
.auth-panel .brand-mark { justify-content: center; }
.muted { color: #6b7280; }
.stack { display: flex; flex-direction: column; gap: 14px; }
.stack label, .audit-filters label { display: flex; flex-direction: column; gap: 6px; font-weight: 500; }
input, textarea, select { border: 1px solid #d5dbe3; border-radius: 5px; padding: 8px 10px; background: #fff; color: #111827; outline: none; min-width: 0; transition: border-color .16s ease, box-shadow .16s ease, background-color .16s ease; }
input:focus, textarea:focus, select:focus { border-color: #2563eb; box-shadow: none; }
.btn { border: 1px solid #d4dae2; background: #fff; color: #374151; border-radius: 5px; padding: 8px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; height: 38px; min-height: 38px; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease, transform .12s ease, opacity .16s ease; }
.btn:hover { background: #f3f4f6; }
.btn:not(:disabled):active { transform: translateY(1px); }
.btn:disabled { opacity: .6; cursor: default; }
.btn-primary { background: #2563eb; color: #fff; border-color: #2563eb; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { color: #b91c1c; border-color: #fecaca; background: #fff; }
.btn-danger:hover { background: #fef2f2; }
.btn-ghost { border-color: transparent; }
.error { display: flex; align-items: flex-start; gap: 7px; padding: 9px 11px; background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c; border-radius: 5px; font-size: 13px; }
.info { display: flex; align-items: flex-start; gap: 7px; padding: 9px 11px; background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; border-radius: 5px; font-size: 13px; }
.error .text-button { margin-left: auto; }
.text-button { border: 0; background: transparent; color: #1d4ed8; text-decoration: underline; cursor: pointer; padding: 2px 4px; }
.field-hint { display: block; color: #6b7280; font-size: 12px; font-weight: 400; }
.notice-region { position: fixed; top: 66px; right: 18px; z-index: 30; display: flex; flex-direction: column; gap: 8px; width: min(380px, calc(100% - 36px)); pointer-events: none; }
.notice { pointer-events: auto; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 6px; border: 1px solid; background: #fff; box-shadow: 0 8px 22px #1118271c; animation: notice-in .18s ease-out; }
.notice > span { display: flex; align-items: flex-start; gap: 7px; }
.notice-success { color: #166534; border-color: #bbf7d0; }
.notice-error { color: #b91c1c; border-color: #fecaca; }
.notice-info { color: #1e40af; border-color: #bfdbfe; }
@keyframes notice-in { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
.tooltip { position: fixed; z-index: 100; padding: 5px 8px; border: 1px solid #374151; border-radius: 4px; background: #1f2937; color: #fff; box-shadow: 0 5px 14px #11182733; font-size: 12px; font-weight: 500; line-height: 1.3; white-space: nowrap; pointer-events: none; animation: tooltip-in .14s ease-out both; }
@keyframes tooltip-in { from { opacity: 0; } to { opacity: 1; } }
.app-shell { min-height: 100vh; }
.topbar { height: 56px; background: #fff; border-bottom: 1px solid #e5e7eb; display: flex; align-items: center; padding: 0 24px; justify-content: space-between; }
.topbar .brand-mark { font-size: 18px; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.user-chip { padding: 5px 9px; background: #f3f4f6; border-radius: 4px; color: #4b5563; font-size: 13px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.icon-btn { width: 32px; height: 32px; border: 1px solid transparent; background: transparent; border-radius: 5px; display: inline-grid; place-items: center; color: #4b5563; padding: 0; flex: 0 0 auto; position: relative; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease, transform .12s ease; }
.icon-btn:hover { background: #f3f4f6; }
.icon-btn:not(:disabled):active { transform: translateY(1px); }
.icon-btn.compact { width: 26px; height: 26px; }
.danger-icon { color: #b91c1c; }
.body-shell { display: flex; min-height: calc(100vh - 56px); }
.sidebar { width: 200px; background: #fff; border-right: 1px solid #e5e7eb; padding: 18px 12px; display: flex; flex-direction: column; gap: 4px; flex: 0 0 200px; }
.nav-item { border: 0; background: transparent; color: #4b5563; border-radius: 5px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; text-align: left; transition: background-color .16s ease, color .16s ease, box-shadow .16s ease; }
.nav-item:hover { background: #f3f4f6; }
.nav-item.active { background: #e8efff; color: #1d4ed8; font-weight: 600; }
main { flex: 1; min-width: 0; }
.page { padding: 28px 32px; max-width: 1500px; margin: auto; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 22px; }
.eyebrow { font-size: 12px; color: #6b7280; margin-bottom: 3px; }
.page h1 { font-size: 25px; line-height: 1.2; margin: 0; color: #111827; }
.head-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.export-option { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 4px; color: #4b5563; font-size: 13px; white-space: nowrap; cursor: pointer; }
.export-option input { accent-color: #2563eb; }
.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 12px 0; border-top: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; }
.month-input { width: 130px; }
.segmented { display: flex; border: 1px solid #d5dbe3; border-radius: 5px; overflow: hidden; margin-left: 8px; }
.segmented button { border: 0; background: #fff; padding: 7px 12px; color: #6b7280; transition: background-color .16s ease, color .16s ease; }
.segmented button + button { border-left: 1px solid #d5dbe3; }
.segmented button.active { background: #e8efff; color: #1d4ed8; font-weight: 600; }
.search { margin-left: auto; position: relative; display: flex; align-items: center; color: #9ca3af; }
.search > svg { position: absolute; left: 9px; pointer-events: none; }
.search input { padding-left: 30px; width: 240px; padding-right: 32px; }
.search-clear { position: absolute; right: 5px; border: 0; background: transparent; color: #6b7280; display: grid; place-items: center; padding: 4px; transition: background-color .16s ease, color .16s ease; }
.search-clear:hover { background: #f3f4f6; color: #1f2937; }
.check { display: flex; align-items: center; gap: 6px; color: #4b5563; font-weight: 400 !important; flex-direction: row !important; }
.check input { accent-color: #2563eb; }
.summary { display: flex; align-items: baseline; gap: 14px; padding: 17px 0 12px; }
.summary span { color: #6b7280; }
.summary strong { font-size: 22px; color: #111827; font-variant-numeric: tabular-nums; }
.selection-bar { display: flex; align-items: center; gap: 14px; padding: 10px 12px; margin: 12px 0 0; background: #eef5f1; border: 1px solid #c8ded4; border-radius: 6px; color: #1f4d43; }
.selection-bar strong { font-variant-numeric: tabular-nums; }
.selection-bar .text-button { margin-left: auto; }
.table-wrap { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; overflow: auto; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th, td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #eef0f3; vertical-align: middle; }
th { font-size: 12px; color: #6b7280; font-weight: 600; background: #fafbfc; white-space: nowrap; }
tbody tr { transition: background .12s; cursor: pointer; }
tbody tr:hover, tbody tr:focus { background: #f8fafc; }
tbody tr:last-child td { border-bottom: 0; }
.amount { font-variant-numeric: tabular-nums; font-weight: 600; color: #111827; white-space: nowrap; }
.note-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.status-unreimbursed { color: #92400e; background: #fef3c7; }
.status-reimbursed { color: #166534; background: #dcfce7; }
.status-disabled { color: #6b7280; background: #f3f4f6; }
.missing { color: #b91c1c; font-size: 12px; }
.missing-label { display: inline-block; max-width: 220px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; white-space: nowrap; }
.row-action { border: 0; background: transparent; color: #6b7280; padding: 5px; border-radius: 4px; display: inline-grid; place-items: center; position: relative; transition: background-color .16s ease, color .16s ease, box-shadow .16s ease, transform .12s ease; }
.row-action:hover { background: #eef2ff; color: #2563eb; }
.row-action:not(:disabled):active { transform: translateY(1px); }
.row-actions { display: flex; align-items: center; gap: 3px; }
.row-action-danger { color: #b91c1c; }
.row-action-danger:hover { color: #991b1b; background: #fef2f2; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.table-more { display: flex; justify-content: center; padding: 14px; border-top: 1px solid #eef0f3; }
.empty { min-height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #9ca3af; gap: 10px; padding: 30px; }
.empty p { margin: 0; color: #6b7280; }
.inline-loading { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 8px; color: #6b7280; }
.error.banner { margin: 12px 0; }
.drawer-backdrop, .modal-backdrop { position: fixed; inset: 0; background: #11182745; z-index: 10; display: flex; justify-content: flex-end; overflow: clip; animation: overlay-in .2s ease-out both; }
.drawer { width: min(540px, 100%); background: #fff; height: 100%; padding: 22px 24px; overflow: auto; box-shadow: -8px 0 25px #1118271c; animation: drawer-in .24s cubic-bezier(.22,.8,.26,1) both; }
.drawer-head, .modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
.drawer-head h2, .modal-head h2 { font-size: 20px; margin: 0; color: #111827; }
.drawer-form { padding-bottom: 20px; }
.drawer-actions, .modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
.file-pick { display: flex; flex-direction: column; gap: 7px; }
.field-label { font-weight: 500; }
.required { color: #dc2626; }
.file-input { position: relative; display: inline-flex; align-items: center; gap: 6px; width: max-content; max-width: 100%; padding: 8px 11px; border: 1px dashed #cbd5e1; border-radius: 5px; color: #4b5563; font-weight: 400; }
.file-input:focus-within { border-color: #2563eb; box-shadow: none; }
.file-input input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
.file-list { list-style: none; display: flex; flex-direction: column; gap: 5px; padding: 0; margin: 0; }
.file-list li { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; padding: 5px 7px; background: #f1f5f9; color: #475569; border-radius: 4px; font-size: 12px; }
.file-list li > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-list small { color: #94a3b8; }
.invoice-policy { display: flex; flex-direction: column; gap: 8px; }
.invoice-missing-toggle { width: max-content; }
.invoice-policy-hint { margin-top: -2px; }
.invoice-reason { display: flex; flex-direction: column; gap: 6px; font-weight: 500; }
.delete-invoice-reason { margin-top: 14px; }
.detail { display: flex; flex-direction: column; gap: 14px; }
.detail-error { display: flex; flex-direction: column; gap: 14px; }
.detail-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.detail-amount { font-size: 32px; font-weight: 700; color: #111827; padding: 8px 0 4px; font-variant-numeric: tabular-nums; }
.detail-row { display: flex; gap: 14px; }
.detail-row > span:first-child { width: 72px; color: #6b7280; flex-shrink: 0; }
.note-text { white-space: pre-wrap; word-break: break-word; }
.section-title { font-weight: 600; border-top: 1px solid #e5e7eb; padding-top: 18px; }
.section-title .muted { font-weight: 400; margin-left: 5px; }
.attachments { display: flex; flex-direction: column; gap: 7px; }
.attachment { display: flex; justify-content: space-between; align-items: center; gap: 8px; border: 1px solid #e5e7eb; border-radius: 5px; padding: 8px 10px; font-size: 13px; min-width: 0; }
.attachment-name { display: flex; align-items: center; gap: 7px; min-width: 0; flex: 1; }
.attachment-name > span:not(.attachment-icon) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attachment-name small { color: #94a3b8; white-space: nowrap; }
.attachment-icon { display: grid; place-items: center; flex: 0 0 auto; color: #64748b; }
.attachment-actions { display: flex; gap: 5px; flex: 0 0 auto; }
.attachment-actions a { color: #2563eb; text-decoration: none; display: grid; place-items: center; }
.timeline { display: flex; flex-direction: column; gap: 8px; border-left: 2px solid #e5e7eb; padding-left: 12px; }
.timeline-item { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; font-size: 12px; }
.timeline-item span { color: #9ca3af; }
.timeline-item strong { font-size: 12px; font-weight: 500; color: #374151; }
.timeline-item small { grid-column: 1 / -1; color: #9ca3af; }
.modal-backdrop { z-index: 20; align-items: center; justify-content: center; padding: 20px; }
.modal { width: min(460px, 100%); max-height: min(700px, calc(100vh - 40px)); overflow: auto; background: #fff; border-radius: 8px; border: 1px solid #dfe4ea; box-shadow: 0 18px 42px #1118272e; padding: 22px 24px; animation: modal-in .2s cubic-bezier(.22,.8,.26,1) both; }
.modal-head { margin-bottom: 14px; }
.modal-body { color: #374151; }
.modal-footer { margin-top: 20px; }
.modal-message { margin: 0; line-height: 1.65; }
.secret-box { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px; background: #f8fafc; border: 1px solid #dbe4ee; border-radius: 5px; }
.secret-box code { overflow-wrap: anywhere; color: #111827; font-size: 15px; }
.copy-feedback { font-size: 13px; }
.copy-success { color: #166534; }
.copy-failed { color: #b91c1c; }
.conflict-summary { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-top: 14px; padding: 10px 12px; background: #f8fafc; border-radius: 5px; }
.conflict-summary span { color: #6b7280; }
.preview-frame { width: 100%; min-height: 260px; max-height: 60vh; display: grid; place-items: center; background: #f1f5f9; border-radius: 5px; overflow: auto; }
.preview-frame img { display: block; max-width: 100%; max-height: 56vh; object-fit: contain; }
.preview-frame iframe { border: 0; width: 100%; height: 56vh; min-height: 360px; background: #fff; }
.audit-filters { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; padding: 12px 0; border-top: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; margin-bottom: 16px; }
.audit-filters label { width: min(300px, 100%); }
.audit-filters select { min-width: 150px; }
.outcome { font-size: 12px; }
.outcome-success { color: #166534; }
.outcome-denied, .outcome-failure { color: #b91c1c; }
.update-page { max-width: 1100px; }
.update-loading, .update-empty { min-height: 240px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: #6b7280; }
.update-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.update-card { display: flex; align-items: flex-start; gap: 12px; min-width: 0; padding: 18px; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; }
.update-card-icon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; color: #1d4ed8; background: #e8efff; border-radius: 5px; }
.update-card > div:last-child { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.update-label { display: block; color: #6b7280; font-size: 12px; }
.update-version { font-size: 24px; line-height: 1.2; color: #111827; }
.update-release, .update-job { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; margin-bottom: 16px; }
.update-release-head, .update-job-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
.update-release h2 { margin: 3px 0 2px; font-size: 22px; color: #111827; }
.update-badge, .update-job-status { display: inline-flex; align-items: center; min-height: 26px; padding: 3px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.update-badge-new { color: #1d4ed8; background: #e8efff; }
.update-badge-current { color: #166534; background: #dcfce7; }
.update-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 20px 0; padding: 14px 0; border-top: 1px solid #eef0f3; border-bottom: 1px solid #eef0f3; }
.update-facts div { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.update-facts span { color: #6b7280; font-size: 12px; }
.update-facts strong { overflow-wrap: anywhere; font-size: 14px; color: #374151; }
.text-success { color: #166534 !important; }
.text-danger { color: #b91c1c !important; }
.update-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.update-job { margin-bottom: 0; }
.update-job-head strong { display: block; margin-top: 3px; color: #111827; }
.update-job-queued, .update-job-downloading, .update-job-verifying, .update-job-staged, .update-job-backing_up, .update-job-applying { color: #1d4ed8; background: #e8efff; }
.update-job-completed { color: #166534; background: #dcfce7; }
.update-job-failed, .update-job-cancelled { color: #b91c1c; background: #fef2f2; }
.update-progress { height: 7px; margin: 18px 0 10px; overflow: hidden; background: #e5e7eb; border-radius: 999px; }
.update-progress span { display: block; height: 100%; background: #2563eb; border-radius: inherit; transition: width .35s ease; }
.mobile-only { display: none; }
.page-transition { animation: page-in .22s ease-out both; }
.head-actions .btn { height: 38px; min-height: 38px; }
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes drawer-in { from { opacity: 0; transform: translate3d(28px, 0, 0); } to { opacity: 1; transform: translate3d(0, 0, 0); } }
@keyframes modal-in { from { opacity: 0; transform: translate3d(0, 8px, 0) scale(.98); } to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } }
@keyframes page-in { from { opacity: 0; } to { opacity: 1; } }
@media (max-width: 900px) {
.sidebar { position: fixed; left: -212px; top: 56px; bottom: 0; z-index: 9; visibility: hidden; transition: left .2s, visibility 0s linear .2s; box-shadow: 4px 0 12px #11182718; }
.sidebar.open { left: 0; visibility: visible; transition-delay: 0s; }
.mobile-nav-backdrop { position: fixed; inset: 56px 0 0; z-index: 8; border: 0; background: #11182735; cursor: default; animation: overlay-in .18s ease-out both; }
.mobile-only { display: inline-grid; }
.topbar { padding: 0 14px; }
.topbar .brand-mark { margin-right: auto; margin-left: 10px; }
.page { padding: 20px 14px; }
.page-head { align-items: center; }
.page h1 { font-size: 22px; }
.toolbar { gap: 6px; }
.search { order: 5; width: 100%; margin-left: 0; }
.search input { width: 100%; }
.segmented { margin-left: 2px; }
.summary { padding-top: 14px; }
.drawer { padding: 18px; }
.user-chip { max-width: 110px; }
.head-actions .btn:first-child { display: inline-flex; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
.spin { animation: none !important; }
}
@media (max-width: 560px) {
.page-head { align-items: flex-start; flex-direction: column; }
.head-actions { width: 100%; justify-content: stretch; }
.head-actions .btn { flex: 1; }
.export-option { width: 100%; min-height: 30px; }
.toolbar { align-items: stretch; }
.month-input { flex: 1; min-width: 110px; }
.check { margin-left: 2px; }
.modal { padding: 18px; }
.modal-footer .btn, .drawer-actions .btn { flex: 1; }
.detail-row { align-items: flex-start; }
.detail-row > span:first-child { width: 60px; }
.update-overview { grid-template-columns: 1fr; }
.update-release, .update-job { padding: 16px; }
.update-facts { grid-template-columns: 1fr; gap: 10px; }
.update-release-head, .update-job-head { flex-direction: column; }
.update-actions { justify-content: stretch; }
.update-actions .btn { flex: 1; }
}