From 9719429f4ab812132f8bdc72309de4ae152d6349 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Sat, 29 Aug 2026 00:57:16 +0800 Subject: [PATCH] feat: add TallyNote local reimbursement ledger --- .dockerignore | 7 + .env.example | 35 + .gitea/workflows/release.yml | 44 + .gitignore | 16 + Dockerfile | 24 + README.md | 103 + artifacts/tallynote-architecture.html | 14770 +++++++++++++++++++ artifacts/tallynote.architecture.json | 192 + bin/tallynote | 8 + docker-compose.yml | 25 + docs/release.md | 78 + drizzle.config.ts | 10 + index.html | 13 + install.sh | 880 ++ migrations/0000_initial.sql | 128 + migrations/0001_invoice_missing_reason.sql | 1 + migrations/0002_update_jobs.sql | 19 + migrations/0003_update_job_ownership.sql | 7 + package.json | 64 + playwright.config.ts | 25 + pnpm-lock.yaml | 4576 ++++++ scripts/build-release.sh | 52 + scripts/publish-gitea-release.sh | 249 + scripts/tallynote-update-runner.sh | 244 + scripts/tallynote-update.sh | 95 + scripts/test-installer.sh | 194 + server/app.ts | 1654 +++ server/audit.ts | 39 + server/cli/admin-init.ts | 97 + server/cli/update.ts | 418 + server/config.ts | 252 + server/db/index.ts | 46 + server/db/schema.ts | 160 + server/errors.ts | 27 + server/exporter.ts | 276 + server/files.ts | 252 + server/index.ts | 54 + server/security.ts | 52 + server/update-service.ts | 327 + server/update.ts | 992 ++ shared/contracts.ts | 121 + systemd/tallynote-update.path | 10 + systemd/tallynote-update.service | 33 + systemd/tallynote.env.example | 15 + systemd/tallynote.service | 38 + tests/api.test.ts | 393 + tests/core.test.ts | 44 + tests/download-audit.test.ts | 58 + tests/e2e/smoke.spec.ts | 9 + tests/export.test.ts | 188 + tests/migration.test.ts | 58 + tests/security.test.ts | 64 + tests/update-api.test.ts | 134 + tests/update.test.ts | 294 + tsconfig.json | 12 + tsconfig.server.json | 13 + tsconfig.web.json | 11 + vite.config.ts | 21 + vitest.config.ts | 10 + web/index.html | 9 + web/src/main.tsx | 892 ++ web/src/styles.css | 268 + 62 files changed, 29200 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 artifacts/tallynote-architecture.html create mode 100644 artifacts/tallynote.architecture.json create mode 100755 bin/tallynote create mode 100644 docker-compose.yml create mode 100644 docs/release.md create mode 100644 drizzle.config.ts create mode 100644 index.html create mode 100755 install.sh create mode 100644 migrations/0000_initial.sql create mode 100644 migrations/0001_invoice_missing_reason.sql create mode 100644 migrations/0002_update_jobs.sql create mode 100644 migrations/0003_update_job_ownership.sql create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100755 scripts/build-release.sh create mode 100755 scripts/publish-gitea-release.sh create mode 100755 scripts/tallynote-update-runner.sh create mode 100755 scripts/tallynote-update.sh create mode 100755 scripts/test-installer.sh create mode 100644 server/app.ts create mode 100644 server/audit.ts create mode 100644 server/cli/admin-init.ts create mode 100644 server/cli/update.ts create mode 100644 server/config.ts create mode 100644 server/db/index.ts create mode 100644 server/db/schema.ts create mode 100644 server/errors.ts create mode 100644 server/exporter.ts create mode 100644 server/files.ts create mode 100644 server/index.ts create mode 100644 server/security.ts create mode 100644 server/update-service.ts create mode 100644 server/update.ts create mode 100644 shared/contracts.ts create mode 100644 systemd/tallynote-update.path create mode 100644 systemd/tallynote-update.service create mode 100644 systemd/tallynote.env.example create mode 100644 systemd/tallynote.service create mode 100644 tests/api.test.ts create mode 100644 tests/core.test.ts create mode 100644 tests/download-audit.test.ts create mode 100644 tests/e2e/smoke.spec.ts create mode 100644 tests/export.test.ts create mode 100644 tests/migration.test.ts create mode 100644 tests/security.test.ts create mode 100644 tests/update-api.test.ts create mode 100644 tests/update.test.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.server.json create mode 100644 tsconfig.web.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts create mode 100644 web/index.html create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..12f2495 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +data +.git +playwright-report +test-results +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e317a66 --- /dev/null +++ b/.env.example @@ -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 +# /.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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4b155c0 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..938b48c --- /dev/null +++ b/.gitignore @@ -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* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f2bbfd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0120dd7 --- /dev/null +++ b/README.md @@ -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/` 加 `/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-<架构>-.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 反代访问。 diff --git a/artifacts/tallynote-architecture.html b/artifacts/tallynote-architecture.html new file mode 100644 index 0000000..255ad77 --- /dev/null +++ b/artifacts/tallynote-architecture.html @@ -0,0 +1,14770 @@ + + + + + + + TallyNote 本地优先报销账本架构 + + + + + + + + + + + +
+ +
+
+
+

TallyNote 本地优先报销账本架构

