feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.git
|
||||
playwright-report
|
||||
test-results
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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" "$@"
|
||||
@@ -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:
|
||||
@@ -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、备份和磁盘权限。拥有服务器文件权限的人仍可直接读取底层财务数据。
|
||||
@@ -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
@@ -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
@@ -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 "$@"
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+4576
File diff suppressed because it is too large
Load Diff
Executable
+52
@@ -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"
|
||||
Executable
+249
@@ -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"
|
||||
Executable
+244
@@ -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
|
||||
Executable
+95
@@ -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'
|
||||
Executable
+194
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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!`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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("原始发票文件已丢失,无法重新取得");
|
||||
});
|
||||
});
|
||||
@@ -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("普通备注");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>);
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user