+
+
+ + + + + + + +
+ + TallyNote 本地优先报销账本架构 + 由 Archify 生成的架构图。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 使用者 / 管理员 · Chrome 浏览器 · 架构组件 + + + + 使用者 / 管理员 + Chrome 浏览器 + + + + React 工作台 · React 19 + Vite · TallyNote 单实例本地运行时(localhost / Docker) · 桌面优先 + + + + React 工作台 + React 19 + Vite + 桌面优先 + + + + Fastify REST API · /api/* + 静态托管 · TallyNote 单实例本地运行时(localhost / Docker) › 受保护 API 边界 · 单进程入口 + + + + Fastify REST API + /api/* + 静态托管 + 单进程入口 + + + + 认证与请求防护 · Argon2id · Cookie · CSRF · TallyNote 单实例本地运行时(localhost / Docker) › 受保护 API 边界 · HttpOnly / Origin + + + + 认证与请求防护 + Argon2id · Cookie · CSRF + HttpOnly / Origin + + + + 账目业务服务 · 筛选 · 状态 · 乐观锁 · TallyNote 单实例本地运行时(localhost / Docker) › 受保护 API 边界 · 至少一张凭证 + + + + 账目业务服务 + 筛选 · 状态 · 乐观锁 + 至少一张凭证 + + + + SQLite + Drizzle · WAL · 外键 · 事务 · TallyNote 单实例本地运行时(localhost / Docker) · 同机持久化 + + + + SQLite + Drizzle + WAL · 外键 · 事务 + 同机持久化 + + + + 附件流水线 · staging · 内容识别 · 晋级 · TallyNote 单实例本地运行时(localhost / Docker) › 受保护 API 边界 · 20 MB / 文件 + + + + 附件流水线 + staging · 内容识别 · 晋级 + 20 MB / 文件 + + + + 本地数据文件系统 · files / staging / exports · TallyNote 单实例本地运行时(localhost / Docker) · 同一卷 · UUID 路径 + + + + 本地数据文件系统 + files / staging / exports + 同一卷 · UUID 路径 + + + + 异步导出构建器 · Excel · ZIP · manifest · TallyNote 单实例本地运行时(localhost / Docker) › 受保护 API 边界 · 会话绑定任务 + + + + 异步导出构建器 + Excel · ZIP · manifest + 会话绑定任务 + + + + 审计子系统 · 追加写入 · 前后值 · 请求 ID · TallyNote 单实例本地运行时(localhost / Docker) › 受保护 API 边界 · 只读查询 + + + + 审计子系统 + 追加写入 · 前后值 · 请求 ID + 只读查询 + + + + 管理员管理 · 初始化 · 停用 · 重置 · TallyNote 单实例本地运行时(localhost / Docker) › 受保护 API 边界 · 最后管理员保护 + + + + 管理员管理 + 初始化 · 停用 · 重置 + 最后管理员保护 + + + + 启动清理与恢复 · 孤儿文件 · 过期任务 · 会话 · TallyNote 单实例本地运行时(localhost / Docker) · 每 60 秒 + 重启 + + + + 启动清理与恢复 + 孤儿文件 · 过期任务 · 会话 + 每 60 秒 + 重启 + + + + + + 浏览器交互 + + + + HTTP / REST + + + + Cookie + CSRF / Origin + + + + 账目路由 + + + + SQLite transaction + + + + multipart 上传 + + + + staging → atomic promote + + + + snapshot → async job + + + + 快照读取 + + + + ZIP / Excel / manifest + + + + 操作审计 + + + + 管理员管理 + + + + 孤儿文件 / 过期任务 + + + + + + TallyNote 单实例本地运行时(localhost / Docker) + + + + + 受保护 API 边界 + + + + + 图例 + + + 前端 + + + + 后端 + + + + 数据库 + + + + 云服务 + + + + 安全 + + + + 外部系统 + + + +

+ + + + + + + + + +
+ + +
+
+
+
+

请求与数据

+
+
    +
  • • 前端通过统一 API 客户端携带会话 Cookie 和 CSRF 令牌
  • +
  • • 账目写入、附件元数据和审计在 SQLite 事务内保持一致
  • +
+
+ +
+
+
+

文件可靠性

+
+
    +
  • • 附件先落 staging,完成格式、大小和内容校验后原子晋级
  • +
  • • 失败路径删除已晋级字节,启动时继续清理孤儿文件
  • +
+
+ +
+
+
+

导出与治理

+
+
    +
  • • 导出冻结不可变快照,后台生成 Excel、ZIP 和可选 manifest
  • +
  • • 管理员变更、记录操作、附件和导出都进入只读审计日志
  • +
+
+
+ +
+ + + + diff --git a/artifacts/tallynote.architecture.json b/artifacts/tallynote.architecture.json new file mode 100644 index 0000000..875382b --- /dev/null +++ b/artifacts/tallynote.architecture.json @@ -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", "管理员变更、记录操作、附件和导出都进入只读审计日志"] + } + ] +} diff --git a/bin/tallynote b/bin/tallynote new file mode 100755 index 0000000..b5ade82 --- /dev/null +++ b/bin/tallynote @@ -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" "$@" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1330b2d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..3995ca9 --- /dev/null +++ b/docs/release.md @@ -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// # 只读发布代码 +/opt/tallynote/current -> releases/ +/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///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、备份和磁盘权限。拥有服务器文件权限的人仍可直接读取底层财务数据。 diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..5f740d9 --- /dev/null +++ b/drizzle.config.ts @@ -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", + }, +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..60f83a1 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + TallyNote · 采购报销记录 + + +
+ + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a6e4bc3 --- /dev/null +++ b/install.sh @@ -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 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 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 "$@" diff --git a/migrations/0000_initial.sql b/migrations/0000_initial.sql new file mode 100644 index 0000000..0e26224 --- /dev/null +++ b/migrations/0000_initial.sql @@ -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); diff --git a/migrations/0001_invoice_missing_reason.sql b/migrations/0001_invoice_missing_reason.sql new file mode 100644 index 0000000..66504d4 --- /dev/null +++ b/migrations/0001_invoice_missing_reason.sql @@ -0,0 +1 @@ +ALTER TABLE expenses ADD COLUMN invoice_missing_reason TEXT; diff --git a/migrations/0002_update_jobs.sql b/migrations/0002_update_jobs.sql new file mode 100644 index 0000000..24a339e --- /dev/null +++ b/migrations/0002_update_jobs.sql @@ -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); diff --git a/migrations/0003_update_job_ownership.sql b/migrations/0003_update_job_ownership.sql new file mode 100644 index 0000000..7e27fc1 --- /dev/null +++ b/migrations/0003_update_job_ownership.sql @@ -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); diff --git a/package.json b/package.json new file mode 100644 index 0000000..be357c8 --- /dev/null +++ b/package.json @@ -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" + } + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3dfaa3b --- /dev/null +++ b/playwright.config.ts @@ -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", + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6a774ca --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4576 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + uuid: '>=11.1.1' + +importers: + + .: + dependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.1.2 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.1.1 + '@fastify/multipart': + specifier: ^9.2.1 + version: 9.4.0 + '@fastify/static': + specifier: ^10.1.3 + version: 10.1.3 + archiver: + specifier: ^8.0.0 + version: 8.0.0 + argon2: + specifier: ^0.44.0 + version: 0.44.0 + better-sqlite3: + specifier: ^12.2.0 + version: 12.11.1 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) + exceljs: + specifier: ^4.4.0 + version: 4.4.0 + fast-xml-parser: + specifier: ^5.2.5 + version: 5.11.0 + fastify: + specifier: ^5.4.0 + version: 5.12.1 + lucide-react: + specifier: ^0.542.0 + version: 0.542.0(react@19.2.8) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 + react: + specifier: ^19.1.1 + version: 19.2.8 + react-dom: + specifier: ^19.1.1 + version: 19.2.8(react@19.2.8) + sharp: + specifier: ^0.35.4 + version: 0.35.4(@types/node@24.13.3) + yauzl: + specifier: ^3.2.0 + version: 3.4.0 + zod: + specifier: ^4.1.5 + version: 4.4.3 + devDependencies: + '@playwright/test': + specifier: ^1.55.0 + version: 1.62.1 + '@types/archiver': + specifier: ^8.0.0 + version: 8.0.0 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/node': + specifier: ^24.3.0 + version: 24.13.3 + '@types/react': + specifier: ^19.1.12 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.9 + version: 19.2.5(@types/react@19.2.18) + '@types/yauzl': + specifier: ^2.10.3 + version: 2.10.3 + '@vitejs/plugin-react': + specifier: ^5.0.2 + version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12)) + concurrently: + specifier: ^9.2.1 + version: 9.2.4 + drizzle-kit: + specifier: ^0.31.4 + version: 0.31.10 + tsx: + specifier: ^4.20.5 + version: 4.23.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vite: + specifier: ^7.1.3 + version: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(tsx@4.23.12) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + + '@fastify/accept-negotiator@2.1.0': + resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/busboy@3.2.2': + resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==} + + '@fastify/cookie@11.1.2': + resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/helmet@13.1.1': + resolution: {integrity: sha512-bSat5DTq8geASv8G6P0KW1UbltZ+xGD/zyd9S72pT7ogAHehcsWL85GdjMRCjDsExJvaEvgEZ52qU/2HXirVCw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/multipart@9.4.0': + resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.1': + resolution: {integrity: sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==} + + '@fastify/static@10.1.3': + resolution: {integrity: sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.63.0': + resolution: {integrity: sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.0': + resolution: {integrity: sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.0': + resolution: {integrity: sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.0': + resolution: {integrity: sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.0': + resolution: {integrity: sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.0': + resolution: {integrity: sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + resolution: {integrity: sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + resolution: {integrity: sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + resolution: {integrity: sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.0': + resolution: {integrity: sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + resolution: {integrity: sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.0': + resolution: {integrity: sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + resolution: {integrity: sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + resolution: {integrity: sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + resolution: {integrity: sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + resolution: {integrity: sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + resolution: {integrity: sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.0': + resolution: {integrity: sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.0': + resolution: {integrity: sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.0': + resolution: {integrity: sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.0': + resolution: {integrity: sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + resolution: {integrity: sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + resolution: {integrity: sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.0': + resolution: {integrity: sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.0': + resolution: {integrity: sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==} + cpu: [x64] + os: [win32] + + '@types/archiver@8.0.0': + resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + archiver@8.0.0: + resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} + engines: {node: '>=18'} + + argon2@0.44.0: + resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} + engines: {node: '>=16.17.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.1: + resolution: {integrity: sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.2: + resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.19: + resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + compress-commons@7.0.1: + resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} + engines: {node: '>=18'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concurrently@9.2.4: + resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==} + engines: {node: '>=18'} + hasBin: true + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + crc32-stream@7.0.1: + resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} + engines: {node: '>=18'} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + electron-to-chromium@1.5.415: + resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + + fast-uri@4.1.3: + resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==} + + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.0: + resolution: {integrity: sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw==} + hasBin: true + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.12.1: + resolution: {integrity: sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fastq@1.20.2: + resolution: {integrity: sha512-UpGiiODyCGprM8EPP6JodP6jC9Rws6TCuiDOD+nn0CJhR8guI3g/ozo4ugL0vJ+Yz1UtJuuRPqvQuybVOF1VQA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.542.0: + resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.95.0: + resolution: {integrity: sha512-T9iGctuocf0qIWFFOTxPzjT5q0SILqaBYXt272tlBHvTKC5+3JnkMirLxNJNkXHtFyBjU2Jx+NL4Zipr0B/c6Q==} + engines: {node: '>=10'} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdir-glob@3.0.0: + resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} + engines: {node: '>=18'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.63.0: + resolution: {integrity: sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.1: + resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + + zip-stream@7.0.5: + resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} + engines: {node: '>=18'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@drizzle-team/brocli@0.10.2': {} + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@epic-web/invariant@1.0.0': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.3 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@fastify/accept-negotiator@2.1.0': {} + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.3 + + '@fastify/busboy@3.2.2': {} + + '@fastify/cookie@11.1.2': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.0 + + '@fastify/deepmerge@3.2.1': {} + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/helmet@13.1.1': + dependencies: + fastify-plugin: 6.0.0 + helmet: 8.3.0 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@9.4.0': + dependencies: + '@fastify/busboy': 3.2.2 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@fastify/send@4.1.1': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@10.1.3': + dependencies: + '@fastify/accept-negotiator': 2.1.0 + '@fastify/error': 4.2.0 + '@fastify/send': 4.1.1 + content-disposition: 2.0.1 + fastify-plugin: 6.0.0 + fastq: 1.20.2 + glob: 13.0.6 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/ms@2.0.2': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nodable/entities@3.0.0': {} + + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + + '@phc/format@1.0.0': {} + + '@pinojs/redact@0.4.0': {} + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.63.0': + optional: true + + '@rollup/rollup-android-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-x64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.0': + optional: true + + '@types/archiver@8.0.0': + dependencies: + '@types/node': 24.13.3 + '@types/readdir-glob': 1.1.5 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.13.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@14.18.63': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.5(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 24.13.3 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.13.3 + + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anynum@1.0.1: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + archiver@8.0.0: + dependencies: + async: 3.2.6 + buffer-crc32: 1.0.0 + is-stream: 4.0.1 + lazystream: 1.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + readdir-glob: 3.0.0 + tar-stream: 3.2.1 + zip-stream: 7.0.5 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + argon2@0.44.0: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + assertion-error@2.0.1: {} + + async@3.2.6: {} + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + bare-events@2.9.2: {} + + bare-fs@4.8.1: + dependencies: + bare-events: 2.9.2 + bare-path: 3.1.1 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.8.1 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.2: + dependencies: + bare-path: 3.1.1 + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.19: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + big-integer@1.6.52: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.4.7: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.19 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.415 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + buffer-crc32@0.2.13: {} + + buffer-crc32@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + + cac@6.7.14: {} + + caniuse-lite@1.0.30001810: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chownr@1.1.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + compress-commons@7.0.1: + dependencies: + crc-32: 1.2.2 + crc32-stream: 7.0.1 + is-stream: 4.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + concat-map@0.0.1: {} + + concurrently@9.2.4: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.9.0 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + content-disposition@2.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + crc32-stream@7.0.1: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.12 + + drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + better-sqlite3: 12.11.1 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + electron-to-chromium@1.5.415: {} + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-module-lexer@1.7.0: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.23 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 11.1.1 + + expand-template@2.0.3: {} + + expect-type@1.4.0: {} + + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.3 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.6: {} + + fast-uri@4.1.3: {} + + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.0: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.12.1: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fastq@1.20.2: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-uri-to-path@1.0.0: {} + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + helmet@8.3.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@2.5.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-stream@4.0.1: {} + + is-unsafe@2.0.2: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + listenercount@1.0.1: {} + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + + loupe@3.2.1: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.542.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mime@3.0.0: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.95.0: + dependencies: + semver: 7.8.5 + + node-addon-api@8.9.2: {} + + node-gyp-build@4.8.4: {} + + node-releases@2.0.53: {} + + normalize-path@3.0.0: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + pako@1.0.11: {} + + path-expression-matcher@1.6.2: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.95.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + process-nextick-args@2.0.1: {} + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + process@0.11.10: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + quick-format-unescaped@4.0.4: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + readdir-glob@3.0.0: + dependencies: + minimatch: 10.2.6 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rollup@4.63.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.0 + '@rollup/rollup-android-arm64': 4.63.0 + '@rollup/rollup-darwin-arm64': 4.63.0 + '@rollup/rollup-darwin-x64': 4.63.0 + '@rollup/rollup-freebsd-arm64': 4.63.0 + '@rollup/rollup-freebsd-x64': 4.63.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.0 + '@rollup/rollup-linux-arm-musleabihf': 4.63.0 + '@rollup/rollup-linux-arm64-gnu': 4.63.0 + '@rollup/rollup-linux-arm64-musl': 4.63.0 + '@rollup/rollup-linux-loong64-gnu': 4.63.0 + '@rollup/rollup-linux-loong64-musl': 4.63.0 + '@rollup/rollup-linux-ppc64-gnu': 4.63.0 + '@rollup/rollup-linux-ppc64-musl': 4.63.0 + '@rollup/rollup-linux-riscv64-gnu': 4.63.0 + '@rollup/rollup-linux-riscv64-musl': 4.63.0 + '@rollup/rollup-linux-s390x-gnu': 4.63.0 + '@rollup/rollup-linux-x64-gnu': 4.63.0 + '@rollup/rollup-linux-x64-musl': 4.63.0 + '@rollup/rollup-openbsd-x64': 4.63.0 + '@rollup/rollup-openharmony-arm64': 4.63.0 + '@rollup/rollup-win32-arm64-msvc': 4.63.0 + '@rollup/rollup-win32-ia32-msvc': 4.63.0 + '@rollup/rollup-win32-x64-gnu': 4.63.0 + '@rollup/rollup-win32-x64-msvc': 4.63.0 + fsevents: 2.3.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sharp@0.35.4(@types/node@24.13.3): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 24.13.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.9.0: {} + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.1: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tmp@0.2.7: {} + + toad-cache@3.7.4: {} + + toidentifier@1.0.1: {} + + traverse@0.3.9: {} + + tree-kill@1.2.2: {} + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + uuid@11.1.1: {} + + vite-node@3.2.4(@types/node@24.13.3)(tsx@4.23.12): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.63.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + tsx: 4.23.12 + + vitest@3.2.7(@types/node@24.13.3)(tsx@4.23.12): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + vite-node: 3.2.4(@types/node@24.13.3)(tsx@4.23.12) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + xml-naming@0.3.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + + zip-stream@7.0.5: + dependencies: + compress-commons: 7.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + zod@4.4.3: {} diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..cc81e30 --- /dev/null +++ b/scripts/build-release.sh @@ -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" diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh new file mode 100755 index 0000000..5a24f93 --- /dev/null +++ b/scripts/publish-gitea-release.sh @@ -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" diff --git a/scripts/tallynote-update-runner.sh b/scripts/tallynote-update-runner.sh new file mode 100755 index 0000000..4c97577 --- /dev/null +++ b/scripts/tallynote-update-runner.sh @@ -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 diff --git a/scripts/tallynote-update.sh b/scripts/tallynote-update.sh new file mode 100755 index 0000000..81c5b77 --- /dev/null +++ b/scripts/tallynote-update.sh @@ -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 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' diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh new file mode 100755 index 0000000..bf3fab8 --- /dev/null +++ b/scripts/test-installer.sh @@ -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' diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..97cb04b --- /dev/null +++ b/server/app.ts @@ -0,0 +1,1654 @@ +import { existsSync } from "node:fs"; +import { lstat, rm, stat, unlink } from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; +import cookie from "@fastify/cookie"; +import helmet from "@fastify/helmet"; +import multipart from "@fastify/multipart"; +import fastifyStatic from "@fastify/static"; +import { z, ZodError } from "zod"; +import { + adminStatusSchema, + amountToCents, + attachmentKindSchema, + attachmentDeleteSchema, + changePasswordSchema, + createAdminSchema, + expenseInputSchema, + expenseStatusSchema, + expenseUpdateSchema, + exportRequestSchema, + loginSchema, + permanentDeleteSchema, + statusUpdateSchema, + updateApplySchema, + versionSchema, + type AttachmentKind, + type ExpenseStatus, + type UpdateJobStatus, +} from "../shared/contracts.js"; +import { writeAudit } from "./audit.js"; +import type { AppConfig } from "./config.js"; +import type { DatabaseContext } from "./db/index.js"; +import { AppError, errorPayload, notFound } from "./errors.js"; +import { buildExportJob, expireExports, insertExportJob, type ExportExpense, type ExportSnapshot } from "./exporter.js"; +import { + discardStaged, + fileReadStream, + processFileDeletions, + promoteStagedFile, + safeReadStream, + safeStoragePath, + stageMultipartFile, + type StagedFile, +} from "./files.js"; +import { + constantTimeEqual, + hashPassword, + normalizeUsername, + randomToken, + sha256, + temporaryPassword, + validateNewPassword, + verifyPassword, +} from "./security.js"; +import { + ACTIVE_UPDATE_STATUSES, + checkForUpdate, + publicCheckFromCache, + publicUpdateJob, + readCachedRelease, + writeUpdateRequest, + type UpdateRequest, +} from "./update-service.js"; + +type AdminRow = { + id: string; + username: string; + usernameNorm: string; + displayName: string; + passwordHash: string; + status: "active" | "disabled"; + mustChangePassword: number; + authVersion: number; + version: number; + createdAt: number; + lastLoginAt: number | null; + disabledAt: number | null; +}; + +type AuthContext = { + tokenHash: string; + csrfHash: string; + admin: AdminRow; +}; + +declare module "fastify" { + interface FastifyRequest { + auth?: AuthContext; + } +} + +const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const sessionCookie = "tally_session"; +const csrfCookie = "tally_csrf"; + +type UpdateRateState = { checkedAt: number; appliedAt: number }; +const updateRateStates = new WeakMap>(); + +function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState { + let states = updateRateStates.get(database); + if (!states) { + states = new Map(); + updateRateStates.set(database, states); + } + let state = states.get(adminId); + if (!state) { + state = { checkedAt: 0, appliedAt: 0 }; + states.set(adminId, state); + } + return state; +} + +function enforceUpdateCooldown( + database: DatabaseContext["sqlite"], + config: AppConfig, + adminId: string, + operation: "check" | "apply", + reply: FastifyReply, +): void { + const state = updateRateState(database, adminId); + const now = Date.now(); + const previous = operation === "check" ? state.checkedAt : state.appliedAt; + const cooldown = operation === "check" ? config.updateCheckCooldownMs : config.updateApplyCooldownMs; + if (cooldown > 0 && previous > 0 && now - previous < cooldown) { + const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000)); + reply.header("Retry-After", retryAfter); + throw new AppError(429, "UPDATE_RATE_LIMITED", operation === "check" + ? "检查更新过于频繁,请稍后再试" + : "更新操作过于频繁,请稍后再试"); + } + if (operation === "check") state.checkedAt = now; + else state.appliedAt = now; +} + +function adminSelect(alias = ""): string { + const column = (name: string) => alias ? `${alias}.${name}` : name; + return ` + ${column("id")}, ${column("username")}, ${column("username_norm")} AS usernameNorm, ${column("display_name")} AS displayName, + ${column("password_hash")} AS passwordHash, ${column("status")}, ${column("must_change_password")} AS mustChangePassword, + ${column("auth_version")} AS authVersion, ${column("version")}, ${column("created_at")} AS createdAt, + ${column("last_login_at")} AS lastLoginAt, ${column("disabled_at")} AS disabledAt + `; +} + +function publicAdmin(admin: AdminRow) { + return { + id: admin.id, + username: admin.username, + displayName: admin.displayName, + status: admin.status, + mustChangePassword: Boolean(admin.mustChangePassword), + version: admin.version, + createdAt: admin.createdAt, + lastLoginAt: admin.lastLoginAt, + disabledAt: admin.disabledAt, + }; +} + +function cookieOptions(config: AppConfig, httpOnly: boolean) { + return { + path: "/", + httpOnly, + secure: config.cookieSecure, + sameSite: "strict" as const, + }; +} + +function clearSessionCookies(reply: FastifyReply, config: AppConfig): void { + reply.clearCookie(sessionCookie, cookieOptions(config, true)); + reply.clearCookie(csrfCookie, cookieOptions(config, false)); +} + +function createSession(database: DatabaseContext, config: AppConfig, admin: AdminRow, reply: FastifyReply): AuthContext { + const token = randomToken(); + const csrf = randomToken(); + const tokenHash = sha256(token); + const csrfHash = sha256(csrf); + const now = Date.now(); + database.sqlite.prepare(` + INSERT INTO sessions ( + token_hash, admin_id, csrf_hash, auth_version, created_at, + last_seen_at, idle_expires_at, absolute_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(tokenHash, admin.id, csrfHash, admin.authVersion, now, now, now + config.sessionIdleMs, now + config.sessionAbsoluteMs); + reply.setCookie(sessionCookie, token, { ...cookieOptions(config, true), maxAge: Math.floor(config.sessionAbsoluteMs / 1000) }); + reply.setCookie(csrfCookie, csrf, { ...cookieOptions(config, false), maxAge: Math.floor(config.sessionAbsoluteMs / 1000) }); + return { tokenHash, csrfHash, admin }; +} + +function authenticate(request: FastifyRequest, database: DatabaseContext, config: AppConfig): AuthContext { + const token = request.cookies[sessionCookie]; + if (!token || token.length > 128) throw new AppError(401, "AUTH_REQUIRED", "请先登录"); + const tokenHash = sha256(token); + const row = database.sqlite.prepare(` + SELECT s.token_hash AS tokenHash, s.csrf_hash AS csrfHash, + s.auth_version AS sessionAuthVersion, s.last_seen_at AS lastSeenAt, + s.idle_expires_at AS idleExpiresAt, s.absolute_expires_at AS absoluteExpiresAt, + ${adminSelect("a")} + FROM sessions s JOIN admins a ON a.id=s.admin_id WHERE s.token_hash=? + `).get(tokenHash) as (AdminRow & { + tokenHash: string; + csrfHash: string; + sessionAuthVersion: number; + lastSeenAt: number; + idleExpiresAt: number; + absoluteExpiresAt: number; + }) | undefined; + const now = Date.now(); + if (!row || row.status !== "active" || row.sessionAuthVersion !== row.authVersion || row.idleExpiresAt <= now || row.absoluteExpiresAt <= now) { + if (row) database.sqlite.prepare("DELETE FROM sessions WHERE token_hash=?").run(tokenHash); + throw new AppError(401, "AUTH_REQUIRED", "登录已失效,请重新登录"); + } + if (now - row.lastSeenAt >= 5 * 60 * 1000) { + database.sqlite.prepare("UPDATE sessions SET last_seen_at=?, idle_expires_at=? WHERE token_hash=?") + .run(now, Math.min(now + config.sessionIdleMs, row.absoluteExpiresAt), tokenHash); + } + const auth = { tokenHash: row.tokenHash, csrfHash: row.csrfHash, admin: row }; + request.auth = auth; + return auth; +} + +function assertCsrf(request: FastifyRequest, auth: AuthContext): void { + const cookieToken = request.cookies[csrfCookie] ?? ""; + const headerToken = typeof request.headers["x-csrf-token"] === "string" ? request.headers["x-csrf-token"] : ""; + if (!cookieToken || !headerToken || !constantTimeEqual(cookieToken, headerToken) || !constantTimeEqual(sha256(cookieToken), auth.csrfHash)) { + throw new AppError(403, "CSRF_INVALID", "请求安全令牌无效,请刷新页面后重试"); + } +} + +function guard(database: DatabaseContext, config: AppConfig, options: { allowPasswordChange?: boolean } = {}) { + return async (request: FastifyRequest) => { + const auth = authenticate(request, database, config); + if (unsafeMethods.has(request.method)) assertCsrf(request, auth); + if (!options.allowPasswordChange && auth.admin.mustChangePassword) { + throw new AppError(403, "PASSWORD_CHANGE_REQUIRED", "首次登录需要先修改密码"); + } + }; +} + +function expenseBaseSelect(): string { + return ` + e.id, e.paid_at AS paidAt, e.amount_cents AS amountCents, e.note, + e.invoice_missing_reason AS invoiceMissingReason, e.status, + e.version, e.created_at AS createdAt, e.updated_at AS updatedAt, + e.reimbursed_at AS reimbursedAt, e.deleted_at AS deletedAt, + creator.display_name AS createdByName, updater.display_name AS updatedByName, + SUM(CASE WHEN a.kind='payment_proof' THEN 1 ELSE 0 END) AS paymentProofCount, + SUM(CASE WHEN a.kind='invoice' THEN 1 ELSE 0 END) AS invoiceCount + `; +} + +type ExpenseRow = { + id: string; + paidAt: number; + amountCents: number; + note: string; + invoiceMissingReason: string | null; + status: ExpenseStatus; + version: number; + createdAt: number; + updatedAt: number; + reimbursedAt: number | null; + deletedAt: number | null; + createdByName: string; + updatedByName: string; + paymentProofCount: number; + invoiceCount: number; +}; + +type AttachmentRow = { + id: string; + expenseId: string; + kind: AttachmentKind; + storagePath: string; + originalName: string; + mimeType: string; + sizeBytes: number; + sha256: string; + createdAt: number; +}; + +function listAttachments(database: DatabaseContext, expenseId: string): AttachmentRow[] { + return database.sqlite.prepare(` + SELECT id, expense_id AS expenseId, kind, storage_path AS storagePath, + original_name AS originalName, mime_type AS mimeType, size_bytes AS sizeBytes, + sha256, created_at AS createdAt + FROM attachments WHERE expense_id=? ORDER BY created_at, id + `).all(expenseId) as AttachmentRow[]; +} + +function publicAttachment(row: AttachmentRow) { + return { + id: row.id, + expenseId: row.expenseId, + kind: row.kind, + originalName: row.originalName, + mimeType: row.mimeType, + sizeBytes: row.sizeBytes, + sha256: row.sha256, + createdAt: row.createdAt, + previewable: row.mimeType.startsWith("image/") || row.mimeType === "application/pdf", + }; +} + +function getExpense(database: DatabaseContext, id: string, includeDeleted = false): ExpenseRow | undefined { + return database.sqlite.prepare(` + SELECT ${expenseBaseSelect()} + FROM expenses e + LEFT JOIN attachments a ON a.expense_id=e.id + JOIN admins creator ON creator.id=e.created_by + JOIN admins updater ON updater.id=e.updated_by + WHERE e.id=? ${includeDeleted ? "" : "AND e.deleted_at IS NULL"} + GROUP BY e.id + `).get(id) as ExpenseRow | undefined; +} + +function publicExpense(database: DatabaseContext, row: ExpenseRow, withAttachments = false) { + return { + ...row, + paymentProofCount: Number(row.paymentProofCount), + invoiceCount: Number(row.invoiceCount), + ...(withAttachments ? { attachments: listAttachments(database, row.id).map(publicAttachment) } : {}), + }; +} + +function normalizeInvoiceMissingReason(value: string | null | undefined): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return normalized || null; +} + +function assertInvoiceCoverage(invoiceCount: number, reason: string | null, missingStatus = 400): void { + const normalizedReason = normalizeInvoiceMissingReason(reason); + if (invoiceCount > 0 && normalizedReason) { + throw new AppError(400, "INVOICE_REASON_WITH_INVOICE", "已有发票时不能填写无发票原因"); + } + if (invoiceCount < 1 && !normalizedReason) { + throw new AppError(missingStatus, "INVOICE_OR_REASON_REQUIRED", "请上传发票,或勾选“无发票”并填写原因"); + } +} + +export function zonedMonthBounds(month: string, timezone: string): [number, number] { + const match = /^(\d{4})-(\d{2})$/.exec(month); + if (!match) throw new AppError(400, "VALIDATION_ERROR", "月份格式无效"); + const year = Number(match[1]); + const monthIndex = Number(match[2]) - 1; + if (monthIndex < 0 || monthIndex > 11) throw new AppError(400, "VALIDATION_ERROR", "月份格式无效"); + const toUtc = (targetYear: number, targetMonth: number) => { + const target = Date.UTC(targetYear, targetMonth, 1, 0, 0, 0); + let guess = target; + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); + for (let iteration = 0; iteration < 4; iteration += 1) { + const parts = Object.fromEntries(formatter.formatToParts(guess).map((part) => [part.type, part.value])); + const observed = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second)); + const difference = target - observed; + guess += difference; + if (difference === 0) break; + } + return guess; + }; + return [toUtc(year, monthIndex), toUtc(year, monthIndex + 1)]; +} + +const listQuerySchema = z.object({ + month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/), + status: expenseStatusSchema.default("unreimbursed"), + query: z.string().max(200).default(""), + missingInvoice: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), +}).strict(); + +function filteredExpenses(database: DatabaseContext, config: AppConfig, query: z.infer): ExpenseRow[] { + const [start, end] = zonedMonthBounds(query.month, config.timezone); + const escaped = query.query.replace(/[\\%_]/g, "\\$&"); + return database.sqlite.prepare(` + SELECT ${expenseBaseSelect()} + FROM expenses e + LEFT JOIN attachments a ON a.expense_id=e.id + JOIN admins creator ON creator.id=e.created_by + JOIN admins updater ON updater.id=e.updated_by + WHERE e.deleted_at IS NULL AND e.status=? AND e.paid_at>=? AND e.paid_at Date.now()) throw new AppError(429, "REAUTH_RATE_LIMITED", "密码确认尝试过多,请稍后再试"); +} + +function recordReauthFailure(database: DatabaseContext, request: FastifyRequest, adminId: string): void { + const key = reauthKey(request, adminId); + const now = Date.now(); + const current = database.sqlite.prepare("SELECT window_start AS windowStart, failures FROM login_attempts WHERE key_hash=?").get(key) as { windowStart: number; failures: number } | undefined; + const fresh = !current || current.windowStart <= now - 15 * 60 * 1000; + const failures = fresh ? 1 : current.failures + 1; + const blockedUntil = failures >= 5 ? now + 15 * 60 * 1000 : null; + database.sqlite.prepare(` + INSERT INTO login_attempts(key_hash, window_start, failures, blocked_until) VALUES (?, ?, ?, ?) + ON CONFLICT(key_hash) DO UPDATE SET window_start=excluded.window_start, failures=excluded.failures, blocked_until=excluded.blocked_until + `).run(key, fresh ? now : current.windowStart, failures, blockedUntil); +} + +function clearReauthFailures(database: DatabaseContext, request: FastifyRequest, adminId: string): void { + database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash=?").run(reauthKey(request, adminId)); +} + +async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig): Promise<{ fields: Record; files: StagedFile[] }> { + const fields: Record = {}; + const files: StagedFile[] = []; + try { + for await (const part of request.parts()) { + if (part.type === "field") { + if (!["paidAt", "amount", "note", "invoiceMissingReason"].includes(part.fieldname)) { + throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段"); + } + if (part.fieldname in fields) { + throw new AppError(400, "DUPLICATE_FIELD", "表单字段不能重复"); + } + fields[part.fieldname] = String(part.value); + continue; + } + if (files.length >= config.maxFilesPerRequest) throw new AppError(413, "TOO_MANY_FILES", "一次请求上传的文件过多"); + const kind = part.fieldname === "paymentProofs" ? "payment_proof" : part.fieldname === "invoices" ? "invoice" : null; + if (!kind) { + part.file.resume(); + throw new AppError(400, "UNKNOWN_FILE_FIELD", "未知的附件字段"); + } + files.push(await stageMultipartFile(config, part, kind)); + } + return { fields, files }; + } catch (error) { + await discardStaged(files); + throw error; + } +} + +async function promoteAll(config: AppConfig, files: StagedFile[]): Promise> { + const promoted: Array = []; + try { + for (const file of files) promoted.push({ ...file, storagePath: await promoteStagedFile(config, file) }); + return promoted; + } catch (error) { + await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined))); + await discardStaged(files); + throw error; + } +} + +function conflict(database: DatabaseContext, id: string): never { + const current = getExpense(database, id, true); + if (!current) notFound("账目不存在"); + throw new AppError(409, "VERSION_CONFLICT", "记录已被其他管理员修改", { + currentVersion: current.version, + current: publicExpense(database, current, true), + }); +} + +export async function buildApp(database: DatabaseContext, config: AppConfig) { + const app = Fastify({ + logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false, + // Fastify's runtime accepts a numeric hop count, while its v5 typings do + // not expose that overload. Keep the validated numeric value and bridge + // the declaration at this boundary. + trustProxy: config.trustProxy as any, + bodyLimit: config.maxRecordBytes + 2 * 1024 * 1024, + requestIdHeader: false, + genReqId: () => randomUUID(), + }); + const dummyPasswordHash = await hashPassword(randomToken()); + + await app.register(cookie); + await app.register(helmet, { + ...(config.isLocalOrigin ? { hsts: false } : {}), + frameguard: { action: "deny" }, + referrerPolicy: { policy: "no-referrer" }, + crossOriginOpenerPolicy: { policy: "same-origin" }, + crossOriginResourcePolicy: { policy: "same-origin" }, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + imgSrc: ["'self'", "blob:", "data:"], + objectSrc: ["'none'"], + frameSrc: ["'self'"], + "frame-ancestors": ["'none'"], + "base-uri": ["'none'"], + "form-action": ["'self'"], + ...(config.isLocalOrigin ? { "upgrade-insecure-requests": null } : {}), + }, + }, + }); + await app.register(multipart, { + limits: { fileSize: config.maxFileBytes, files: config.maxFilesPerRequest, fields: 20, parts: config.maxFilesPerRequest + 20 }, + throwFileSizeLimit: true, + }); + + // Financial records, attachment bytes and update metadata must never be + // retained by a browser, reverse proxy or shared cache. Keep this global so + // future authenticated routes inherit the same privacy boundary. + app.addHook("onSend", async (request, reply, payload) => { + if (request.url.split("?", 1)[0]!.startsWith("/api/")) { + reply.header("Cache-Control", "no-store"); + reply.header("Pragma", "no-cache"); + reply.header("Vary", "Cookie"); + } + return payload; + }); + + app.addHook("onRequest", async (request) => { + if (!unsafeMethods.has(request.method) || !request.url.startsWith("/api/")) return; + const origin = request.headers.origin; + const allowed = new Set([config.publicOrigin]); + if (!config.isProduction) { + allowed.add("http://127.0.0.1:5173"); + allowed.add("http://localhost:5173"); + } + if (typeof origin !== "string" || !allowed.has(origin)) { + throw new AppError(403, "ORIGIN_FORBIDDEN", "请求来源不受信任"); + } + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error)); + if (error instanceof ZodError) { + const appError = new AppError(400, "VALIDATION_ERROR", "提交内容不符合要求", error.issues); + return reply.code(400).send(errorPayload(request, appError)); + } + if ((error as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") { + const appError = new AppError(413, "FILE_TOO_LARGE", "单个文件超过大小限制"); + return reply.code(413).send(errorPayload(request, appError)); + } + const fastifyError = error as { code?: string; statusCode?: number }; + if (fastifyError.code === "FST_ERR_CTP_INVALID_JSON_BODY" || fastifyError.code === "FST_ERR_CTP_EMPTY_JSON_BODY") { + const appError = new AppError(400, "INVALID_JSON", "请求 JSON 格式无效"); + return reply.code(400).send(errorPayload(request, appError)); + } + if (fastifyError.statusCode === 413 || ["FST_REQ_BODY_TOO_LARGE", "FST_ERR_CTP_BODY_TOO_LARGE", "FST_FIELDS_LIMIT", "FST_FILES_LIMIT", "FST_PARTS_LIMIT"].includes(fastifyError.code ?? "")) { + const appError = new AppError(413, "REQUEST_TOO_LARGE", "请求内容超过大小或数量限制"); + return reply.code(413).send(errorPayload(request, appError)); + } + if (typeof fastifyError.statusCode === "number" && fastifyError.statusCode >= 400 && fastifyError.statusCode < 500) { + const appError = new AppError(fastifyError.statusCode, "BAD_REQUEST", "请求无法处理"); + return reply.code(fastifyError.statusCode).send(errorPayload(request, appError)); + } + request.log.error(error); + return reply.code(500).send(errorPayload(request, new AppError(500, "INTERNAL_ERROR", "服务器处理请求时发生错误"))); + }); + + app.get("/health", async () => ({ status: "ok", initialized: Boolean(database.sqlite.prepare("SELECT 1 FROM admins LIMIT 1").get()) })); + + app.get("/api/auth/status", async () => ({ initialized: Boolean(database.sqlite.prepare("SELECT 1 FROM admins LIMIT 1").get()), timezone: config.timezone })); + + app.post("/api/auth/login", { bodyLimit: 16 * 1024 }, async (request, reply) => { + const input = loginSchema.parse(request.body); + const normalized = normalizeUsername(input.username); + const now = Date.now(); + const ipKey = sha256(`ip:${request.ip}`); + const pairKey = sha256(`pair:${request.ip}:${normalized}`); + const limits = [ + { key: ipKey, maximum: 20 }, + { key: pairKey, maximum: 5 }, + ]; + for (const limit of limits) { + const row = database.sqlite.prepare("SELECT failures, blocked_until AS blockedUntil FROM login_attempts WHERE key_hash=?").get(limit.key) as { failures: number; blockedUntil: number | null } | undefined; + if (row?.blockedUntil && row.blockedUntil > now) { + reply.header("Retry-After", Math.ceil((row.blockedUntil - now) / 1000)); + throw new AppError(429, "LOGIN_RATE_LIMITED", "登录尝试过多,请稍后再试"); + } + } + const admin = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE username_norm=?`).get(normalized) as AdminRow | undefined; + const valid = await verifyPassword(admin?.passwordHash ?? dummyPasswordHash, input.password); + if (!admin || admin.status !== "active" || !valid) { + const updateLimit = database.sqlite.transaction(() => { + for (const limit of limits) { + const current = database.sqlite.prepare("SELECT window_start AS windowStart, failures FROM login_attempts WHERE key_hash=?").get(limit.key) as { windowStart: number; failures: number } | undefined; + const freshWindow = !current || current.windowStart <= now - 15 * 60 * 1000; + const failures = freshWindow ? 1 : current.failures + 1; + const blockedUntil = failures >= limit.maximum ? now + 15 * 60 * 1000 : null; + database.sqlite.prepare(` + INSERT INTO login_attempts(key_hash, window_start, failures, blocked_until) VALUES (?, ?, ?, ?) + ON CONFLICT(key_hash) DO UPDATE SET window_start=excluded.window_start, failures=excluded.failures, blocked_until=excluded.blocked_until + `).run(limit.key, freshWindow ? now : current.windowStart, failures, blockedUntil); + } + writeAudit(database.sqlite, { + requestId: request.id, + actorUsername: normalized, + action: "auth.login", + targetType: "session", + outcome: "denied", + metadata: { ipHash: sha256(request.ip) }, + }); + }); + updateLimit(); + throw new AppError(401, "INVALID_CREDENTIALS", "用户名或密码不正确"); + } + database.sqlite.transaction(() => { + database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash IN (?, ?)").run(ipKey, pairKey); + database.sqlite.prepare("UPDATE admins SET last_login_at=? WHERE id=?").run(now, admin.id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: admin.id, + actorUsername: admin.username, + action: "auth.login", + targetType: "session", + outcome: "success", + }); + })(); + const updatedAdmin = { ...admin, lastLoginAt: now }; + createSession(database, config, updatedAdmin, reply); + reply.header("Cache-Control", "no-store"); + return { admin: publicAdmin(updatedAdmin) }; + }); + + app.get("/api/auth/session", { preHandler: guard(database, config, { allowPasswordChange: true }) }, async (request, reply) => { + reply.header("Cache-Control", "no-store"); + return { admin: publicAdmin(request.auth!.admin) }; + }); + + app.post("/api/auth/logout", { preHandler: guard(database, config, { allowPasswordChange: true }) }, async (request, reply) => { + database.sqlite.transaction(() => { + database.sqlite.prepare("DELETE FROM sessions WHERE token_hash=?").run(request.auth!.tokenHash); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "auth.logout", + targetType: "session", + targetId: request.auth!.tokenHash.slice(0, 12), + }); + })(); + clearSessionCookies(reply, config); + return reply.code(204).send(); + }); + + app.post("/api/auth/change-password", { preHandler: guard(database, config, { allowPasswordChange: true }), bodyLimit: 16 * 1024 }, async (request, reply) => { + const input = changePasswordSchema.parse(request.body); + const policyError = validateNewPassword(input.newPassword); + if (policyError) throw new AppError(400, "PASSWORD_POLICY_FAILED", policyError); + assertReauthAllowed(database, request, request.auth!.admin.id); + if (!await verifyPassword(request.auth!.admin.passwordHash, input.currentPassword)) { + recordReauthFailure(database, request, request.auth!.admin.id); + throw new AppError(401, "CURRENT_PASSWORD_INVALID", "当前密码不正确"); + } + clearReauthFailures(database, request, request.auth!.admin.id); + if (await verifyPassword(request.auth!.admin.passwordHash, input.newPassword)) { + throw new AppError(409, "PASSWORD_REUSE_NOT_ALLOWED", "新密码不能与当前密码相同"); + } + const passwordHash = await hashPassword(input.newPassword); + const now = Date.now(); + database.sqlite.transaction(() => { + database.sqlite.prepare(` + UPDATE admins SET password_hash=?, must_change_password=0, auth_version=auth_version+1, + version=version+1, password_changed_at=? WHERE id=? + `).run(passwordHash, now, request.auth!.admin.id); + database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(request.auth!.admin.id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "admin.password_changed", + targetType: "admin", + targetId: request.auth!.admin.id, + }); + })(); + const updated = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(request.auth!.admin.id) as AdminRow; + createSession(database, config, updated, reply); + reply.header("Cache-Control", "no-store"); + return { admin: publicAdmin(updated) }; + }); + + app.get("/api/admins", { preHandler: guard(database, config) }, async () => { + const rows = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins ORDER BY created_at`).all() as AdminRow[]; + return { items: rows.map(publicAdmin) }; + }); + + app.post("/api/admins", { preHandler: guard(database, config) }, async (request, reply) => { + const input = createAdminSchema.parse(request.body); + const usernameNorm = normalizeUsername(input.username); + if ([...usernameNorm].length < 3) throw new AppError(400, "VALIDATION_ERROR", "用户名至少需要 3 个字符"); + const password = temporaryPassword(); + const passwordHash = await hashPassword(password); + const id = randomUUID(); + const now = Date.now(); + try { + database.sqlite.transaction(() => { + database.sqlite.prepare(` + INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, + must_change_password, auth_version, version, created_at, created_by) + VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?, ?) + `).run(id, input.username.normalize("NFKC").trim(), usernameNorm, input.displayName, passwordHash, now, request.auth!.admin.id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "admin.created", + targetType: "admin", + targetId: id, + after: { username: input.username, displayName: input.displayName, status: "active" }, + }); + }).immediate(); + } catch (error) { + if (String(error).includes("UNIQUE")) throw new AppError(409, "USERNAME_TAKEN", "用户名已存在"); + throw error; + } + const created = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; + reply.header("Cache-Control", "no-store"); + return reply.code(201).send({ admin: publicAdmin(created), temporaryPassword: password }); + }); + + app.patch("/api/admins/:id", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = z.object({ displayName: z.string().trim().min(1).max(80), version: z.number().int().positive() }).strict().parse(request.body); + const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined; + if (!existing) notFound("管理员不存在"); + const result = database.sqlite.transaction(() => { + const update = database.sqlite.prepare("UPDATE admins SET display_name=?, version=version+1 WHERE id=? AND version=?").run(input.displayName, id, input.version); + if (update.changes !== 1) throw new AppError(409, "VERSION_CONFLICT", "管理员资料已被更新"); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "admin.updated", + targetType: "admin", + targetId: id, + before: { displayName: existing.displayName }, + after: { displayName: input.displayName }, + }); + return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; + })(); + return { admin: publicAdmin(result) }; + }); + + app.put("/api/admins/:id/status", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = adminStatusSchema.parse(request.body); + const result = database.sqlite.transaction(() => { + const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined; + if (!existing) notFound("管理员不存在"); + if (existing.version !== input.version) throw new AppError(409, "VERSION_CONFLICT", "管理员状态已被更新"); + if (existing.status === input.status) return existing; + if (id === request.auth!.admin.id && input.status === "disabled") { + throw new AppError(409, "SELF_DISABLE_FORBIDDEN", "不能停用当前登录的管理员账号"); + } + if (input.status === "disabled") { + const active = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins WHERE status='active'").get() as { count: number }; + if (active.count <= 1) throw new AppError(409, "LAST_ACTIVE_ADMIN", "不能停用最后一个有效管理员"); + } + const now = Date.now(); + database.sqlite.prepare(` + UPDATE admins SET status=?, version=version+1, auth_version=auth_version+1, + disabled_at=?, disabled_by=? WHERE id=? AND version=? + `).run(input.status, input.status === "disabled" ? now : null, input.status === "disabled" ? request.auth!.admin.id : null, id, input.version); + if (input.status === "disabled") database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: input.status === "disabled" ? "admin.disabled" : "admin.enabled", + targetType: "admin", + targetId: id, + before: { status: existing.status }, + after: { status: input.status }, + }); + return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; + }).immediate(); + return { admin: publicAdmin(result) }; + }); + + app.post("/api/admins/:id/reset-password", { preHandler: guard(database, config) }, async (request, reply) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + if (id === request.auth!.admin.id) throw new AppError(409, "SELF_RESET_FORBIDDEN", "不能重置当前登录管理员的密码,请使用修改密码功能"); + const input = versionSchema.parse(request.body); + const password = temporaryPassword(); + const passwordHash = await hashPassword(password); + const updated = database.sqlite.transaction(() => { + const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined; + if (!existing) notFound("管理员不存在"); + const result = database.sqlite.prepare(` + UPDATE admins SET password_hash=?, must_change_password=1, auth_version=auth_version+1, + version=version+1, password_changed_at=NULL WHERE id=? AND version=? + `).run(passwordHash, id, input.version); + if (result.changes !== 1) throw new AppError(409, "VERSION_CONFLICT", "管理员资料已被更新"); + database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "admin.password_reset", + targetType: "admin", + targetId: id, + }); + return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow; + })(); + reply.header("Cache-Control", "no-store"); + return { admin: publicAdmin(updated), temporaryPassword: password }; + }); + + app.get("/api/update/status", { preHandler: guard(database, config) }, async (request, reply) => { + // Release metadata and task state should never be stored by an upstream + // proxy or a shared browser cache. + reply.header("Cache-Control", "no-store"); + const cached = publicCheckFromCache(database.sqlite, config); + const row = database.sqlite.prepare(` + SELECT id, status, version, platform, asset_name AS assetName, + size_bytes AS sizeBytes, error_message AS errorMessage, + created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt + FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1 + `).get(request.auth!.admin.id) as Record | undefined; + return { + ...cached, + strategy: config.updateStrategy, + job: publicUpdateJob(row), + }; + }); + + app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { + try { + enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply); + const result = await checkForUpdate(database.sqlite, config); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "update.checked", + targetType: "system", + metadata: { + currentVersion: result.currentVersion, + latestVersion: result.latest?.version ?? null, + compatible: result.latest?.compatible ?? false, + integrityReady: result.latest?.integrityReady ?? false, + }, + }); + reply.header("Cache-Control", "no-store"); + return { ...result, strategy: config.updateStrategy }; + } catch (error) { + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "update.checked", + targetType: "system", + outcome: "failure", + }); + throw error; + } + }); + + app.post("/api/update/apply", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { + const input = updateApplySchema.parse(request.body); + let applyAuditRecorded = false; + let applyAuditTarget: string | undefined; + try { + if (config.updateStrategy !== "systemd") { + throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新"); + } + // Preserve the actionable in-progress response for duplicate clicks before + // applying the per-admin cooldown. + const activeBeforeCheck = database.sqlite.prepare(` + SELECT id FROM update_jobs + WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) + ORDER BY created_at DESC LIMIT 1 + `).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; + if (activeBeforeCheck) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); + enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply); + // Re-fetch before applying. A cached tag is only a display hint; the + // server must verify the release and digest immediately before queuing it. + const checked = await checkForUpdate(database.sqlite, config); + const requestedVersion = input.version.replace(/^v/i, ""); + if (!checked.latest || checked.latest.version !== requestedVersion || !checked.latest.isNewer) { + throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新"); + } + if (!checked.latest.compatible || !checked.latest.integrityReady) { + throw new AppError(409, "UPDATE_NOT_VERIFIED", "该版本没有匹配当前平台且可验证的发布文件"); + } + const cached = readCachedRelease(database.sqlite, config); + const releaseAsset = cached?.asset; + if (!cached || !releaseAsset?.sha256 || !releaseAsset.url || cached.version !== requestedVersion) { + throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新"); + } + const expectedSha256 = releaseAsset.sha256; + const active = database.sqlite.transaction(() => { + const existing = database.sqlite.prepare(` + SELECT id, status FROM update_jobs + WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) + ORDER BY created_at DESC LIMIT 1 + `).get(...ACTIVE_UPDATE_STATUSES) as { id: string; status: UpdateJobStatus } | undefined; + if (existing) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); + const id = randomUUID(); + const now = Date.now(); + database.sqlite.prepare(` + INSERT INTO update_jobs( + id, admin_id, session_hash, request_id, requested_at, status, + version, platform, release_url, asset_name, asset_url, + expected_sha256, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + request.auth!.admin.id, + request.auth!.tokenHash, + request.id, + now, + requestedVersion, + checked.platform.target, + cached.metadataUrl, + releaseAsset.name, + releaseAsset.url, + expectedSha256, + now, + now, + ); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "update.apply_requested", + targetType: "update", + targetId: id, + after: { version: requestedVersion, platform: checked.platform.target, assetName: releaseAsset.name }, + }); + return { id, now }; + }).immediate(); + applyAuditTarget = active.id; + const updateRequest: UpdateRequest = { + jobId: active.id, + version: requestedVersion, + metadataUrl: cached.metadataUrl, + assetUrl: releaseAsset.url, + assetName: releaseAsset.name, + expectedSha256, + requestedAt: active.now, + currentLink: config.currentLink, + releasesDir: config.releasesDir, + dataDir: config.dataDir, + }; + try { + await writeUpdateRequest(config, updateRequest); + } catch { + database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=? AND status='queued'").run("无法创建系统更新请求", Date.now(), active.id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "update.apply_requested", + targetType: "update", + targetId: active.id, + outcome: "failure", + }); + applyAuditRecorded = true; + throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限"); + } + reply.header("Cache-Control", "no-store"); + return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion } }); + } catch (error) { + if (!applyAuditRecorded) { + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "update.apply_requested", + targetType: "update", + ...(applyAuditTarget ? { targetId: applyAuditTarget } : {}), + outcome: "failure", + }); + } + throw error; + } + }); + + app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const row = database.sqlite.prepare(` + SELECT id, status, version, platform, asset_name AS assetName, + size_bytes AS sizeBytes, error_message AS errorMessage, + created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt + FROM update_jobs WHERE id=? AND admin_id=? + `).get(id, request.auth!.admin.id) as Record | undefined; + if (!row) notFound("更新任务不存在"); + reply.header("Cache-Control", "no-store"); + return { job: publicUpdateJob(row) }; + }); + + app.get("/api/expenses", { preHandler: guard(database, config) }, async (request) => { + const query = listQuerySchema.parse(request.query); + const rows = filteredExpenses(database, config, query); + return { + items: rows.map((row) => publicExpense(database, row)), + summary: { count: rows.length, amountCents: rows.reduce((sum, row) => sum + row.amountCents, 0) }, + }; + }); + + app.get("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const row = getExpense(database, id); + if (!row) notFound("账目不存在"); + const timeline = database.sqlite.prepare(` + SELECT id, occurred_at AS occurredAt, actor_username AS actorUsername, action, + before_json AS beforeJson, after_json AS afterJson, metadata_json AS metadataJson + FROM audit_events WHERE target_type='expense' AND target_id=? ORDER BY occurred_at DESC, id DESC + `).all(id); + return { expense: publicExpense(database, row, true), timeline }; + }); + + app.post("/api/expenses", { preHandler: guard(database, config) }, async (request, reply) => { + if (!request.isMultipart()) throw new AppError(415, "MULTIPART_REQUIRED", "新建账目必须使用附件表单提交"); + const { fields, files } = await parseExpenseMultipart(request, config); + let input: z.infer; + try { + input = expenseInputSchema.parse({ + paidAt: fields.paidAt, + amount: fields.amount, + note: fields.note ?? "", + invoiceMissingReason: fields.invoiceMissingReason, + }); + } catch (error) { + await discardStaged(files); + throw error; + } + const paymentProofs = files.filter((file) => file.kind === "payment_proof"); + if (paymentProofs.length < 1) { + await discardStaged(files); + throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证"); + } + const invoiceFiles = files.filter((file) => file.kind === "invoice"); + const requestedInvoiceMissingReason = normalizeInvoiceMissingReason(input.invoiceMissingReason); + try { + assertInvoiceCoverage(invoiceFiles.length, requestedInvoiceMissingReason); + } catch (error) { + await discardStaged(files); + throw error; + } + const invoiceMissingReason = invoiceFiles.length > 0 ? null : requestedInvoiceMissingReason; + const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0); + if (totalBytes > config.maxRecordBytes) { + await discardStaged(files); + throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); + } + const paidAt = Date.parse(input.paidAt); + if (!Number.isFinite(paidAt)) { + await discardStaged(files); + throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); + } + let amountCents: number; + try { + amountCents = amountToCents(input.amount); + } catch { + await discardStaged(files); + throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数"); + } + const promoted = await promoteAll(config, files); + const id = randomUUID(); + const now = Date.now(); + try { + database.sqlite.transaction(() => { + 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(id, paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, now, request.auth!.admin.id); + const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; + if (globalBytes + totalBytes > config.maxTotalBytes) { + throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); + } + const insertAttachment = database.sqlite.prepare(` + INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, + mime_type, size_bytes, sha256, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const file of promoted) { + insertAttachment.run(file.id, id, file.kind, file.storagePath, file.originalName, file.mimeType, file.sizeBytes, file.sha256, now, request.auth!.admin.id); + } + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.created", + targetType: "expense", + targetId: id, + after: { + paidAt, + amountCents, + note: input.note, + invoiceMissingReason, + status: "unreimbursed", + attachmentCount: promoted.length, + paymentProofCount: paymentProofs.length, + invoiceCount: invoiceFiles.length, + attachmentKinds: promoted.map((file) => file.kind), + }, + }); + }).immediate(); + } catch (error) { + await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined))); + throw error; + } + const created = getExpense(database, id)!; + return reply.code(201).send({ expense: publicExpense(database, created, true) }); + }); + + app.patch("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = expenseUpdateSchema.parse(request.body); + const paidAt = Date.parse(input.paidAt); + if (!Number.isFinite(paidAt)) throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); + let amountCents: number; + try { + amountCents = amountToCents(input.amount); + } catch { + throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数"); + } + const requestedInvoiceMissingReason = input.invoiceMissingReason === undefined + ? undefined + : normalizeInvoiceMissingReason(input.invoiceMissingReason); + const now = Date.now(); + database.sqlite.transaction(() => { + const before = getExpense(database, id); + if (!before) notFound("账目不存在"); + if (before.version !== input.version) conflict(database, id); + const invoiceMissingReason = requestedInvoiceMissingReason === undefined + ? before.invoiceMissingReason + : requestedInvoiceMissingReason; + assertInvoiceCoverage(Number(before.invoiceCount), invoiceMissingReason); + const result = database.sqlite.prepare(` + UPDATE expenses SET paid_at=?, amount_cents=?, note=?, invoice_missing_reason=?, version=version+1, + updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL + `).run(paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, id, input.version); + if (result.changes !== 1) conflict(database, id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.updated", + targetType: "expense", + targetId: id, + before: { + paidAt: before.paidAt, + amountCents: before.amountCents, + note: before.note, + invoiceMissingReason: before.invoiceMissingReason, + version: before.version, + }, + after: { paidAt, amountCents, note: input.note, invoiceMissingReason, version: input.version + 1 }, + }); + }).immediate(); + return { expense: publicExpense(database, getExpense(database, id)!, true) }; + }); + + app.post("/api/expenses/:id/status", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = statusUpdateSchema.parse(request.body); + const before = getExpense(database, id); + if (!before) notFound("账目不存在"); + if (before.status === input.status) { + if (before.version !== input.version) conflict(database, id); + return { expense: publicExpense(database, before, true) }; + } + const now = Date.now(); + database.sqlite.transaction(() => { + const result = database.sqlite.prepare(` + UPDATE expenses SET status=?, version=version+1, updated_at=?, updated_by=?, + reimbursed_at=?, reimbursed_by=? WHERE id=? AND version=? AND deleted_at IS NULL + `).run(input.status, now, request.auth!.admin.id, input.status === "reimbursed" ? now : null, input.status === "reimbursed" ? request.auth!.admin.id : null, id, input.version); + if (result.changes !== 1) conflict(database, id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.status_changed", + targetType: "expense", + targetId: id, + before: { status: before.status, version: before.version }, + after: { status: input.status, version: input.version + 1 }, + }); + })(); + return { expense: publicExpense(database, getExpense(database, id)!, true) }; + }); + + app.post("/api/expenses/:id/attachments", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const kind = attachmentKindSchema.parse((request.query as { kind?: string }).kind); + if (!request.isMultipart()) throw new AppError(415, "MULTIPART_REQUIRED", "附件必须使用文件表单提交"); + const existing = getExpense(database, id); + if (!existing) notFound("账目不存在"); + const fields: Record = {}; + const staged: StagedFile[] = []; + try { + for await (const part of request.parts()) { + if (part.type === "field") { + if (part.fieldname !== "version") throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段"); + if (part.fieldname in fields) throw new AppError(400, "DUPLICATE_FIELD", "表单字段不能重复"); + fields[part.fieldname] = String(part.value); + } else { + const expectedField = kind === "payment_proof" ? "paymentProofs" : "invoices"; + if (part.fieldname !== expectedField) throw new AppError(400, "UNKNOWN_FILE_FIELD", "附件字段与类型不匹配"); + if (staged.length >= config.maxFilesPerRequest) throw new AppError(413, "TOO_MANY_FILES", "单次上传文件数量超过限制"); + staged.push(await stageMultipartFile(config, part, kind)); + } + } + } catch (error) { + await discardStaged(staged); + throw error; + } + let version: number; + try { + version = z.coerce.number().int().positive().parse(fields.version); + } catch (error) { + await discardStaged(staged); + throw error; + } + if (staged.length < 1) throw new AppError(400, "FILE_REQUIRED", "请选择至少一个附件"); + const promoted = await promoteAll(config, staged); + const now = Date.now(); + try { + database.sqlite.transaction(() => { + const current = getExpense(database, id); + if (!current) notFound("账目不存在"); + if (current.version !== version) conflict(database, id); + const nextInvoiceMissingReason = kind === "invoice" ? null : normalizeInvoiceMissingReason(current.invoiceMissingReason); + const nextInvoiceCount = Number(current.invoiceCount) + (kind === "invoice" ? promoted.length : 0); + assertInvoiceCoverage(nextInvoiceCount, nextInvoiceMissingReason); + const currentBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total; + if (currentBytes + promoted.reduce((sum, file) => sum + file.sizeBytes, 0) > config.maxRecordBytes) { + throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); + } + const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; + if (globalBytes + promoted.reduce((sum, file) => sum + file.sizeBytes, 0) > config.maxTotalBytes) { + throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); + } + const updated = database.sqlite.prepare("UPDATE expenses SET invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL") + .run(nextInvoiceMissingReason, now, request.auth!.admin.id, id, version); + if (updated.changes !== 1) conflict(database, id); + const insert = database.sqlite.prepare(` + INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, + size_bytes, sha256, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const file of promoted) insert.run(file.id, id, kind, file.storagePath, file.originalName, file.mimeType, file.sizeBytes, file.sha256, now, request.auth!.admin.id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.attachments_added", + targetType: "expense", + targetId: id, + before: { + invoiceMissingReason: current.invoiceMissingReason, + invoiceCount: Number(current.invoiceCount), + version: current.version, + }, + after: { + kind, + invoiceMissingReason: nextInvoiceMissingReason, + invoiceCount: nextInvoiceCount, + version: version + 1, + files: promoted.map((file) => ({ id: file.id, name: file.originalName, size: file.sizeBytes })), + }, + }); + }).immediate(); + } catch (error) { + await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined))); + throw error; + } + return { expense: publicExpense(database, getExpense(database, id)!, true) }; + }); + + app.delete("/api/attachments/:id", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = attachmentDeleteSchema.parse(request.body); + const attachment = database.sqlite.prepare(` + SELECT id, expense_id AS expenseId, kind, storage_path AS storagePath, + original_name AS originalName, mime_type AS mimeType, size_bytes AS sizeBytes, + sha256, created_at AS createdAt FROM attachments WHERE id=? + `).get(id) as AttachmentRow | undefined; + if (!attachment) notFound("附件不存在"); + database.sqlite.transaction(() => { + const expense = getExpense(database, attachment.expenseId); + if (!expense) notFound("账目不存在"); + if (expense.version !== input.version) conflict(database, expense.id); + if (attachment.kind === "payment_proof") { + const count = database.sqlite.prepare("SELECT COUNT(*) AS count FROM attachments WHERE expense_id=? AND kind='payment_proof'").get(expense.id) as { count: number }; + if (count.count <= 1) throw new AppError(409, "LAST_PAYMENT_PROOF", "必须先上传替代凭证,才能删除最后一张付款凭证"); + } + const requestedReason = input.invoiceMissingReason === undefined + ? undefined + : normalizeInvoiceMissingReason(input.invoiceMissingReason); + const remainingInvoiceCount = attachment.kind === "invoice" + ? Number((database.sqlite.prepare("SELECT COUNT(*) AS count FROM attachments WHERE expense_id=? AND kind='invoice' AND id<>?").get(expense.id, id) as { count: number }).count) + : Number(expense.invoiceCount); + const nextInvoiceMissingReason = requestedReason === undefined + ? normalizeInvoiceMissingReason(expense.invoiceMissingReason) + : requestedReason; + assertInvoiceCoverage(remainingInvoiceCount, nextInvoiceMissingReason, 409); + const deleted = database.sqlite.prepare("DELETE FROM attachments WHERE id=? AND expense_id=?").run(id, expense.id); + if (deleted.changes !== 1) notFound("附件不存在"); + const now = Date.now(); + const updated = database.sqlite.prepare("UPDATE expenses SET invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL") + .run(nextInvoiceMissingReason, now, request.auth!.admin.id, expense.id, input.version); + if (updated.changes !== 1) conflict(database, expense.id); + enqueueFileDeletion(database, attachment.storagePath, "attachment_deleted"); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.attachment_deleted", + targetType: "expense", + targetId: expense.id, + before: { + id: attachment.id, + kind: attachment.kind, + name: attachment.originalName, + sha256: attachment.sha256, + invoiceMissingReason: expense.invoiceMissingReason, + invoiceCount: Number(expense.invoiceCount), + version: expense.version, + }, + after: { + invoiceMissingReason: nextInvoiceMissingReason, + invoiceCount: remainingInvoiceCount, + version: input.version + 1, + }, + }); + })(); + // Finish the queued byte-deletion attempt before responding. Missing + // bytes are treated as already gone; retryable filesystem failures remain + // visible in the deletion queue for the janitor. + await processFileDeletions(database.sqlite, config); + return { expense: publicExpense(database, getExpense(database, attachment.expenseId)!, true) }; + }); + + app.get("/api/attachments/:id/content", { preHandler: guard(database, config) }, async (request, reply) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const attachment = database.sqlite.prepare(` + SELECT a.id, a.expense_id AS expenseId, a.kind, a.storage_path AS storagePath, + a.original_name AS originalName, a.mime_type AS mimeType, a.size_bytes AS sizeBytes, + a.sha256, a.created_at AS createdAt FROM attachments a JOIN expenses e ON e.id=a.expense_id + WHERE a.id=? AND e.deleted_at IS NULL + `).get(id) as AttachmentRow | undefined; + if (!attachment) notFound("附件不存在"); + try { + const fileInfo = await lstat(safeStoragePath(config.filesDir, attachment.storagePath)); + if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) throw new Error("attachment type"); + } catch { + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.attachment_read", + targetType: "expense", + targetId: attachment.expenseId, + outcome: "failure", + metadata: { attachmentId: attachment.id, mode: "missing" }, + }); + throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用"); + } + const download = (request.query as { download?: string }).download === "1"; + const inline = !download && (attachment.mimeType.startsWith("image/") || attachment.mimeType === "application/pdf"); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: download ? "expense.attachment_downloaded" : "expense.attachment_previewed", + targetType: "expense", + targetId: attachment.expenseId, + metadata: { attachmentId: attachment.id, kind: attachment.kind, sizeBytes: attachment.sizeBytes }, + }); + reply.header("Content-Type", attachment.mimeType); + reply.header("Content-Length", attachment.sizeBytes); + reply.header("X-Content-Type-Options", "nosniff"); + reply.header("Cache-Control", "private, no-store"); + if (inline) { + // PDF previews are rendered in the authenticated same-origin dialog; + // keep downloads unframeable while allowing this narrow preview case. + reply.header("Content-Security-Policy", "sandbox"); + reply.header("X-Frame-Options", "SAMEORIGIN"); + } + reply.header("Content-Disposition", `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`); + return reply.send(await fileReadStream(config, attachment.storagePath)); + }); + + app.delete("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = versionSchema.parse(request.body); + const before = getExpense(database, id); + if (!before) notFound("账目不存在"); + const now = Date.now(); + database.sqlite.transaction(() => { + const result = database.sqlite.prepare(` + UPDATE expenses SET deleted_at=?, deleted_by=?, version=version+1, + updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL + `).run(now, request.auth!.admin.id, now, request.auth!.admin.id, id, input.version); + if (result.changes !== 1) conflict(database, id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.trashed", + targetType: "expense", + targetId: id, + before: { status: before.status, version: before.version }, + after: { deletedAt: now, version: input.version + 1 }, + }); + })(); + return { expense: publicExpense(database, getExpense(database, id, true)!, true) }; + }); + + app.get("/api/trash", { preHandler: guard(database, config) }, async () => { + const rows = database.sqlite.prepare(` + SELECT ${expenseBaseSelect()} + FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id + JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by + WHERE e.deleted_at IS NOT NULL GROUP BY e.id ORDER BY e.deleted_at DESC + `).all() as ExpenseRow[]; + return { items: rows.map((row) => publicExpense(database, row)) }; + }); + + app.post("/api/trash/:id/restore", { preHandler: guard(database, config) }, async (request) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = versionSchema.parse(request.body); + const existing = getExpense(database, id, true); + if (!existing || !existing.deletedAt) notFound("回收站中没有该账目"); + const now = Date.now(); + database.sqlite.transaction(() => { + const result = database.sqlite.prepare(` + UPDATE expenses SET deleted_at=NULL, deleted_by=NULL, version=version+1, + updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NOT NULL + `).run(now, request.auth!.admin.id, id, input.version); + if (result.changes !== 1) conflict(database, id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.restored", + targetType: "expense", + targetId: id, + before: { deletedAt: existing.deletedAt, version: existing.version }, + after: { deletedAt: null, version: input.version + 1 }, + }); + })(); + return { expense: publicExpense(database, getExpense(database, id)!, true) }; + }); + + app.delete("/api/trash/:id", { preHandler: guard(database, config) }, async (request, reply) => { + const id = z.string().uuid().parse((request.params as { id: string }).id); + const input = permanentDeleteSchema.parse(request.body); + assertReauthAllowed(database, request, request.auth!.admin.id); + if (!await verifyPassword(request.auth!.admin.passwordHash, input.password)) { + recordReauthFailure(database, request, request.auth!.admin.id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.purge", + targetType: "expense", + targetId: id, + outcome: "denied", + }); + throw new AppError(401, "CURRENT_PASSWORD_INVALID", "密码确认失败"); + } + clearReauthFailures(database, request, request.auth!.admin.id); + const existing = getExpense(database, id, true); + if (!existing || !existing.deletedAt) notFound("回收站中没有该账目"); + const attachmentRows = listAttachments(database, id); + const exportFiles: string[] = []; + database.sqlite.transaction(() => { + const current = database.sqlite.prepare("SELECT version, deleted_at AS deletedAt FROM expenses WHERE id=?").get(id) as { version: number; deletedAt: number | null } | undefined; + if (!current || !current.deletedAt) notFound("回收站中没有该账目"); + if (current.version !== input.version) conflict(database, id); + for (const attachment of attachmentRows) enqueueFileDeletion(database, attachment.storagePath, "expense_purged"); + const jobs = database.sqlite.prepare("SELECT id, status, file_path AS filePath, snapshot_json AS snapshotJson FROM export_jobs WHERE status IN ('queued','building','ready')").all() as Array<{ id: string; status: string; filePath: string | null; snapshotJson: string }>; + for (const job of jobs) { + const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot; + if (!snapshot.expenses.some((expense) => expense.id === id)) continue; + database.sqlite.prepare("UPDATE export_jobs SET status='expired', file_path=NULL WHERE id=?").run(job.id); + if (job.filePath) exportFiles.push(job.filePath); + } + database.sqlite.prepare("DELETE FROM expenses WHERE id=?").run(id); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "expense.purged", + targetType: "expense", + targetId: id, + before: { + paidAt: existing.paidAt, + amountCents: existing.amountCents, + note: existing.note, + invoiceMissingReason: existing.invoiceMissingReason, + status: existing.status, + deletedAt: existing.deletedAt, + attachments: attachmentRows.map((item) => ({ kind: item.kind, name: item.originalName, size: item.sizeBytes, sha256: item.sha256 })), + }, + after: { permanentlyDeleted: true }, + }); + }).immediate(); + await Promise.all(exportFiles.map((file) => unlink(safeStoragePath(config.exportsDir, file)).catch(() => undefined))); + await processFileDeletions(database.sqlite, config); + return reply.code(204).send(); + }); + + app.get("/api/audit", { preHandler: guard(database, config) }, async (request) => { + const query = z.object({ + actorId: z.string().uuid().optional(), + action: z.string().max(100).optional(), + targetType: z.string().max(50).optional(), + targetId: z.string().max(100).optional(), + limit: z.coerce.number().int().min(1).max(500).default(200), + offset: z.coerce.number().int().min(0).max(100_000).default(0), + }).strict().parse(request.query); + const clauses: string[] = []; + const values: unknown[] = []; + if (query.actorId) { clauses.push("actor_admin_id=?"); values.push(query.actorId); } + if (query.action) { clauses.push("action=?"); values.push(query.action); } + if (query.targetType) { clauses.push("target_type=?"); values.push(query.targetType); } + if (query.targetId) { clauses.push("target_id=?"); values.push(query.targetId); } + values.push(query.limit, query.offset); + const rows = database.sqlite.prepare(` + SELECT id, occurred_at AS occurredAt, request_id AS requestId, + actor_admin_id AS actorAdminId, actor_username AS actorUsername, + action, target_type AS targetType, target_id AS targetId, outcome, + before_json AS beforeJson, after_json AS afterJson, metadata_json AS metadataJson + FROM audit_events ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY occurred_at DESC, id DESC LIMIT ? OFFSET ? + `).all(...values); + return { items: rows }; + }); + + app.post("/api/exports", { preHandler: guard(database, config) }, async (request, reply) => { + const selection = exportRequestSchema.parse(request.body); + let rows: ExpenseRow[]; + if ("ids" in selection) { + const placeholders = selection.ids.map(() => "?").join(","); + rows = database.sqlite.prepare(` + SELECT ${expenseBaseSelect()} + FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id + JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by + WHERE e.deleted_at IS NULL AND e.id IN (${placeholders}) GROUP BY e.id ORDER BY e.paid_at DESC, e.id DESC + `).all(...selection.ids) as ExpenseRow[]; + if (rows.length !== new Set(selection.ids).size) throw new AppError(404, "EXPORT_RECORD_NOT_FOUND", "部分勾选记录不存在或已进入回收站"); + } else { + rows = filteredExpenses(database, config, { ...selection, missingInvoice: selection.missingInvoice }); + } + if (rows.length === 0) throw new AppError(400, "EMPTY_EXPORT", "没有可导出的记录"); + if (rows.length > config.maxExportRecords) throw new AppError(413, "EXPORT_TOO_LARGE", "导出记录数超过限制"); + const snapshot: ExportSnapshot = { + expenses: rows.map((row): ExportExpense => ({ + id: row.id, + paidAt: row.paidAt, + amountCents: row.amountCents, + note: row.note, + invoiceMissingReason: row.invoiceMissingReason, + status: row.status, + attachments: listAttachments(database, row.id), + })), + includeManifest: selection.includeManifest, + }; + const snapshotBytes = snapshot.expenses.reduce((sum, expense) => sum + expense.attachments.reduce((attachmentSum, attachment) => attachmentSum + attachment.sizeBytes, 0), 0); + if (snapshotBytes > config.maxExportBytes) throw new AppError(413, "EXPORT_TOO_LARGE", "导出附件总大小超过限制"); + const jobId = database.sqlite.transaction(() => { + const activeJobs = database.sqlite.prepare("SELECT COUNT(*) AS count FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").get(Date.now()) as { count: number }; + if (activeJobs.count >= config.maxConcurrentExports) throw new AppError(429, "EXPORT_BUSY", "当前导出任务较多,请稍后再试"); + const storedBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM export_jobs WHERE status='ready' AND expires_at > ?").get(Date.now()) as { total: number }).total; + if (storedBytes + snapshotBytes > config.maxExportStorageBytes) { + throw new AppError(413, "EXPORT_STORAGE_LIMIT", "导出缓存空间已满,请先下载或等待旧导出过期"); + } + const id = insertExportJob(database.sqlite, config, { + adminId: request.auth!.admin.id, + sessionHash: request.auth!.tokenHash, + selection, + snapshot, + }); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "export.created", + targetType: "export", + targetId: id, + metadata: { expenseIds: rows.map((row) => row.id), count: rows.length, amountCents: rows.reduce((sum, row) => sum + row.amountCents, 0), includeManifest: selection.includeManifest }, + }); + return id; + }).immediate(); + void buildExportJob(database.sqlite, config, jobId); + return reply.code(202).send({ job: { id: jobId, status: "queued" } }); + }); + + app.get("/api/exports/:id", { preHandler: guard(database, config) }, async (request) => { + await expireExports(database.sqlite, config); + const id = z.string().uuid().parse((request.params as { id: string }).id); + const job = database.sqlite.prepare(` + SELECT id, status, file_name AS fileName, size_bytes AS sizeBytes, + error_message AS errorMessage, created_at AS createdAt, ready_at AS readyAt, + expires_at AS expiresAt FROM export_jobs WHERE id=? AND session_hash=? + `).get(id, request.auth!.tokenHash); + if (!job) notFound("导出任务不存在"); + return { job }; + }); + + app.get("/api/exports/:id/download", { preHandler: guard(database, config) }, async (request, reply) => { + await expireExports(database.sqlite, config); + const id = z.string().uuid().parse((request.params as { id: string }).id); + const job = database.sqlite.prepare(` + SELECT status, file_path AS filePath, file_name AS fileName, size_bytes AS sizeBytes + FROM export_jobs WHERE id=? AND session_hash=? + `).get(id, request.auth!.tokenHash) as { status: string; filePath: string | null; fileName: string; sizeBytes: number | null } | undefined; + if (!job) notFound("导出任务不存在"); + if (job.status !== "ready" || !job.filePath) throw new AppError(409, "EXPORT_NOT_READY", "导出文件尚未生成完成"); + writeAudit(database.sqlite, { + requestId: request.id, + actorAdminId: request.auth!.admin.id, + actorUsername: request.auth!.admin.username, + action: "export.downloaded", + targetType: "export", + targetId: id, + metadata: { sizeBytes: job.sizeBytes ?? null }, + }); + reply.header("Content-Type", "application/zip"); + if (job.sizeBytes) reply.header("Content-Length", job.sizeBytes); + reply.header("Cache-Control", "private, no-store"); + reply.header("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(job.fileName)}`); + return reply.send(await safeReadStream(config.exportsDir, job.filePath)); + }); + + if (existsSync(config.webDir)) { + await app.register(fastifyStatic, { root: config.webDir, wildcard: false }); + app.setNotFoundHandler((request, reply) => { + if (request.url.startsWith("/api/")) return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在"))); + return reply.sendFile("index.html"); + }); + } else { + app.get("/", async () => ({ name: "TallyNote", mode: "api", hint: "开发界面运行在 Vite 端口" })); + } + + return app; +} diff --git a/server/audit.ts b/server/audit.ts new file mode 100644 index 0000000..148818c --- /dev/null +++ b/server/audit.ts @@ -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), + ); +} diff --git a/server/cli/admin-init.ts b/server/cli/admin-init.ts new file mode 100644 index 0000000..24c8c9a --- /dev/null +++ b/server/cli/admin-init.ts @@ -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 { + if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码"); + output.write(prompt); + return await new Promise((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; +}); diff --git a/server/cli/update.ts b/server/cli/update.ts new file mode 100644 index 0000000..65736e4 --- /dev/null +++ b/server/cli/update.ts @@ -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; + +/** 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 | 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[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): 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 { + 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 { + const platform = options.platform ?? detectPlatform(); + const jobId = options.jobId ?? randomUUID(); + let resolved: Awaited> | 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 { + 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; + }); +} diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..222a1e9 --- /dev/null +++ b/server/config.ts @@ -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; + +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. + } + }; +} diff --git a/server/db/index.ts b/server/db/index.ts new file mode 100644 index 0000000..f239392 --- /dev/null +++ b/server/db/index.ts @@ -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; +}; + +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 }) }; +} diff --git a/server/db/schema.ts b/server/db/schema.ts new file mode 100644 index 0000000..0d02824 --- /dev/null +++ b/server/db/schema.ts @@ -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), +]); diff --git a/server/errors.ts b/server/errors.ts new file mode 100644 index 0000000..21ca86e --- /dev/null +++ b/server/errors.ts @@ -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); +} diff --git a/server/exporter.ts b/server/exporter.ts new file mode 100644 index 0000000..af19160 --- /dev/null +++ b/server/exporter.ts @@ -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(); + +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 { + 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 { + 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 { + 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 { + 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((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(); + 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 { + 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 { + 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 { + 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 }); + } +} diff --git a/server/files.ts b/server/files.ts new file mode 100644 index 0000000..56e7eae --- /dev/null +++ b/server/files.ts @@ -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(" { + await new Promise((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 (/ { + 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 { + 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 { + await Promise.all(files.map((file) => rm(file.stagingPath, { force: true }))); +} + +export async function processFileDeletions(sqlite: Database.Database, config: AppConfig): Promise { + 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 { + 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 { + 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 => { + 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 { + 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(); + } +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..71dde82 --- /dev/null +++ b/server/index.ts @@ -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; +}); diff --git a/server/security.ts b/server/security.ts new file mode 100644 index 0000000..8e058fd --- /dev/null +++ b/server/security.ts @@ -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 { + return argon2.hash(password, ARGON_OPTIONS); +} + +export async function verifyPassword(hash: string, password: string): Promise { + 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!`; +} diff --git a/server/update-service.ts b/server/update-service.ts new file mode 100644 index 0000000..7debb2a --- /dev/null +++ b/server/update-service.ts @@ -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; + 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 { + 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 { + 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 | undefined): Record | 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, + }; +} diff --git a/server/update.ts b/server/update.ts new file mode 100644 index 0000000..5464f5e --- /dev/null +++ b/server/update.ts @@ -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)[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 { + 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 { + 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; + 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; + 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 { + 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 { + 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 { + 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 { + 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 { + if (stream.write(chunk)) return; + await new Promise((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 { + 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 | undefined; + let renamed = false; + const walk = async (directory: string, prefix: string): Promise => { + 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 { + 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 { + 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 { + 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((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((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; + private buffered = Buffer.alloc(0) as Buffer; + private done = false; + + constructor(private readonly stream: Readable) { + this.iterator = stream[Symbol.asyncIterator](); + } + + private async fill(minimum: number): Promise { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 => { + 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 { + 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 { + 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; diff --git a/shared/contracts.ts b/shared/contracts.ts new file mode 100644 index 0000000..edfee52 --- /dev/null +++ b/shared/contracts.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +export const expenseStatusSchema = z.enum(["unreimbursed", "reimbursed"]); +export type ExpenseStatus = z.infer; + +export const attachmentKindSchema = z.enum(["payment_proof", "invoice"]); +export type AttachmentKind = z.infer; + +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; + +/** 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); +} diff --git a/systemd/tallynote-update.path b/systemd/tallynote-update.path new file mode 100644 index 0000000..260d4ce --- /dev/null +++ b/systemd/tallynote-update.path @@ -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 diff --git a/systemd/tallynote-update.service b/systemd/tallynote-update.service new file mode 100644 index 0000000..3ec011a --- /dev/null +++ b/systemd/tallynote-update.service @@ -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 diff --git a/systemd/tallynote.env.example b/systemd/tallynote.env.example new file mode 100644 index 0000000..d0ef014 --- /dev/null +++ b/systemd/tallynote.env.example @@ -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 diff --git a/systemd/tallynote.service b/systemd/tallynote.service new file mode 100644 index 0000000..a6de39f --- /dev/null +++ b/systemd/tallynote.service @@ -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 diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..74cb525 --- /dev/null +++ b/tests/api.test.ts @@ -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>; + let database: ReturnType; + let config: ReturnType; + + 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("") }, + ]); + 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("") }, + ]); + 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("") }, + ]); + 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("") }, + ]); + 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("原始发票文件已丢失,无法重新取得"); + }); +}); diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..ed90e43 --- /dev/null +++ b/tests/core.test.ts @@ -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("普通备注"); + }); +}); diff --git a/tests/download-audit.test.ts b/tests/download-audit.test.ts new file mode 100644 index 0000000..6f253a6 --- /dev/null +++ b/tests/download-audit.test.ts @@ -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; + let database: ReturnType; + let app: Awaited>; + 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"); + }); +}); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..7fedfba --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -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(); +}); diff --git a/tests/export.test.ts b/tests/export.test.ts new file mode 100644 index 0000000..4f33359 --- /dev/null +++ b/tests/export.test.ts @@ -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> { + 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(); + 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; + let database: ReturnType; + 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"); + }); +}); diff --git a/tests/migration.test.ts b/tests/migration.test.ts new file mode 100644 index 0000000..2881057 --- /dev/null +++ b/tests/migration.test.ts @@ -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 | 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 }); + } + }); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..0f483ef --- /dev/null +++ b/tests/security.test.ts @@ -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 }); + } + }); +}); diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts new file mode 100644 index 0000000..6442ddf --- /dev/null +++ b/tests/update-api.test.ts @@ -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; + let database: ReturnType; + let app: Awaited>; + 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"); + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..38c6eac --- /dev/null +++ b/tests/update.test.ts @@ -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 }); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c7fbef7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..c221a2c --- /dev/null +++ b/tsconfig.server.json @@ -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"] +} diff --git a/tsconfig.web.json b/tsconfig.web.json new file mode 100644 index 0000000..8df1fc3 --- /dev/null +++ b/tsconfig.web.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..18c7ff6 --- /dev/null +++ b/vite.config.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, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a3925c4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + pool: "forks", + fileParallelism: false, + }, +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c929670 --- /dev/null +++ b/web/index.html @@ -0,0 +1,9 @@ + + + + + + TallyNote 账目台 + +
+ diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..c0becec --- /dev/null +++ b/web/src/main.tsx @@ -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(url: string, init: RequestInit = {}): Promise { + 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 & { kind?: "default" | "primary" | "danger" | "ghost" }) { + return ; +} + +function TooltipLayer() { + const activeRef = useRef(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("[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({tooltip.label}, 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
{notices.map((notice) =>
{notice.kind === "error" ? : }{notice.message}
)}
; +} + +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
TallyNote

账目与凭证工作台

{notice &&
{notice}
}
{error &&
{error}
}
; +} + +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
首次登录保护

管理员 {admin.displayName} 需要设置新密码(至少 12 位)。

{error &&
{error}
}
; +} + +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(null); + const modalRef = useRef(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("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("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(
event.target === event.currentTarget && onClose()}>

{title}

{children}
{footer &&
{footer}
}
, 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 undefined : onClose} footer={<>}>

{message}

; +} + +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 undefined : onClose} footer={<>}> +

{message}

+ {requiresReason &&