diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.git-hooks/commit-msg.mjs b/.git-hooks/commit-msg.mjs new file mode 100644 index 0000000..d55445f --- /dev/null +++ b/.git-hooks/commit-msg.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +/** + * commit-msg hook: validates commit message against project convention. + * + * Format: `:: (): ` + * Example: `:sparkles: feat(auth): add OAuth login support` + * + * Rules: + * - Must start with :: + * - Must contain (): + * - type must be one of the known types + * - subject must be lowercase, no period at end + * - First line max 72 characters + * - No Chinese characters in the first line + */ + +import { readFileSync } from 'node:fs'; + +const COMMIT_TYPES = [ + 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', + 'test', 'build', 'ci', 'chore', 'revert', + 'security', 'a11y', 'i18n', 'release', 'hotfix', +]; + +const EMOJI_PATTERN = /^:[a-z0-9_+-]+:\s+/; +const FORMAT_PATTERN = /^:[a-z0-9_+-]+:\s+([a-z]+)(\([a-z0-9_-]+\))?(!)?:\s+.+/; + +function validate(filePath) { + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n').filter(Boolean); + + if (lines.length === 0) { + error('提交信息不能为空'); + } + + const firstLine = lines[0]; + const restLines = lines.slice(1); + + // Check first line length + if (firstLine.length > 72) { + error(`提交信息第一行超过 72 个字符(当前 ${firstLine.length})`); + } + + // Check emoji prefix + if (!EMOJI_PATTERN.test(firstLine)) { + error(`提交信息必须以 :emoji: 开头,如 ':sparkles: feat(auth): xxx'`); + } + + // Check format + const match = firstLine.match(FORMAT_PATTERN); + if (!match) { + error( + `提交信息格式错误。\n` + + `格式: :: (): \n` + + `示例: :sparkles: feat(auth): add OAuth login support`, + ); + } + + if (match) { + const type = match[1]; + if (!COMMIT_TYPES.includes(type)) { + error( + `未知的 type "${type}"。\n` + + `允许的值: ${COMMIT_TYPES.join(', ')}`, + ); + } + } + + // Check no Chinese characters in first line + const chinesePattern = /[一-鿿㐀-䶿]/; + if (chinesePattern.test(firstLine)) { + error('提交信息第一行不能包含中文字符,请使用英文'); + } + + // Check no trailing period on first line + if (firstLine.endsWith('.')) { + error('提交信息第一行不能以句号结尾'); + } + + // body lines should be wrapped at 72 characters + for (let i = 0; i < restLines.length; i++) { + const line = restLines[i]; + // Skip blank lines and comment lines + if (line === '' || line.startsWith('#')) continue; + if (line.length > 72) { + error(`第 ${i + 2} 行超过 72 个字符(当前 ${line.length})`); + } + } + + process.exit(0); +} + +function error(msg) { + console.error(`\n❌ 提交信息格式检查未通过:\n ${msg}\n`); + console.error( + '正确的格式: :: (): \n' + + '示例:\n' + + ' :sparkles: feat(auth): add OAuth login support\n' + + ' :bug: fix(navbar): resolve mobile overflow\n' + + ' :recycle: refactor(utils): extract date formatting\n', + ); + process.exit(1); +} + +validate(process.argv[2]); diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9b002cd --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,135 @@ +name: Deploy Frontend + Backend + +on: + push: + branches: [main] + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/transcircle-backend + +jobs: + frontend: + name: Build & Deploy Frontend + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + + - run: pnpm run build + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist --project-name=transcircle-submit --branch=${{ github.ref_name }} + + backend: + name: Build & Push Backend Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable pnpm via corepack + run: | + corepack enable + corepack prepare pnpm@11.4.0 --activate + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install & build backend + working-directory: ./functions + run: | + pnpm install + pnpm run build + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix={{branch}}- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./functions + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + db-migrate: + name: Database Migration + runs-on: ubuntu-latest + needs: [backend] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable pnpm via corepack + run: | + corepack enable + corepack prepare pnpm@11.4.0 --activate + + - name: Run database setup + working-directory: ./functions + env: + DB_CONFIG__DB_HOSTNAME: ${{ secrets.DB_HOST }} + DB_CONFIG__DB_PORT: ${{ secrets.DB_PORT || '3306' }} + DB_CONFIG__DB_USERNAME: ${{ secrets.DB_USER }} + DB_CONFIG__DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_CONFIG__DB_DATABASE: ${{ secrets.DB_NAME || 'transcircle' }} + run: | + pnpm install + pnpm run db:setup diff --git a/.github/workflows/story-rebuild.yml b/.github/workflows/story-rebuild.yml new file mode 100644 index 0000000..6b09460 --- /dev/null +++ b/.github/workflows/story-rebuild.yml @@ -0,0 +1,44 @@ +name: Rebuild Story Site + +on: + repository_dispatch: + types: [story-rebuild] + # Allow manual trigger for testing + workflow_dispatch: + inputs: + api_base: + description: 'API base URL to fetch published stories' + required: true + default: 'https://api.transcircle.org' + +env: + # Default API base — overridden by dispatch payload or workflow_dispatch input + API_BASE: ${{ github.event.client_payload.api_base || github.event.inputs.api_base || 'https://api.transcircle.org' }} + +jobs: + build-and-deploy: + name: Build & Deploy Story Site + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + + steps: + - uses: actions/checkout@v4 + + # ── Fetch published submissions from the API ── + - name: Fetch published stories + run: | + mkdir -p story/data + # API returns { data: [...], requestId: "..." } — extract the array + RESPONSE=$(curl -sSf "${{ env.API_BASE }}/v1/stories/published" -H "Accept: application/json" 2>/dev/null || echo '{"data":[]}') + echo "$RESPONSE" | jq '.data // []' > story/data/submissions.json + echo "Story entries: $(jq 'length' story/data/submissions.json)" + + # ── Deploy to Cloudflare Pages (separate project for story site) ── + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy story --project-name=transcircle-story --branch=${{ github.ref_name }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9806bb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +node_modules/ +dist/ +.wrangler/ +.dev.vars +story/.wrangler/ + +# build outputs +*.tsbuildinfo +functions/dist/ + +# logs +functions/logs/ + +# local config +functions/config.toml +config.toml + +# local env +.env +.env.local + +# generated lockfile (functions is standalone, lockfile created by CI) +functions/pnpm-lock.yaml + +# dev/plan documents +docs/superpowers/ +*.toml +.jwt-keys/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7fa3d4a --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +prefer-offline=true +registry=https://registry.npmmirror.com/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3b2cd06 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..000d921 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,476 @@ +# TransCircle 部署指南 + +## 架构总览 + +``` +用户 → submit.transcircle.org (Cloudflare Pages, 前端 SPA) + ↓ /v1/* 代理到后端 + → api.transcircle.org (Railway / Fly.io, Express + MySQL) +``` + +本仓库 `TransCircle-Frontend` 同时包含: + +| 组件 | 路径 | 技术栈 | +|------|------|--------| +| 前端 SPA | `src/` | React + Vite,部署到 Cloudflare Pages | +| 后端 API | `functions/` | Express 5 + mysql2,部署到 Node.js 主机 | +| 数据库 | — | MySQL 8+(云服务或自建) | + +--- + +## 目录 + +1. [前置准备](#1-前置准备) +2. [MySQL 数据库部署](#2-mysql-数据库部署) +3. [后端部署(Railway)](#3-后端部署-railway) +4. [后端部署(Fly.io 备选)](#4-后端部署-flyio-备选) +5. [前端部署(Cloudflare Pages)](#5-前端部署-cloudflare-pages) +6. [环境变量速查](#6-环境变量速查) +7. [本地开发快速启动](#7-本地开发快速启动) +8. [验证清单](#8-验证清单) + +--- + +## 1. 前置准备 + +### 1.1 注册账号 + +| 服务 | 用途 | 注册链接 | +|------|------|----------| +| Cloudflare | 前端托管(免费计划即可) | https://dash.cloudflare.com | +| Railway / Fly.io | 后端运行(有免费额度) | https://railway.app / https://fly.io | +| MySQL 云服务 | 数据库 | Railway 内置 MySQL / PlanetScale / 阿里云 RDS | +| GitHub | 代码托管 + CI/CD | 已有 | + +### 1.2 OAuth App 注册 + +**GitHub OAuth App:** + +1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App +2. 填写: + - Application name: `TransCircle` + - Homepage URL: `https://submit.transcircle.org`(开发期用 `http://localhost:5173`) + - Authorization callback URL: `https://api.transcircle.org/v1/auth/oauth/github/callback` +3. 保存后复制 **Client ID** 和 **Client Secret** + +**X (Twitter) OAuth 2.0:** + +1. https://developer.twitter.com → Projects & Apps → 你的项目 → User authentication settings +2. App permissions: `Read` +3. Type of App: `Web App, Automated App or Bot` +4. Callback URI / Redirect URL: `https://api.transcircle.org/v1/auth/oauth/x/callback` +5. 保存后复制 **Client ID** 和 **Client Secret** + +### 1.3 临时管理员令牌 + +```bash +openssl rand -hex 32 +# 输出如:a1b2c3d4e5f6...(64 字符) +# 保存到 ADMIN.TEMP_ADMIN_TOKEN 配置 +``` + +--- + +## 2. MySQL 数据库部署 + +### 2.1 Railway MySQL(推荐,与后端同平台) + +1. Railway Dashboard → New Project → `Provision MySQL` +2. 等待创建完成,点击 MySQL 服务 → Connect → 复制连接信息 +3. 记录以下值: + - `MYSQL_URL`(例如 `mysql://user:password@host:port/database`) + - 或分别记录 HOST / PORT / USER / PASSWORD / DATABASE + +### 2.2 PlanetScale(备选) + +1. https://planetscale.com → Create database → `transcircle` +2. 连接标签页 → 复制密码 +3. 连接串格式:`mysql://user:password@aws.connect.psdb.cloud/transcircle?ssl={"rejectUnauthorized":true}` + +### 2.3 阿里云 RDS(中国用户备选) + +1. RDS 控制台 → 创建实例 → MySQL 8.0 +2. 设置白名单:`0.0.0.0/0`(生产环境建议限缩到后端 IP) +3. 创建数据库:`create database transcircle character set utf8mb4 collate utf8mb4_unicode_ci;` + +### 2.4 初始化表结构 + +连接成功后执行以下任一方式: + +**方式 A:脚本自动执行** +```bash +cd functions +# 先配好 config.toml 的 DB_CONFIG 部分 +# 或设环境变量:DB_CONFIG__DB_HOSTNAME DB_CONFIG__DB_USERNAME 等 +npm run db:setup +``` + +**方式 B:手动导入** +```bash +# 用 MySQL CLI +mysql -h -u -p transcircle < functions/schema.sql +``` + +**方式 C:Railway 内置** +```bash +# Railway CLI +railway run --service transcircle-db "mysql -u root < functions/schema.sql" +``` + +--- + +## 3. 后端部署(Railway) + +### 3.1 项目结构准备 + +Railway 可以部署本仓库的 `functions/` 子目录。需要先调整 `functions/package.json` 让它能在 Railway 上被正确识别: + +```diff + // functions/package.json + { + "name": "transcircle-backend", + "scripts": { + "start": "node dist/Index.js", + "build": "tsc", ++ "postinstall": "npm run build" + } + } + ``` + + > 或通过 Railway Dashboard -> Start Command 手动设为 `node dist/Index.js` + +### 3.2 部署步骤 + +1. **Railway Dashboard** → New Project → `Deploy from GitHub repo` +2. 选择 `TransCircle/TransCircle-Frontend` 仓库 +3. **关键:设置 Root Directory 为 `functions/`** + - 在 Railway 项目设置 → Root Directory → 输入 `functions` +4. Railway 会自动检测 `package.json` 并执行 `npm install` + `postinstall`(build) + +### 3.3 配置环境变量 + +在 Railway Dashboard → 项目 → Variables 中设置以下环境变量: + +```env +# NODE_ENV 必须设为 production +NODE_ENV=production + +# 数据库 +DB_CONFIG__DB_HOSTNAME=<你的 MySQL 主机> +DB_CONFIG__DB_PORT=3306 +DB_CONFIG__DB_USERNAME=<用户名> +DB_CONFIG__DB_PASSWORD=<密码> +DB_CONFIG__DB_DATABASE=transcircle + +# Session / JWT 密钥(务必修改为随机字符串!) +SESSION__SESS_SECRET= +SESSION__SESS_MAXAGE=86400 + +# 服务端口(Railway 自动分配 80/443,也可固定) +SERV_CONFIG__SERV_PORT=1145 + +# OAuth +OAUTH__GITHUB_CLIENT_ID=<从 GitHub OAuth App 获取> +OAUTH__GITHUB_CLIENT_SECRET=<从 GitHub OAuth App 获取> +OAUTH__X_CLIENT_ID=<从 X Developer Portal 获取> +OAUTH__X_CLIENT_SECRET=<从 X Developer Portal 获取> + +# 管理员 +ADMIN__TEMP_ADMIN_TOKEN= + +# CORS — 允许前端域名 +CORS__ALLOWED_ORIGINS=https://submit.transcircle.org,https://*.transcircle.pages.dev + +# 前端 URL(用于 OAuth 回调跳转) +APP_URL=https://submit.transcircle.org +``` + +### 3.4 获取后端域名 + +Railway 部署完成后会自动分配一个域名,例如 `transcircle-frontend.up.railway.app`。 + +在 Railway Dashboard → Networking → Generate Domain,得到 `*.railway.app` 域名。 +**记下这个域名**,稍后需要配到前端的 `API_BASE`。 + +### 3.5 更新 OAuth 回调 URL + +部署完成后,回到 GitHub OAuth App 和 X Developer Portal,将回调 URL 更新为实际域名: + +``` +https:///v1/auth/oauth/github/callback +https:///v1/auth/oauth/x/callback +``` + +--- + +## 4. 后端部署(Fly.io 备选) + +> 如果 Railway 在中国访问慢,可选用 Fly.io + +### 4.1 安装 flyctl + +```bash +# macOS / Linux +curl -fsSL https://fly.io/install.sh | sh + +# Windows (Git Bash) +curl -fsSL https://fly.io/install.sh | sh + +# 登录 +fly auth login +``` + +### 4.2 创建 fly.toml + +在 `functions/` 目录下创建 `fly.toml`: + +```toml +app = "transcircle-api" +primary_region = "hkg" # 香港节点 + +[build] + builder = "heroku/buildpacks:20" + +[http_service] + internal_port = 1145 + force_https = true + auto_stop_machines = false + auto_start_machines = true + min_machines_running = 1 +``` + +### 4.3 部署 + +```bash +cd functions +fly launch --no-deploy +fly secrets set \ + NODE_ENV=production \ + DB_CONFIG__DB_HOSTNAME=... \ + DB_CONFIG__DB_USERNAME=... \ + DB_CONFIG__DB_PASSWORD=... \ + DB_CONFIG__DB_DATABASE=transcircle \ + SESSION__SESS_SECRET=... \ + OAUTH__GITHUB_CLIENT_ID=... \ + OAUTH__GITHUB_CLIENT_SECRET=... \ + OAUTH__X_CLIENT_ID=... \ + OAUTH__X_CLIENT_SECRET=... \ + ADMIN__TEMP_ADMIN_TOKEN=... \ + CORS__ALLOWED_ORIGINS=https://submit.transcircle.org \ + APP_URL=https://submit.transcircle.org +fly deploy +``` + +部署完成后: +```bash +fly open +# 得到 https://transcircle-api.fly.dev/ +``` + +--- + +## 5. 前端部署(Cloudflare Pages) + +### 5.1 配置生产 API 地址 + +部署前确保 `src/config.ts` 的生产环境 `API_BASE` 指向实际后端域名: + +```ts +const API_BASE: string = import.meta.env.PROD + ? 'https://<后端域名>' // ← 改为 Railway 或 Fly.io 域名 + : '/v1' +``` + +> 例如:`'https://transcircle-frontend.up.railway.app'` + +### 5.2 方式 A:通过 GitHub Actions(推荐) + +已有 `.github/workflows/deploy.yml`,在仓库 Settings → Secrets and variables → Actions 添加: + +| Secret | 值 | +|--------|-----| +| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token(见下方说明) | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Dashboard 右侧的 Account ID | + +**获取 Cloudflare API Token:** + +1. Cloudflare Dashboard → My Profile → API Tokens → Create Token +2. 选 **Create Custom Token**: + +| 字段 | 值 | +|------|-----| +| Permissions → Account → Cloudflare Pages | `Edit` | +| Account Resources | 当前账号 | +| TTL | 365 days | + +3. 复制生成的 token + +### 5.3 方式 B:手动部署 + +```bash +# 1. 构建 +pnpm install +pnpm run build + +# 2. 部署 +npx wrangler pages deploy dist --project-name=transcircle-submit --branch=main +``` + +### 5.4 绑定自定义域名 + +1. Cloudflare Dashboard → Workers & Pages → `transcircle-submit` +2. Custom domains → Add custom domain → 输入 `submit.transcircle.org` +3. 在 DNS 提供商处添加 CNAME 记录指向 `transcircle-submit.pages.dev` +4. 等待 SSL 证书签发(几分钟) + +### 5.5 更新 CORS 配置 + +回到后端环境变量,确保 `CORS__ALLOWED_ORIGINS` 包含前端域名: + +``` +CORS__ALLOWED_ORIGINS=https://submit.transcircle.org,https://*.transcircle.pages.dev +``` + +--- + +## 6. 环境变量速查 + +### 所有配置项(对应 config.toml 各段) + +| 环境变量 | 对应 TOML | 说明 | +|----------|-----------|------| +| `NODE_ENV` | — | `production` 启用安全模式(secure cookie 等) | +| `DB_CONFIG__DB_HOSTNAME` | `[DB_CONFIG] DB_HOSTNAME` | MySQL 主机 | +| `DB_CONFIG__DB_PORT` | `[DB_CONFIG] DB_PORT` | MySQL 端口,默认 3306 | +| `DB_CONFIG__DB_USERNAME` | `[DB_CONFIG] DB_USERNAME` | MySQL 用户名 | +| `DB_CONFIG__DB_PASSWORD` | `[DB_CONFIG] DB_PASSWORD` | MySQL 密码 | +| `DB_CONFIG__DB_DATABASE` | `[DB_CONFIG] DB_DATABASE` | 数据库名,默认 transcircle | +| `SESSION__SESS_SECRET` | `[SESSION] SESS_SECRET` | JWT 签名密钥(必改!) | +| `SESSION__SESS_MAXAGE` | `[SESSION] SESS_MAXAGE` | Refresh token 有效期(秒),默认 86400 | +| `SERV_CONFIG__SERV_PORT` | `[SERV_CONFIG] SERV_PORT` | Express 监听端口,默认 1145 | +| `OAUTH__GITHUB_CLIENT_ID` | `[OAUTH] GITHUB_CLIENT_ID` | GitHub OAuth Client ID | +| `OAUTH__GITHUB_CLIENT_SECRET` | `[OAUTH] GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | +| `OAUTH__X_CLIENT_ID` | `[OAUTH] X_CLIENT_ID` | X OAuth 2.0 Client ID | +| `OAUTH__X_CLIENT_SECRET` | `[OAUTH] X_CLIENT_SECRET` | X OAuth 2.0 Client Secret | +| `ADMIN__TEMP_ADMIN_TOKEN` | `[ADMIN] TEMP_ADMIN_TOKEN` | 临时管理员令牌 | +| `CORS__ALLOWED_ORIGINS` | `[CORS] ALLOWED_ORIGINS` | 允许的前端域名,逗号分隔 | +| `APP_URL` | — | 前端 URL(OAuth 回调跳转用) | +| `DEBUG_CONFIG__DEBUG_MODE` | `[DEBUG_CONFIG] DEBUG_MODE` | 调试模式,生产关掉 | + +> 环境变量名 = `TOML段名__键名`,双下划线分隔。 +> 所有配置可混用:既可以用 `config.toml` 文件,也可以用环境变量覆盖。 +> 生产环境**推荐全用环境变量**,不部署 `config.toml` 文件。 + +--- + +## 7. 本地开发快速启动 + +### 7.1 启动数据库 + +```bash +# 确保本地 MySQL 运行中 +# 建库(如果还没有) +mysql -u root -e "CREATE DATABASE IF NOT EXISTS transcircle CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +# 导表结构 +mysql -u root transcircle < functions/schema.sql + +# 或一键脚本 +cd functions && npx tsx src/setup.ts +``` + +### 7.2 启动后端 + +```bash +# 在 functions/ 目录下 +cd functions +cp config.toml.example config.toml # 按需编辑 +npm install +npm run dev +# → http://localhost:1145 +``` + +### 7.3 启动前端 + +```bash +# 在项目根目录下 +cd TransCircle-Frontend +npm install +npm run dev +# → http://localhost:5173 (自动代理 /v1 → :1145) +``` + +### 7.4 同时启动前后端 + +```bash +npm run dev:all +# 前端 :5173 + 后端 :1145 同时运行 +``` + +--- + +## 8. 验证清单 + +### 前端验证 + +- [ ] `submit.transcircle.org` 可访问,页面正常渲染 +- [ ] 投稿页面 → 填写表单 → 匿名投稿成功,返回 TC-xxx ID +- [ ] GitHub OAuth 登录 → 跳转到 GitHub → 授权 → 跳回,登录状态显示 +- [ ] X OAuth 登录 → 同理 +- [ ] 已登录用户投稿 → 投稿关联到账号 + +### 后端验证 + +- [ ] `GET /v1/health` 返回 `{"status":"ok","timestamp":...}` +- [ ] `POST /v1/contributions` 匿名投稿返回 201 +- [ ] `POST /v1/auth/oauth/exchange` 正确兑换 loginCode +- [ ] `POST /v1/admin/contributions/:id/review` 审核通过/拒绝正常(需 admin 身份) +- [ ] 限流生效:`X-RateLimit-Limit` 和 `X-RateLimit-Remaining` 响应头存在 +- [ ] CORS 正确:非允许域名返回 403 / 无响应 + +### 安全验证 + +- [ ] Refresh token 是 `HttpOnly` cookie,JS 不可读 +- [ ] 临时管理员令牌不在生产环境长期使用 +- [ ] `SESSION__SESS_SECRET` 已改为随机字符串,非默认值 +- [ ] HTTPS 已启用(Cloudflare + Railway/Fly.io 自动) +- [ ] 投稿 honeypot 字段可以拦截简单爬虫 + +--- + +## 调试技巧 + +### 查看后端日志 + +```bash +# Railway +railway logs --service transcircle-api + +# Fly.io +fly logs +``` + +### 测试 API + +```bash +# 健康检查 +curl https:///v1/health + +# 匿名投稿 +curl -X POST https:///v1/contributions \ + -H 'Content-Type: application/json' \ + -d '{"title":"测试","content":"测试正文","category":"观点评论","submitMode":"submit"}' + +# 带 token 的管理接口 +curl https:///v1/admin/contributions?status=pending \ + -H 'Authorization: Bearer ' +``` + +### 常见问题 + +| 问题 | 排查 | +|------|------| +| 前端 404 | Check `wrangler.jsonc` 的 `pages_build_output_dir` 是否为 `dist` | +| API 返回 502 | 后端未启动或端口不对,检查 `SERV_CONFIG__SERV_PORT` | +| OAuth 回调失败 | 回调 URL 必须与 OAuth App 注册时完全一致(含末尾斜杠) | +| 数据库连接失败 | 检查白名单、SSL 模式、凭据是否正确 | +| CORS 错误 | 检查 `CORS__ALLOWED_ORIGINS` 是否包含当前前端域名 | diff --git a/docs/architecture/pending-csrf-idempotency.md b/docs/architecture/pending-csrf-idempotency.md new file mode 100644 index 0000000..09bbcf1 --- /dev/null +++ b/docs/architecture/pending-csrf-idempotency.md @@ -0,0 +1,161 @@ +# 待讨论架构问题文档 + +## H1: OAuth CSRF Token 生产部署通道 + +### 当前状况 + +- **Config**: `src/config.ts` 生产 API 为 `https://api.transcircle.org/v1` +- **Cookie**: `oauth_pending_csrf` 设于 API host(`api.transcircle.org`),`Path=/v1/auth/oauth` +- **前端读法**: `document.cookie.match(/oauth_pending_csrf=/)`(`src/api/client.ts:90-94`) +- **保存兜底**: `saveCsrfToken()` 写入 sessionStorage(`OAuthCallback.tsx:31-32`、`OAuthContinue.tsx:18-19`、`OAuthBinding.tsx`) + +### 问题 + +``` +Frontend: transcircle.org ─── document.cookie: ❌ 看不到 api.transcircle.org 的 cookie +Backend API: api.transcircle.org ── Set-Cookie: oauth_pending_csrf=... Path=/v1/auth/oauth +``` + +`document.cookie` **只能读取与当前页面同源的 cookie**。跨 host 场景下,前端永远读不到这个值 → `X-CSRF-Token` 恒为空 → API 返回 403 CSRF_TOKEN_INVALID。 + +目前 dev 能跑通是因为 Vite proxy 同源,生产则断裂。 + +### 方案选项 + +#### A. 同源反向代理 (推荐) + +在 `transcircle.org` 部署一个反向代理(如 nginx/Caddy),将 `/v1/*` 转发到 `api.transcircle.org/v1/*`。 + +``` +用户 → transcircle.org → nginx → api.transcircle.org/v1/* + ↑ + Set-Cookie 现在与前站同源 +``` + +- 优点:cookie 自动同源,前端代码无需改动;整个 CSRF 机制按设计工作 +- 缺点:需要配置反代;API 服务器仍需区分来源 + +#### B. CSRF via Response Body + +OAuth start 和 callback 的响应体同时返回 CSRF token(除 Set-Cookie 外): + +```json +{ + "data": { "authorizationUrl": "...", "csrfToken": "..." }, + "requestId": "req_xxx" +} +``` + +前端从 JSON 响应中提取 token,存入 sessionStorage,后续通过 `X-CSRF-Token` header 传递。 + +- 优点:不依赖同源 cookie;前端已有 `saveCsrfToken()` 基础设施 +- 缺点:需要后端配合在多个 OAuth 端点响应中增加 `csrfToken` 字段 + +#### C. URL Fragment 传递 + +OAuth callback URL 中携带 CSRF token 作为 query 参数: + +``` +/auth/callback?status=login_ok&loginCode=xxx&csrfToken=xxx +``` + +- 优点:实现简单 +- 缺点:token 可能出现在服务器日志中;URL 长度受限 + +#### D. 放宽 Cookie Domain + +后端设置 `Domain=transcircle.org` 使 cookie 跨子域可读: + +``` +Set-Cookie: oauth_pending_csrf=...; Domain=transcircle.org; Path=/v1/auth/oauth; Secure; HttpOnly; SameSite=Lax +``` + +- 优点:前端代码无需改动 +- 缺点:安全边界降低;需要确保所有子域 HTTPS + +### 推荐方案 + +**方案 B(Response Body)** 最适合当前架构: + +- 前端已有 `saveCsrfToken()` / `getCsrfToken()` 的 sessionStorage 通道 +- 不需要基础设施变更(不改 nginx/域名) +- 只需要后端在 OAuth start 和 callback 响应中增加 `csrfToken` 字段 +- 需要后端的端点: + - `GET /v1/auth/oauth/:provider/start` → 返回 `{ csrfToken }` + - OAuth callback → 返回 `{ ...params, csrfToken }` + +--- + +## M9: Idempotency-Key 提升到表单意图层 + +### 当前状况 + +`src/api/client.ts:229`: + +```typescript +const idempotencyKey = options.idempotent ? newIdempotencyKey() : undefined +``` + +Key 在 `apiRequest()` 每次调用时生成。如果请求超时/5xx 后用户再次点击提交按钮,新的请求会生成新的 key,服务端幂等快照永远不会命中。 + +### 目标 + +为每个 **业务意图**(注册、投稿提交、编辑申请创建)生成一次 key,在超时重试间复用。 + +### 修改方案 + +#### 1. 暴露 setIdempotencyKey 到 client.ts + +```typescript +// client.ts — 添加 per-intent key 支持 +let _intentKey: string | null = null + +export function setIdempotencyKey(key: string | null): void { + _intentKey = key +} + +export function getIntentKey(): string | null { + return _intentKey +} +``` + +#### 2. 修改 apiRequest 优先使用意图级 key + +```typescript +// 在 apiRequest 中 +const idempotencyKey = options.idempotent ? _intentKey || newIdempotencyKey() : undefined +if (idempotencyKey && !_intentKey) { + _intentKey = idempotencyKey +} +``` + +#### 3. 在表单提交入口设置 key + +```typescript +// RegisterDirect.tsx 提交前: +setIdempotencyKey(crypto.randomUUID()) + +// SubmitForm.tsx 提交前: +setIdempotencyKey(crypto.randomUUID()) + +// EditRequestForm.tsx 提交前: +setIdempotencyKey(crypto.randomUUID()) +``` + +在 401 重试后或表单重置时清除: + +```typescript +// 成功后或表单重置时: +setIdempotencyKey(null) +``` + +### 需要注意 + +- 必须确保 key 在重试间保持不变,但在新的意图开始时重新生成 +- 表单组件的 `handleSubmit` 需要设置 key(而不是在 render 中设置) +- `RegisterDirect.tsx:81`、`SubmitForm.tsx:144`、`EditRequestForm.tsx` 需要接入 +- 确保在 401 自动重试后 key 不被重新生成(当前行为正确:同一 `apiRequest` 调用内复用) + +### 实施时机 + +M9 为低严重度问题,可在下一次表单相关迭代中实施。当前已在 `client.ts` 中添加 TODO 注释。 diff --git a/eslint.config.js b/eslint.config.js index ef614d2..af3fc26 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,9 +6,10 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'functions/dist']), + // Frontend source: browser + React { - files: ['**/*.{ts,tsx}'], + files: ['src/**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -19,4 +20,19 @@ export default defineConfig([ globals: globals.browser, }, }, + // Backend (functions): Node.js, no React + { + files: ['functions/**/*.ts'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + ], + languageOptions: { + globals: globals.node, + }, + rules: { + // Allow Node.js require-style where needed + '@typescript-eslint/no-require-imports': 'off', + }, + }, ]) diff --git a/functions/.dockerignore b/functions/.dockerignore new file mode 100644 index 0000000..3ac0b96 --- /dev/null +++ b/functions/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +src/ +logs/ +config.toml +.git +*.ts +*.tsbuildinfo diff --git a/functions/Dockerfile b/functions/Dockerfile new file mode 100644 index 0000000..56d1df4 --- /dev/null +++ b/functions/Dockerfile @@ -0,0 +1,27 @@ +# TransCircle Backend — Express + MySQL +# Build stage: CI compiles TypeScript externally (pnpm run build), +# this image only packages the compiled output and production deps. + +FROM node:22-alpine AS runner + +WORKDIR /app + +# Copy production dependency manifests +COPY package.json pnpm-lock.yaml ./ + +# Install pnpm and production dependencies only +RUN corepack enable && \ + pnpm install --prod --frozen-lockfile + +# Copy pre-built output from CI +COPY dist/ ./dist/ +COPY config.toml.example ./config.toml.example + +# Create runtime directories (logs for logging, image for uploads, .jwt-keys for Ed25519 key pair) +RUN mkdir -p logs image .jwt-keys + +EXPOSE 1145 + +ENV NODE_ENV=production + +CMD ["node", "dist/Index.js"] diff --git a/functions/config.toml.example b/functions/config.toml.example new file mode 100644 index 0000000..a178e90 --- /dev/null +++ b/functions/config.toml.example @@ -0,0 +1,58 @@ +[DEBUG_CONFIG] +DEBUG_MODE = false +APISERV_ENABLE = true + +[LOGGER] +LOGFILE_FORMAT = "yyyyMMDD" +LOGTIME_FORMAT = "yyyy-MM-DD HH:mm:ss" + +[SESSION] +SESS_MAXAGE = 86400 +SESS_SECRET = "" + +[DB_CONFIG] +# 去 MySQL 服务商处获取以下几项!!! +# 创建数据库时设的管理员账号 +DB_USERNAME = "" +# 数据库密码 +DB_PASSWORD = "" +# 库名 +DB_DATABASE = "" +# 数据库主机地址 +DB_HOSTNAME = "" +DB_PORT = 3306 + +[SERV_CONFIG] +SERV_HOSTNAME = "0.0.0.0" +SERV_PORT = 1145 + +[OAUTH] +GITHUB_CLIENT_ID = "" +GITHUB_CLIENT_SECRET = "" + +X_CLIENT_ID = "" +X_CLIENT_SECRET = "" + +[ADMIN] +TEMP_ADMIN_TOKEN = "" + +[APP] +# 前端页面 URL(用于 OAuth 回调重定向等场景) +FRONTEND_URL = "https://submit.transcircle.org" +# API 基础 URL(用于图片 URL 等返回给客户端的地址) +API_URL = "https://api.transcircle.org" + +[CORS] +ALLOWED_ORIGINS = "https://submit.transcircle.org,http://localhost:5173,http://localhost:4173" + +[STORY] +# 故事站仓库名,格式 owner/repo(如 TransCircle/transcircle-story) +STORY_REPO = "" +# GitHub PAT: Settings → Developer settings → Personal access tokens → Fine-grained tokens +# 需要 Contents: write 权限(用于 repository_dispatch) +STORY_REPO_TOKEN = "" +# 公开 API 地址 +STORY_API_BASE = "https://api.transcircle.org" + +[GITHUB] +GITHUB_ORG = "TransCircle" diff --git a/functions/migrations/001_idempotency_cache.sql b/functions/migrations/001_idempotency_cache.sql new file mode 100644 index 0000000..9699ba8 --- /dev/null +++ b/functions/migrations/001_idempotency_cache.sql @@ -0,0 +1,16 @@ +-- Migration 001: Idempotency cache table +-- api.md §12 幂等性: stores (userId, key) → (fingerprint, responseSnapshot) for 24h + +CREATE TABLE IF NOT EXISTS idempotency_cache ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + cacheKey VARCHAR(255) NOT NULL, + fingerprint VARCHAR(128) NOT NULL, + responseBody JSON NULL, + expiresAt BIGINT NOT NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + UNIQUE (cacheKey), + INDEX idx_idempotency_expires (expiresAt) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; diff --git a/functions/migrations/002_rate_limits_unique.sql b/functions/migrations/002_rate_limits_unique.sql new file mode 100644 index 0000000..d08d6c7 --- /dev/null +++ b/functions/migrations/002_rate_limits_unique.sql @@ -0,0 +1 @@ +ALTER TABLE rate_limits ADD UNIQUE INDEX idx_rate_limits_bucketKey (bucketKey); diff --git a/functions/migrations/003_fk_restrict_anonymous_user.sql b/functions/migrations/003_fk_restrict_anonymous_user.sql new file mode 100644 index 0000000..d4090f1 --- /dev/null +++ b/functions/migrations/003_fk_restrict_anonymous_user.sql @@ -0,0 +1,20 @@ +-- Migration: Change contributions.authorUserId FK from SET NULL to RESTRICT +-- and create an anonymous reference user for GDPR-deleted content. +-- +-- Per api.md §15.11: contributions.authorUserId must be ON DELETE RESTRICT. +-- When a user is fully deleted (GDPR), their contributions are reassigned to +-- a shared anonymous user rather than set to NULL. + +-- 1. Create anonymous reference user for orphaned content per api.md §2.4 +INSERT IGNORE INTO users (id, username, email, displayName, passwordHash, status, createdAt, updatedAt) +VALUES ('usr_00000000000000000000000000', 'anonymous', NULL, 'Deleted User', NULL, 'deleted', 0, 0); + +-- 2. Reassign any existing orphaned contributions to the anonymous user +UPDATE contributions SET authorUserId = 'usr_00000000000000000000000000' +WHERE authorUserId IS NULL; + +-- 3. Change FK constraint (requires dropping and recreating) +ALTER TABLE contributions DROP FOREIGN KEY contributions_ibfk_1; +ALTER TABLE contributions MODIFY authorUserId VARCHAR(64) NOT NULL DEFAULT 'usr_00000000000000000000000000'; +ALTER TABLE contributions ADD CONSTRAINT contributions_author_fk + FOREIGN KEY (authorUserId) REFERENCES users (id) ON DELETE RESTRICT; diff --git a/functions/migrations/004_submitter_hash_not_null.sql b/functions/migrations/004_submitter_hash_not_null.sql new file mode 100644 index 0000000..e558a03 --- /dev/null +++ b/functions/migrations/004_submitter_hash_not_null.sql @@ -0,0 +1,10 @@ +-- Migration 004: Make submitterIpHash and submitterUserAgentHash NOT NULL +-- Per api.md §15.11, these columns should be NOT NULL. +-- Backfill any existing NULL rows before applying the constraint. + +UPDATE contributions SET submitterIpHash = '' WHERE submitterIpHash IS NULL; +UPDATE contributions SET submitterUserAgentHash = '' WHERE submitterUserAgentHash IS NULL; + +ALTER TABLE contributions + MODIFY COLUMN submitterIpHash VARCHAR(64) NOT NULL, + MODIFY COLUMN submitterUserAgentHash VARCHAR(64) NOT NULL; diff --git a/functions/migrations/005_refresh_token_expiresat_index.sql b/functions/migrations/005_refresh_token_expiresat_index.sql new file mode 100644 index 0000000..2d4cc97 --- /dev/null +++ b/functions/migrations/005_refresh_token_expiresat_index.sql @@ -0,0 +1,2 @@ +-- Add expiresAt index to refresh_token_events for efficient cleanup (api.md §15.5) +CREATE INDEX idx_rt_expiresAt ON refresh_token_events (expiresAt); diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 0000000..5122667 --- /dev/null +++ b/functions/package.json @@ -0,0 +1,39 @@ +{ + "name": "transcircle-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/Index.ts", + "build": "tsc", + "start": "node dist/Index.js", + "typecheck": "tsc --noEmit", + "db:setup": "tsx src/setup.ts", + "postinstall": "npm run build" + }, + "dependencies": { + "@noble/ed25519": "^3.1.0", + "@simplewebauthn/server": "^13.3.1", + "argon2": "^0.44.0", + "cookie-parser": "^1.4.7", + "dompurify": "^3.4.7", + "express": "^5.2.1", + "jsdom": "^29.1.1", + "marked": "^18.0.4", + "multer": "^2.1.1", + "mysql2": "^3.14.1", + "qrcode": "^1.5.4", + "toml": "^4.1.1", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.6", + "@types/jsdom": "^28.0.3", + "@types/multer": "^2.1.0", + "@types/node": "^22.15.17", + "@types/qrcode": "^1.5.6", + "tsx": "^4.20.0", + "typescript": "~5.8.3" + } +} diff --git a/functions/schema.sql b/functions/schema.sql new file mode 100644 index 0000000..67aa52c --- /dev/null +++ b/functions/schema.sql @@ -0,0 +1,477 @@ +CREATE TABLE users +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(32) NOT NULL UNIQUE, + email VARCHAR(254) NULL UNIQUE, + emailVerified BOOLEAN NOT NULL DEFAULT FALSE, + displayName VARCHAR(50) NOT NULL, + avatarUrl VARCHAR(512) NULL, + passwordHash VARCHAR(255) NULL, + passwordUpdatedAt BIGINT NULL, + passwordHistory JSON NOT NULL DEFAULT ('[]'), + tokenVersion BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'pending_verification' CHECK (status IN + ('active', + 'banned', + 'merged', + 'pending_verification', + 'pending_deletion', + 'deleted')), + mergedIntoUserId VARCHAR(64) NULL, + deletedAt BIGINT NULL, + emailVerifiedSource VARCHAR(64) NULL COMMENT '记录来源: oauth:github / oauth:x / platform', + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + updatedAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + lastLoginAt BIGINT NULL, + + INDEX idx_status (status), + INDEX idx_createdAt (createdAt), + + FOREIGN KEY (mergedIntoUserId) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE oauth_accounts +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + userId VARCHAR(64) NOT NULL, + provider VARCHAR(16) NOT NULL CHECK (provider IN ('github', 'x')), + providerUserId VARCHAR(64) NOT NULL, + providerUsername VARCHAR(64) NULL, + providerDisplayName VARCHAR(100) NULL, + providerAvatarUrl VARCHAR(512) NULL, + providerEmail VARCHAR(254) NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + updatedAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + UNIQUE (provider, providerUserId), + UNIQUE (userId, provider), + INDEX idx_oauth_userId (userId), + + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE auth_tokens +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + userId VARCHAR(64) NULL, + type VARCHAR(32) NOT NULL CHECK (type IN ('email_verify', + 'password_reset', + 'merge', + 'mfa_challenge', + 'passkey_register', + 'passkey_login', + 'totp_setup', + 'oauth_state', + 'oauth_login_code', + 'oauth_pending_registration', + 'account_delete_cancel', + 'step_up_challenge')), + tokenHash VARCHAR(128) NOT NULL, + metadata JSON NOT NULL DEFAULT ('{}'), + failedAttempts INT NOT NULL DEFAULT 0, + expiresAt BIGINT NOT NULL, + usedAt BIGINT NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + UNIQUE (tokenHash), + INDEX idx_auth_user_type (userId, type), + INDEX idx_auth_expiresAt (expiresAt), + + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE sessions +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + userId VARCHAR(64) NOT NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + lastUsedAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + expiresAt BIGINT NOT NULL, + revokedAt BIGINT NULL, + revokedReason VARCHAR(32) NULL CHECK (revokedReason IS NULL OR + revokedReason IN + ('logout', 'logout_all', + 'password_changed', + 'refresh_reuse_detected', + 'account_banned', + 'account_merged', + 'account_pending_deletion', + 'role_changed', 'manual_revoke', + 'user_revoked', 'oauth_unbound')), + ipHash VARCHAR(64) NOT NULL, + ipPrefix VARCHAR(64) NOT NULL, + userAgentHash VARCHAR(64) NOT NULL, + deviceSummary JSON NOT NULL DEFAULT ('{}'), + lastStepUpAt BIGINT NULL, + lastStepUpMethod VARCHAR(16) NULL CHECK (lastStepUpMethod IS NULL OR + lastStepUpMethod IN + ('password', 'passkey', 'totp', + 'recovery_code')), + loginMethod VARCHAR(32) NOT NULL DEFAULT 'password' CHECK (loginMethod IN + ('password', + 'passkey', + 'oauth:github', + 'oauth:x')), + + INDEX idx_sessions_user_revoked (userId, revokedAt), + INDEX idx_sessions_expiresAt (expiresAt), + + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE refresh_token_events +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + sessionId VARCHAR(64) NOT NULL, + tokenHash VARCHAR(128) NOT NULL, + tokenPrefix VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK (status IN + ('active', + 'rotated', + 'reused', + 'revoked')), + rotatedToHash VARCHAR(128) NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + expiresAt BIGINT NOT NULL, + usedAt BIGINT NULL, + + UNIQUE (tokenHash), + INDEX idx_rt_events_session (sessionId, createdAt), + INDEX idx_rt_expiresAt (expiresAt), + + FOREIGN KEY (sessionId) REFERENCES sessions (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE mfa_totp_credentials +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + userId VARCHAR(64) NOT NULL, + secretEncrypted VARBINARY(256) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (status IN + ('pending', + 'active', + 'frozen', + 'disabled')), + algorithm VARCHAR(16) NOT NULL DEFAULT 'SHA1', + digits TINYINT NOT NULL DEFAULT 6, + `period` SMALLINT NOT NULL DEFAULT 30, + lastUsedTimeStep BIGINT NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + enabledAt BIGINT NULL, + lastUsedAt BIGINT NULL, + + INDEX idx_mfa_totp_user (userId), + + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +DELIMITER // +CREATE TRIGGER trg_mfa_totp_unique_active_insert + BEFORE INSERT + ON mfa_totp_credentials + FOR EACH ROW +BEGIN + IF NEW.status = 'active' AND EXISTS (SELECT 1 + FROM mfa_totp_credentials + WHERE userId = NEW.userId + AND status = 'active') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '每个用户只能有一个 active 的 TOTP 凭据'; + END IF; +END// +DELIMITER ; + +CREATE TABLE mfa_recovery_codes +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + userId VARCHAR(64) NOT NULL, + codeHash VARCHAR(255) NOT NULL, + usedAt BIGINT NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + UNIQUE (codeHash), + INDEX idx_mfa_recovery_user_used (userId, usedAt), + + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE passkeys +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + userId VARCHAR(64) NOT NULL, + name VARCHAR(50) NOT NULL, + credentialId VARBINARY(1024) NOT NULL, + credentialIdB64 VARCHAR(1024) NOT NULL, + publicKey VARBINARY(1024) NOT NULL, + signCount BIGINT NOT NULL DEFAULT 0, + transports JSON NOT NULL DEFAULT ('[]'), + aaguid VARCHAR(36) NULL, + attestationFormat VARCHAR(32) NULL, + backupEligible BOOLEAN NOT NULL DEFAULT FALSE, + backupState BOOLEAN NOT NULL DEFAULT FALSE, + signCountSupported BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'frozen')), + frozenReason VARCHAR(64) NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + lastUsedAt BIGINT NULL, + + UNIQUE (credentialIdB64(191)), + UNIQUE (credentialId), + INDEX idx_passkeys_user_status (userId, status), + + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE roles +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + name VARCHAR(32) NOT NULL CHECK (name IN ('admin', 'editor', 'reviewer')), + description VARCHAR(200) NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + UNIQUE (name) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE user_roles +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + userId VARCHAR(64) NOT NULL, + roleId VARCHAR(64) NOT NULL, + grantedBy VARCHAR(64) NOT NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + expiresAt BIGINT NULL, + + UNIQUE (userId, roleId), + INDEX idx_user_roles_user (userId), + + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (roleId) REFERENCES roles (id) ON DELETE RESTRICT, + FOREIGN KEY (grantedBy) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE contributions +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + authorUserId VARCHAR(64) NOT NULL, + title VARCHAR(120) NOT NULL, + summary VARCHAR(300) NULL, + contentRaw TEXT NOT NULL, + contentFormat VARCHAR(16) NOT NULL CHECK (contentFormat IN ('markdown', 'plain_text')), + contentHtml TEXT NOT NULL, + rendererVersion VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'draft' CHECK (status IN + ('draft', + 'pending', + 'in_review', + 'approved', + 'rejected', + 'published', + 'hidden', + 'withdrawn', + 'deleted')), + version INT NOT NULL DEFAULT 1, + language VARCHAR(16) NOT NULL DEFAULT 'zh-CN' CHECK (language IN + ('zh-CN', + 'zh-TW', + 'en', + 'ja', + 'other')), + tags JSON NOT NULL DEFAULT ('[]'), + idempotencyKey VARCHAR(64) NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + updatedAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + submittedAt BIGINT NULL, + publishedAt BIGINT NULL, + deletedAt BIGINT NULL, + submitterIpHash VARCHAR(64) NOT NULL, + submitterUserAgentHash VARCHAR(64) NOT NULL, + + INDEX idx_contrib_author_created (authorUserId, createdAt), + INDEX idx_contrib_status_created (status, createdAt), + INDEX idx_contrib_publishedAt (publishedAt), + UNIQUE (authorUserId, idempotencyKey), + + FOREIGN KEY (authorUserId) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE contribution_review_events +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + contributionId VARCHAR(64) NOT NULL, + reviewerUserId VARCHAR(64) NOT NULL, + action VARCHAR(32) NOT NULL CHECK (action IN + ('review', 'publish', 'hide', + 'restore', 'delete', + 'edit_request_resolve')), + fromStatus VARCHAR(16) NOT NULL, + toStatus VARCHAR(16) NOT NULL, + publicNote VARCHAR(500) NULL, + internalNote VARCHAR(1000) NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + requestId VARCHAR(64) NOT NULL, + + INDEX idx_rev_events_contribution (contributionId, createdAt), + INDEX idx_rev_events_reviewer (reviewerUserId, createdAt), + + FOREIGN KEY (contributionId) REFERENCES contributions (id) ON DELETE CASCADE, + FOREIGN KEY (reviewerUserId) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE audit_logs +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + actorUserId VARCHAR(64) NULL, + action VARCHAR(64) NOT NULL, + resourceType VARCHAR(32) NOT NULL, + resourceId VARCHAR(64) NULL, + `before` JSON NULL, + after JSON NULL, + metadata JSON NOT NULL DEFAULT ('{}'), + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + requestId VARCHAR(64) NOT NULL, + ipHash VARCHAR(64) NOT NULL, + userAgentHash VARCHAR(64) NOT NULL, + prevHash VARCHAR(64) NULL, + entryHash VARCHAR(64) NOT NULL, + + UNIQUE (entryHash), + INDEX idx_audit_actor (actorUserId, createdAt), + INDEX idx_audit_resource (resourceType, resourceId, createdAt), + INDEX idx_audit_action (action, createdAt), + + FOREIGN KEY (actorUserId) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +DELIMITER // +CREATE TRIGGER trg_audit_no_update + BEFORE UPDATE + ON audit_logs + FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = + 'audit_logs is append-only: UPDATE not allowed'; +END// + +CREATE TRIGGER trg_audit_no_delete + BEFORE DELETE + ON audit_logs + FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = + 'audit_logs is append-only: DELETE not allowed'; +END// +DELIMITER ; + +CREATE TABLE contribution_edit_requests +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + contributionId VARCHAR(64) NOT NULL, + requesterId VARCHAR(64) NOT NULL, + reason VARCHAR(500) NOT NULL, + proposedTitle VARCHAR(120) NULL, + proposedContent TEXT NULL, + proposedContentFormat VARCHAR(16) NULL CHECK (proposedContentFormat IS NULL OR + proposedContentFormat IN + ('markdown', 'plain_text')), + proposedSummary VARCHAR(300) NULL, + proposedTags JSON NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (status IN + ('pending', + 'in_review', + 'approved', + 'rejected', + 'withdrawn')), + version INT NOT NULL DEFAULT 1, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + updatedAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + INDEX idx_edit_req_contribution (contributionId, createdAt), + INDEX idx_edit_req_requester (requesterId, createdAt), + INDEX idx_edit_req_status (status, createdAt), + + FOREIGN KEY (contributionId) REFERENCES contributions (id) ON DELETE RESTRICT, + FOREIGN KEY (requesterId) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE edit_request_votes +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + editRequestId VARCHAR(64) NOT NULL, + reviewerUserId VARCHAR(64) NOT NULL, + vote VARCHAR(16) NOT NULL CHECK (vote IN ('approve', 'reject')), + note VARCHAR(500) NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + UNIQUE (editRequestId, reviewerUserId), + INDEX idx_vote_edit_request (editRequestId, createdAt), + + FOREIGN KEY (editRequestId) REFERENCES contribution_edit_requests (id) ON DELETE CASCADE, + FOREIGN KEY (reviewerUserId) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE images +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + uploaderId VARCHAR(64) NOT NULL, + originalFilename VARCHAR(255) NULL, + mimeType VARCHAR(32) NOT NULL CHECK (mimeType IN + ('image/jpeg', 'image/png', + 'image/gif', 'image/webp')), + size BIGINT NOT NULL, + width INT NOT NULL, + height INT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + storageKey VARCHAR(255) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'deleted')), + deletedAt BIGINT NULL, + createdAt BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW()) * 1000), + + UNIQUE (sha256), + INDEX idx_images_uploader (uploaderId, createdAt), + + FOREIGN KEY (uploaderId) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE rate_limits +( + id VARCHAR(64) NOT NULL PRIMARY KEY, + bucketKey VARCHAR(128) NOT NULL, + windowStart BIGINT NOT NULL, + count INT NOT NULL DEFAULT 1, + createdAt BIGINT NOT NULL, + + INDEX idx_bucket (bucketKey, windowStart) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; \ No newline at end of file diff --git a/functions/src/Config.ts b/functions/src/Config.ts new file mode 100644 index 0000000..e93b543 --- /dev/null +++ b/functions/src/Config.ts @@ -0,0 +1,65 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as toml from 'toml'; + +interface ConfigSection { + [key: string]: string | number | bigint | boolean | object | undefined; +} + +// Resolve config.toml from functions/ root +const CONFIG_PATH = path.resolve(import.meta.dirname, '..', 'config.toml'); +const CONFIG_EXAMPLE = path.resolve(import.meta.dirname, '..', 'config.toml.example'); + +if (!fs.existsSync(CONFIG_PATH)) { + if (fs.existsSync(CONFIG_EXAMPLE)) { + fs.copyFileSync(CONFIG_EXAMPLE, CONFIG_PATH); + console.warn('config.toml not found — copied from config.toml.example'); + } else { + console.error('Neither config.toml nor config.toml.example found'); + process.exit(1); + } +} + +let config: Record; +try { + config = toml.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); +} catch (err) { + console.error('Failed to parse config.toml:', err); + process.exit(1); +} + +// Environment variable overrides: SECTION__KEY +for (const [envKey, envValue] of Object.entries(process.env)) { + if (envValue === undefined) continue; + const sep = envKey.indexOf('__'); + if (sep === -1) continue; + const section = envKey.slice(0, sep); + const key = envKey.slice(sep + 2); + if (!section || !key) continue; + const sectionObj = config[section]; + if (!sectionObj || !(key in sectionObj)) continue; + + const orig = sectionObj[key]; + switch (typeof orig) { + case 'boolean': { + const v = envValue.toLowerCase(); + sectionObj[key] = v === 'true' || v === '1'; + break; + } + case 'number': { + const n = Number(envValue); + if (!isNaN(n)) sectionObj[key] = n; + break; + } + case 'bigint': + try { sectionObj[key] = BigInt(envValue); } catch { /* ignore */ } + break; + case 'object': + try { sectionObj[key] = JSON.parse(envValue); } catch { /* ignore */ } + break; + default: + sectionObj[key] = envValue; + } +} + +export const conf = config; diff --git a/functions/src/Database.ts b/functions/src/Database.ts new file mode 100644 index 0000000..3470146 --- /dev/null +++ b/functions/src/Database.ts @@ -0,0 +1,53 @@ +import mysql from 'mysql2/promise'; +import { conf } from './Config'; + +const dbConf = conf.DB_CONFIG as Record; + +const pool = mysql.createPool({ + host: (dbConf.DB_HOSTNAME as string) || 'localhost', + port: Number(dbConf.DB_PORT) || 3306, + user: (dbConf.DB_USERNAME as string) || 'transcircle', + password: (dbConf.DB_PASSWORD as string) || '', + database: (dbConf.DB_DATABASE as string) || 'transcircle', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + charset: 'utf8mb4', + timezone: '+00:00', +}); + +/** Execute a query, return rows */ +export async function query( + sql: string, + params?: unknown[], +): Promise { + // Use query() instead of execute() to avoid "Incorrect arguments to mysqld_stmt_execute" + // with LIMIT ? in MySQL 8.4 + mysql2 3.x + const [rows] = await pool.query(sql, params as mysql.ExecuteValues); + return rows as unknown as T; +} + +/** Execute a query, return first row or null */ +export async function queryOne( + sql: string, + params?: unknown[], +): Promise { + const rows = await query(sql, params); + return rows.length > 0 ? rows[0] : null; +} + +/** Execute a write query (INSERT/UPDATE/DELETE), return ResultSetHeader */ +export async function exec( + sql: string, + params?: unknown[], +): Promise { + const [result] = await pool.execute(sql, params as mysql.ExecuteValues); + return result; +} + +/** Get a raw connection from the pool (for transactions) */ +export async function getConnection(): Promise { + return pool.getConnection(); +} + +export default pool; diff --git a/functions/src/Index.ts b/functions/src/Index.ts new file mode 100644 index 0000000..b716c33 --- /dev/null +++ b/functions/src/Index.ts @@ -0,0 +1,28 @@ +import app from './Router'; +import { log } from './Logger'; +import { conf } from './Config'; +import { ensureMiddlewareTables } from './init'; + +const servConf = conf.SERV_CONFIG as Record | undefined; +const debugConf = conf.DEBUG_CONFIG as Record | undefined; + +const PORT = Number(servConf?.SERV_PORT) || 1145; +const HOST = (servConf?.SERV_HOSTNAME as string) || '0.0.0.0'; + +log('=-=-=-=-=-=-=-=-= TransCircle Backend Starting =-=-=-=-=-=-=-=-='); + +// Eagerly verify middleware tables before accepting requests +ensureMiddlewareTables().catch((err) => + log(`WARNING: middleware table check failed: ${err.message}`) +); + +if (debugConf?.APISERV_ENABLE !== false) { + app.listen(PORT, HOST, () => { + log(`Backend server started at http://${HOST}:${PORT}/`); + log(`API base: http://${HOST}:${PORT}/v1/`); + }); +} else { + log('API server is disabled by config (APISERV_ENABLE = false)'); +} + +log('=-=-=-=-=-=-=-=-= Server Running =-=-=-=-=-=-=-=-='); diff --git a/functions/src/Logger.ts b/functions/src/Logger.ts new file mode 100644 index 0000000..c5e2f96 --- /dev/null +++ b/functions/src/Logger.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { conf } from './Config'; + +const loggerConf = conf.LOGGER as Record; +const debugConf = conf.DEBUG_CONFIG as Record; + +const logDir = path.resolve(import.meta.dirname, '..', 'logs'); + +function pad(n: number): string { + return n.toString().padStart(2, '0'); +} + +function formatTimestamp(format: string): string { + const d = new Date(); + const map: Record = { + yyyy: d.getFullYear().toString(), + MM: pad(d.getMonth() + 1), + DD: pad(d.getDate()), + HH: pad(d.getHours()), + mm: pad(d.getMinutes()), + ss: pad(d.getSeconds()), + }; + let result = format; + for (const [k, v] of Object.entries(map)) { + result = result.replace(k, v); + } + return result; +} + +export function log(context: string): void { + const timeFmt = loggerConf.LOGTIME_FORMAT || 'yyyy-MM-DD HH:mm:ss'; + const fileFmt = loggerConf.LOGFILE_FORMAT || 'yyyyMMDD'; + const timestamp = formatTimestamp(timeFmt); + + const lines = context.split(/\r?\n/); + const data = lines.map((line) => `[${timestamp}] ${line}`).join('\n'); + + console.log(data); + + if (debugConf?.DEBUG_MODE) return; + + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + const logfile = path.join(logDir, formatTimestamp(fileFmt) + '.log'); + fs.appendFile(logfile, data + '\n', 'utf-8', (err) => { + if (err) console.error(`Failed to log: ${err.message}`); + }); +} diff --git a/functions/src/Router.ts b/functions/src/Router.ts new file mode 100644 index 0000000..0e6348b --- /dev/null +++ b/functions/src/Router.ts @@ -0,0 +1,331 @@ +import express, { type Express, type Request, type Response, type NextFunction } from 'express' +import cookieParser from 'cookie-parser' +import path from 'path' +import { fileURLToPath } from 'url' +import { log } from './Logger' +import { requestId } from './middleware/requestId' +import { corsHandler } from './middleware/cors' +import { rateLimit } from './middleware/rateLimit' +import { sendError, Errors } from './utils/response' +import { sendSuccess } from './utils/response' +import pool from './Database' +import { metrics, pushHistogramSample } from './utils/metrics' + +// Import route modules +import authRoutes from './routes/auth' +import authRegisterRoutes from './routes/auth-register' +import authLoginRoutes from './routes/auth-login' +import authPasswordRoutes from './routes/auth-password' +import authSessionsRoutes from './routes/auth-sessions' +import authTotpRoutes from './routes/auth-totp' +import authStepupRoutes from './routes/auth-stepup' +import authPasskeyRoutes from './routes/auth-passkey' +import authOauthBindRoutes from './routes/auth-oauth-bind' +import authOauthNativeRoutes from './routes/auth-oauth-native' +import authMergeRoutes from './routes/auth-merge' +import meRoutes from './routes/me' +import contributionRoutes from './routes/contributions' +import adminRoutes from './routes/admin' +import adminUsersRoutes from './routes/admin-users' +import adminAuditRoutes from './routes/admin-audit' +import publicContributionsRoutes from './routes/public-contributions' +import editRequestRoutes from './routes/edit-requests' +import imagesRoutes from './routes/images' +import storiesRoutes from './routes/stories' +import openapiRoutes from './routes/openapi' + +const app: Express = express() + +// ── Global middleware ─────────────────────────── +app.use(requestId) +app.use(corsHandler) +app.use(express.json()) +app.use(express.urlencoded({ extended: true })) +app.use(cookieParser()) +app.use(rateLimit) + +// ── Request logging + metrics ─────────────────── +app.use((req: Request, res: Response, next: NextFunction) => { + metrics.httpTotal++ + const start = Date.now() + res.on('finish', () => { + const duration = Date.now() - start + // Record request duration histogram per api.md §13.2.2 + pushHistogramSample(metrics.httpRequestDurationSeconds, duration / 1000) + const method = req.method.padEnd(6, ' ') + const status = res.statusCode.toString().padStart(3, ' ') + metrics.httpByStatus[res.statusCode] = (metrics.httpByStatus[res.statusCode] || 0) + 1 + const pathGroup = req.path.replace(/\/[a-z0-9_]{26,}/g, '/:id') + metrics.httpByPath[pathGroup] = (metrics.httpByPath[pathGroup] || 0) + 1 + const dimKey = `${req.method}|${pathGroup}|${res.statusCode}` + metrics.httpByMethodPathStatus[dimKey] = (metrics.httpByMethodPathStatus[dimKey] || 0) + 1 + log(`${method} ${req.path} -> ${status} ${duration}ms [${req.requestId}]`) + }) + next() +}) + +// ── Health checks per api.md §13.1 ───────────── +async function runHealthChecks(): Promise<{ status: string; checks: Record }> { + const checks: Record = {} + let allOk = true + + // Database check + try { + await pool.query('SELECT 1') + checks.database = 'ok' + } catch { + checks.database = 'error' + allOk = false + } + + // ObjectStorage check (config-based, no runtime dep on probe) + const storageConfigured = !!(process.env.IMAGE_STORAGE_DIR || process.env.S3_BUCKET || process.env.R2_BUCKET) + checks.objectStorage = storageConfigured ? 'ok' : 'degraded' + if (!storageConfigured) allOk = false + + // KMS check (config-based, key dir or env presence) + const kmsConfigured = !!(process.env.JWT_KEY_DIR || process.env.KMS_KEY_ID) + checks.kms = kmsConfigured ? 'ok' : 'degraded' + if (!kmsConfigured) allOk = false + + return { status: allOk ? 'ok' : 'degraded', checks } +} + +app.get('/healthz', async (req: Request, res: Response) => { + const { status, checks } = await runHealthChecks() + const isHealthy = status === 'ok' + if (!isHealthy) { + res.setHeader('Retry-After', '5') + } + sendSuccess(res, { + status, + service: 'transcircle-api', + version: '1.0.0', + commit: process.env.GIT_COMMIT || 'unknown', + uptimeSeconds: Math.floor(process.uptime()), + checks, + }, req.requestId, isHealthy ? 200 : 503) +}) + +app.get('/readyz', async (req: Request, res: Response) => { + const { status, checks } = await runHealthChecks() + const isReady = status === 'ok' + if (!isReady) { + res.setHeader('Retry-After', '5') + } + sendSuccess(res, { + status, + service: 'transcircle-api', + version: '1.0.0', + commit: process.env.GIT_COMMIT || 'unknown', + uptimeSeconds: Math.floor(process.uptime()), + checks, + }, req.requestId, isReady ? 200 : 503) +}) + +// ── Serve frontend static files (同源部署 — api.md §1.6.2 CSRF/Cookie 模型) +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const distPath = path.join(__dirname, '../../dist') +app.use(express.static(distPath)) + +// ── API Routes ────────────────────────────────── +app.use('/v1/auth', authRoutes) +app.use('/v1/auth', authRegisterRoutes) +app.use('/v1/auth', authLoginRoutes) +app.use('/v1', authPasswordRoutes) // /auth/password/forgot, /auth/password/reset, /me/password +app.use('/v1', authSessionsRoutes) // /auth/session, /auth/logout-all, /me/sessions +app.use('/v1', authTotpRoutes) // /me/mfa/totp/*, /auth/mfa/totp/verify +app.use('/v1', authStepupRoutes) // /auth/step-up/start, /auth/step-up/verify +app.use('/v1', authPasskeyRoutes) // /me/passkeys/*, /auth/passkey/login/* +app.use('/v1', authOauthBindRoutes) // /me/oauth +app.use('/v1', authOauthNativeRoutes) // /auth/oauth/native/* +app.use('/v1', authMergeRoutes) // /auth/merge +app.use('/v1/me', meRoutes) +app.use('/v1/contributions', contributionRoutes) +app.use('/v1/admin', adminRoutes) +app.use('/v1/admin', adminUsersRoutes) +app.use('/v1/admin', adminAuditRoutes) +app.use('/v1/public', publicContributionsRoutes) +app.use('/v1', editRequestRoutes) // /v1/contributions/:id/edit-requests, /v1/me/edit-requests/*, /v1/admin/edit-requests/* +app.use('/v1/images', imagesRoutes) // api.md §11 +app.use('/v1', openapiRoutes) // /v1/openapi.json — api.md §13.4 +app.use('/v1/stories', storiesRoutes) + +// ── Metrics endpoint per api.md §13.2 ────────── +app.get('/metrics', (req: Request, res: Response) => { + const basicAuth = req.headers.authorization + const metricsUser = process.env.METRICS_BASIC_AUTH_USER + const metricsPass = process.env.METRICS_BASIC_AUTH_PASS + + if (metricsUser && metricsPass) { + if (!basicAuth || !basicAuth.startsWith('Basic ')) { + res.setHeader('WWW-Authenticate', 'Basic realm="metrics"') + res.status(401).end('Unauthorized') + return + } + const decoded = Buffer.from(basicAuth.slice(6), 'base64').toString('utf-8') + const [user, pass] = decoded.split(':') + if (user !== metricsUser || pass !== metricsPass) { + res.status(403).end('Forbidden') + return + } + } + + const start = Date.now() + // Basic process metrics per spec + const uptime = Math.floor(process.uptime()) + const mem = process.memoryUsage() + + const lines = [ + '# HELP http_requests_total Total HTTP requests', + '# TYPE http_requests_total counter', + `http_requests_total{method="all",route="all",status="all"} ${metrics.httpTotal}`, + ...Object.entries(metrics.httpByStatus).map( + ([status, count]) => `http_requests_total{method="all",route="all",status="${status}"} ${count}`, + ), + ...Object.entries(metrics.httpByPath).map( + ([path, count]) => `http_requests_total{method="all",route="${path}",status="all"} ${count}`, + ), + ...Object.entries(metrics.httpByMethodPathStatus).map( + ([dimKey, count]) => { + const [method, route, status] = dimKey.split('|') + return `http_requests_total{method="${method}",route="${route}",status="${status}"} ${count}` + }, + ), + '', + // ── auth_login_total ── + '# HELP auth_login_total Total login attempts', + '# TYPE auth_login_total counter', + ...Object.entries(metrics.authLoginTotal).map( + ([result, count]) => `auth_login_total{result="${result}"} ${count}`, + ), + '', + // ── auth_token_refresh_total ── + '# HELP auth_token_refresh_total Token refresh results', + '# TYPE auth_token_refresh_total counter', + ...Object.entries(metrics.authTokenRefreshTotal).map( + ([result, count]) => `auth_token_refresh_total{result="${result}"} ${count}`, + ), + '', + // ── auth_oauth_callback_total ── + '# HELP auth_oauth_callback_total OAuth callback results', + '# TYPE auth_oauth_callback_total counter', + ...Object.entries(metrics.authOauthCallbackTotal).map( + ([key, count]) => { + const [provider, status] = key.split('|') + return `auth_oauth_callback_total{provider="${provider}",status="${status}"} ${count}` + }, + ), + '', + // ── mfa_verify_total ── + '# HELP mfa_verify_total MFA verification attempts', + '# TYPE mfa_verify_total counter', + ...Object.entries(metrics.mfaVerifyTotal).map( + ([key, count]) => { + const [method, result] = key.split('|') + return `mfa_verify_total{method="${method}",result="${result}"} ${count}` + }, + ), + '', + // ── passkey_replay_detected_total ── + '# HELP passkey_replay_detected_total Passkey replay detections', + '# TYPE passkey_replay_detected_total counter', + `passkey_replay_detected_total ${metrics.passkeyReplayDetectedTotal}`, + '', + // ── rate_limited_total ── + '# HELP rate_limited_total Rate limited requests', + '# TYPE rate_limited_total counter', + ...Object.entries(metrics.rateLimitedTotal).map( + ([key, count]) => { + const [route, dimension] = key.split('|') + return `rate_limited_total{route="${route}",dimension="${dimension}"} ${count}` + }, + ), + '', + // ── image_upload_total ── + '# HELP image_upload_total Image upload results', + '# TYPE image_upload_total counter', + ...Object.entries(metrics.imageUploadTotal).map( + ([key, count]) => { + const [result, mime] = key.split('|') + return `image_upload_total{result="${result}",mime="${mime}"} ${count}` + }, + ), + '', + // ── contribution_review_latency_seconds ── + '# HELP contribution_review_latency_seconds Review latency from submission to first review', + '# TYPE contribution_review_latency_seconds histogram', + `contribution_review_latency_seconds_count ${metrics.contributionReviewLatencySeconds.length}`, + metrics.contributionReviewLatencySeconds.length > 0 + ? `contribution_review_latency_seconds_sum ${metrics.contributionReviewLatencySeconds.reduce((a, b) => a + b, 0)}` + : 'contribution_review_latency_seconds_sum 0', + ...(() => { + const buckets = [60, 300, 600, 1800, 3600, 7200, 14400, 28800, 86400] + return buckets.map(le => { + const count = metrics.contributionReviewLatencySeconds.filter(v => v <= le).length + return `contribution_review_latency_seconds_bucket{le="${le}"} ${count}` + }) + })(), + `contribution_review_latency_seconds_bucket{le="+Inf"} ${metrics.contributionReviewLatencySeconds.length}`, + '', + // ── http_request_duration_seconds (api.md §13.2.2) ── + '# HELP http_request_duration_seconds HTTP request duration in seconds', + '# TYPE http_request_duration_seconds histogram', + `http_request_duration_seconds_count ${metrics.httpRequestDurationSeconds.length}`, + metrics.httpRequestDurationSeconds.length > 0 + ? `http_request_duration_seconds_sum ${metrics.httpRequestDurationSeconds.reduce((a, b) => a + b, 0)}` + : 'http_request_duration_seconds_sum 0', + ...(() => { + const buckets = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] + return buckets.map(le => { + const count = metrics.httpRequestDurationSeconds.filter(v => v <= le).length + return `http_request_duration_seconds_bucket{le="${le}"} ${count}` + }) + })(), + `http_request_duration_seconds_bucket{le="+Inf"} ${metrics.httpRequestDurationSeconds.length}`, + '# HELP process_uptime_seconds Process uptime in seconds', + '# TYPE process_uptime_seconds gauge', + `process_uptime_seconds ${uptime}`, + '', + '# HELP process_memory_bytes Process memory usage in bytes', + '# TYPE process_memory_bytes gauge', + `process_memory_bytes{type="rss"} ${mem.rss}`, + `process_memory_bytes{type="heapTotal"} ${mem.heapTotal}`, + `process_memory_bytes{type="heapUsed"} ${mem.heapUsed}`, + '', + '# HELP nodejs_eventloop_lag_seconds Event loop lag', + '# TYPE nodejs_eventloop_lag_seconds gauge', + `nodejs_eventloop_lag_seconds ${Date.now() - start}`, + '', + ] + + res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + res.status(200).end(lines.join('\n')) +}) + +// ── SPA fallback — Express 5 不支持 `app.get('*')`,改用中间件 ── +app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith('/v1/') || req.path === '/healthz' || req.path === '/readyz' || req.path === '/metrics') { + return next() + } + res.sendFile(path.join(distPath, 'index.html')) +}) + +// ── 404 handler ───────────────────────────────── +app.use((req: Request, res: Response) => { + sendError(res, 'NOT_FOUND', `请求的接口不存在: ${req.method} ${req.path}`, req.requestId, 404) +}) + +// ── Global error handler ──────────────────────── +app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { + void _next + log(`ERROR: ${err.message}`) + if (err.stack) { + const line = err.stack.split('\n')[1]?.trim() + if (line) log(line) + } + sendError(res, Errors.INTERNAL_ERROR.code, '服务器内部错误,请稍后重试', req.requestId, 500) +}) + +export default app diff --git a/functions/src/init.ts b/functions/src/init.ts new file mode 100644 index 0000000..ed9e14a --- /dev/null +++ b/functions/src/init.ts @@ -0,0 +1,29 @@ +import { log } from './Logger'; +import { queryOne } from './Database'; + +/** + * Eagerly verify middleware tables exist at startup. + * Calls after DB pool is ready, before the server starts listening. + * + * The canonical schema is in functions/schema.sql — run `pnpm run db:setup` + * to apply it. This check only logs warnings; the middleware handles + * missing tables gracefully by degrading (e.g. skipping rate limiting). + */ +export async function ensureMiddlewareTables(): Promise { + try { + await queryOne(`SELECT 1 FROM rate_limits LIMIT 1`); + log('rate_limits table: OK'); + } catch { + log('WARNING: rate_limits table not found'); + log(' Run "pnpm run db:setup" to initialize the database schema.'); + log(' Until then, rate limiting will be unavailable.'); + } + + // Check sessions table (used by auth middleware) + try { + await queryOne(`SELECT 1 FROM sessions LIMIT 1`); + log('sessions table: OK'); + } catch { + log('WARNING: sessions table not found'); + } +} diff --git a/functions/src/middleware/auth.ts b/functions/src/middleware/auth.ts new file mode 100644 index 0000000..1293d80 --- /dev/null +++ b/functions/src/middleware/auth.ts @@ -0,0 +1,176 @@ +import type { Request, Response, NextFunction } from 'express' +import { verifyJwt } from '../utils/jwt' +import { sendError, Errors } from '../utils/response' +import { exec, queryOne } from '../Database' +import { conf } from '../Config' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + user?: { + userId: string + sessionId: string + tokenVersion: number + roles: string[] + } + } + } +} + +/** + * Requires a valid JWT Bearer token or TEMP_ADMIN_TOKEN. + * Sets req.user on success. + */ +// Lazy-ensure temp admin user exists in DB (for FK constraints in audit/review tables) +let _tempAdminEnsured = false + +export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise { + const authHeader = req.headers.authorization + if (!authHeader?.startsWith('Bearer ')) { + sendError(res, Errors.UNAUTHORIZED.code, Errors.UNAUTHORIZED.message, req.requestId, Errors.UNAUTHORIZED.status) + return + } + + const token = authHeader.slice(7) + + // Check TEMP_ADMIN_TOKEN (dev/debug bypass: token in memory only, not a real JWT) + const adminConf = conf.ADMIN as Record | undefined + const tempAdminToken = adminConf?.TEMP_ADMIN_TOKEN as string | undefined + if (tempAdminToken && token === tempAdminToken) { + if (!_tempAdminEnsured) { + await exec( + `INSERT IGNORE INTO users (id, username, displayName, status, createdAt) + VALUES ('usr_temp_admin', 'temp_admin', '临时管理员', 'active', UNIX_TIMESTAMP(NOW()) * 1000)`, + ) + _tempAdminEnsured = true + } + req.user = { + userId: 'usr_temp_admin', + sessionId: 'sess_temp_admin', + tokenVersion: 0, + roles: ['admin', 'reviewer'], + } + next() + return + } + + const payload = await verifyJwt(token) + if (!payload) { + sendError(res, Errors.UNAUTHORIZED.code, '登录已过期,请重新登录', req.requestId, Errors.UNAUTHORIZED.status) + return + } + + // Verify session is still valid + const session = await queryOne( + `SELECT id, revokedAt, expiresAt FROM sessions WHERE id = ? AND userId = ?`, + [payload.sid, payload.sub], + ) + if (!session || session.revokedAt || session.expiresAt < Date.now()) { + sendError(res, Errors.UNAUTHORIZED.code, '会话已失效,请重新登录', req.requestId, Errors.UNAUTHORIZED.status) + return + } + + // Verify tokenVersion matches and user status is allowed + const user = await queryOne( + `SELECT tokenVersion, status FROM users WHERE id = ?`, + [payload.sub], + ) + if (!user || user.tokenVersion !== payload.tokenVersion) { + sendError(res, Errors.UNAUTHORIZED.code, '登录已过期,请重新登录', req.requestId, Errors.UNAUTHORIZED.status) + return + } + // Per api.md §1.1 user status table — each status gets its own error code + if (user.status !== 'active' && user.status !== 'pending_verification') { + switch (user.status) { + case 'banned': + sendError(res, Errors.ACCOUNT_BANNED.code, Errors.ACCOUNT_BANNED.message, req.requestId, Errors.ACCOUNT_BANNED.status) + return + case 'merged': + sendError(res, Errors.ACCOUNT_MERGED.code, Errors.ACCOUNT_MERGED.message, req.requestId, Errors.ACCOUNT_MERGED.status) + return + case 'pending_deletion': + sendError(res, Errors.ACCOUNT_PENDING_DELETION.code, Errors.ACCOUNT_PENDING_DELETION.message, req.requestId, Errors.ACCOUNT_PENDING_DELETION.status) + return + case 'deleted': + sendError(res, Errors.ACCOUNT_DELETED.code, Errors.ACCOUNT_DELETED.message, req.requestId, Errors.ACCOUNT_DELETED.status) + return + default: + sendError(res, Errors.UNAUTHORIZED.code, '账户状态异常,无法访问', req.requestId, Errors.UNAUTHORIZED.status) + return + } + } + + req.user = { + userId: payload.sub, + sessionId: payload.sid, + tokenVersion: payload.tokenVersion, + roles: payload.roles, + } + + next() +} + +/** + * Optional auth — sets req.user if a valid token is present, but doesn't fail if absent. + */ +export async function optionalAuth(req: Request, _res: Response, next: NextFunction): Promise { + const authHeader = req.headers.authorization + if (!authHeader?.startsWith('Bearer ')) { + next() + return + } + + const token = authHeader.slice(7) + const payload = await verifyJwt(token) + if (payload) { + const session = await queryOne( + `SELECT id, revokedAt, expiresAt FROM sessions WHERE id = ? AND userId = ?`, + [payload.sid, payload.sub], + ) + if (session && !session.revokedAt && session.expiresAt >= Date.now()) { + const user = await queryOne( + `SELECT tokenVersion, status FROM users WHERE id = ?`, + [payload.sub], + ) + if (user && user.tokenVersion === payload.tokenVersion && (user.status === 'active' || user.status === 'pending_verification')) { + req.user = { + userId: payload.sub, + sessionId: payload.sid, + tokenVersion: payload.tokenVersion, + roles: payload.roles, + } + } + } + } + + next() +} + +/** + * Requires the user to have at least one of the specified roles. + * Must be used after requireAuth. + */ +export function requireRole(...allowedRoles: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + sendError(res, Errors.UNAUTHORIZED.code, Errors.UNAUTHORIZED.message, req.requestId, Errors.UNAUTHORIZED.status) + return + } + + if (!req.user.roles.some((r) => allowedRoles.includes(r))) { + sendError(res, Errors.FORBIDDEN.code, Errors.FORBIDDEN.message, req.requestId, Errors.FORBIDDEN.status) + return + } + + next() + } +} + +/** + * 要求用户拥有 reviewer 或 admin 角色。 + * admin 具有全部 reviewer 权限(api.md §15.10 权限映射)。 + */ +export const requireReviewer = requireRole('reviewer', 'admin') +/** 同 requireReviewer — admin 角色也可通过 */ +export const requireAdmin = requireReviewer diff --git a/functions/src/middleware/cors.ts b/functions/src/middleware/cors.ts new file mode 100644 index 0000000..ea4060f --- /dev/null +++ b/functions/src/middleware/cors.ts @@ -0,0 +1,35 @@ +import type { Request, Response, NextFunction } from 'express'; +import { conf } from '../Config'; + +const corsConf = conf.CORS as Record | undefined; +const rawOrigins = corsConf?.ALLOWED_ORIGINS as string | undefined; +const appConf = conf.APP as Record | undefined; +const frontendUrl = appConf?.FRONTEND_URL || ''; +const isProduction = !frontendUrl.includes('localhost') && !frontendUrl.includes('127.0.0.1'); + +const ALLOWED_ORIGINS: string[] = rawOrigins + ? rawOrigins.split(',').map((s) => s.trim()).filter(Boolean) + : isProduction + ? [frontendUrl || 'https://submit.transcircle.org'] + : ['http://localhost:5173', 'http://localhost:4173']; + +export function corsHandler(req: Request, res: Response, next: NextFunction): void { + const origin = req.headers.origin; + + if (origin && ALLOWED_ORIGINS.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token, X-Requested-With'); + res.setHeader('Access-Control-Expose-Headers', 'X-Request-Id, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset'); + res.setHeader('Vary', 'Origin'); + } + + if (req.method === 'OPTIONS') { + res.removeHeader('Content-Type'); + res.status(204).end(); + return; + } + + next(); +} diff --git a/functions/src/middleware/idempotency.ts b/functions/src/middleware/idempotency.ts new file mode 100644 index 0000000..79c87cb --- /dev/null +++ b/functions/src/middleware/idempotency.ts @@ -0,0 +1,82 @@ +import type { Request, Response, NextFunction } from 'express' +import { exec, queryOne } from '../Database' +import { ulid } from '../utils/ulid' +import { sendError, Errors } from '../utils/response' + +/** + * Idempotency-Key middleware for POST write endpoints (api.md §12 幂等性). + * + * Usage: + * router.post('/resource', idempotencyKey, handler) + * + * The middleware reads Idempotency-Key header, computes a request fingerprint (SHA-256 of body), + * and on duplicate key + body returns the cached response. + * Same key with different body returns 409 IDEMPOTENCY_KEY_MISMATCH. + * Retention: 24 hours via idempotency_cache table. + */ +export async function idempotencyKey(req: Request, res: Response, next: NextFunction): Promise { + const key = req.headers['idempotency-key'] as string | undefined + + if (!key) { + next() + return + } + + if (key.length < 16 || key.length > 64) { + sendError(res, Errors.BAD_REQUEST.code, 'Idempotency-Key 格式无效', req.requestId, 400) + return + } + // Per api.md §12: Idempotency-Key must be UUID v4 or ULID + const isUuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key) + const isUlid = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/i.test(key) + if (!isUuidV4 && !isUlid) { + sendError(res, Errors.BAD_REQUEST.code, 'Idempotency-Key 须为 UUID v4 或 ULID 格式', req.requestId, 400) + return + } + + const userId = (req as Request & { user?: { userId: string } }).user?.userId || 'anonymous' + const cacheKey = `idemp:${userId}:${key}` + + // Compute request fingerprint + const bodyStr = JSON.stringify(req.body || {}) + const enc = new TextEncoder() + const digest = await crypto.subtle.digest('SHA-256', enc.encode(bodyStr)) + const fingerprint = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') + + const existing = await queryOne( + `SELECT fingerprint, responseBody FROM idempotency_cache WHERE cacheKey = ? AND expiresAt > ?`, + [cacheKey, Date.now()], + ) + + if (existing) { + if (existing.fingerprint !== fingerprint) { + sendError(res, Errors.IDEMPOTENCY_KEY_MISMATCH.code, 'Idempotency-Key 与请求体不匹配', req.requestId, 409) + return + } + if (existing.responseBody) { + try { + const parsed = JSON.parse(existing.responseBody as string) as { status?: number; body?: unknown } + if (parsed.body) { + res.status(parsed.status || 200).json(parsed.body) + return + } + } catch { /* fall through */ } + } + } + + // Capture response to cache it + const originalJson = res.json.bind(res) + res.json = function (body: unknown) { + const snapshot = { status: res.statusCode, body } + exec( + `INSERT INTO idempotency_cache (id, cacheKey, fingerprint, responseBody, expiresAt, createdAt) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE responseBody = VALUES(responseBody)`, + [ulid(), cacheKey, fingerprint, JSON.stringify(snapshot), Date.now() + 86_400_000, Date.now()], + ).catch(() => { /* best-effort */ }) + return originalJson(body) + } + + next() +} diff --git a/functions/src/middleware/rateLimit.ts b/functions/src/middleware/rateLimit.ts new file mode 100644 index 0000000..76d8053 --- /dev/null +++ b/functions/src/middleware/rateLimit.ts @@ -0,0 +1,155 @@ +import type { Request, Response, NextFunction } from 'express'; +import { exec, queryOne } from '../Database'; +import { sendError, Errors } from '../utils/response'; +import { ulid } from '../utils/ulid'; +import { metrics } from '../utils/metrics'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + rateLimitAction?: string; + } + } +} + +interface RateLimitConfig { + /** Max requests in the window */ + max: number; + /** Window in milliseconds */ + windowMs: number; + /** Name for error messages */ + name: string; +} + +const LIMITS: Record = { + default: { max: 600, windowMs: 60_000, name: 'general' }, // 600/min — api.md §13.3 + submit: { max: 5, windowMs: 600_000, name: '投稿' }, // 5/10min per user — api.md §3.1 + 'submit:ip': { max: 20, windowMs: 600_000, name: '投稿IP' }, // 20/10min per IP — api.md §3.1 + auth: { max: 30, windowMs: 300_000, name: '登录' }, // 30/5min — api.md §1.3 + register: { max: 30, windowMs: 3600_000, name: '注册' }, // 30/hour — api.md §1.1 + 'register:ip': { max: 30, windowMs: 3600_000, name: '注册IP' }, // 同IP 30/h — api.md §1.1 + // Per-email registration limit: 24h/5 — api.md §1.1 + 'register:email': { max: 5, windowMs: 86_400_000, name: '注册邮箱' }, + // Per-username registration limit: 24h/5 — api.md §1.1 + 'register:username': { max: 5, windowMs: 86_400_000, name: '注册用户名' }, + // Login failure per IP×identifier: 15min/10 failures — api.md §1.3 + 'login:fail:ip_identifier': { max: 10, windowMs: 900_000, name: '登录失败' }, + // Login failure per account: 15min/5 failures — api.md §1.3 + 'login:fail:account': { max: 5, windowMs: 900_000, name: '登录锁定' }, + // Email resend: 1h/5 per email — api.md §1.2.2 + 'email:resend': { max: 5, windowMs: 3600_000, name: '邮件重发' }, + // Password reset: 1h/3 per email — api.md §1.4.1 + 'password:forgot:email': { max: 3, windowMs: 3600_000, name: '重置密码' }, + 'password:forgot': { max: 20, windowMs: 3600_000, name: '重置密码IP' }, + 'password:reset': { max: 10, windowMs: 300_000, name: '重置提交' }, + admin: { max: 60, windowMs: 60_000, name: '管理' }, // 60/min + image: { max: 20, windowMs: 3600_000, name: '图片' }, // 20/h per user — api.md §11.1 + 'image:ip': { max: 100, windowMs: 3600_000, name: '图片IP' }, // 100/h per IP — api.md §11.1 +}; + +/** MySQL-based rate limiter middleware. + * Uses a bucket per (key, action) — the key is typically IP. + * + * Canonical schema is in functions/schema.sql (rate_limits table). + * The middleware assumes the table exists — if it doesn't, the + * try/catch in rateLimitCheck degrades gracefully (no rate limiting). + * + * Set `req.rateLimitAction` to use a specific action bucket instead of 'default'. + */ +export function rateLimit(_req: Request, _res: Response, next: NextFunction): void { + // Synchronous middleware wrapper — the actual async check runs in rateLimitCheck after body parsing. + next(); +} + +/** + * Async rate limit check — run after body parsing. + * Supports both IP-based (default) and user-based rate limiting when req.user is set. + */ +export async function rateLimitCheck( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const action = req.rateLimitAction || 'default'; + const config = LIMITS[action] || LIMITS.default; + const ip = req.ip || req.socket.remoteAddress || 'unknown'; + + const now = Date.now(); + const windowStart = Math.floor(now / config.windowMs) * config.windowMs; + + // Per-IP bucket (always applies) + const ipBucketKey = `rl:${ip}:${action}:${windowStart}`; + // Per-user bucket (when authenticated — api.md §13.3: 300/min global) + const userId = (req.user as { userId?: string } | undefined)?.userId + const userBucketKey = userId ? `rl:u:${userId}:${action}:${windowStart}` : null + // Per-user global bucket (300/min independent of action) + const userGlobalBucketKey = userId ? `rl:ug:${userId}:${Math.floor(now / 60000) * 60000}` : null + + // Cleanup old windows (best-effort) + try { + await exec(`DELETE FROM rate_limits WHERE windowStart < ?`, [now - config.windowMs * 2]); + } catch { /* ignore cleanup errors */ } + + try { + // Check both IP and user buckets + const buckets = [ipBucketKey] + if (userBucketKey) buckets.push(userBucketKey) + if (userGlobalBucketKey) buckets.push(userGlobalBucketKey) + + for (const bucketKey of buckets) { + await exec( + `INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) + VALUES (?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE count = count + 1`, + [ulid(), bucketKey, windowStart, now], + ); + + const updated = await queryOne( + `SELECT count FROM rate_limits WHERE bucketKey = ?`, + [bucketKey], + ); + const currentCount = (updated?.count as number) || 1; + + // User global bucket uses separate 300/min limit (api.md §13.3) + const isUserGlobal = bucketKey.startsWith('rl:ug:') + const effectiveMax = isUserGlobal ? 300 : config.max + const effectiveWindowMs = isUserGlobal ? 60000 : config.windowMs + const effectiveWindowStart = isUserGlobal + ? Math.floor(now / 60000) * 60000 + : windowStart + + if (currentCount > effectiveMax) { + // Track rate limited metric per api.md §13.2.2 + const rlRoute = req.path.replace(/\/[a-z0-9_]{26,}/g, '/:id'); + const rlDim = isUserGlobal ? 'user' : (userBucketKey === bucketKey ? 'user_action' : 'ip'); + metrics.rateLimitedTotal[`${rlRoute}|${rlDim}`] = (metrics.rateLimitedTotal[`${rlRoute}|${rlDim}`] || 0) + 1; + + const retryAfter = Math.ceil((effectiveWindowStart + effectiveWindowMs - now) / 1000); + res.setHeader('Retry-After', String(retryAfter)); + res.setHeader('X-RateLimit-Limit', String(effectiveMax)); + res.setHeader('X-RateLimit-Remaining', '0'); + res.setHeader('X-RateLimit-Reset', String(effectiveWindowStart + effectiveWindowMs)); + + sendError( + res, + Errors.RATE_LIMITED.code, + isUserGlobal + ? `请求频率过高,请 ${retryAfter} 秒后重试` + : `${config.name}请求过于频繁,请 ${retryAfter} 秒后重试`, + req.requestId, + Errors.RATE_LIMITED.status, + ); + return; + } + + res.setHeader('X-RateLimit-Limit', String(effectiveMax)); + res.setHeader('X-RateLimit-Remaining', String(effectiveMax - currentCount)); + res.setHeader('X-RateLimit-Reset', String(effectiveWindowStart + effectiveWindowMs)); + } + } catch { + // DB error — proceed without rate limiting + } + + next(); +} diff --git a/functions/src/middleware/requestId.ts b/functions/src/middleware/requestId.ts new file mode 100644 index 0000000..144d011 --- /dev/null +++ b/functions/src/middleware/requestId.ts @@ -0,0 +1,20 @@ +import type { Request, Response, NextFunction } from 'express'; +import { ulid } from '../utils/ulid'; + +/** + * Injects a unique request ID (ULID) into every request. + * Available as req.requestId. + */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + requestId: string; + } + } +} + +export function requestId(req: Request, _res: Response, next: NextFunction): void { + req.requestId = `req_${ulid()}`; + next(); +} diff --git a/functions/src/routes/admin-audit.ts b/functions/src/routes/admin-audit.ts new file mode 100644 index 0000000..e3ef4a4 --- /dev/null +++ b/functions/src/routes/admin-audit.ts @@ -0,0 +1,60 @@ +import { Router } from 'express' +import { query } from '../Database' +import { sendSuccess } from '../utils/response' +import { requireAuth, requireReviewer } from '../middleware/auth' +import { requirePerm } from '../utils/permissions' + +const router: Router = Router() +router.use(requireAuth, requireReviewer) + +// GET /admin/audit-logs — api.md §8 +router.get('/audit-logs', requirePerm('audit:read'), async (req, res) => { + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + const actorId = req.query.actorId as string | undefined + const action = req.query.action as string | undefined + const resourceType = req.query.resourceType as string | undefined + const resourceId = req.query.resourceId as string | undefined + + let whereClause = 'WHERE 1=1' + const params: unknown[] = [] + + if (actorId) { whereClause += ' AND actorUserId = ?'; params.push(actorId) } + if (action) { whereClause += ' AND action = ?'; params.push(action) } + if (resourceType) { whereClause += ' AND resourceType = ?'; params.push(resourceType) } + if (resourceId) { whereClause += ' AND resourceId = ?'; params.push(resourceId) } + if (req.query.from) { whereClause += ' AND createdAt >= ?'; params.push(parseInt(req.query.from as string, 10)) } + if (req.query.to) { whereClause += ' AND createdAt <= ?'; params.push(parseInt(req.query.to as string, 10)) } + if (cursor) { + whereClause += ' AND createdAt < ?' + params.push(parseInt(Buffer.from(cursor, 'base64url').toString('utf-8'), 10)) + } + + params.push(limit + 1) + + const rows = await query( + `SELECT id, actorUserId, action, resourceType, resourceId, \`before\`, after, createdAt, requestId, prevHash, entryHash + FROM audit_logs ${whereClause} ORDER BY createdAt DESC LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + const data = (rows as Array>).map((r) => ({ + id: r.id, actorUserId: r.actorUserId, action: r.action, + resourceType: r.resourceType, resourceId: r.resourceId, + before: r.before || null, after: r.after || null, + createdAt: r.createdAt, requestId: r.requestId, + prevHash: r.prevHash || null, + entryHash: r.entryHash || null, + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') + : null + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +export default router diff --git a/functions/src/routes/admin-users.ts b/functions/src/routes/admin-users.ts new file mode 100644 index 0000000..dc15217 --- /dev/null +++ b/functions/src/routes/admin-users.ts @@ -0,0 +1,370 @@ +import { Router } from 'express' +import { query, queryOne, exec, getConnection } from '../Database' +import { sendSuccess, sendError, Errors } from '../utils/response' +import { requireAuth, requireReviewer } from '../middleware/auth' +import { revokeUserSessions } from '../utils/session' +import { writeAuditLog } from '../utils/audit' +import { genId } from '../utils/ulid' +import { requirePerm } from '../utils/permissions' + +const router: Router = Router() +router.use(requireAuth, requireReviewer) + +function getParamString(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] + return value ?? '' +} + +// GET /admin/users — api.md §7.1 +router.get('/users', requirePerm('user:read'), async (req, res) => { + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + const keyword = req.query.keyword as string | undefined + const status = req.query.status as string | undefined + const role = req.query.role as string | undefined + + let whereClause = 'WHERE 1=1' + const params: unknown[] = [] + + if (keyword) { + whereClause += ` AND (u.username LIKE ? OR u.email LIKE ? OR u.displayName LIKE ?)` + const kw = `%${keyword}%` + params.push(kw, kw, kw) + } + if (status) { + whereClause += ` AND u.status = ?` + params.push(status) + } + if (role) { + whereClause += ` AND EXISTS (SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = u.id AND r.name = ?)` + params.push(role) + } + if (cursor) { + whereClause += ` AND u.createdAt < ?` + params.push(parseInt(Buffer.from(cursor, 'base64url').toString('utf-8'), 10)) + } + + params.push(limit + 1) + + const rows = await query( + `SELECT u.id, u.username, u.displayName, u.email, u.emailVerified, u.status, u.createdAt, u.lastLoginAt + FROM users u ${whereClause} ORDER BY u.createdAt DESC LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + const data = await Promise.all((rows as Array>).map(async (r) => { + let roles: string[] = [] + try { + const roleRows = await query( + `SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ?`, + [r.id], + ) + roles = (roleRows as Array<{ name: string }>).map(rr => rr.name) + } catch { /* no roles */ } + return { + id: r.id, username: r.username, displayName: r.displayName, + email: r.email, emailVerified: !!r.emailVerified, + status: r.status, roles, + createdAt: r.createdAt, lastLoginAt: r.lastLoginAt || null, + } + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') + : null + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +// GET /admin/users/:id — api.md §7.2 +router.get('/users/:id', requirePerm('user:read'), async (req, res) => { + const row = await queryOne( + `SELECT * FROM users WHERE id = ?`, + [req.params.id], + ) + + if (!row) { + sendError(res, Errors.USER_NOT_FOUND.code, '用户不存在', req.requestId, Errors.USER_NOT_FOUND.status) + return + } + + const oauthAccounts = await query( + `SELECT provider, providerUsername, createdAt as boundAt FROM oauth_accounts WHERE userId = ?`, + [req.params.id], + ) as unknown as Array> + + const passkeyCount = await queryOne( + `SELECT COUNT(*) as cnt FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.params.id], + ) + + const totpEnabled = await queryOne( + `SELECT id FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [req.params.id], + ) + + // Fetch actual roles with grant metadata per api.md §7.2 + const userRoles = await query( + `SELECT r.id, r.name, ur.grantedBy, ur.createdAt, ur.expiresAt + FROM user_roles ur JOIN roles r ON r.id = ur.roleId + WHERE ur.userId = ?`, + [req.params.id], + ) as unknown as Array> + + const roles = userRoles.map((ur) => ({ + id: ur.id as string, + name: ur.name, + grantedBy: ur.grantedBy || '', + createdAt: ur.createdAt || 0, + expiresAt: ur.expiresAt || null, + })) + + sendSuccess(res, { + id: row.id, username: row.username, displayName: row.displayName, + email: row.email, emailVerified: !!row.emailVerified, + avatarUrl: row.avatarUrl || null, status: row.status, + roles, + oauthAccounts: oauthAccounts.map((a: Record) => ({ + provider: a.provider, providerUsername: a.providerUsername, boundAt: a.boundAt, + })), + security: { + hasPassword: !!(row as Record).passwordHash, + totpEnabled: !!totpEnabled, + passkeyCount: (passkeyCount?.cnt as number) || 0, + }, + createdAt: row.createdAt, lastLoginAt: row.lastLoginAt || null, + }, req.requestId) +}) + +// POST /admin/users/:id/ban — api.md §7.5 +router.post('/users/:id/ban', requirePerm('user:ban'), async (req, res) => { + const userId = getParamString(req.params.id) + const reason = (req.body?.reason as string) || '' + if (!reason) { + sendError(res, Errors.VALIDATION_ERROR.code, '封禁原因必填', req.requestId, 422) + return + } + + const user = await queryOne(`SELECT id, status FROM users WHERE id = ?`, [userId]) + if (!user) { + sendError(res, Errors.USER_NOT_FOUND.code, '用户不存在', req.requestId, Errors.USER_NOT_FOUND.status) + return + } + + await exec(`UPDATE users SET status = 'banned', tokenVersion = tokenVersion + 1, updatedAt = ? WHERE id = ?`, [Date.now(), userId]) + await revokeUserSessions(userId, 'account_banned') + + // Freeze all active passkeys per api.md §7.5 + await exec(`UPDATE passkeys SET status = 'frozen', frozenReason = 'account_banned' WHERE userId = ? AND status = 'active'`, [userId]) + // Disable MFA TOTP per api.md §7.5 + await exec(`UPDATE mfa_totp_credentials SET status = 'disabled' WHERE userId = ? AND status = 'active'`, [userId]) + + // Count revoked sessions + const bannedSessions = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND revokedReason = 'account_banned'`, + [userId], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'user.ban', + resourceType: 'user', + resourceId: userId, + after: { reason }, + }) + + sendSuccess(res, { userId: userId, status: 'banned', revokedSessions: (bannedSessions?.cnt as number) || 0 }, req.requestId) +}) + +// POST /admin/users/:id/unban — api.md §7.5 +router.post('/users/:id/unban', requirePerm('user:ban'), async (req, res) => { + const userId = getParamString(req.params.id) + const reason = (req.body?.reason as string) || '' + if (!reason) { + sendError(res, Errors.VALIDATION_ERROR.code, '解封原因必填', req.requestId, 422) + return + } + + const user = await queryOne(`SELECT id, status FROM users WHERE id = ?`, [userId]) + if (!user) { + sendError(res, Errors.USER_NOT_FOUND.code, '用户不存在', req.requestId, Errors.USER_NOT_FOUND.status) + return + } + + await exec(`UPDATE users SET status = 'active', tokenVersion = tokenVersion + 1, updatedAt = ? WHERE id = ?`, [Date.now(), userId]) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'user.unban', + resourceType: 'user', + resourceId: userId, + after: { reason }, + }) + + sendSuccess(res, { userId: userId, status: 'active', revokedSessions: 0 }, req.requestId) +}) + +// POST /admin/users/:id/roles — api.md §7.3 授予角色 +router.post('/users/:id/roles', requirePerm('role:grant'), async (req, res) => { + const userId = getParamString(req.params.id) + const { roleId, expiresAt } = req.body as { roleId?: string; expiresAt?: number | null } + if (!roleId) { + sendError(res, Errors.VALIDATION_ERROR.code, 'roleId 必填', req.requestId, 422) + return + } + + const targetUser = await queryOne(`SELECT id FROM users WHERE id = ?`, [userId]) + if (!targetUser) { + sendError(res, Errors.USER_NOT_FOUND.code, '用户不存在', req.requestId, Errors.USER_NOT_FOUND.status) + return + } + + // 禁止给自己加角色(防误操作) + if (userId === req.user!.userId) { + sendError(res, 'CANNOT_GRANT_SELF', '不能给自己授予角色,请找其他管理员操作', req.requestId, 403) + return + } + + const roleRow = await queryOne(`SELECT id, name FROM roles WHERE id = ?`, [roleId]) + if (!roleRow) { + sendError(res, 'ROLE_NOT_FOUND', '角色不存在', req.requestId, 404) + return + } + + // Check if already granted + const existingGrant = await queryOne( + `SELECT id FROM user_roles WHERE userId = ? AND roleId = ?`, + [userId, roleId], + ) + if (existingGrant) { + sendError(res, 'ROLE_ALREADY_GRANTED', '用户已拥有该角色', req.requestId, 409) + return + } + + const now = Date.now() + const conn = await getConnection() + try { + await conn.beginTransaction() + + await conn.execute( + `INSERT INTO user_roles (id, userId, roleId, grantedBy, createdAt, expiresAt) + VALUES (?, ?, ?, ?, ?, ?)`, + [genId('ur_'), userId, roleId, req.user!.userId, now, expiresAt || null], + ) + + // Invalidate all sessions — role change requires re-login + await conn.execute(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [userId]) + await conn.execute(`UPDATE sessions SET revokedAt = ?, revokedReason = 'role_changed' WHERE userId = ? AND revokedAt IS NULL`, [now, userId]) + await conn.execute( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ? AND revokedReason = 'role_changed') + AND status IN ('active', 'rotated')`, + [userId], + ) + + await conn.commit() + + const revoked = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND revokedReason = 'role_changed'`, + [userId], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'role.grant', + resourceType: 'user', + resourceId: userId, + after: { roleId, roleName: roleRow.name, expiresAt: expiresAt || null }, + }) + + sendSuccess(res, { + userId, + roleId, + grantedBy: req.user!.userId, + createdAt: now, + expiresAt: expiresAt || null, + revokedSessions: (revoked?.cnt as number) || 0, + }, req.requestId, 201) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('Role grant error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '角色授予失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +// DELETE /admin/users/:id/roles/:roleId — api.md §7.4 撤销角色 +router.delete('/users/:id/roles/:roleId', requirePerm('role:revoke'), async (req, res) => { + const userId = getParamString(req.params.id) + const roleId = getParamString(req.params.roleId) + + const existingGrant = await queryOne( + `SELECT ur.id, r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ? AND ur.roleId = ?`, + [userId, roleId], + ) + if (!existingGrant) { + sendError(res, 'ROLE_NOT_FOUND', '该用户没有此角色', req.requestId, 404) + return + } + + // 禁止撤销自己的角色(防误操作锁死) + if (userId === req.user!.userId) { + sendError(res, 'CANNOT_REVOKE_SELF', '不能撤销自己的角色', req.requestId, 403) + return + } + + const now = Date.now() + const conn = await getConnection() + try { + await conn.beginTransaction() + + await conn.execute(`DELETE FROM user_roles WHERE userId = ? AND roleId = ?`, [userId, roleId]) + + // Invalidate sessions + await conn.execute(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [userId]) + await conn.execute(`UPDATE sessions SET revokedAt = ?, revokedReason = 'role_changed' WHERE userId = ? AND revokedAt IS NULL`, [now, userId]) + await conn.execute( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ? AND revokedReason = 'role_changed') + AND status IN ('active', 'rotated')`, + [userId], + ) + + await conn.commit() + + const revoked = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND revokedReason = 'role_changed'`, + [userId], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'role.revoke', + resourceType: 'user', + resourceId: userId, + after: { roleId, roleName: existingGrant.name }, + }) + + sendSuccess(res, { + userId, + roleId, + revokedSessions: (revoked?.cnt as number) || 0, + }, req.requestId) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('Role revoke error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '角色撤销失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +export default router diff --git a/functions/src/routes/admin.ts b/functions/src/routes/admin.ts new file mode 100644 index 0000000..ace220a --- /dev/null +++ b/functions/src/routes/admin.ts @@ -0,0 +1,596 @@ +import { Router, type Router as RouterType } from 'express' +import { query, queryOne, exec } from '../Database' +import { genId } from '../utils/ulid' +import { sendSuccess, sendError, Errors, zodErrorsToDetails } from '../utils/response' +import { requireAuth, requireReviewer } from '../middleware/auth' +import { reviewSchema } from '../utils/validation' +import { rateLimitCheck } from '../middleware/rateLimit' +import { conf } from '../Config' +import { writeAuditLog } from '../utils/audit' +import { purgeContributionCache } from '../utils/cdn' +import { requirePerm, requirePermission } from '../utils/permissions' + +const router: RouterType = Router() + +function getRouteId(req: import('express').Request): string { + const value = req.params.id as string | string[] | undefined + if (Array.isArray(value)) return value[0] + return value ?? '' +} + +// All admin routes require auth + admin +router.use(requireAuth, requireReviewer) + +// ────────────────────────────────────────────── +// GET /admin/contributions — api.md §6.1 +// ────────────────────────────────────────────── +router.get('/contributions', requirePerm('contribution:read'), async (req, res) => { + const status = (req.query.status as string) || 'pending' + const cursor = req.query.cursor as string | undefined + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const authorId = req.query.authorId as string | undefined + const keyword = req.query.keyword as string | undefined + const reviewerId = req.query.reviewerId as string | undefined + const createdFrom = req.query.createdFrom as string | undefined + const createdTo = req.query.createdTo as string | undefined + const sort = (req.query.sort as string) || 'createdAt_desc' + + const validStatuses = ['draft', 'pending', 'in_review', 'approved', 'rejected', 'published', 'hidden', 'withdrawn', 'deleted'] + if (!validStatuses.includes(status)) { + sendError(res, Errors.VALIDATION_ERROR.code, `无效的状态: ${status}`, req.requestId, Errors.VALIDATION_ERROR.status) + return + } + + let whereClause = `WHERE c.status = ?` + let orderClause = 'ORDER BY c.createdAt DESC' + const params: unknown[] = [status] + + if (authorId) { + whereClause += ` AND c.authorUserId = ?` + params.push(authorId) + } + if (keyword) { + whereClause += ` AND (c.title LIKE ? OR c.summary LIKE ?)` + const kw = `%${keyword}%` + params.push(kw, kw) + } + if (reviewerId) { + whereClause += ` AND EXISTS (SELECT 1 FROM contribution_review_events re WHERE re.contributionId = c.id AND re.reviewerUserId = ? AND re.action = 'review')` + params.push(reviewerId) + } + if (createdFrom) { + whereClause += ` AND c.createdAt >= ?` + params.push(parseInt(createdFrom, 10)) + } + if (createdTo) { + whereClause += ` AND c.createdAt <= ?` + params.push(parseInt(createdTo, 10)) + } + if (sort === 'createdAt_asc') orderClause = 'ORDER BY c.createdAt ASC' + else if (sort === 'updatedAt_desc') orderClause = 'ORDER BY c.updatedAt DESC' + else if (sort === 'updatedAt_asc') orderClause = 'ORDER BY c.updatedAt ASC' + + if (cursor) { + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf-8') + const cursorTs = parseInt(decoded, 10) + if (isNaN(cursorTs)) { + sendError(res, Errors.VALIDATION_ERROR.code, '无效的 cursor', req.requestId, Errors.VALIDATION_ERROR.status) + return + } + whereClause += ` AND c.createdAt < ?` + params.push(cursorTs) + } catch { + sendError(res, Errors.VALIDATION_ERROR.code, '无效的 cursor 格式', req.requestId, Errors.VALIDATION_ERROR.status) + return + } + } + + params.push(limit + 1) + + const rows = await query( + `SELECT c.id, c.title, c.summary, c.status, c.version, c.createdAt, c.updatedAt, + c.submittedAt, c.publishedAt, + u.id as authorUserId, u.displayName, u.avatarUrl + FROM contributions c + LEFT JOIN users u ON u.id = c.authorUserId + ${whereClause} + ${orderClause} + LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + const submissions = rows.map((row: Record) => ({ + id: row.id as string, + title: row.title as string, + summary: ((row.summary as string) || '').slice(0, 120) || null, + status: row.status as string, + createdAt: row.createdAt as number, + updatedAt: row.updatedAt as number, + author: row.displayName + ? { + id: (row.authorUserId as string) || null, + displayName: (row.displayName || row.username) as string, + avatarUrl: (row.avatarUrl as string) || null, + } + : null, + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') + : null + + sendSuccess(res, submissions, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +// ────────────────────────────────────────────── +// GET /admin/contributions/stats — api.md §6.8 +// Must be registered BEFORE /:id to avoid Express matching 'stats' as :id +// ────────────────────────────────────────────── +router.get('/contributions/stats', requirePerm('contribution:read'), async (req, res) => { + const rows = await query( + `SELECT status, COUNT(*) as count FROM contributions GROUP BY status`, + ) as Array> + + const totals: Record = { draft: 0, pending: 0, in_review: 0, approved: 0, rejected: 0, published: 0, hidden: 0, withdrawn: 0, deleted: 0 } + for (const r of rows) { totals[r.status as string] = r.count as number } + + const now = Date.now() + const from = req.query.from ? parseInt(req.query.from as string, 10) : undefined + const to = req.query.to ? parseInt(req.query.to as string, 10) : undefined + const todayStart = now - (now % 86400000) + const weekAgo = now - 7 * 86400000 + const monthAgo = from || (now - 30 * 86400000) + const toTime = to || now + + // Use subquery to get first review time per contribution, avoiding CROSS JOIN + const submissions = await query( + `SELECT SUM(CASE WHEN submittedAt >= ? THEN 1 ELSE 0 END) as today, + SUM(CASE WHEN submittedAt >= ? THEN 1 ELSE 0 END) as last7Days, + SUM(CASE WHEN submittedAt >= ? AND submittedAt <= ? THEN 1 ELSE 0 END) as last30Days, + AVG(minReviewTime - submittedAt) / 1000 as avgLatency + FROM ( + SELECT c.id, c.submittedAt, + (SELECT MIN(createdAt) FROM contribution_review_events + WHERE contributionId = c.id AND action = 'review') as minReviewTime + FROM contributions c + WHERE c.submittedAt IS NOT NULL AND c.submittedAt <= ? + ) sub`, + [todayStart, weekAgo, monthAgo, toTime, toTime], + ) + + const s = submissions[0] as Record || {} + + sendSuccess(res, { + totals, + submissions: { today: s.today || 0, last7Days: s.last7Days || 0, last30Days: s.last30Days || 0 }, + averageReviewLatencySeconds: Math.round((s.avgLatency as number) || 0), + }, req.requestId) +}) + +// ────────────────────────────────────────────── +// GET /admin/contributions/:id — api.md §6.2 +// ────────────────────────────────────────────── +router.get('/contributions/:id', requirePerm('contribution:read'), async (req, res) => { + const id = getRouteId(req) + + const row = await queryOne( + `SELECT c.*, u.id as authorUserId, u.username, u.displayName, u.avatarUrl, u.emailVerified + FROM contributions c + LEFT JOIN users u ON u.id = c.authorUserId + WHERE c.id = ?`, + [id], + ) + + if (!row) { + sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, Errors.CONTRIBUTION_NOT_FOUND.status) + return + } + + // Query latest review event for this contribution (api.md §6.2) + // Use toStatus rather than action: action is always 'review' per insert, toStatus carries the decision + const latestReview = await queryOne( + `SELECT reviewerUserId, createdAt as reviewedAt, toStatus as decision, publicNote, internalNote + FROM contribution_review_events + WHERE contributionId = ? AND action = 'review' + ORDER BY createdAt DESC LIMIT 1`, + [id], + ) + + // internalNote requires contribution:internal-note:read per api.md §6.2 + const canReadInternalNote = await requirePermission(req.user!.userId, 'contribution:internal-note:read') + + let review: { reviewerUserId: string | null; reviewedAt: number | null; decision: string | null; publicNote: string | null; internalNote: string | null } + if (latestReview) { + const decision = (latestReview.decision as string) === 'approved' ? 'approved' + : (latestReview.decision as string) === 'rejected' ? 'rejected' + : null + review = { + reviewerUserId: latestReview.reviewerUserId, + reviewedAt: latestReview.reviewedAt, + decision, + publicNote: latestReview.publicNote || null, + internalNote: canReadInternalNote ? (latestReview.internalNote || null) : null, + } + } else { + review = { reviewerUserId: null, reviewedAt: null, decision: null, publicNote: null, internalNote: null } + } + + // Fetch hide/delete reason for hidden/deleted contributions + let hideReason: string | null = null + if (row.status === 'hidden' || row.status === 'deleted') { + const ev = await queryOne( + `SELECT publicNote FROM contribution_review_events + WHERE contributionId = ? AND action IN ('hide', 'delete') + ORDER BY createdAt DESC LIMIT 1`, + [id], + ) + if (ev) hideReason = ev.publicNote as string | null + } + + sendSuccess(res, { + id: row.id, + title: row.title, + summary: row.summary || null, + contentRaw: row.contentRaw, + contentHtml: row.contentHtml || null, + contentFormat: row.contentFormat, + tags: typeof row.tags === 'string' ? JSON.parse(row.tags as string) : row.tags, + language: row.language, + status: row.status, + version: row.version, + author: row.username + ? { + id: row.authorUserId, + username: row.username, + displayName: row.displayName || row.username, + avatarUrl: row.avatarUrl, + emailVerified: !!row.emailVerified, + } + : null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + submittedAt: row.submittedAt || null, + publishedAt: row.publishedAt || null, + review, + hideReason, + }, req.requestId) +}) + +// ────────────────────────────────────────────── +// POST /admin/contributions/:id/review — api.md §6.3 +// ────────────────────────────────────────────── +router.post('/contributions/:id/review', requirePerm('contribution:review'), (req, _res, next) => { req.rateLimitAction = 'admin'; next(); }, rateLimitCheck, async (req, res) => { + const id = getRouteId(req) + const parsed = reviewSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, Errors.VALIDATION_ERROR.status, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const { decision, toStatus, expectedVersion, internalNote, publicNote } = parsed.data + + // api.md §15.10: writing internalNote requires contribution:internal-note:read + if (internalNote && !(await requirePermission(req.user!.userId, 'contribution:internal-note:read'))) { + sendError(res, Errors.FORBIDDEN.code, '缺少权限: contribution:internal-note:read', req.requestId, Errors.FORBIDDEN.status) + return + } + + const contribution = await queryOne( + `SELECT id, status, version FROM contributions WHERE id = ?`, + [id], + ) + + if (!contribution) { + sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, Errors.CONTRIBUTION_NOT_FOUND.status) + return + } + + if (contribution.status !== 'pending' && contribution.status !== 'in_review') { + sendError(res, Errors.INVALID_STATE_TRANSITION.code, `投稿状态为 ${contribution.status},不可审核`, req.requestId, Errors.INVALID_STATE_TRANSITION.status) + return + } + + if (contribution.version !== expectedVersion) { + sendError(res, Errors.VERSION_CONFLICT.code, `版本冲突:当前版本为 ${contribution.version},期望 ${expectedVersion}`, req.requestId, Errors.VERSION_CONFLICT.status) + return + } + + // Determine target status per api.md §6.3: + // - decision=approved → approved, decision=rejected → rejected + // - toStatus=in_review → in_review (request revisions) + let toStatusFinal: string + if (toStatus === 'in_review') { + toStatusFinal = 'in_review' + } else if (decision === 'approved') { + toStatusFinal = 'approved' + } else { + toStatusFinal = 'rejected' + } + + const now = Date.now() + const newVersion = contribution.version + 1 + + const result = await exec( + `UPDATE contributions SET status = ?, version = ?, updatedAt = ? WHERE id = ? AND version = ? AND (status = 'pending' OR status = 'in_review')`, + [toStatusFinal, newVersion, now, id, expectedVersion], + ) + + if (result.affectedRows === 0) { + sendError(res, Errors.VERSION_CONFLICT.code, '审核失败,请刷新后重试', req.requestId, Errors.VERSION_CONFLICT.status) + return + } + + // Record review event + await exec( + `INSERT INTO contribution_review_events (id, contributionId, reviewerUserId, action, fromStatus, toStatus, publicNote, internalNote, createdAt, requestId) + VALUES (?, ?, ?, 'review', ?, ?, ?, ?, ?, ?)`, + [genId('rev_'), id, req.user!.userId, contribution.status, toStatusFinal, publicNote || null, internalNote || null, now, req.requestId], + ) + + // Write audit log per api.md §6.3 + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.review', + resourceType: 'contribution', + resourceId: String(id), + before: { status: String(contribution.status), version: Number(expectedVersion) }, + after: { status: String(toStatusFinal), version: Number(newVersion) }, + }) + +sendSuccess(res, { + id, + status: toStatusFinal, + version: newVersion, + review: { + reviewerUserId: req.user!.userId, + reviewedAt: now, + decision: toStatusFinal === 'in_review' ? null : (decision || null), + publicNote: publicNote || null, + internalNote: internalNote || null, + }, + }, req.requestId) +}) + +// ── Story rebuild trigger ───────────── +interface StoryConfig { + STORY_REPO?: string + STORY_REPO_TOKEN?: string +} + +async function triggerStoryRebuild(req: import('express').Request): Promise { + const storyConf = conf.STORY as StoryConfig | undefined + const token = storyConf?.STORY_REPO_TOKEN + const repo = storyConf?.STORY_REPO + + if (!token || !repo) return + + const res = await fetch(`https://api.github.com/repos/${repo}/dispatches`, { + method: 'POST', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + 'User-Agent': 'TransCircle', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event_type: 'story-rebuild', + client_payload: { + api_base: ((conf.STORY as Record)?.STORY_API_BASE) || '', + triggered_by: req.user?.userId || 'unknown', + }, + }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`GitHub dispatch failed (${res.status}): ${text}`) + } +} + +// ────────────────────────────────────────────── +// POST /admin/contributions/:id/publish — api.md §6.5 +// ────────────────────────────────────────────── +router.post('/contributions/:id/publish', requirePerm('contribution:publish'), async (req, res) => { + const id = getRouteId(req) + const expectedVersion = req.body?.expectedVersion as number | undefined + const publicNote = req.body?.publicNote as string | undefined + + if (!expectedVersion) { + sendError(res, Errors.VALIDATION_ERROR.code, 'expectedVersion 必填', req.requestId, 422) + return + } + + const contrib = await queryOne(`SELECT id, status, version FROM contributions WHERE id = ?`, [id]) + if (!contrib) { sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404); return } + if (contrib.status !== 'approved') { sendError(res, 'INVALID_STATE_TRANSITION', '当前状态不可发布', req.requestId, 409); return } + if (contrib.version !== expectedVersion) { sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status); return } + + const now = Date.now() + const newVersion = contrib.version + 1 + + await exec(`UPDATE contributions SET status = 'published', version = ?, publishedAt = ?, updatedAt = ? WHERE id = ? AND version = ?`, [newVersion, now, now, id, expectedVersion]) + + await exec( + `INSERT INTO contribution_review_events (id, contributionId, reviewerUserId, action, fromStatus, toStatus, publicNote, createdAt, requestId) + VALUES (?, ?, ?, 'publish', 'approved', 'published', ?, ?, ?)`, + [genId('rev_'), id, req.user!.userId, publicNote || null, now, req.requestId], + ) + await writeAuditLog(req, { actorUserId: req.user!.userId, action: 'contribution.publish', resourceType: 'contribution', resourceId: id, after: { status: 'published' } }) + + // CDN purge per api.md §6.4 (publishing fresh content — purge cache so it's immediately visible) + purgeContributionCache(id).catch((err) => console.error('[CDN] purge error:', err)) + + // Trigger story site rebuild on publish (api.md §6.5 — stories only serve published content) + triggerStoryRebuild(req).catch((err) => console.error('Story rebuild trigger failed:', err)) + + sendSuccess(res, { id, status: 'published', version: newVersion, publishedAt: now }, req.requestId) +}) + +// POST /admin/contributions/:id/hide — api.md §6.4 +router.post('/contributions/:id/hide', requirePerm('contribution:hide'), async (req, res) => { + const id = getRouteId(req) + const expectedVersion = req.body?.expectedVersion as number | undefined + const reason = req.body?.reason as string | undefined + const publicNote = req.body?.publicNote as string | undefined + const internalNote = req.body?.internalNote as string | undefined + if (!expectedVersion || !reason) { sendError(res, Errors.VALIDATION_ERROR.code, '缺少参数', req.requestId, 422); return } + + // api.md §15.10: writing internalNote requires contribution:internal-note:read + if (internalNote && !(await requirePermission(req.user!.userId, 'contribution:internal-note:read'))) { + sendError(res, Errors.FORBIDDEN.code, '缺少权限: contribution:internal-note:read', req.requestId, Errors.FORBIDDEN.status) + return + } + + const contrib = await queryOne(`SELECT id, status, version FROM contributions WHERE id = ?`, [id]) + if (!contrib) { sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404); return } + if (contrib.status !== 'published') { sendError(res, 'INVALID_STATE_TRANSITION', '当前状态不可隐藏', req.requestId, 409); return } + if (contrib.version !== expectedVersion) { sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status); return } + + const now = Date.now(); const newVersion = contrib.version + 1 + await exec( + `UPDATE contributions SET status = 'hidden', version = ?, updatedAt = ? WHERE id = ? AND version = ?`, + [newVersion, now, id, expectedVersion], + ) + await exec( + `INSERT INTO contribution_review_events (id, contributionId, reviewerUserId, action, fromStatus, toStatus, publicNote, internalNote, createdAt, requestId) + VALUES (?, ?, ?, 'hide', 'published', 'hidden', ?, ?, ?, ?)`, + [genId('rev_'), id, req.user!.userId, publicNote || reason || null, internalNote || null, now, req.requestId], + ) + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.hide', + resourceType: 'contribution', + resourceId: id, + after: { reason, publicNote, internalNote }, + }) + + // Per api.md §6.4: purge CDN cache for hidden contribution + purgeContributionCache(id).catch((err) => console.error('[CDN] purge error:', err)) + + sendSuccess(res, { + id, status: 'hidden', version: newVersion, + review: { + reviewerUserId: req.user!.userId, + reviewedAt: now, + decision: null, + publicNote: publicNote || null, + internalNote: internalNote || null, + }, + }, req.requestId) +}) + +// POST /admin/contributions/:id/restore — api.md §6.4 +router.post('/contributions/:id/restore', requirePerm('contribution:restore'), async (req, res) => { + const id = getRouteId(req) + const expectedVersion = req.body?.expectedVersion as number | undefined + if (!expectedVersion) { sendError(res, Errors.VALIDATION_ERROR.code, 'expectedVersion 必填', req.requestId, 422); return } + + const contrib = await queryOne(`SELECT id, status, version FROM contributions WHERE id = ?`, [id]) + if (!contrib) { sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404); return } + if (contrib.status !== 'hidden') { sendError(res, 'INVALID_STATE_TRANSITION', '当前状态不可恢复', req.requestId, 409); return } + if (contrib.version !== expectedVersion) { sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status); return } + + const now = Date.now(); const newVersion = contrib.version + 1 + await exec( + `UPDATE contributions SET status = 'published', version = ?, updatedAt = ? WHERE id = ? AND version = ?`, + [newVersion, now, id, expectedVersion], + ) + await exec( + `INSERT INTO contribution_review_events (id, contributionId, reviewerUserId, action, fromStatus, toStatus, createdAt, requestId) + VALUES (?, ?, ?, 'restore', 'hidden', 'published', ?, ?)`, + [genId('rev_'), id, req.user!.userId, now, req.requestId], + ) + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.restore', + resourceType: 'contribution', + resourceId: id, + after: { status: 'published' }, + }) + + sendSuccess(res, { id, status: 'published', version: newVersion }, req.requestId) +}) + +// POST /admin/contributions/:id/delete — api.md §6.6 +router.post('/contributions/:id/delete', requirePerm('contribution:delete'), async (req, res) => { + const id = getRouteId(req) + const expectedVersion = req.body?.expectedVersion as number | undefined + const reason = req.body?.reason as string | undefined + if (!expectedVersion || !reason) { sendError(res, Errors.VALIDATION_ERROR.code, '缺少参数', req.requestId, 422); return } + + const validFromStatuses = ['draft', 'rejected', 'withdrawn', 'hidden', 'approved'] + const contrib = await queryOne(`SELECT id, status, version FROM contributions WHERE id = ?`, [id]) + if (!contrib) { sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404); return } + if (!validFromStatuses.includes(contrib.status)) { sendError(res, 'INVALID_STATE_TRANSITION', '当前状态不可删除', req.requestId, 409); return } + if (contrib.version !== expectedVersion) { sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status); return } + + const now = Date.now(); const newVersion = contrib.version + 1 + await exec( + `UPDATE contributions SET status = 'deleted', version = ?, deletedAt = ?, updatedAt = ? WHERE id = ? AND version = ?`, + [newVersion, now, now, id, expectedVersion], + ) + // Record review event per api.md §6.6 + await exec( + `INSERT INTO contribution_review_events (id, contributionId, reviewerUserId, action, fromStatus, toStatus, publicNote, createdAt, requestId) + VALUES (?, ?, ?, 'delete', ?, 'deleted', ?, ?, ?)`, + [genId('rev_'), id, req.user!.userId, contrib.status, reason || null, now, req.requestId], + ) + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.delete', + resourceType: 'contribution', + resourceId: id, + after: { reason }, + }) + + // Per api.md §6.6: purge CDN cache for deleted contribution + purgeContributionCache(id).catch((err) => console.error('[CDN] purge error:', err)) + + sendSuccess(res, { id, status: 'deleted', version: newVersion, deletedAt: now }, req.requestId) +}) + +// GET /admin/contributions/:id/review-events — api.md §6.7 +router.get('/contributions/:id/review-events', requirePerm('contribution:audit:read'), async (req, res) => { + const id = getRouteId(req) + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + + let whereClause = 'WHERE contributionId = ?' + const params: unknown[] = [id] + if (cursor) { whereClause += ' AND createdAt < ?'; params.push(parseInt(Buffer.from(cursor, 'base64url').toString('utf-8'), 10)) } + params.push(limit + 1) + + const rows = await query( + `SELECT e.id, e.contributionId, e.reviewerUserId, e.fromStatus, e.toStatus, + e.publicNote, e.internalNote, e.createdAt, e.requestId, + u.displayName as reviewerDisplayName + FROM contribution_review_events e + LEFT JOIN users u ON u.id = e.reviewerUserId + ${whereClause} ORDER BY e.createdAt DESC LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + // internalNote requires contribution:internal-note:read per api.md §6.7 + const canReadIntNote = await requirePermission(req.user!.userId, 'contribution:internal-note:read') + + const data = (rows as Array>).map((r) => ({ + id: r.id, contributionId: r.contributionId, + reviewer: { id: r.reviewerUserId, displayName: r.reviewerDisplayName || null }, + fromStatus: r.fromStatus, toStatus: r.toStatus, + publicNote: r.publicNote || null, + internalNote: canReadIntNote ? (r.internalNote || null) : null, + createdAt: r.createdAt, requestId: r.requestId, + })) + + const nextCursor = hasMore ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') : null + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +export default router diff --git a/functions/src/routes/auth-login.ts b/functions/src/routes/auth-login.ts new file mode 100644 index 0000000..34f2f9c --- /dev/null +++ b/functions/src/routes/auth-login.ts @@ -0,0 +1,269 @@ +import { Router } from 'express' +import { queryOne, exec, getConnection } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { signJwt } from '../utils/jwt' +import { verifyPassword, hashPassword } from '../utils/password' +import { metrics } from '../utils/metrics' +import { sendSuccess, sendError, Errors, zodErrorsToDetails } from '../utils/response' +import { createSession, hmacToken, isNativeClient } from '../utils/session' +import { loginSchema } from '../utils/validation' +import { rateLimitCheck } from '../middleware/rateLimit' +import { findUserByUsername, findUserByEmail } from '../utils/users' +import { conf } from '../Config' +import { writeAuditLog } from '../utils/audit' + +const router: Router = Router() +const SESSION_CONF = conf.SESSION as Record | undefined + +// Pre-computed dummy argon2id hash for constant-time login response (api.md §1.3) +// Prevents timing-based username enumeration by always running a password verify. +// Generated with: argon2.hash('__constant_time_dummy__', { type: argon2.argon2id, memoryCost: 65536, timeCost: 3 }) +let _dummyHash: string | undefined +async function getDummyHash(): Promise { + if (!_dummyHash) { + _dummyHash = await hashPassword('__constant_time_dummy__') + } + return _dummyHash +} + +// Helper: atomic check-and-increment login failure count (api.md §1.3: 5 failures → 423) +// 使用事务 + FOR UPDATE 保证精确计数 +// 原子记录登录失败 + 锁检查 (api.md §1.3) +async function recordLoginFailureAtomic(userId: string, ip: string, identifier: string): Promise { + const conn = await getConnection() + try { + await conn.beginTransaction() + const now = Date.now() + const windowStart = Math.floor(now / 900_000) * 900_000 + + const lockoutKey = `login:lockout:${userId}:${windowStart}` + const [lockRows] = await conn.execute( + `SELECT id, count FROM rate_limits WHERE bucketKey = ? FOR UPDATE`, + [lockoutKey], + ) + if (lockRows[0]?.id) { + await conn.execute(`UPDATE rate_limits SET count = count + 1 WHERE id = ?`, [lockRows[0].id]) + } else { + await conn.execute( + `INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) VALUES (?, ?, ?, 1, ?)`, + [ulid(), lockoutKey, windowStart, now], + ) + } + + const ipIdentKey = `login:ip:ident:${ip}:${identifier}:${windowStart}` + await conn.execute( + `INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) VALUES (?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE count = count + 1`, + [ulid(), ipIdentKey, windowStart, now], + ) + + await conn.commit() + } catch { + await conn.rollback().catch(() => {}) + } finally { + conn.release() + } +} + +// Helper: clear login failure records on success +async function clearLoginFailures(userId: string): Promise { + try { + await exec(`DELETE FROM rate_limits WHERE bucketKey LIKE ?`, [`login:lockout:${userId}:%`]) + } catch { /* best-effort */ } +} + +// POST /auth/login — api.md §1.3 +router.post('/login', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const parsed = loginSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, 422, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const { identifier, password } = parsed.data + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const now = Date.now() + + // Find user by username or email per api.md §1.3: use proper email regex, not includes('@') + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier) + const user = isEmail + ? await findUserByEmail(identifier) + : await findUserByUsername(identifier) + + if (!user) { + // api.md §1.3: Constant-time response to prevent enumeration. + // Verify with dummy hash so response time matches "wrong password" path. + const dummyHash = await getDummyHash() + await verifyPassword(dummyHash, password) + + writeAuditLog(req, { + actorUserId: null, + action: 'auth.login.fail', + resourceType: 'user', + resourceId: '', + after: { reason: 'user_not_found' }, + }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, Errors.INVALID_CREDENTIALS.code, Errors.INVALID_CREDENTIALS.message, req.requestId, Errors.INVALID_CREDENTIALS.status) + metrics.authLoginTotal["invalid_credentials"] = (metrics.authLoginTotal["invalid_credentials"] || 0) + 1 + return + } + + // Check account lockout per api.md §1.3: 5 failures in 15 min → 423 + // 使用事务 + FOR UPDATE 保证原子性 + const lockoutWindowStart = Math.floor(now / 900_000) * 900_000 // 15 min windows + const lockoutBucketKey = `login:lockout:${user.id}:${lockoutWindowStart}` + { + const conn = await getConnection() + try { + await conn.beginTransaction() + const [lRows] = await conn.execute( + `SELECT count FROM rate_limits WHERE bucketKey = ? FOR UPDATE`, + [lockoutBucketKey], + ) + if ((lRows[0]?.count as number) >= 5) { + await conn.rollback() + const retryAfter = Math.ceil((lockoutWindowStart + 900_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + metrics.authLoginTotal["locked"] = (metrics.authLoginTotal["locked"] || 0) + 1 + sendError(res, 'ACCOUNT_LOCKED', '账户已锁定,请 15 分钟后重试', req.requestId, 423) + return + } + await conn.commit() + } finally { + conn.release() + } + } + + // IP×identifier rate limit per api.md §1.3: 10 failures in 15 min + const ipIdentKey = `login:ip:ident:${req.ip || 'unknown'}:${identifier}` + const ipIdentWindow = Math.floor(now / 900_000) * 900_000 + const ipIdentBucketKey = `${ipIdentKey}:${ipIdentWindow}` + const ipIdentRecord = await queryOne( + `SELECT count FROM rate_limits WHERE bucketKey = ?`, + [ipIdentBucketKey], + ) + if (ipIdentRecord && (ipIdentRecord.count as number) >= 10) { + const retryAfter = Math.ceil((ipIdentWindow + 900_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + sendError(res, Errors.RATE_LIMITED.code, `登录尝试过多,请 ${retryAfter} 秒后重试`, req.requestId, Errors.RATE_LIMITED.status) + return + } + + // Check user status + if (user.status === 'banned') { + writeAuditLog(req, { actorUserId: user.id, action: 'auth.login.fail', resourceType: 'user', resourceId: user.id, after: { reason: 'banned' } }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, 'ACCOUNT_BANNED', '账户已被封禁', req.requestId, 403) + return + } + if (user.status === 'merged') { + writeAuditLog(req, { actorUserId: user.id, action: 'auth.login.fail', resourceType: 'user', resourceId: user.id, after: { reason: 'merged' } }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, 'ACCOUNT_MERGED', '账户已被合并', req.requestId, 403) + return + } + if (user.status === 'pending_deletion') { + writeAuditLog(req, { actorUserId: user.id, action: 'auth.login.fail', resourceType: 'user', resourceId: user.id, after: { reason: 'pending_deletion' } }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, 'ACCOUNT_PENDING_DELETION', '账户正在注销,需先撤销注销', req.requestId, 403) + return + } + if (user.status === 'deleted') { + writeAuditLog(req, { actorUserId: user.id, action: 'auth.login.fail', resourceType: 'user', resourceId: user.id, after: { reason: 'deleted' } }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, Errors.ACCOUNT_DELETED.code, Errors.ACCOUNT_DELETED.message, req.requestId, Errors.ACCOUNT_DELETED.status) + return + } + + // Verify password + const userRow = await queryOne(`SELECT passwordHash FROM users WHERE id = ?`, [user.id]) + if (!userRow?.passwordHash) { + await recordLoginFailureAtomic(user.id, ip, identifier) + writeAuditLog(req, { actorUserId: user.id, action: 'auth.login.fail', resourceType: 'user', resourceId: user.id, after: { reason: 'no_password' } }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, Errors.INVALID_CREDENTIALS.code, Errors.INVALID_CREDENTIALS.message, req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + + const valid = await verifyPassword(userRow.passwordHash as string, password) + if (!valid) { + await recordLoginFailureAtomic(user.id, ip, identifier) + writeAuditLog(req, { actorUserId: user.id, action: 'auth.login.fail', resourceType: 'user', resourceId: user.id, after: { reason: 'wrong_password' } }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, Errors.INVALID_CREDENTIALS.code, Errors.INVALID_CREDENTIALS.message, req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + + // Clear lockout on successful login + await clearLoginFailures(user.id) + + // Audit log: login success + await writeAuditLog(req, { + actorUserId: user.id, + action: 'auth.login.success', + resourceType: 'user', + resourceId: user.id, + after: { method: 'password' }, + }).catch((e: unknown) => console.error('audit error:', e)) + + // Check MFA — both TOTP and Passkey per api.md §1.3 + const totpEnabled = await queryOne( + `SELECT id FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [user.id], + ) + const passkeyActive = await queryOne( + `SELECT COUNT(*) as cnt FROM passkeys WHERE userId = ? AND status = 'active'`, + [user.id], + ) + const passkeyCount = (passkeyActive?.cnt as number) || 0 + + const availableMethods: string[] = [] + if (totpEnabled) availableMethods.push('totp') + if (passkeyCount > 0) availableMethods.push('passkey') + + if (availableMethods.length > 0) { + // Generate MFA challenge + const mfaChallenge = genId('mfa_chal_') + const challengeHash = await hmacToken(mfaChallenge) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'mfa_challenge', ?, JSON_OBJECT('loginMethod', 'password'), ?, ?)`, + [ulid(), user.id, challengeHash, now + 300_000, now], + ) + metrics.authLoginTotal["mfa_required"] = (metrics.authLoginTotal["mfa_required"] || 0) + 1 + + sendSuccess(res, { + mfaRequired: true, + mfaChallengeToken: mfaChallenge, + mfaChallengeExpiresIn: 300, + availableMethods, + }, req.requestId) + return + } + + // Create session + const ua = req.headers['user-agent'] || 'unknown' + const { sessionId, refreshToken } = await createSession(user.id, user.roles, 'password', ip, ua) + + const accessToken = await signJwt({ + sub: user.id, sid: sessionId, tokenVersion: user.tokenVersion, + roles: user.roles, aud: 'transcircle-web', iss: 'https://api.transcircle.org', + }) + + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', refreshToken, { + httpOnly: true, secure: true, + sameSite: 'lax', path: '/v1/auth', maxAge: maxAge * 1000, + }) + + // api.md §1.3: 浏览器场景通过 Cookie 下发时,响应体省略 refreshToken + const nativeClient = isNativeClient(req) + const loginResp: Record = { + mfaRequired: false, + accessToken, tokenType: 'Bearer', expiresIn: 900, + user: { + id: user.id, username: user.username, + displayName: user.displayName, avatarUrl: user.avatarUrl, + emailVerified: user.emailVerified, + }, + } + metrics.authLoginTotal["success"] = (metrics.authLoginTotal["success"] || 0) + 1 + if (nativeClient) loginResp.refreshToken = refreshToken + sendSuccess(res, loginResp, req.requestId) +}) + +export default router diff --git a/functions/src/routes/auth-merge.ts b/functions/src/routes/auth-merge.ts new file mode 100644 index 0000000..588e29b --- /dev/null +++ b/functions/src/routes/auth-merge.ts @@ -0,0 +1,165 @@ +import { Router } from 'express' +import { queryOne, getConnection } from '../Database' +import { sendSuccess, sendError, Errors } from '../utils/response' +import { genId } from '../utils/ulid' +import { hmacToken } from '../utils/session' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { canonicalJson, sha256base64url } from '../utils/audit' + +const router: Router = Router() + +// POST /auth/merge — api.md §1.8 +router.post('/auth/merge', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { mergeToken, confirm } = req.body as { mergeToken?: string; confirm?: boolean } + + if (!mergeToken || confirm !== true) { + sendError(res, Errors.BAD_REQUEST.code, '请求参数错误', req.requestId, 400) + return + } + + // Check step-up + const session = await queryOne( + `SELECT lastStepUpAt FROM sessions WHERE id = ?`, + [req.user!.sessionId], + ) + if (!session?.lastStepUpAt || Date.now() - session.lastStepUpAt > 300_000) { + sendError(res, 'STEP_UP_REQUIRED', '需要二次认证', req.requestId, 403) + return + } + + const tokenHash = await hmacToken(mergeToken) + const record = await queryOne( + `SELECT id, userId, metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'merge' AND usedAt IS NULL AND expiresAt > ?`, + [tokenHash, Date.now()], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '合并令牌无效', req.requestId, 410) + return + } + + const meta = typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata + const now = Date.now() + + // Verify the current user is the primary account owner (api.md §1.8 security check) + const primaryUserId = meta.primaryUserId as string | undefined + if (!primaryUserId || req.user!.userId !== primaryUserId) { + sendError(res, Errors.UNAUTHORIZED.code, '仅主账号可发起合并', req.requestId, Errors.UNAUTHORIZED.status) + return + } + + // Count sessions before revoking (outside transaction — informational) + const conflictSessions = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND revokedAt IS NULL`, + [meta.conflictUserId], + ) + const primaryOtherSessions = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND id != ? AND revokedAt IS NULL`, + [req.user!.userId, req.user!.sessionId], + ) + + // ── Transaction: all merge operations atomically ── + const conn = await getConnection() + try { + await conn.beginTransaction() + + // Migrate resources per api.md §1.8: + // - contributions: authorUserId → primary + await conn.execute(`UPDATE contributions SET authorUserId = ? WHERE authorUserId = ?`, [req.user!.userId, meta.conflictUserId]) + // - edit_requests: requesterId → primary + await conn.execute(`UPDATE contribution_edit_requests SET requesterId = ? WHERE requesterId = ?`, [req.user!.userId, meta.conflictUserId]) + // - images: uploaderId → primary + await conn.execute(`UPDATE images SET uploaderId = ? WHERE uploaderId = ?`, [req.user!.userId, meta.conflictUserId]) + // - oauth_accounts: if provider conflict on primary, keep primary's + await conn.execute( + `DELETE oa FROM oauth_accounts oa + INNER JOIN oauth_accounts primary_oa ON primary_oa.userId = ? AND primary_oa.provider = oa.provider + WHERE oa.userId = ?`, + [req.user!.userId, meta.conflictUserId], + ) + await conn.execute(`UPDATE oauth_accounts SET userId = ? WHERE userId = ?`, [req.user!.userId, meta.conflictUserId]) + + // Freeze passkeys on merged account + await conn.execute(`UPDATE passkeys SET status = 'frozen', frozenReason = 'account_merged' WHERE userId = ? AND status = 'active'`, [meta.conflictUserId]) + + // Disable MFA TOTP on merged account + await conn.execute(`UPDATE mfa_totp_credentials SET status = 'disabled' WHERE userId = ?`, [meta.conflictUserId]) + + // Disable MFA recovery codes on merged account + await conn.execute(`UPDATE mfa_recovery_codes SET usedAt = ? WHERE userId = ? AND usedAt IS NULL`, [now, meta.conflictUserId]) + + // Mark merged account + await conn.execute(`UPDATE users SET status = 'merged', mergedIntoUserId = ?, tokenVersion = tokenVersion + 1, updatedAt = ? WHERE id = ?`, [req.user!.userId, now, meta.conflictUserId]) + + // Revoke all sessions of merged account + await conn.execute(`UPDATE sessions SET revokedAt = ?, revokedReason = 'account_merged' WHERE userId = ? AND revokedAt IS NULL`, [now, meta.conflictUserId]) + await conn.execute( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ? AND revokedAt = ?) + AND status IN ('active', 'rotated')`, + [meta.conflictUserId, now], + ) + + // Mark token as used + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [now, record.id]) + + // Revoke current user's other sessions (keep current) + await conn.execute(`UPDATE sessions SET revokedAt = ?, revokedReason = 'account_merged' WHERE userId = ? AND id != ? AND revokedAt IS NULL`, [now, req.user!.userId, req.user!.sessionId]) + await conn.execute( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ? AND id != ? AND revokedAt = ?) + AND status IN ('active', 'rotated')`, + [req.user!.userId, req.user!.sessionId, now], + ) + await conn.execute(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [req.user!.userId]) + + // Audit log inside transaction (api.md §1.8: 操作写入 audit_logs) + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const mergeAuditId = genId('aud_') + const [lastMergeAudit] = await conn.execute( + `SELECT entryHash FROM audit_logs ORDER BY createdAt DESC LIMIT 1 FOR UPDATE`, + ) + const mergePrevHash = (lastMergeAudit[0]?.entryHash as string) ?? null + const mergeAfterData = { mergedFromUserId: meta.conflictUserId, provider: meta.provider } + const mergeCanonicalAfter = canonicalJson(mergeAfterData) + const mergeRecordData = { + id: mergeAuditId, actorUserId: req.user!.userId, action: 'account.merge', + resourceType: 'user', resourceId: req.user!.userId, + before: null, after: mergeAfterData, metadata: {}, + createdAt: now, requestId: req.requestId, + ipHash: await hmacToken(ip), uaHash: await hmacToken(ua), + } + const mergeCanonicalRecord = canonicalJson(mergeRecordData) + const mergeChainInput = mergePrevHash ? `${mergePrevHash}|${mergeCanonicalRecord}` : mergeCanonicalRecord + const mergeEntryHash = await sha256base64url(mergeChainInput) + await conn.execute( + `INSERT INTO audit_logs (id, actorUserId, action, resourceType, resourceId, \`before\`, after, metadata, createdAt, requestId, ipHash, userAgentHash, prevHash, entryHash) + VALUES (?, ?, 'account.merge', 'user', ?, NULL, ?, '{}', ?, ?, ?, ?, ?, ?)`, + [mergeAuditId, req.user!.userId, req.user!.userId, + mergeCanonicalAfter, now, req.requestId, + await hmacToken(ip), await hmacToken(ua), + mergePrevHash, mergeEntryHash], + ) + + await conn.commit() + + const totalRevoked = ((conflictSessions?.cnt as number) || 0) + ((primaryOtherSessions?.cnt as number) || 0) + + sendSuccess(res, { + merged: true, + primaryUserId: req.user!.userId, + mergedFromUserId: meta.conflictUserId, + revokedSessions: totalRevoked, + }, req.requestId) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('Merge error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '合并失败,请重试', req.requestId, 500) + } finally { + conn.release() + } +}) + +export default router diff --git a/functions/src/routes/auth-oauth-bind.ts b/functions/src/routes/auth-oauth-bind.ts new file mode 100644 index 0000000..7fb922d --- /dev/null +++ b/functions/src/routes/auth-oauth-bind.ts @@ -0,0 +1,209 @@ +import { Router } from 'express' +import { query, exec, queryOne, getConnection } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { sendSuccess, sendError, Errors } from '../utils/response' +import { requireAuth } from '../middleware/auth' +import { hmacToken } from '../utils/session' +import { writeAuditLog } from '../utils/audit' +import { conf } from '../Config' + +const router: Router = Router() +const oauthConf = conf.OAUTH as Record | undefined +const API_URL = (conf.APP as Record | undefined)?.API_URL || 'https://api.transcircle.org' + +async function startBindFlow(provider: 'github' | 'x', userId: string, req: import('express').Request, res: import('express').Response): Promise { + let clientId: string | undefined + let callbackUrl: string + if (provider === 'github') { + clientId = oauthConf?.GITHUB_CLIENT_ID + callbackUrl = `${API_URL}/v1/auth/oauth/github/callback` + } else { + clientId = oauthConf?.X_CLIENT_ID + callbackUrl = `${API_URL}/v1/auth/oauth/x/callback` + } + + if (!clientId) { + sendError(res, Errors.OAUTH_PROVIDER_ERROR.code, `${provider} OAuth 未配置`, req.requestId, 503) + return + } + + const state = genId('ost_') + const stateHash = await hmacToken(state) + const stateIp = req.ip || req.socket.remoteAddress || 'unknown' + const stateUa = req.headers['user-agent'] || 'unknown' + + let authorizationUrl: string + let codeVerifier: string | undefined + if (provider === 'github') { + const githubScopes = 'read:user+user:email' + authorizationUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${githubScopes}&state=${state}` + } else { + const xScopes = encodeURIComponent('tweet.read users.read') + codeVerifier = ulid() + ulid() + const ce = new TextEncoder() + const cd = await crypto.subtle.digest('SHA-256', ce.encode(codeVerifier)) + const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(cd))) + .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') + authorizationUrl = `https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${xScopes}&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256` + } + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'oauth_state', ?, ?, ?, ?)`, + [ulid(), userId, stateHash, + JSON.stringify({ + provider, mode: 'bind', userId, + ...(codeVerifier ? { codeVerifier } : {}), + ipHash: await hmacToken(stateIp), uaHash: await hmacToken(stateUa), + }), + Date.now() + 600_000, Date.now()], + ) + + sendSuccess(res, { authorizationUrl }, req.requestId) +} + +// GET /me/oauth — api.md §1.7.3 +router.get('/me/oauth', requireAuth, async (req, res) => { + const accounts = await query( + `SELECT provider, providerUsername, providerDisplayName, providerAvatarUrl, createdAt as boundAt + FROM oauth_accounts WHERE userId = ?`, + [req.user!.userId], + ) + + sendSuccess(res, (accounts as Array>).map((a) => ({ + provider: a.provider, + providerUsername: a.providerUsername || null, + providerDisplayName: a.providerDisplayName || null, + providerAvatarUrl: a.providerAvatarUrl || null, + boundAt: a.boundAt, + })), req.requestId) +}) + +// DELETE /me/oauth/{provider} — api.md §1.7.2 +router.delete('/me/oauth/:provider', requireAuth, async (req, res) => { + const { provider } = req.params + + if (provider !== 'github' && provider !== 'x') { + sendError(res, Errors.BAD_REQUEST.code, '无效的 provider', req.requestId, 400) + return + } + + // Check step-up (5 min) + const session = await queryOne( + `SELECT lastStepUpAt FROM sessions WHERE id = ?`, + [req.user!.sessionId], + ) + if (!session?.lastStepUpAt || Date.now() - session.lastStepUpAt > 300_000) { + sendError(res, 'STEP_UP_REQUIRED', '需要二次认证', req.requestId, 403) + return + } + + const oauth = await queryOne( + `SELECT id FROM oauth_accounts WHERE userId = ? AND provider = ?`, + [req.user!.userId, provider], + ) + if (!oauth) { + sendError(res, 'OAUTH_NOT_BOUND', '该 provider 未绑定', req.requestId, 404) + return + } + + // Check there are other login methods (api.md §1.7.2) + const otherProviders = await queryOne( + `SELECT COUNT(*) as cnt FROM oauth_accounts WHERE userId = ? AND provider != ?`, + [req.user!.userId, provider], + ) + const hasPasswordAndVerified = await queryOne( + `SELECT passwordHash FROM users WHERE id = ? AND passwordHash IS NOT NULL AND emailVerified = TRUE`, + [req.user!.userId], + ) + const hasPasskey = await queryOne( + `SELECT COUNT(*) as cnt FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) + + if (!hasPasswordAndVerified && !hasPasskey && (otherProviders?.cnt as number || 0) === 0) { + sendError(res, 'LAST_LOGIN_METHOD', '解绑后将无登录方式', req.requestId, 409) + return + } + + const now = Date.now() + const conn = await getConnection() + try { + await conn.beginTransaction() + + await conn.execute(`DELETE FROM oauth_accounts WHERE userId = ? AND provider = ?`, [req.user!.userId, provider]) + + // Revoke sessions created through this OAuth provider (per api.md §1.7.2) + await conn.execute( + `UPDATE sessions SET revokedAt = ?, revokedReason = 'manual_revoke' WHERE userId = ? AND loginMethod = ? AND revokedAt IS NULL`, + [now, req.user!.userId, `oauth:${provider}`], + ) + // Revoke refresh tokens for those OAuth sessions per api.md §1.7.2 + await conn.execute( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ? AND loginMethod = ?) + AND status IN ('active', 'rotated')`, + [req.user!.userId, `oauth:${provider}`], + ) + + // Bump tokenVersion to invalidate all access tokens + await conn.execute(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [req.user!.userId]) + + await conn.commit() + + const oauthSessions = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND loginMethod = ? AND revokedAt = ?`, + [req.user!.userId, `oauth:${provider}`, now], + ) + + // 审计日志 (事务外写入,可见已提交的数据) + writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'oauth.unbind', + resourceType: 'oauth_account', + resourceId: req.user!.userId, + after: { provider, unbound: true }, + }).catch((e: unknown) => console.error('audit error:', e)) + + sendSuccess(res, { + provider, + unbound: true, + revokedSessions: (oauthSessions?.cnt as number) || 0, + }, req.requestId) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('OAuth unbind error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '解绑失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +// GET /me/oauth/github/bind/start — api.md §1.7.1 +router.get('/me/oauth/github/bind/start', requireAuth, async (req, res) => { + // Check provider not already bound + const existing = await queryOne( + `SELECT id FROM oauth_accounts WHERE userId = ? AND provider = 'github'`, + [req.user!.userId], + ) + if (existing) { + sendError(res, 'PROVIDER_ALREADY_BOUND', 'GitHub 账号已绑定', req.requestId, 409) + return + } + await startBindFlow('github', req.user!.userId, req, res) +}) + +// GET /me/oauth/x/bind/start — api.md §1.7.1 +router.get('/me/oauth/x/bind/start', requireAuth, async (req, res) => { + const existing = await queryOne( + `SELECT id FROM oauth_accounts WHERE userId = ? AND provider = 'x'`, + [req.user!.userId], + ) + if (existing) { + sendError(res, 'PROVIDER_ALREADY_BOUND', 'X 账号已绑定', req.requestId, 409) + return + } + await startBindFlow('x', req.user!.userId, req, res) +}) + +export default router diff --git a/functions/src/routes/auth-oauth-native.ts b/functions/src/routes/auth-oauth-native.ts new file mode 100644 index 0000000..09e6199 --- /dev/null +++ b/functions/src/routes/auth-oauth-native.ts @@ -0,0 +1,767 @@ +import { Router } from 'express' +import { exec, queryOne, getConnection } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { signJwt } from '../utils/jwt' +import { sendSuccess, sendError, Errors, zodErrorsToDetails } from '../utils/response' +import { hmacToken, createSession, generateHighEntropyToken } from '../utils/session' +import { hashPassword } from '../utils/password' +import { encryptOAuthPii, decryptOAuthPii } from '../utils/crypto' +import { rateLimitCheck } from '../middleware/rateLimit' +import { idempotencyKey } from '../middleware/idempotency' +import { writeAuditLog } from '../utils/audit' +import { findUserById } from '../utils/users' +import { completeRegistrationSchema } from '../utils/validation' +import { isPasswordNotLeaked } from '../utils/hibp' +import { sendEmail, buildVerificationEmail } from '../utils/mail' +import { conf } from '../Config' + +const router: Router = Router() +const oauthConf = conf.OAUTH as Record | undefined +const API_URL = (conf.APP as Record | undefined)?.API_URL || 'https://api.transcircle.org' + +// Per api.md §1.6.7: redirectUri must be validated against allowlist +const REDIRECT_URI_ALLOWLIST = [ + 'transcircle://oauth/callback', +] +// Also allow all loopback addresses (127.0.0.1:*) per OAuth native spec +function isValidRedirectUri(uri: string): boolean { + if (REDIRECT_URI_ALLOWLIST.includes(uri)) return true + if (/^http:\/\/127\.0\.0\.1(?::\d+)?\/callback$/.test(uri)) return true + return false +} + +// ─── 1.6.7 Native OAuth Start (PKCE) ──────────────────────────────── +router.post('/auth/oauth/native/start', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { provider, codeChallenge, codeChallengeMethod, redirectUri, mode } = req.body as { + provider?: string + codeChallenge?: string + codeChallengeMethod?: string + redirectUri?: string + mode?: 'login' | 'bind' + } + + if (!provider || !codeChallenge || !redirectUri || !mode) { + sendError(res, Errors.BAD_REQUEST.code, '缺少必要参数', req.requestId, 400) + return + } + if (!['github', 'x'].includes(provider)) { + sendError(res, Errors.VALIDATION_ERROR.code, 'provider 必须为 github 或 x', req.requestId, 422) + return + } + if (codeChallengeMethod !== 'S256') { + sendError(res, Errors.VALIDATION_ERROR.code, 'codeChallengeMethod 必须为 S256', req.requestId, 422) + return + } + if (!isValidRedirectUri(redirectUri)) { + sendError(res, Errors.VALIDATION_ERROR.code, 'redirectUri 不在 allowlist 中', req.requestId, 422) + return + } + if (codeChallenge.length < 43 || codeChallenge.length > 128) { + sendError(res, Errors.VALIDATION_ERROR.code, 'codeChallenge 长度必须在 43-128 字符之间', req.requestId, 422) + return + } + + // Bind mode requires auth + step-up + let bindUserId: string | undefined + if (mode === 'bind') { + const authHeader = req.headers.authorization + if (!authHeader?.startsWith('Bearer ')) { + sendError(res, Errors.UNAUTHORIZED.code, '绑定模式需要登录', req.requestId, Errors.UNAUTHORIZED.status) + return + } + const { verifyJwt } = await import('../utils/jwt') + const payload = await verifyJwt(authHeader.slice(7)) + if (!payload) { + sendError(res, Errors.UNAUTHORIZED.code, '登录已过期', req.requestId, Errors.UNAUTHORIZED.status) + return + } + // Check step-up + const session = await queryOne( + `SELECT lastStepUpAt FROM sessions WHERE id = ? AND userId = ?`, + [payload.sid, payload.sub], + ) + if (!session?.lastStepUpAt || Date.now() - session.lastStepUpAt > 300_000) { + sendError(res, 'STEP_UP_REQUIRED', '需要二次认证', req.requestId, 403) + return + } + bindUserId = payload.sub + + // Per api.md §1.6.7: audit oauth bind start + writeAuditLog(req, { + actorUserId: payload.sub, + action: 'oauth.bind.start', + resourceType: 'oauth_account', + resourceId: payload.sub, + after: { provider, mode: 'bind', channel: 'native' }, + }).catch((e: unknown) => console.error('audit error:', e)) + } + + // Get client ID + let clientId: string + let callbackUrl: string + if (provider === 'github') { + clientId = oauthConf?.GITHUB_CLIENT_ID as string + callbackUrl = `${API_URL}/v1/auth/oauth/github/callback` + } else { + clientId = oauthConf?.X_CLIENT_ID as string + callbackUrl = `${API_URL}/v1/auth/oauth/x/callback` + } + + if (!clientId) { + sendError(res, Errors.OAUTH_PROVIDER_ERROR.code, `${provider} OAuth 未配置`, req.requestId, 502) + return + } + + // Generate state and store PKCE params + const state = genId('ost_') + const stateHash = await hmacToken(state) + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'oauth_state', ?, ?, ?, ?)`, + [ulid(), bindUserId || null, stateHash, + JSON.stringify({ + provider, mode, userId: bindUserId || null, redirectUri, + codeChallenge, codeChallengeMethod, channel: 'native', + ipHash: await hmacToken(ip), uaHash: await hmacToken(ua), + }), + Date.now() + 600_000, Date.now()], + ) + + let authorizationUrl: string + if (provider === 'github') { + const githubScopes = 'read:user+user:email' + authorizationUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${githubScopes}&state=${state}` + } else { + const xScopes = encodeURIComponent('tweet.read users.read') + authorizationUrl = `https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${xScopes}&state=${state}` + } + + sendSuccess(res, { authorizationUrl, stateExpiresIn: 600 }, req.requestId) +}) + +// ─── 1.6.8 Native OAuth Exchange (PKCE) ───────────────────────────── +router.post('/auth/oauth/native/exchange', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { provider, code, state, codeVerifier } = req.body as { + provider?: string + code?: string + state?: string + codeVerifier?: string + } + + if (!provider || !code || !state || !codeVerifier) { + sendError(res, Errors.BAD_REQUEST.code, '缺少必要参数', req.requestId, 400) + return + } + + // Verify state — differentiate missing vs expired per api.md §1.6.8 + const stateHash = await hmacToken(state) + const stateRecord = await queryOne( + `SELECT id, metadata, expiresAt FROM auth_tokens WHERE tokenHash = ? AND type = 'oauth_state' AND usedAt IS NULL`, + [stateHash], + ) + + if (!stateRecord) { + sendError(res, 'BAD_STATE', 'state 无效', req.requestId, 400) + return + } + + // Expired state returns 410 TOKEN_INVALID_OR_EXPIRED per spec + if ((stateRecord.expiresAt as number) < Date.now()) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'state 已过期', req.requestId, 410) + return + } + + const meta = typeof stateRecord.metadata === 'string' ? JSON.parse(stateRecord.metadata) : stateRecord.metadata + + if (meta.channel !== 'native') { + sendError(res, 'BAD_STATE', 'state 通道不匹配', req.requestId, 400) + return + } + + // Verify PKCE code verifier + const enc = new TextEncoder() + const challengeDigest = await crypto.subtle.digest('SHA-256', enc.encode(codeVerifier)) + const computedChallenge = btoa(String.fromCharCode(...new Uint8Array(challengeDigest))) + .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') + + if (computedChallenge !== meta.codeChallenge) { + sendError(res, 'PKCE_VERIFICATION_FAILED', 'PKCE 验证失败', req.requestId, 422) + return + } + + // Mark state as used + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), stateRecord.id]) + + // Exchange code with provider (reuse existing callback logic) + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const now = Date.now() + + let providerUserId: string + let providerData: { username: string; displayName: string; avatarUrl: string | null; email: string | null; emailVerified: boolean } + + try { + if (provider === 'github') { + const tokenRes = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_id: oauthConf?.GITHUB_CLIENT_ID, + client_secret: oauthConf?.GITHUB_CLIENT_SECRET, + code, + }), + }) + const tokenData = await tokenRes.json() as { access_token?: string; error?: string } + if (!tokenData.access_token) { + sendError(res, 'OAUTH_ERROR', 'OAuth 授权失败', req.requestId, 400) + return + } + + const userRes = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${tokenData.access_token}`, 'User-Agent': 'TransCircle' }, + }) + const ghUser = await userRes.json() as { id: number; login: string; name: string | null; avatar_url: string | null; email: string | null } + providerUserId = String(ghUser.id) + providerData = { + username: ghUser.login, + displayName: ghUser.name || ghUser.login, + avatarUrl: ghUser.avatar_url, + email: ghUser.email || null, + emailVerified: !!ghUser.email, + } + } else { + const tokenRes = await fetch('https://api.twitter.com/2/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa(`${oauthConf?.X_CLIENT_ID}:${oauthConf?.X_CLIENT_SECRET}`)}`, + }, + body: new URLSearchParams({ + code, grant_type: 'authorization_code', + redirect_uri: `${API_URL}/v1/auth/oauth/x/callback`, + code_verifier: codeVerifier, + }), + }) + const tokenData = await tokenRes.json() as { access_token?: string; error?: string } + if (!tokenData.access_token) { + sendError(res, 'OAUTH_ERROR', 'OAuth 授权失败', req.requestId, 400) + return + } + + const userRes = await fetch('https://api.twitter.com/2/users/me', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }) + const xData = await userRes.json() as { data?: { id: string; username: string; name: string } } + if (!xData.data) { + sendError(res, 'OAUTH_ERROR', '获取用户信息失败', req.requestId, 400) + return + } + providerUserId = xData.data.id + providerData = { + username: xData.data.username, + displayName: xData.data.name || xData.data.username, + avatarUrl: null, + email: null, + emailVerified: false, + } + } + } catch { + sendError(res, Errors.OAUTH_PROVIDER_ERROR.code, 'OAuth 提供商返回错误', req.requestId, 502) + return + } + + // Look up existing OAuth binding + const existing = await queryOne( + `SELECT oa.userId, u.status FROM oauth_accounts oa JOIN users u ON u.id = oa.userId + WHERE oa.provider = ? AND oa.providerUserId = ?`, + [provider, providerUserId], + ) + + if (existing) { + // Already bound to local account + if (meta.mode === 'bind' && meta.userId) { + if (existing.userId === meta.userId) { + sendSuccess(res, { status: 'bind_already_self', provider }, req.requestId) + return + } else { + // Merge conflict + const mergeToken = generateHighEntropyToken() + const mergeHash = await hmacToken(mergeToken) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'merge', ?, ?, ?, ?)`, + [ulid(), existing.userId, mergeHash, + JSON.stringify({ primaryUserId: meta.userId, conflictUserId: existing.userId, provider }), + now + 600_000, now], + ) + sendSuccess(res, { + status: 'bind_conflict_merge', + mergeToken, + conflictUserId: existing.userId, + expiresIn: 600, + }, req.requestId) + return + } + } + + // Login mode + switch (existing.status) { + case 'active': + case 'pending_verification': { + const user = await findUserById(existing.userId) + if (!user) { + sendError(res, Errors.INTERNAL_ERROR.code, '用户查询失败', req.requestId, 500) + return + } + // Audit login success per api.md §1.6.8 + writeAuditLog(req, { + actorUserId: existing.userId, + action: 'auth.login.success', + resourceType: 'user', + resourceId: existing.userId, + after: { method: `oauth:${provider}` }, + }).catch((e: unknown) => console.error('audit error:', e)) + + const { sessionId, refreshToken } = await createSession( + existing.userId, user.roles, `oauth:${provider}`, ip, ua, + ) + const accessToken = await signJwt({ + sub: existing.userId, sid: sessionId, tokenVersion: user.tokenVersion, + roles: user.roles, aud: 'transcircle-web', iss: 'https://api.transcircle.org', + }) + sendSuccess(res, { + status: 'login_ok', + accessToken, tokenType: 'Bearer', expiresIn: 900, + refreshToken, + user: { + id: user.id, username: user.username, + displayName: user.displayName, avatarUrl: user.avatarUrl, + emailVerified: user.emailVerified, + }, + }, req.requestId) + return + } + case 'banned': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'banned' } }).catch((e: unknown) => console.error('audit error:', e)) + sendSuccess(res, { status: 'login_blocked', code: 'ACCOUNT_BANNED' }, req.requestId) + return + case 'merged': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'merged' } }).catch((e: unknown) => console.error('audit error:', e)) + sendSuccess(res, { status: 'login_blocked', code: 'ACCOUNT_MERGED' }, req.requestId) + return + case 'pending_deletion': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'pending_deletion' } }).catch((e: unknown) => console.error('audit error:', e)) + sendSuccess(res, { status: 'login_blocked', code: 'ACCOUNT_PENDING_DELETION' }, req.requestId) + return + case 'deleted': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'deleted' } }).catch((e: unknown) => console.error('audit error:', e)) + sendSuccess(res, { status: 'login_blocked', code: 'ACCOUNT_DELETED' }, req.requestId) + return + default: + sendSuccess(res, { status: 'oauth_error' }, req.requestId) + return + } + } + + // OAuth not bound — generate pendingHandle + const pendingHandle = genId('opt_') + const pendingHash = await hmacToken(pendingHandle) + const csrfToken = genId('ocs_') + + const pendingMode = meta.mode === 'bind' ? 'binding' : 'registration' + const pendingUserId = meta.mode === 'bind' ? (meta.userId || null) : null + + // Encrypt PII per api.md §1.6.2 + const nativePii = await encryptOAuthPii({ + providerUsername: providerData.username, + providerDisplayName: providerData.displayName, + providerAvatarUrl: providerData.avatarUrl, + providerEmail: providerData.email, + providerEmailVerified: providerData.emailVerified, + csrfToken, + }) + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'oauth_pending_registration', ?, ?, ?, ?)`, + [ulid(), pendingUserId, pendingHash, + JSON.stringify({ + provider, providerUserId, mode: pendingMode, userId: pendingUserId, + pii: nativePii, + requestIpHash: await hmacToken(ip), + userAgentHash: await hmacToken(ua), + channel: 'native', + }), + now + 600_000, now], + ) + + sendSuccess(res, { + status: pendingMode === 'binding' ? 'pending_binding' : 'pending_registration', + pendingHandle, + csrfToken, + expiresIn: 600, + provider, + suggestedEmail: providerData.email || null, + suggestedDisplayName: providerData.displayName || null, + providerEmailVerified: providerData.emailVerified, + }, req.requestId) +}) + +// ─── 1.6.9 Native OAuth Complete Registration ────────────────────── +router.post('/auth/oauth/native/complete-registration', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, idempotencyKey, async (req, res) => { + const { pendingHandle, csrfToken: bodyCsrf, provider, username, password, displayName, emailMatchesProvider } = req.body as { + pendingHandle?: string; csrfToken?: string; provider?: string + username?: string; email?: string; password?: string + displayName?: string; emailMatchesProvider?: boolean + } + // Normalize email: lowercase per api.md §1.1 + const email = (req.body as Record)?.email as string | undefined + + if (!pendingHandle || !bodyCsrf || !provider || !username || !email || !password || !displayName) { + sendError(res, Errors.BAD_REQUEST.code, '缺少必要参数', req.requestId, 400) + return + } + + const normalizedEmail = email.toLowerCase() + + // Validate fields + const parsed = completeRegistrationSchema.safeParse({ username, email: normalizedEmail, password, displayName, emailMatchesProvider }) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '字段校验失败', req.requestId, 422, zodErrorsToDetails(parsed.error.flatten())) + return + } + + // api.md §1.1: HIBP k-Anonymity leak check + if (!await isPasswordNotLeaked(password)) { + sendError(res, Errors.VALIDATION_ERROR.code, '该密码已在公开泄露中出现,请更换密码', req.requestId, 422) + return + } + + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const now = Date.now() + + // Look up pending handle + const handleHash = await hmacToken(pendingHandle) + const record = await queryOne( + `SELECT id, userId, metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'oauth_pending_registration' AND usedAt IS NULL AND expiresAt > ?`, + [handleHash, now], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'pendingHandle 无效或已过期', req.requestId, 410) + return + } + + const meta = typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata + + // Decrypt PII (api.md §1.6.2) + if (meta.pii) { + try { + const pii = await decryptOAuthPii(meta.pii) + Object.assign(meta, pii) + } catch { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '加密数据损坏', req.requestId, 410) + return + } + } + + if (meta.channel !== 'native') { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'pendingHandle 通道不匹配', req.requestId, 410) + return + } + + if (meta.csrfToken !== bodyCsrf) { + sendError(res, 'CSRF_TOKEN_INVALID', 'CSRF 令牌无效', req.requestId, 403) + return + } + + if (meta.provider !== provider) { + sendError(res, Errors.BAD_REQUEST.code, 'provider 不匹配', req.requestId, 400) + return + } + + // Transaction + const { getConnection } = await import('../Database') + const conn = await getConnection() + + try { + await conn.beginTransaction() + + // Mark pending token used — check usedAt IS NULL to prevent concurrent consumption (api.md §1.6.9) + const [lockRows] = await conn.execute( + `SELECT id FROM auth_tokens WHERE id = ? AND usedAt IS NULL FOR UPDATE`, [record.id], + ) + if ((lockRows as unknown[]).length === 0) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'pendingHandle 已被使用', req.requestId, 410) + return + } + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ? AND usedAt IS NULL`, [now, record.id]) + + // Check uniqueness + const [uRows] = await conn.execute( + `SELECT id FROM users WHERE username = ?`, [username], + ) + if ((uRows as unknown[]).length > 0) { + await conn.rollback() + sendError(res, Errors.USERNAME_TAKEN.code, Errors.USERNAME_TAKEN.message, req.requestId, Errors.USERNAME_TAKEN.status) + return + } + + const [eRows] = await conn.execute( + `SELECT id FROM users WHERE email = ?`, [normalizedEmail], + ) + if ((eRows as unknown[]).length > 0) { + await conn.rollback() + sendError(res, Errors.EMAIL_TAKEN.code, '该邮箱已被注册', req.requestId, 409, undefined, { nextAction: 'password_forgot' }) + return + } + + const pwdHash = await hashPassword(password) + const userId = genId('usr_') + const emailVerified = !!(emailMatchesProvider && meta.providerEmailVerified) + + const emailVerifiedSource = emailVerified ? `oauth:${meta.provider}` : null + await conn.execute( + `INSERT INTO users (id, username, email, emailVerified, emailVerifiedSource, displayName, avatarUrl, passwordHash, passwordUpdatedAt, passwordHistory, status, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, username, normalizedEmail, emailVerified, emailVerifiedSource, displayName, meta.providerAvatarUrl || null, pwdHash, now, JSON.stringify([pwdHash]), + emailVerified ? 'active' : 'pending_verification', now, now], + ) + + await conn.execute( + `INSERT INTO oauth_accounts (id, userId, provider, providerUserId, providerUsername, providerDisplayName, providerAvatarUrl, providerEmail, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [genId('oauth_'), userId, meta.provider, meta.providerUserId, meta.providerUsername || null, meta.providerDisplayName || null, meta.providerAvatarUrl || null, meta.providerEmail || null, now, now], + ) + + const user = await findUserById(userId) + if (!user) { + await conn.rollback() + sendError(res, Errors.INTERNAL_ERROR.code, '用户创建失败', req.requestId, 500) + return + } + + const { sessionId, refreshToken } = await createSession(user.id, user.roles, `oauth:${provider}`, ip, ua) + + const accessToken = await signJwt({ + sub: user.id, sid: sessionId, tokenVersion: user.tokenVersion, + roles: user.roles, aud: 'transcircle-web', iss: 'https://api.transcircle.org', + }) + + await writeAuditLog(req, { + actorUserId: userId, action: 'auth.register', resourceType: 'user', resourceId: userId, + after: { username, email: normalizedEmail, provider }, + }) + await writeAuditLog(req, { + actorUserId: userId, action: 'oauth.bind', resourceType: 'oauth_account', resourceId: userId, + after: { provider, bound: true }, + }) + + await conn.commit() + + // Send verification email if needed per api.md §1.6.9 + let verificationEmailSent = false + if (!emailVerified && normalizedEmail) { + const rawVerifyToken = genId('ev_') + const verifyHash = await hmacToken(rawVerifyToken) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'email_verify', ?, '{}', ?, ?)`, + [ulid(), userId, verifyHash, Date.now() + 86400_000, Date.now()], + ).catch((e: unknown) => + console.error('verify token insert error:', (e as Error).message || e), + ) + verificationEmailSent = await sendEmail(buildVerificationEmail(normalizedEmail, rawVerifyToken)) + } + + sendSuccess(res, { + user: { + id: user.id, username, email: normalizedEmail, + displayName: user.displayName, avatarUrl: user.avatarUrl, + emailVerified, status: emailVerified ? 'active' : 'pending_verification', + createdAt: user.createdAt, + }, + boundProvider: provider, + accessToken, tokenType: 'Bearer', expiresIn: 900, + refreshToken, + verificationEmailSent, + }, req.requestId, 201) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('native complete-registration error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '注册失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +// ─── 1.6.10 Native OAuth Complete Binding ─────────────────────────── +router.post('/auth/oauth/native/complete-binding', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const authHeader = req.headers.authorization + if (!authHeader?.startsWith('Bearer ')) { + sendError(res, Errors.UNAUTHORIZED.code, Errors.UNAUTHORIZED.message, req.requestId, Errors.UNAUTHORIZED.status) + return + } + + const { verifyJwt } = await import('../utils/jwt') + const payload = await verifyJwt(authHeader.slice(7)) + if (!payload) { + sendError(res, Errors.UNAUTHORIZED.code, '登录已过期', req.requestId, Errors.UNAUTHORIZED.status) + return + } + + // Check user active + const user = await findUserById(payload.sub) + if (!user || user.status !== 'active') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + // Step-up + const session = await queryOne( + `SELECT lastStepUpAt FROM sessions WHERE id = ? AND userId = ?`, + [payload.sid, payload.sub], + ) + if (!session?.lastStepUpAt || Date.now() - session.lastStepUpAt > 300_000) { + sendError(res, 'STEP_UP_REQUIRED', '需要二次认证', req.requestId, 403) + return + } + + const { pendingHandle, csrfToken: bodyCsrf, provider } = req.body as { + pendingHandle?: string; csrfToken?: string; provider?: string + } + + if (!pendingHandle || !bodyCsrf || !provider) { + sendError(res, Errors.BAD_REQUEST.code, '缺少必要参数', req.requestId, 400) + return + } + + const handleHash = await hmacToken(pendingHandle) + const record = await queryOne( + `SELECT id, metadata FROM auth_tokens + WHERE tokenHash = ? AND type = 'oauth_pending_registration' AND usedAt IS NULL AND expiresAt > ?`, + [handleHash, Date.now()], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'pendingHandle 无效或已过期', req.requestId, 410) + return + } + + const meta = typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata + + // Decrypt PII (api.md §1.6.2) + if (meta.pii) { + try { + const pii = await decryptOAuthPii(meta.pii) + Object.assign(meta, pii) + } catch { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '加密数据损坏', req.requestId, 410) + return + } + } + + if (meta.channel !== 'native') { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '通道不匹配', req.requestId, 410) + return + } + + if (meta.csrfToken !== bodyCsrf) { + sendError(res, 'CSRF_TOKEN_INVALID', 'CSRF 令牌无效', req.requestId, 403) + return + } + + if (meta.provider !== provider) { + sendError(res, Errors.BAD_REQUEST.code, 'provider 不匹配', req.requestId, 400) + return + } + + // Check provider not already bound + const existingBind = await queryOne( + `SELECT id FROM oauth_accounts WHERE userId = ? AND provider = ?`, + [payload.sub, provider], + ) + if (existingBind) { + sendError(res, Errors.PROVIDER_ALREADY_BOUND.code, Errors.PROVIDER_ALREADY_BOUND.message, req.requestId, Errors.PROVIDER_ALREADY_BOUND.status) + return + } + + // ── 事务: 原子标记 used + 写入 oauth_accounts ── + const conn = await getConnection() + try { + await conn.beginTransaction() + + // FOR UPDATE 锁定 pending token 防止并发消费 (api.md §1.6.10) + const [lockRows] = await conn.execute( + `SELECT id FROM auth_tokens + WHERE id = ? AND usedAt IS NULL + FOR UPDATE`, + [record.id], + ) + if ((lockRows as unknown[]).length === 0) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'pendingHandle 已被使用', req.requestId, 410) + return + } + + // 消费 pending token + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ? AND usedAt IS NULL`, [Date.now(), record.id]) + + // FOR UPDATE 检查 OAuth 身份是否已被其他账号绑定 + const [lockOa] = await conn.execute( + `SELECT id FROM oauth_accounts WHERE provider = ? AND providerUserId = ? FOR UPDATE`, + [meta.provider, meta.providerUserId], + ) + if ((lockOa as unknown[]).length > 0) { + await conn.rollback() + sendError(res, Errors.OAUTH_ALREADY_LINKED.code, '该 OAuth 身份已被绑定', req.requestId, 409) + return + } + + // 写入 oauth_accounts (UNIQUE 约束做最终安全网) + try { + await conn.execute( + `INSERT INTO oauth_accounts (id, userId, provider, providerUserId, providerUsername, providerDisplayName, providerAvatarUrl, providerEmail, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [genId('oauth_'), payload.sub, meta.provider, meta.providerUserId, meta.providerUsername || null, meta.providerDisplayName || null, meta.providerAvatarUrl || null, meta.providerEmail || null, Date.now(), Date.now()], + ) + } catch (insertErr: unknown) { + const mysqlErr = insertErr as { code?: string } + if (mysqlErr.code === 'ER_DUP_ENTRY') { + await conn.rollback() + sendError(res, Errors.OAUTH_ALREADY_LINKED.code, '该 OAuth 身份已被绑定', req.requestId, 409) + return + } + throw insertErr + } + + // 事务内写审计日志 + await writeAuditLog(req, { + actorUserId: payload.sub, action: 'oauth.bind', resourceType: 'oauth_account', + resourceId: payload.sub, + after: { provider, channel: 'native' }, + }) + + await conn.commit() + + sendSuccess(res, { + userId: payload.sub, + boundProvider: meta.provider, + providerUsername: meta.providerUsername || null, + boundAt: Date.now(), + }, req.requestId) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('native complete-binding error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '绑定失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +export default router diff --git a/functions/src/routes/auth-passkey.ts b/functions/src/routes/auth-passkey.ts new file mode 100644 index 0000000..feb4fe3 --- /dev/null +++ b/functions/src/routes/auth-passkey.ts @@ -0,0 +1,633 @@ +import { Router } from 'express' +import { exec, query, queryOne, getConnection } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { signJwt } from '../utils/jwt' +import { sendSuccess, sendError, Errors, sendNoContent } from '../utils/response' +import { hmacToken, createSession, isNativeClient } from '../utils/session' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { writeAuditLog } from '../utils/audit' +import { conf } from '../Config' + +const router: Router = Router() +const SESSION_CONF = conf.SESSION as Record | undefined +const RP_ID = 'transcircle.org' +const RP_NAME = 'TransCircle' +const ORIGIN = (conf.APP as Record | undefined)?.FRONTEND_URL || 'https://submit.transcircle.org' + +// ─── 1.10.1 Start Passkey Registration ──────────────────────────────── +router.post('/me/passkeys/register/start', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const user = await queryOne(`SELECT status FROM users WHERE id = ?`, [req.user!.userId]) + if (!user || user.status !== 'active') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证,不能注册 Passkey', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + const existingCreds = await query( + `SELECT credentialIdB64 FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) as unknown as Array<{ credentialIdB64: string }> + + const excludeCredentials = existingCreds.map((c) => ({ + type: 'public-key' as const, + id: c.credentialIdB64, + })) + + const { generateRegistrationOptions } = await import('@simplewebauthn/server') + const userEntity = await queryOne(`SELECT id, username, email, displayName FROM users WHERE id = ?`, [req.user!.userId]) + if (!userEntity) { + sendError(res, Errors.INTERNAL_ERROR.code, '用户不存在', req.requestId, 500) + return + } + + const registrationId = genId('pk_reg_') + const registrationHash = await hmacToken(registrationId) + + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: RP_ID, + userName: userEntity.email || userEntity.username, + userDisplayName: userEntity.displayName, + // Use a stable user ID encoding + userID: new TextEncoder().encode(userEntity.id), + attestationType: 'none', + excludeCredentials, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'required', + }, + supportedAlgorithmIDs: [-8, -7, -257] as const, + timeout: 60000, + }) + + // Store challenge + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'passkey_register', ?, ?, ?, ?)`, + [ulid(), req.user!.userId, registrationHash, + JSON.stringify({ challenge: options.challenge, userId: req.user!.userId }), + Date.now() + 600_000, Date.now()], + ) + + sendSuccess(res, { + registrationId, + publicKey: { + challenge: options.challenge, + rp: options.rp, + user: { + id: options.user.id, + name: options.user.name, + displayName: options.user.displayName, + }, + pubKeyCredParams: options.pubKeyCredParams, + timeout: options.timeout, + attestation: options.attestation, + authenticatorSelection: options.authenticatorSelection, + excludeCredentials: options.excludeCredentials, + }, + expiresIn: 600, + }, req.requestId) +}) + +// ─── 1.10.2 Finish Passkey Registration ────────────────────────────── +router.post('/me/passkeys/register/finish', requireAuth, async (req, res) => { + const { registrationId, name, credential } = req.body as { + registrationId?: string + name?: string + credential?: { + id: string + rawId: string + type: string + response: { + clientDataJSON: string + attestationObject: string + transports?: string[] + } + clientExtensionResults?: Record + } + } + + if (!registrationId || !name || !credential) { + sendError(res, Errors.BAD_REQUEST.code, '缺少必要参数', req.requestId, 400) + return + } + + if ([...name].length < 1 || [...name].length > 50) { + sendError(res, Errors.VALIDATION_ERROR.code, '名称需 1-50 个字符', req.requestId, 422) + return + } + + // Check uniqueness of name within account + const existingName = await queryOne( + `SELECT id FROM passkeys WHERE userId = ? AND name = ?`, + [req.user!.userId, name], + ) + if (existingName) { + sendError(res, 'PASSKEY_NAME_TAKEN', '该名称已被使用', req.requestId, 409) + return + } + + // Look up registration challenge + const regHash = await hmacToken(registrationId) + const record = await queryOne( + `SELECT id, metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'passkey_register' AND usedAt IS NULL AND expiresAt > ?`, + [regHash, Date.now()], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '注册令牌无效或已过期', req.requestId, 410) + return + } + + const meta = typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata + + const { verifyRegistrationResponse } = await import('@simplewebauthn/server') + const verification = await verifyRegistrationResponse({ + response: credential as Parameters[0]['response'], + expectedChallenge: meta.challenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + requireUserVerification: true, + }) + + if (!verification.verified || !verification.registrationInfo) { + sendError(res, 'PASSKEY_VERIFICATION_FAILED', 'Passkey 验证失败', req.requestId, 422) + return + } + + const regInfo = verification.registrationInfo + const regCred = regInfo.credential + const { aaguid, credentialDeviceType, credentialBackedUp } = regInfo + + // Check duplicate credentialId + const credIdB64 = credential.id + const existingCred = await queryOne( + `SELECT id FROM passkeys WHERE credentialIdB64 = ?`, + [credIdB64], + ) + if (existingCred) { + sendError(res, 'PASSKEY_ALREADY_REGISTERED', '该 Passkey 已注册', req.requestId, 409) + return + } + + // Store credential + const pkId = genId('pk_') + const signCountSupported = regCred.counter > 0 + + // credentialPublicKey needs conversion from Uint8Array to compatible format + const pkHex = Buffer.from(regCred.publicKey).toString('hex') + + await exec( + `INSERT INTO passkeys (id, userId, name, credentialId, credentialIdB64, publicKey, signCount, transports, aaguid, attestationFormat, backupEligible, backupState, signCountSupported, status, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`, + [pkId, req.user!.userId, name.trim(), Buffer.from(regCred.id), credIdB64, Buffer.from(pkHex, 'hex'), + regCred.counter, JSON.stringify(regCred.transports || []), aaguid || null, 'none', credentialBackedUp, + credentialDeviceType === 'singleDevice', signCountSupported, Date.now()], + ) + + // Mark challenge used + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), record.id]) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'passkey.create', + resourceType: 'passkey', + resourceId: pkId, + after: { name }, + }) + + sendSuccess(res, { + id: pkId, + name: name.trim(), + credentialId: credIdB64, + createdAt: Date.now(), + }, req.requestId, 201) +}) + +// ─── 1.10.3 List Passkeys ───────────────────────────────────────────── +router.get('/me/passkeys', requireAuth, async (req, res) => { + const rows = await query( + `SELECT id, name, credentialIdB64, transports, status, frozenReason, signCountSupported, createdAt, lastUsedAt + FROM passkeys WHERE userId = ? + ORDER BY createdAt DESC`, + [req.user!.userId], + ) as unknown as Array<{ + id: string; name: string; credentialIdB64: string; transports: string + status: string; frozenReason: string | null; signCountSupported: boolean + createdAt: number; lastUsedAt: number | null + }> + + sendSuccess(res, rows.map((r) => ({ + id: r.id, + name: r.name, + credentialId: r.credentialIdB64, + transports: (() => { try { return JSON.parse(r.transports) as string[] } catch { return [] } })(), + status: r.status, + frozenReason: r.frozenReason || null, + signCountSupported: !!r.signCountSupported, + createdAt: r.createdAt, + lastUsedAt: r.lastUsedAt || null, + })), req.requestId) +}) + +// ─── 1.10.4 Delete Passkey ──────────────────────────────────────────── +router.delete('/me/passkeys/:id', requireAuth, async (req, res) => { + const { id } = req.params + + const pk = await queryOne( + `SELECT id FROM passkeys WHERE id = ? AND userId = ?`, + [id, req.user!.userId], + ) + + if (!pk) { + sendError(res, 'PASSKEY_NOT_FOUND', 'Passkey 不存在', req.requestId, 404) + return + } + + // Check if this is the only login method (api.md §1.10.4) + const hasPassword = await queryOne( + `SELECT passwordHash FROM users WHERE id = ? AND passwordHash IS NOT NULL`, + [req.user!.userId], + ) + const otherPasskeys = await queryOne( + `SELECT COUNT(*) as cnt FROM passkeys WHERE userId = ? AND id != ? AND status = 'active'`, + [req.user!.userId, id], + ) + const oauthCount = await queryOne( + `SELECT COUNT(*) as cnt FROM oauth_accounts WHERE userId = ?`, + [req.user!.userId], + ) + + if (!hasPassword && (otherPasskeys?.cnt as number || 0) === 0 && (oauthCount?.cnt as number || 0) === 0) { + sendError(res, 'LAST_LOGIN_METHOD', '删除后将无任何可用登录方式', req.requestId, 409) + return + } + + await exec(`DELETE FROM passkeys WHERE id = ? AND userId = ?`, [id, req.user!.userId]) + + // Audit log per api.md §15.13 + writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'passkey.delete', + resourceType: 'passkey', + resourceId: id as string, + after: { deletedAt: Date.now() }, + }).catch((e: unknown) => console.error('audit error:', e)) + + sendNoContent(res, req.requestId) +}) + +// ─── 1.10.5 Passkey Login Start ────────────────────────────────────── +router.post('/auth/passkey/login/start', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const identifier = req.body?.identifier as string | undefined + + let userId: string | undefined + + if (identifier) { + const user = identifier.includes('@') + ? await queryOne(`SELECT id FROM users WHERE email = ?`, [identifier]) + : await queryOne(`SELECT id FROM users WHERE username = ?`, [identifier]) + if (user) userId = user.id + } + + const challengeId = genId('pk_login_') + const challengeHash = await hmacToken(challengeId) + const now = Date.now() + + if (userId) { + // User found — return specific allowCredentials + const creds = await query( + `SELECT credentialIdB64, transports FROM passkeys WHERE userId = ? AND status = 'active'`, + [userId], + ) as unknown as Array<{ credentialIdB64: string; transports: string }> + + const { generateAuthenticationOptions } = await import('@simplewebauthn/server') + const parseTransports = (t: string): string[] => { try { return JSON.parse(t) as string[] } catch { return [] } } + const options = await generateAuthenticationOptions({ + rpID: RP_ID, + userVerification: 'required', + allowCredentials: creds.map((c) => ({ + id: c.credentialIdB64, + transports: parseTransports(c.transports), + })) as Parameters[0]['allowCredentials'], + timeout: 60000, + }) + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'passkey_login', ?, ?, ?, ?)`, + [ulid(), userId, challengeHash, + JSON.stringify({ challenge: options.challenge, userId }), now + 600_000, now], + ) + + sendSuccess(res, { + challengeId, + publicKey: { + challenge: options.challenge, + rpId: RP_ID, + timeout: options.timeout, + userVerification: 'required', + allowCredentials: creds.map((c) => ({ + type: 'public-key' as const, + id: c.credentialIdB64, + transports: parseTransports(c.transports), + })), + }, + expiresIn: 300, + }, req.requestId) + } else { + // Username-less login — generate generic challenge + const { generateAuthenticationOptions } = await import('@simplewebauthn/server') + const options = await generateAuthenticationOptions({ + rpID: RP_ID, + userVerification: 'required', + timeout: 60000, + }) + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, NULL, 'passkey_login', ?, ?, ?, ?)`, + [ulid(), challengeHash, + JSON.stringify({ challenge: options.challenge }), now + 600_000, now], + ) + + sendSuccess(res, { + challengeId, + publicKey: { + challenge: options.challenge, + rpId: RP_ID, + timeout: options.timeout, + userVerification: 'required', + }, + expiresIn: 300, + }, req.requestId) + } +}) + +// ─── 1.10.5 Passkey Login Finish (also supports MFA via mfaChallengeToken) ── +router.post('/auth/passkey/login/finish', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { challengeId, credential, mfaChallengeToken } = req.body as { + challengeId?: string + credential?: { + id: string + rawId: string + type: string + response: { + clientDataJSON: string + authenticatorData: string + signature: string + userHandle?: string + } + clientExtensionResults?: Record + } + /** MFA flow: passkey used as second factor — validate MFA challenge */ + mfaChallengeToken?: string + } + + if (!challengeId || !credential) { + sendError(res, Errors.BAD_REQUEST.code, '缺少必要参数', req.requestId, 400) + return + } + + // Validate MFA challenge if provided (api.md §1.9.4) — read-only, do NOT consume yet + let mfaChalRecord: { id: string; userId: string } | null = null + if (mfaChallengeToken) { + const chalHash = await hmacToken(mfaChallengeToken) + const chalRecord = await queryOne( + `SELECT id, userId, failedAttempts FROM auth_tokens WHERE tokenHash = ? AND type = 'mfa_challenge' AND usedAt IS NULL AND expiresAt > ?`, + [chalHash, Date.now()], + ) as { id: string; userId: string; failedAttempts?: number } | null + if (!chalRecord) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'MFA 挑战令牌无效或已过期', req.requestId, 410) + return + } + if ((chalRecord.failedAttempts as number || 0) >= 5) { + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), chalRecord.id]) + sendError(res, 'MFA_CHALLENGE_EXHAUSTED', 'MFA 验证尝试次数过多', req.requestId, 429) + return + } + mfaChalRecord = chalRecord + } + + const challengeHash = await hmacToken(challengeId) + const record = await queryOne( + `SELECT id, userId, metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'passkey_login' AND usedAt IS NULL AND expiresAt > ?`, + [challengeHash, Date.now()], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '挑战令牌无效或已过期', req.requestId, 410) + return + } + + const meta = typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata + + // ── 单个事务: 原子凭证查找 + signCount 校验 + 更新 + challenge 消费 ── + // Variables to carry data from the transaction to session creation + let storedCredUserId: string + let storedCredId: string + { + const conn = await getConnection() + let connReleased = false + try { + await conn.beginTransaction() + + const [lockRows] = await conn.execute( + `SELECT id, userId, credentialId, publicKey, signCount, signCountSupported, status + FROM passkeys WHERE credentialIdB64 = ? FOR UPDATE`, + [credential.id], + ) + const row = lockRows[0] as Record | undefined + if (!row) { + await conn.rollback() + sendError(res, 'INVALID_CREDENTIALS', '凭证未注册', req.requestId, 401) + return + } + + if (row.status === 'frozen') { + await conn.rollback() + sendError(res, 'PASSKEY_FROZEN', 'Passkey 已被冻结', req.requestId, 403) + return + } + + const storedCred = row as unknown as { + id: string; userId: string; credentialId: string; publicKey: string + signCount: number; signCountSupported: boolean; status: string + } + const passkeyUserId = storedCred!.userId + storedCredUserId = storedCred!.userId + storedCredId = storedCred!.id + + // Check user status within transaction + const [uRows] = await conn.execute( + `SELECT id, status FROM users WHERE id = ? FOR UPDATE`, + [passkeyUserId], + ) + const loginUser = uRows[0] as { id: string; status: string } | undefined + if (!loginUser) { + await conn.rollback() + sendError(res, 'INVALID_CREDENTIALS', '用户不存在', req.requestId, 401) + return + } + if (loginUser.status === 'banned') { + await conn.rollback() + sendError(res, Errors.ACCOUNT_BANNED.code, Errors.ACCOUNT_BANNED.message, req.requestId, Errors.ACCOUNT_BANNED.status) + return + } + if (loginUser.status === 'merged') { + await conn.rollback() + sendError(res, Errors.ACCOUNT_MERGED.code, Errors.ACCOUNT_MERGED.message, req.requestId, Errors.ACCOUNT_MERGED.status) + return + } + if (loginUser.status === 'pending_deletion' || loginUser.status === 'deleted') { + await conn.rollback() + sendError(res, 'INVALID_CREDENTIALS', '账户状态异常', req.requestId, 401) + return + } + + // Verify passkey (pure computation, no DB I/O) + const pkHex = storedCred!.publicKey as string + let credentialPublicKey: Uint8Array + try { + credentialPublicKey = new Uint8Array(Buffer.from(pkHex, 'hex')) + } catch { + await conn.rollback() + sendError(res, Errors.INTERNAL_ERROR.code, '凭证数据异常', req.requestId, 500) + return + } + + const { verifyAuthenticationResponse } = await import('@simplewebauthn/server') + let verification + try { + verification = await verifyAuthenticationResponse({ + response: { + id: credential.id, + rawId: credential.rawId, + type: credential.type as 'public-key', + response: { + clientDataJSON: credential.response.clientDataJSON, + authenticatorData: credential.response.authenticatorData, + signature: credential.response.signature, + userHandle: credential.response.userHandle, + }, + clientExtensionResults: credential.clientExtensionResults || {}, + }, + expectedChallenge: meta.challenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + credential: { + id: storedCred!.id as string, + publicKey: credentialPublicKey, + counter: (storedCred!.signCount as number) || 0, + }, + requireUserVerification: true, + }) + } catch { + await conn.rollback() + sendError(res, 'PASSKEY_VERIFICATION_FAILED', '验证失败', req.requestId, 422) + return + } + + if (!verification.verified) { + await conn.rollback() + sendError(res, 'PASSKEY_VERIFICATION_FAILED', '签名验证失败', req.requestId, 422) + return + } + + const { authenticationInfo } = verification + const currentSignCount = storedCred!.signCount as number + const sigCountSupported = storedCred!.signCountSupported as boolean + const newSignCount = authenticationInfo.newCounter + + if (newSignCount > 0) { + if (sigCountSupported && newSignCount <= currentSignCount) { + await conn.execute( + `UPDATE passkeys SET status = 'frozen', frozenReason = 'signcount_replay' WHERE id = ?`, + [storedCred!.id], + ) + await conn.commit() + connReleased = true + sendError(res, 'PASSKEY_REPLAY_DETECTED', '凭证回放检测,已冻结', req.requestId, 422) + return + } + await conn.execute(`UPDATE passkeys SET signCount = ? WHERE id = ?`, [newSignCount, storedCred!.id]) + } + + // Mark passkey challenge as used + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ? AND usedAt IS NULL`, [Date.now(), record.id]) + + // Consume MFA challenge within same transaction (fixes pre-consumption race) + if (mfaChalRecord) { + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ? AND usedAt IS NULL`, [Date.now(), mfaChalRecord.id]) + } + + await conn.commit() + + // Update lastUsedAt (uses pool, not transaction connection) + await exec(`UPDATE passkeys SET lastUsedAt = ? WHERE id = ?`, [Date.now(), storedCredId]) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('passkey login error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '登录失败', req.requestId, 500) + return + } finally { + if (!connReleased) conn.release() + } + } + + // --- Transaction committed, proceed with session creation --- + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + + const userRow = await queryOne( + `SELECT id, username, displayName, avatarUrl, emailVerified, tokenVersion FROM users WHERE id = ?`, + [storedCredUserId], + ) as Record | null + + if (!userRow) { + sendError(res, Errors.INTERNAL_ERROR.code, '用户查询失败', req.requestId, 500) + return + } + + let roles: string[] = [] + try { + const roleRows = await query( + `SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ?`, + [storedCredUserId], + ) + roles = (roleRows as Array<{ name: string }>).map(r => r.name) + } catch { /* no roles */ } + + const { sessionId, refreshToken } = await createSession(storedCredUserId, roles, 'passkey', ip, ua) + + const accessToken = await signJwt({ + sub: storedCredUserId, + sid: sessionId, + tokenVersion: userRow.tokenVersion as number, + roles, + aud: 'transcircle-web', + iss: 'https://api.transcircle.org', + }) + + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', refreshToken, { + httpOnly: true, secure: true, + sameSite: 'lax', path: '/v1/auth', maxAge: maxAge * 1000, + }) + + const nativeClient = isNativeClient(req) + const pkResp: Record = { + mfaRequired: false, + accessToken, tokenType: 'Bearer', expiresIn: 900, + user: { + id: userRow.id, username: userRow.username, + displayName: userRow.displayName, avatarUrl: userRow.avatarUrl || null, + emailVerified: !!userRow.emailVerified, + }, + } + if (nativeClient) pkResp.refreshToken = refreshToken + sendSuccess(res, pkResp, req.requestId) +}) + +export default router diff --git a/functions/src/routes/auth-password.ts b/functions/src/routes/auth-password.ts new file mode 100644 index 0000000..4bfdde3 --- /dev/null +++ b/functions/src/routes/auth-password.ts @@ -0,0 +1,208 @@ +import { Router } from 'express' +import { exec, queryOne } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { hashPassword, verifyPassword } from '../utils/password' +import { sendSuccess, sendError, Errors, zodErrorsToDetails } from '../utils/response' +import { hmacToken, revokeUserSessions } from '../utils/session' +import { passwordResetSchema, validatePassword } from '../utils/validation' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { writeAuditLog } from '../utils/audit' +import { sendEmail, buildPasswordResetEmail } from '../utils/mail' +import { isPasswordNotLeaked } from '../utils/hibp' +import { log } from '../Logger' + +const router: Router = Router() + +// POST /auth/password/forgot — api.md §1.4.1 +router.post('/auth/password/forgot', (req, _res, next) => { req.rateLimitAction = 'password:forgot'; next(); }, rateLimitCheck, async (req, res) => { + const email = req.body?.email as string | undefined + if (!email) { sendError(res, Errors.BAD_REQUEST.code, Errors.BAD_REQUEST.message, req.requestId, 400); return } + + // Per-email rate limit: 3/h per api.md §1.4.1 + const now = Date.now() + const hourWindow = Math.floor(now / 3600_000) * 3600_000 + const emailRateKey = `password:forgot:email:${email.toLowerCase()}:${hourWindow}` + const emailRate = await queryOne(`SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [emailRateKey, hourWindow]) + if ((emailRate?.count as number || 0) >= 3) { + sendError(res, Errors.RATE_LIMITED.code, '该邮箱密码重置请求过于频繁', req.requestId, Errors.RATE_LIMITED.status) + return + } + + const user = await queryOne(`SELECT id, status FROM users WHERE email = ?`, [email]) + if (!user) { sendError(res, 'EMAIL_NOT_FOUND', '邮箱未注册', req.requestId, 404); return } + if (user.status === 'banned') { sendError(res, Errors.ACCOUNT_BANNED.code, '账户已被封禁', req.requestId, Errors.ACCOUNT_BANNED.status); return } + + const resetToken = genId('rst_') + const tokenHash = await hmacToken(resetToken) + await exec(`INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) VALUES (?, ?, 'password_reset', ?, '{}', ?, ?)`, [ulid(), user.id, tokenHash, Date.now() + 3600_000, Date.now()]) + + // Send password reset email per api.md §1.4.1 + await sendEmail(buildPasswordResetEmail(email, resetToken)).catch((e: unknown) => log(`ERROR: failed to send password reset email to ${email}: ${(e as Error).message}`)) + + // Audit log + await writeAuditLog(req, { + actorUserId: user.id, + action: 'auth.password.reset.request', + resourceType: 'user', + resourceId: user.id, + after: {}, + }) + + // Increment per-email rate limit counter + await exec( + `INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) VALUES (?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE count = count + 1`, + [ulid(), emailRateKey, hourWindow, now], + ).catch(() => {}) + + sendSuccess(res, { sent: true }, req.requestId, 202) +}) + +// POST /auth/password/reset — api.md §1.4.2 +router.post('/auth/password/reset', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const parsed = passwordResetSchema.safeParse(req.body) + if (!parsed.success) { sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, 422, zodErrorsToDetails(parsed.error.flatten())); return } + + const { token, newPassword } = parsed.data + const tokenHash = await hmacToken(token) + const now = Date.now() + + const record = await queryOne(`SELECT id, userId FROM auth_tokens WHERE tokenHash = ? AND type = 'password_reset' AND usedAt IS NULL AND expiresAt > ?`, [tokenHash, now]) + if (!record) { sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '令牌无效或已过期', req.requestId, 410); return } + + // api.md §1.1: HIBP k-Anonymity leak check + if (!await isPasswordNotLeaked(newPassword)) { + sendError(res, Errors.VALIDATION_ERROR.code, '该密码已在公开泄露中出现,请更换密码', req.requestId, 422) + return + } + + const passwordHash = await hashPassword(newPassword) + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [now, record.id]) + await exec(`UPDATE users SET passwordHash = ?, tokenVersion = tokenVersion + 1, updatedAt = ? WHERE id = ?`, [passwordHash, now, record.userId]) + await revokeUserSessions(record.userId, 'password_reset') + + // Audit log + await writeAuditLog(req, { + actorUserId: record.userId, + action: 'auth.password.reset', + resourceType: 'user', + resourceId: record.userId, + after: { passwordReset: true }, + }) + + sendSuccess(res, { passwordReset: true }, req.requestId) +}) + +// POST /me/password — api.md §1.5 +router.post('/me/password', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string } + + // Validate password per spec (including username/email check per api.md §1.1) + if (!newPassword) { + sendError(res, Errors.VALIDATION_ERROR.code, '新密码不能为空', req.requestId, 422) + return + } + const userRow = await queryOne(`SELECT username, email, passwordHash, passwordHistory FROM users WHERE id = ?`, [req.user!.userId]) + const pwdErr = validatePassword(newPassword, userRow?.username as string | undefined, userRow?.email as string | undefined) + if (pwdErr) { + sendError(res, Errors.VALIDATION_ERROR.code, pwdErr, req.requestId, 422) + return + } + + // Check password history (api.md §1.1: not among last 5 passwords) + if (userRow?.passwordHistory) { + const { verifyPassword } = await import('../utils/password') + const history = (typeof userRow.passwordHistory === 'string' ? JSON.parse(userRow.passwordHistory) : userRow.passwordHistory) as string[] + for (const oldHash of history) { + if (await verifyPassword(oldHash, newPassword)) { + sendError(res, Errors.VALIDATION_ERROR.code, '新密码不能与最近 5 个历史密码相同', req.requestId, 422) + return + } + } + } + + const now = Date.now() + const userId = req.user!.userId + + if (userRow?.passwordHash) { + if (!currentPassword) { sendError(res, Errors.INVALID_CREDENTIALS.code, '当前密码错误', req.requestId, Errors.INVALID_CREDENTIALS.status); return } + const valid = await verifyPassword(userRow.passwordHash as string, currentPassword) + if (!valid) { sendError(res, Errors.INVALID_CREDENTIALS.code, '当前密码错误', req.requestId, Errors.INVALID_CREDENTIALS.status); return } + } + + // api.md §1.1: HIBP k-Anonymity leak check + if (!await isPasswordNotLeaked(newPassword)) { + sendError(res, Errors.VALIDATION_ERROR.code, '该密码已在公开泄露中出现,请更换密码', req.requestId, 422) + return + } + + const passwordHash = await hashPassword(newPassword) + // Update password history: keep last 5 hashes + const oldHistory = (() => { + try { + const raw = userRow?.passwordHistory + return typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : []) + } catch { return [] } + })() as string[] + const newHistory = [...oldHistory, userRow?.passwordHash].filter(Boolean).slice(-5) + await exec( + `UPDATE users SET passwordHash = ?, passwordUpdatedAt = ?, passwordHistory = ?, tokenVersion = tokenVersion + 1, updatedAt = ? WHERE id = ?`, + [passwordHash, now, JSON.stringify(newHistory), now, userId], + ) + await revokeUserSessions(userId, 'password_changed', req.user!.sessionId) + + // Count revoked sessions + const revoked = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND revokedReason = 'password_changed' AND id != ?`, + [userId, req.user!.sessionId], + ) + + // Issue new access token for current session (api.md §1.5 requires this) + const userAfterUpdate = await queryOne( + `SELECT tokenVersion FROM users WHERE id = ?`, + [userId], + ) + const signJwt = (await import('../utils/jwt')).signJwt + const newAccessToken = await signJwt({ + sub: userId, + sid: req.user!.sessionId, + tokenVersion: (userAfterUpdate?.tokenVersion as number) || 0, + roles: req.user!.roles, + aud: 'transcircle-web', + iss: 'https://api.transcircle.org', + }) + + // Issue new refresh token for current session + const { hmacToken: hmac } = await import('../utils/session') + const { randomBytes } = await import('node:crypto') + const newRefreshToken = randomBytes(32).toString('base64url') + const newRefreshHash = await hmac(newRefreshToken) + const sessionConf = (await import('../Config')).conf.SESSION as Record | undefined + const maxAge = (sessionConf?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + await exec( + `INSERT INTO refresh_token_events (id, sessionId, tokenHash, tokenPrefix, status, createdAt, expiresAt) + VALUES (?, ?, ?, ?, 'active', ?, ?)`, + [genId('rte_'), req.user!.sessionId, newRefreshHash, newRefreshToken.slice(0, 8), now, now + maxAge * 1000], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: userId, + action: 'password.change', + resourceType: 'user', + resourceId: userId, + after: { passwordChanged: true }, + }) + + sendSuccess(res, { + passwordChanged: true, + revokedSessions: (revoked?.cnt as number) || 0, + accessToken: newAccessToken, + tokenType: 'Bearer', + expiresIn: 900, + refreshToken: newRefreshToken, + }, req.requestId) +}) + +export default router diff --git a/functions/src/routes/auth-register.ts b/functions/src/routes/auth-register.ts new file mode 100644 index 0000000..df7505c --- /dev/null +++ b/functions/src/routes/auth-register.ts @@ -0,0 +1,306 @@ +import { Router } from 'express' +import { exec, queryOne } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { hashPassword } from '../utils/password' +import { sendSuccess, sendError, Errors, zodErrorsToDetails } from '../utils/response' +import { registerSchema } from '../utils/validation' +import { hmacToken } from '../utils/session' +import { rateLimitCheck } from '../middleware/rateLimit' +import { idempotencyKey } from '../middleware/idempotency' +import { writeAuditLog } from '../utils/audit' +import { sendEmail, buildVerificationEmail } from '../utils/mail' +import { isPasswordNotLeaked } from '../utils/hibp' +import { lookupAsn, trackGlobalConflict } from '../utils/asn' + +const router: Router = Router() + +/** + * Verify a CAPTCHA token against Cloudflare Turnstile (or reCAPTCHA). + * Returns true if valid or if CAPTCHA_SECRET is not configured (dev mode). + * api.md §1.1: CAPTCHA 升级阈值触发后必须验证 X-Captcha-Token。 + */ +async function verifyCaptcha(token: string): Promise { + const secret = process.env.CAPTCHA_SECRET + if (!secret) return true // skip if not configured + try { + const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret, response: token }), + }) + const data = await res.json() as { success?: boolean } + return data.success === true + } catch { + return false + } +} + +/** Extract IPv4 /24 or IPv6 /48 CIDR prefix from IP */ +function cidrPrefix(ip: string): string { + if (ip.includes(':')) { + // IPv6: take /48 (first 3 groups) + const parts = ip.split(':') + return parts.slice(0, 3).join(':') + '::/48' + } + // IPv4: take /24 + const parts = ip.split('.') + return parts.slice(0, 3).join('.') + '.0/24' +} + +/** Set standard X-RateLimit-* response headers per api.md */ +function setRateLimitHeaders(res: import('express').Response, max: number, remaining: number, windowMs: number): void { + const windowStart = Math.floor(Date.now() / windowMs) * windowMs + res.setHeader('X-RateLimit-Limit', String(max)) + res.setHeader('X-RateLimit-Remaining', String(remaining)) + res.setHeader('X-RateLimit-Reset', String(windowStart + windowMs)) +} + +// POST /auth/register — api.md §1.1 +router.post('/register', (req, _res, next) => { req.rateLimitAction = 'register'; next(); }, rateLimitCheck, idempotencyKey, async (req, res) => { + const parsed = registerSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, 422, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const { username, email, password, displayName } = parsed.data + const now = Date.now() + const ip = req.ip || req.socket.remoteAddress || 'unknown' + + // ── CIDR /24 subnet rate limiting (api.md §1.1: 300/h per /24) ── + const hourWindow = Math.floor(now / 3600_000) * 3600_000 + const subnet = cidrPrefix(ip) + const subnetKey = `register:subnet:${subnet}:${hourWindow}` + const subnetRate = await queryOne(`SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [subnetKey, hourWindow]) + if ((subnetRate?.count as number || 0) >= 300) { + setRateLimitHeaders(res, 300, 0, 3600_000) + sendError(res, Errors.RATE_LIMITED.code, '该网段注册过于频繁', req.requestId, 429) + return + } + + // ── ASN rate limiting (api.md §1.1: 300/h per ASN + 风控审计) ── + const asnInfo = await lookupAsn(ip) + if (asnInfo) { + const asnKey = `register:asn:${asnInfo.asn}:${hourWindow}` + const asnRate = await queryOne(`SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [asnKey, hourWindow]) + if ((asnRate?.count as number || 0) >= 300) { + setRateLimitHeaders(res, 300, 0, 3600_000) + // Write audit log for ASN-level rate limit (风控审计 per api.md §1.1) + const { writeAuditLog } = await import('../utils/audit') + writeAuditLog(req, { + actorUserId: null, + action: 'rate_limit.asn', + resourceType: 'register', + resourceId: '', + after: { asn: asnInfo.asn, org: asnInfo.org, ipPrefix: subnet }, + }).catch((e: unknown) => console.error('audit error:', e)) + sendError(res, Errors.RATE_LIMITED.code, '该网络注册过于频繁', req.requestId, 429) + return + } + } + + // ── CAPTCHA upgrade: track conflicts per IP in 1-min window (api.md §1.1) ── + const captchaWindow = Math.floor(now / 60_000) * 60_000 + const captchaKey = `register:captcha:${ip}:${captchaWindow}` + // Only enforce CAPTCHA after threshold; check is done before conflict increments below + + // Normalize email: lowercase per api.md §1.1 "邮箱本地部分大小写敏感性以服务端归一化为准" + const normalizedEmail = email.toLowerCase() + + // Per-email and per-username rate limits (api.md §1.1: 5/24h) + const dayWindow = Math.floor(now / 86400_000) * 86400_000 + const emailRateKey = `register:email:${normalizedEmail}:${dayWindow}` + const usernameRateKey = `register:username:${username}:${dayWindow}` + const emailRate = await queryOne(`SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [emailRateKey, dayWindow]) + const usernameRate = await queryOne(`SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [usernameRateKey, dayWindow]) + if ((emailRate?.count as number || 0) >= 5) { + const retryAfter = Math.ceil((dayWindow + 86400_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + setRateLimitHeaders(res, 5, 0, 86400_000) + sendError(res, Errors.RATE_LIMITED.code, '该邮箱注册过于频繁', req.requestId, 429) + return + } + if ((usernameRate?.count as number || 0) >= 5) { + const retryAfter = Math.ceil((dayWindow + 86400_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + setRateLimitHeaders(res, 5, 0, 86400_000) + sendError(res, Errors.RATE_LIMITED.code, '该用户名注册过于频繁', req.requestId, 429) + return + } + + // Helper: track count for a rate limit bucket key + async function incrementCounter(bucketKey: string, windowStart: number): Promise { + const existing = await queryOne(`SELECT id, count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [bucketKey, windowStart]) + if (existing) { + await exec(`UPDATE rate_limits SET count = count + 1 WHERE id = ?`, [existing.id]) + } else { + await exec(`INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) VALUES (?, ?, ?, 1, ?)`, [ulid(), bucketKey, windowStart, now]) + } + } + + // ── CAPTCHA upgrade check (api.md §1.1: ≥10 conflicts/min from same IP) ── + const captchaConflictCount = await queryOne( + `SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, + [captchaKey, captchaWindow], + ) + if ((captchaConflictCount?.count as number || 0) >= 10) { + const captchaToken = req.headers['x-captcha-token'] as string | undefined + if (!captchaToken || !(await verifyCaptcha(captchaToken))) { + const retryAfter = Math.ceil((captchaWindow + 60_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + setRateLimitHeaders(res, 10, 0, 60_000) + sendError(res, Errors.RATE_LIMITED.code, '需要 CAPTCHA 验证', req.requestId, 429) + return + } + } + + // Check uniqueness + const existingUser = await queryOne(`SELECT id FROM users WHERE username = ?`, [username]) + if (existingUser) { + // Track conflict for CAPTCHA escalation (api.md §1.1) + await incrementCounter(captchaKey, captchaWindow) + // Track global conflict for PagerDuty alerting (api.md §1.1) + const alert = trackGlobalConflict() + if (alert.triggered) { + console.error(`[ALERT] GLOBAL CONFLICT THRESHOLD: ${alert.count} conflicts in current 5-min window`) + } + sendError(res, Errors.USERNAME_TAKEN.code, Errors.USERNAME_TAKEN.message, req.requestId, 409, undefined, { nextAction: 'choose_other_username' }) + return + } + + const existingEmail = await queryOne(`SELECT id FROM users WHERE email = ?`, [normalizedEmail]) + if (existingEmail) { + // Track conflict for CAPTCHA escalation (api.md §1.1) + await incrementCounter(captchaKey, captchaWindow) + // Track global conflict for PagerDuty alerting (api.md §1.1) + const alert = trackGlobalConflict() + if (alert.triggered) { + console.error(`[ALERT] GLOBAL CONFLICT THRESHOLD: ${alert.count} conflicts in current 5-min window`) + } + sendError(res, Errors.EMAIL_TAKEN.code, '该邮箱已被注册', req.requestId, 409, undefined, { nextAction: 'password_forgot' }) + return + } + + // api.md §1.1: HIBP k-Anonymity leak check + if (!await isPasswordNotLeaked(password)) { + sendError(res, Errors.VALIDATION_ERROR.code, '该密码已在公开泄露中出现,请更换密码', req.requestId, 422) + return + } + + const passwordHash = await hashPassword(password) + const userId = genId('usr_') + + await exec( + `INSERT INTO users (id, username, email, emailVerified, emailVerifiedSource, displayName, passwordHash, passwordUpdatedAt, passwordHistory, status, createdAt, updatedAt) + VALUES (?, ?, ?, FALSE, NULL, ?, ?, ?, ?, 'pending_verification', ?, ?)`, + [userId, username, normalizedEmail, displayName, passwordHash, now, JSON.stringify([passwordHash]), now, now], + ) + + // Generate verification token + const verifyToken = genId('vfy_') + const verifyHash = await hmacToken(verifyToken) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'email_verify', ?, '{}', ?, ?)`, + [ulid(), userId, verifyHash, now + 86400_000, now], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: userId, + action: 'auth.register', + resourceType: 'user', + resourceId: userId, + after: { username, email }, + }) + + // Track per-email, per-username, and CIDR subnet rate limits (api.md §1.1) + for (const [bucket, ws] of [[emailRateKey, dayWindow], [usernameRateKey, dayWindow], [subnetKey, hourWindow]] as const) { + const existingRate = await queryOne(`SELECT id, count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [bucket, ws]) + if (existingRate) { + await exec(`UPDATE rate_limits SET count = count + 1 WHERE id = ?`, [existingRate.id]) + } else { + await exec(`INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) VALUES (?, ?, ?, 1, ?)`, [ulid(), bucket, ws, now]) + } + } + + // Actually send verification email per api.md §1.1 + const emailSent = await sendEmail(buildVerificationEmail(normalizedEmail, verifyToken)) + + sendSuccess(res, { + user: { + id: userId, username, email: normalizedEmail, displayName, + avatarUrl: null, emailVerified: false, + status: 'pending_verification', createdAt: now, + }, + verificationEmailSent: emailSent, + }, req.requestId, 201) +}) + +// POST /auth/email/verify — api.md §1.2.1 +router.post('/email/verify', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const token = req.body?.token as string | undefined + if (!token) { + sendError(res, Errors.BAD_REQUEST.code, Errors.BAD_REQUEST.message, req.requestId, 400) + return + } + + const tokenHash = await hmacToken(token) + const record = await queryOne( + `SELECT id, userId FROM auth_tokens WHERE tokenHash = ? AND type = 'email_verify' AND usedAt IS NULL AND expiresAt > ?`, + [tokenHash, Date.now()], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '令牌无效或已过期', req.requestId, 410) + return + } + + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), record.id]) + await exec(`UPDATE users SET emailVerified = TRUE, emailVerifiedSource = 'email', status = 'active', updatedAt = ? WHERE id = ?`, [Date.now(), record.userId]) + + // Audit log per api.md §15.13 + writeAuditLog(req, { + actorUserId: record.userId, + action: 'auth.email.verify', + resourceType: 'user', + resourceId: record.userId, + after: { emailVerified: true }, + }).catch((e: unknown) => console.error('audit error:', e)) + + sendSuccess(res, { emailVerified: true, userId: record.userId }, req.requestId) +}) + +// POST /auth/email/resend — api.md §1.2.2 +router.post('/email/resend', (req, _res, next) => { req.rateLimitAction = 'email:resend'; next(); }, rateLimitCheck, async (req, res) => { + const email = req.body?.email as string | undefined + if (!email) { + sendError(res, Errors.BAD_REQUEST.code, Errors.BAD_REQUEST.message, req.requestId, 400) + return + } + + const user = await queryOne(`SELECT id, emailVerified FROM users WHERE email = ?`, [email]) + if (!user) { + sendError(res, 'EMAIL_NOT_FOUND', '邮箱未注册', req.requestId, 404) + return + } + + if (user.emailVerified) { + sendError(res, 'EMAIL_ALREADY_VERIFIED', '邮箱已通过验证', req.requestId, 409) + return + } + + // Generate and send new verification token + const verifyToken = genId('vfy_') + const verifyHash = await hmacToken(verifyToken) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'email_verify', ?, '{}', ?, ?)`, + [ulid(), user.id, verifyHash, Date.now() + 86400_000, Date.now()], + ) + const sent = await sendEmail(buildVerificationEmail(email, verifyToken)) + + sendSuccess(res, { sent }, req.requestId, 202) +}) + +export default router diff --git a/functions/src/routes/auth-sessions.ts b/functions/src/routes/auth-sessions.ts new file mode 100644 index 0000000..e200c21 --- /dev/null +++ b/functions/src/routes/auth-sessions.ts @@ -0,0 +1,162 @@ +import { Router } from 'express' +import { query, queryOne, exec } from '../Database' +import { sendSuccess, sendError, Errors, sendNoContent } from '../utils/response' +import { requireAuth } from '../middleware/auth' +import { revokeUserSessions, revokeSession } from '../utils/session' +import { writeAuditLog } from '../utils/audit' + +const router: Router = Router() + +// GET /auth/session — api.md §1.11.1 +router.get('/auth/session', requireAuth, async (req, res) => { + const user = await queryOne( + `SELECT id, username, displayName, avatarUrl, emailVerified, status + FROM users WHERE id = ?`, + [req.user!.userId], + ) + + if (!user) { + sendError(res, Errors.UNAUTHORIZED.code, Errors.UNAUTHORIZED.message, req.requestId, Errors.UNAUTHORIZED.status) + return + } + + // Fetch actual roles from DB (api.md §15.10) + let roles: string[] = [] + try { + const roleRows = await query( + `SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ?`, + [req.user!.userId], + ) + roles = (roleRows as Array<{ name: string }>).map(r => r.name) + } catch { /* no roles */ } + + const session = await queryOne( + `SELECT id, createdAt, expiresAt FROM sessions WHERE id = ?`, + [req.user!.sessionId], + ) + + sendSuccess(res, { + user: { + id: user.id, username: user.username, displayName: user.displayName, + avatarUrl: user.avatarUrl, emailVerified: !!user.emailVerified, + roles, + }, + session: session ? { + id: session.id, createdAt: session.createdAt, expiresAt: session.expiresAt, + } : null, + }, req.requestId) +}) + +// POST /auth/logout-all — api.md §1.11.4 +router.post('/auth/logout-all', requireAuth, async (req, res) => { + await revokeUserSessions(req.user!.userId, 'logout_all') + await exec(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [req.user!.userId]) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'session.revoke_all', + resourceType: 'session', + resourceId: req.user!.userId, + after: { revokedReason: 'logout_all' }, + }) + + // Count revoked sessions + const count = await queryOne( + `SELECT COUNT(*) as cnt FROM sessions WHERE userId = ? AND revokedReason = 'logout_all'`, + [req.user!.userId], + ) + + sendSuccess(res, { revokedSessions: (count?.cnt as number) || 1 }, req.requestId) +}) + +// GET /me/sessions — api.md §1.11.5 +router.get('/me/sessions', requireAuth, async (req, res) => { + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + + let whereClause = `WHERE s.userId = ? AND s.revokedAt IS NULL` + const params: unknown[] = [req.user!.userId] + + if (cursor) { + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf-8') + whereClause += ` AND s.createdAt < ?` + params.push(parseInt(decoded, 10)) + } catch { + sendError(res, Errors.VALIDATION_ERROR.code, '无效的 cursor', req.requestId, Errors.VALIDATION_ERROR.status) + return + } + } + + params.push(limit + 1) + + const rows = await query( + `SELECT s.id, s.createdAt, s.lastUsedAt, s.expiresAt, s.ipPrefix, s.userAgentHash, s.loginMethod, s.deviceSummary + FROM sessions s + ${whereClause} + ORDER BY s.createdAt DESC + LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + const data = (rows as Array>).map((s) => { + const deviceSummary = (() => { + try { + const raw = s.deviceSummary + return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record || {}) + } catch { return {} } + })() as Record + return { + id: s.id, + current: s.id === req.user!.sessionId, + device: { + browser: (deviceSummary.browser as string) || null, + os: (deviceSummary.os as string) || null, + type: (deviceSummary.type as string) || 'desktop', + }, + ipPrefix: s.ipPrefix || null, + createdAt: s.createdAt, + lastUsedAt: s.lastUsedAt, + expiresAt: s.expiresAt, + } + }) + + const nextCursor = hasMore + ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') + : null + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +// DELETE /me/sessions/{id} — api.md §1.11.6 +router.delete('/me/sessions/:id', requireAuth, async (req, res) => { + const { id } = req.params + const session = await queryOne( + `SELECT id FROM sessions WHERE id = ? AND userId = ? AND revokedAt IS NULL`, + [id, req.user!.userId], + ) + + if (!session) { + sendError(res, 'SESSION_NOT_FOUND', 'session 不存在', req.requestId, 404) + return + } + + await revokeSession(id as string, 'user_revoked') + + // Audit log per api.md §1.11.6 + writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'session.revoke', + resourceType: 'session', + resourceId: id as string, + after: { revokedReason: 'user_revoked', sessionId: id as string }, + }).catch((e: unknown) => console.error('audit error:', e)) + + sendNoContent(res, req.requestId) +}) + +export default router diff --git a/functions/src/routes/auth-stepup.ts b/functions/src/routes/auth-stepup.ts new file mode 100644 index 0000000..8a84db8 --- /dev/null +++ b/functions/src/routes/auth-stepup.ts @@ -0,0 +1,424 @@ +import { Router } from 'express' +import { exec, query, queryOne } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { sendSuccess, sendError, Errors } from '../utils/response' +import { hmacToken } from '../utils/session' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { verifyPassword, findUnusedRecoveryCode } from '../utils/password' +import { verifyTotpCode } from '../utils/totp' +import { decryptSecret } from '../utils/crypto' +import { writeAuditLog, canonicalJson, sha256base64url } from '../utils/audit' +import { conf } from '../Config' +import { Buffer } from 'node:buffer' + +const router: Router = Router() + +// POST /auth/step-up/start — api.md §1.12.1 +router.post('/auth/step-up/start', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + // Check user status per spec + const user = await queryOne(`SELECT status FROM users WHERE id = ?`, [req.user!.userId]) + if (!user) { + sendError(res, Errors.UNAUTHORIZED.code, Errors.UNAUTHORIZED.message, req.requestId, Errors.UNAUTHORIZED.status) + return + } + if (user.status === 'banned') { + sendError(res, Errors.ACCOUNT_BANNED.code, Errors.ACCOUNT_BANNED.message, req.requestId, Errors.ACCOUNT_BANNED.status) + return + } + if (user.status === 'pending_verification') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, Errors.EMAIL_NOT_VERIFIED.message, req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + if (user.status === 'merged') { + sendError(res, Errors.ACCOUNT_MERGED.code, Errors.ACCOUNT_MERGED.message, req.requestId, Errors.ACCOUNT_MERGED.status) + return + } + if (user.status === 'pending_deletion') { + sendError(res, Errors.ACCOUNT_PENDING_DELETION.code, Errors.ACCOUNT_PENDING_DELETION.message, req.requestId, Errors.ACCOUNT_PENDING_DELETION.status) + return + } + if (user.status === 'deleted') { + sendError(res, Errors.ACCOUNT_DELETED.code, Errors.ACCOUNT_DELETED.message, req.requestId, Errors.ACCOUNT_DELETED.status) + return + } + + const challengeId = genId('stepup_') + const challengeHash = await hmacToken(challengeId) + + // Determine available methods per api.md §1.12.1 + const availableMethods: string[] = [] + + const hasPassword = await queryOne( + `SELECT passwordHash FROM users WHERE id = ? AND passwordHash IS NOT NULL`, + [req.user!.userId], + ) + if (hasPassword) availableMethods.push('password') + + const hasTotp = await queryOne( + `SELECT id FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [req.user!.userId], + ) + if (hasTotp) availableMethods.push('totp') + + const hasPasskey = await queryOne( + `SELECT COUNT(*) as cnt FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) + const passkeyCount = (hasPasskey?.cnt as number) || 0 + if (passkeyCount > 0) availableMethods.push('passkey') + + // Build challenge metadata before INSERT to avoid UPDATE race (api.md §1.12.1) + const challengeMeta: Record = {} + const passkeyChallengeMeta: Record = {} + + if (passkeyCount > 0) { + const creds = await query( + `SELECT credentialIdB64, transports FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) as unknown as Array<{ credentialIdB64: string; transports: string }>; + + const allowCredentials = creds.map((c) => ({ + type: 'public-key' as const, + id: c.credentialIdB64, + transports: (() => { try { return JSON.parse(c.transports as string) as string[] } catch { return [] } })(), + })) + + const { generateAuthenticationOptions } = await import('@simplewebauthn/server') + const authOptions = await generateAuthenticationOptions({ + rpID: 'transcircle.org', + userVerification: 'required', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + allowCredentials: allowCredentials as any, + timeout: 60000, + }) + + challengeMeta.challenge = authOptions.challenge + challengeMeta.passkey = true + + passkeyChallengeMeta.publicKey = { + challenge: authOptions.challenge, + rpId: 'transcircle.org', + userVerification: 'required', + allowCredentials, + } + } + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'step_up_challenge', ?, ?, ?, ?)`, + [ulid(), req.user!.userId, challengeHash, JSON.stringify(challengeMeta), Date.now() + 300_000, Date.now()], + ) + + const responseData: Record = { + challengeId, + expiresIn: 300, + availableMethods, + } + if (passkeyCount > 0 && passkeyChallengeMeta.publicKey) { + responseData.passkey = passkeyChallengeMeta + } + + sendSuccess(res, responseData, req.requestId) +}) + +// POST /auth/step-up/verify — api.md §1.12.2 +// Must use transaction + SELECT FOR UPDATE for concurrency safety +router.post('/auth/step-up/verify', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { challengeId, method, password, code, passkeyAssertion } = req.body as { + challengeId?: string; method?: string; password?: string; code?: string + passkeyAssertion?: { + id: string; rawId: string; type: string + response: { clientDataJSON: string; authenticatorData: string; signature: string; userHandle?: string } + clientExtensionResults?: Record + } + } + + if (!challengeId || !method) { + sendError(res, Errors.BAD_REQUEST.code, Errors.BAD_REQUEST.message, req.requestId, 400) + return + } + + const challengeHash = await hmacToken(challengeId) + + // Per api.md §1.12.2: single transaction with row-level lock + const { getConnection } = await import('../Database') + const conn = await getConnection() + try { + await conn.beginTransaction() + + const [lockRows] = await conn.execute( + `SELECT id, userId, metadata FROM auth_tokens + WHERE tokenHash = ? AND type = 'step_up_challenge' AND usedAt IS NULL AND expiresAt > ? + FOR UPDATE`, + [challengeHash, Date.now()], + ) + + const rows = lockRows as unknown as Array<{ id: string; userId: string; metadata: string }> + const record = rows[0] ?? null + + if (!record || record.userId !== req.user!.userId) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '挑战令牌无效', req.requestId, 410) + return + } + + // Read challenge metadata for passkey flow + const meta = (() => { + try { + const raw = record.metadata + return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record || {}) + } catch { return {} } + })() as Record + + // --- Method verification (read-only checks, can fail before commiting) --- + + // First verify the method is available to this user per api.md §1.12.2 + const availableMethods: string[] = [] + const hasPwd = await queryOne(`SELECT passwordHash FROM users WHERE id = ? AND passwordHash IS NOT NULL`, [req.user!.userId]) + if (hasPwd) availableMethods.push('password') + const hasTotp = await queryOne(`SELECT id FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, [req.user!.userId]) + if (hasTotp) availableMethods.push('totp', 'recovery_code') + const pkCount = await queryOne(`SELECT COUNT(*) as cnt FROM passkeys WHERE userId = ? AND status = 'active'`, [req.user!.userId]) + if ((pkCount?.cnt as number || 0) > 0) availableMethods.push('passkey') + + if (!availableMethods.includes(method)) { + await conn.rollback() + sendError(res, Errors.BAD_REQUEST.code, `该账户不可用 ${method} 验证方式`, req.requestId, 400) + return + } + + if (method === 'password') { + if (!password) { + await conn.rollback() + sendError(res, Errors.BAD_REQUEST.code, '密码不能为空', req.requestId, 400) + return + } + + const userRow = await queryOne(`SELECT passwordHash FROM users WHERE id = ?`, [req.user!.userId]) + if (!userRow?.passwordHash) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, Errors.INVALID_CREDENTIALS.message, req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + + const valid = await verifyPassword(userRow.passwordHash as string, password) + if (!valid) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, Errors.INVALID_CREDENTIALS.message, req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + } else if (method === 'totp' || method === 'recovery_code') { + if (!code) { + await conn.rollback() + sendError(res, Errors.BAD_REQUEST.code, '验证码不能为空', req.requestId, 400) + return + } + + const cred = await queryOne( + `SELECT id, secretEncrypted, lastUsedTimeStep FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [req.user!.userId], + ) + if (!cred) { + await conn.rollback() + sendError(res, 'TOTP_NOT_ENABLED', 'TOTP 未启用', req.requestId, 404) + return + } + + if (method === 'totp') { + const encryptedSecret = cred.secretEncrypted as string + const secret = await decryptSecret(encryptedSecret) + const lastUsedTimeStep = cred.lastUsedTimeStep as number | null + const candidateTimeStep = await verifyTotpCode(secret, code, lastUsedTimeStep) + if (candidateTimeStep === null) { + await conn.rollback() + sendError(res, 'INVALID_TOTP_CODE', '验证码错误', req.requestId, 422) + return + } + await conn.execute( + `UPDATE mfa_totp_credentials SET lastUsedTimeStep = ? WHERE id = ?`, + [candidateTimeStep, cred.id], + ) + } else { + // recovery_code (argon2id scan per api.md §15.7) + const rcIdFromPool = await findUnusedRecoveryCode(req.user!.userId, code) + if (!rcIdFromPool) { + await conn.rollback() + sendError(res, 'INVALID_TOTP_CODE', '恢复码错误', req.requestId, 422) + return + } + // Double-check within transaction to prevent race + const [rcCheckRows] = await conn.execute( + `SELECT id, usedAt FROM mfa_recovery_codes WHERE id = ? FOR UPDATE`, + [rcIdFromPool], + ) + if (!rcCheckRows[0] || rcCheckRows[0].usedAt) { + await conn.rollback() + sendError(res, 'INVALID_TOTP_CODE', '恢复码已被使用', req.requestId, 422) + return + } + await conn.execute(`UPDATE mfa_recovery_codes SET usedAt = ? WHERE id = ?`, [Date.now(), rcCheckRows[0].id]) + } + } else if (method === 'passkey') { + if (!passkeyAssertion) { + await conn.rollback() + sendError(res, Errors.BAD_REQUEST.code, 'passkeyAssertion 不能为空', req.requestId, 400) + return + } + + const expectedChallenge = meta.challenge as string | undefined + if (!expectedChallenge) { + await conn.rollback() + sendError(res, 'PASSKEY_VERIFICATION_FAILED', '缺少 step-up 挑战数据', req.requestId, 422) + return + } + + const storedCred = await queryOne( + `SELECT id, userId, credentialId, credentialIdB64, publicKey, signCount, signCountSupported, status + FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) + + if (!storedCred) { + await conn.rollback() + sendError(res, 'PASSKEY_VERIFICATION_FAILED', '未找到活跃的 Passkey', req.requestId, 422) + return + } + + if (storedCred.status === 'frozen') { + await conn.rollback() + sendError(res, 'PASSKEY_FROZEN', 'Passkey 已被冻结', req.requestId, 403) + return + } + + const pkHex = storedCred.publicKey as string + let credentialPublicKey: Uint8Array + try { + credentialPublicKey = new Uint8Array(Buffer.from(pkHex, 'hex')) + } catch { + await conn.rollback() + sendError(res, Errors.INTERNAL_ERROR.code, '凭证数据异常', req.requestId, 500) + return + } + + const { verifyAuthenticationResponse } = await import('@simplewebauthn/server') + let verification + try { + verification = await verifyAuthenticationResponse({ + response: { + id: passkeyAssertion.id, rawId: passkeyAssertion.rawId, type: passkeyAssertion.type as 'public-key', + response: { + clientDataJSON: passkeyAssertion.response.clientDataJSON, + authenticatorData: passkeyAssertion.response.authenticatorData, + signature: passkeyAssertion.response.signature, + userHandle: passkeyAssertion.response.userHandle, + }, + clientExtensionResults: passkeyAssertion.clientExtensionResults || {}, + }, + expectedChallenge, + expectedOrigin: (conf.APP as Record | undefined)?.FRONTEND_URL || 'https://submit.transcircle.org', + expectedRPID: 'transcircle.org', + credential: { id: storedCred.id, publicKey: credentialPublicKey, counter: storedCred.signCount as number }, + requireUserVerification: true, + }) + } catch { + await conn.rollback() + sendError(res, 'PASSKEY_VERIFICATION_FAILED', 'Passkey 验证异常', req.requestId, 422) + return + } + + if (!verification.verified) { + await conn.rollback() + sendError(res, 'PASSKEY_VERIFICATION_FAILED', 'Passkey 签名验证失败', req.requestId, 422) + return + } + + const { authenticationInfo } = verification + const currentSignCount = storedCred.signCount as number + const newSignCount = authenticationInfo.newCounter + + if (newSignCount > 0) { + const sigCountSupported = storedCred.signCountSupported as boolean + if (sigCountSupported && newSignCount <= currentSignCount) { + // Freeze inside transaction, then commit atomically + await conn.execute( + `UPDATE passkeys SET status = 'frozen', frozenReason = 'signcount_replay' WHERE id = ?`, + [storedCred.id], + ) + writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'passkey.freeze', + resourceType: 'passkey', + resourceId: storedCred.id, + after: { reason: 'signcount_replay', previousSignCount: currentSignCount }, + }).catch((e: unknown) => console.error('audit error:', e)) + await conn.commit() + sendError(res, 'PASSKEY_REPLAY_DETECTED', 'Passkey 回放检测,已冻结', req.requestId, 422) + return + } + await conn.execute(`UPDATE passkeys SET signCount = ? WHERE id = ?`, [newSignCount, storedCred.id]) + } + } else { + await conn.rollback() + sendError(res, Errors.BAD_REQUEST.code, '不支持的验证方法', req.requestId, 400) + return + } + + // --- All verification passed — commit side effects atomically --- + + const now = Date.now() + + // Mark challenge as used + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [now, record.id]) + + // Update session lastStepUpAt + await conn.execute( + `UPDATE sessions SET lastStepUpAt = ?, lastStepUpMethod = ? WHERE id = ?`, + [now, method, req.user!.sessionId], + ) + + // Audit log entry within the same transaction (per api.md §15.13) + const auditIp = req.ip || req.socket.remoteAddress || 'unknown' + const auditUa = req.headers['user-agent'] || 'unknown' + const stepupAuditId = genId('aud_') + const [lastStepupAudit] = await conn.execute( + `SELECT entryHash FROM audit_logs ORDER BY createdAt DESC LIMIT 1 FOR UPDATE`, + ) + const stepupPrevHash = (lastStepupAudit[0]?.entryHash as string) ?? null + const stepupAfterData = { method } + const stepupRecordData = { + id: stepupAuditId, actorUserId: req.user!.userId, action: 'auth.step_up', + resourceType: 'session', resourceId: req.user!.sessionId, + before: null, after: stepupAfterData, metadata: {}, + createdAt: now, requestId: req.requestId, + ipHash: await hmacToken(auditIp), uaHash: await hmacToken(auditUa), + } + const stepupCanonicalRecord = canonicalJson(stepupRecordData) + const stepupChainInput = stepupPrevHash ? `${stepupPrevHash}|${stepupCanonicalRecord}` : stepupCanonicalRecord + const stepupEntryHash = await sha256base64url(stepupChainInput) + await conn.execute( + `INSERT INTO audit_logs (id, actorUserId, action, resourceType, resourceId, \`before\`, after, metadata, createdAt, requestId, ipHash, userAgentHash, prevHash, entryHash) + VALUES (?, ?, 'auth.step_up', 'session', ?, NULL, ?, '{}', ?, ?, ?, ?, ?, ?)`, + [stepupAuditId, req.user!.userId, req.user!.sessionId, + canonicalJson(stepupAfterData), now, req.requestId, + await hmacToken(auditIp), await hmacToken(auditUa), + stepupPrevHash, stepupEntryHash], + ) + + await conn.commit() + + sendSuccess(res, { + stepUpAt: now, + method, + validForSeconds: 300, + }, req.requestId) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('step-up verify error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '验证失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +export default router diff --git a/functions/src/routes/auth-totp.ts b/functions/src/routes/auth-totp.ts new file mode 100644 index 0000000..564c5bd --- /dev/null +++ b/functions/src/routes/auth-totp.ts @@ -0,0 +1,493 @@ +import { Router } from 'express' +import pool, { exec, queryOne, getConnection } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { signJwt } from '../utils/jwt' +import { sendSuccess, sendError, Errors, sendNoContent } from '../utils/response' +import { metrics } from '../utils/metrics' +import { hmacToken, createSession, isNativeClient } from '../utils/session' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { verifyPassword, hashRecoveryCode, findUnusedRecoveryCode } from '../utils/password' +import { conf } from '../Config' +import { verifyTotpCode } from '../utils/totp' +import { encryptSecret, decryptSecret } from '../utils/crypto' +import { writeAuditLog } from '../utils/audit' +import argon2 from 'argon2' + +/** + * Generate a cryptographically random recovery code in format XXXX-XXXX-XXXX (api.md §1.9.2). + * Uses crypto.getRandomValues for proper entropy (~60 bits per code). + */ +function generateRecoveryCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Crockford Base32 (no ILOU) + const buf = crypto.getRandomValues(new Uint8Array(9)) + const seg1 = chars[buf[0]! % chars.length] + chars[buf[1]! % chars.length] + chars[buf[2]! % chars.length] + chars[buf[3]! % chars.length] + const seg2 = chars[buf[4]! % chars.length] + chars[buf[5]! % chars.length] + chars[buf[6]! % chars.length] + chars[buf[7]! % chars.length] + const seg3 = chars[buf[8]! % chars.length] + chars[buf[0]! % chars.length] + chars[buf[1]! % chars.length] + chars[buf[2]! % chars.length] + return `${seg1}-${seg2}-${seg3}` +} + +const router: Router = Router() +const SESSION_CONF = conf.SESSION as Record | undefined + +// POST /me/mfa/totp/setup — api.md §1.9.1 +router.post('/me/mfa/totp/setup', requireAuth, async (req, res) => { + // Per api.md §1 status table: pending_verification cannot set up TOTP + const statusRow = await queryOne(`SELECT status FROM users WHERE id = ?`, [req.user!.userId]) + if (statusRow?.status !== 'active') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + const existing = await queryOne( + `SELECT id FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [req.user!.userId], + ) + if (existing) { + sendError(res, 'TOTP_ALREADY_ENABLED', 'TOTP 已启用', req.requestId, 409) + return + } + + const setupId = genId('totp_setup_') + const secret = Array.from(crypto.getRandomValues(new Uint8Array(20))) + .map((b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[b % 32]) + .join('') + + const userRow = await queryOne(`SELECT email FROM users WHERE id = ?`, [req.user!.userId]) + const userIdentifier = (userRow?.email as string) || req.user!.userId + const otpauthUrl = `otpauth://totp/TransCircle:${encodeURIComponent(userIdentifier)}?secret=${secret}&issuer=TransCircle&period=30&digits=6&algorithm=SHA1` + + // Encrypt secret per api.md 安全基线: KMS envelope encryption + const encryptedSecret = await encryptSecret(secret) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'totp_setup', ?, ?, ?, ?)`, + [ulid(), req.user!.userId, await hmacToken(setupId), + JSON.stringify({ secret: encryptedSecret }), Date.now() + 600_000, Date.now()], + ) + + // Generate QR code image per api.md §1.9.1 + let qrCodeImage: string | null = null + try { + const qrcode = await import('qrcode') + qrCodeImage = await qrcode.toDataURL(otpauthUrl) + } catch { + // QR generation failure is non-fatal + } + + sendSuccess(res, { + setupId, + secret, + otpauthUrl, + qrCodeImage, + expiresIn: 600, + }, req.requestId) +}) + +// POST /me/mfa/totp/enable — api.md §1.9.2 +router.post('/me/mfa/totp/enable', requireAuth, async (req, res) => { + const isActive = await queryOne(`SELECT id FROM users WHERE id = ? AND status = 'active'`, [req.user!.userId]) + if (!isActive) { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + const { setupId, code } = req.body as { setupId?: string; code?: string } + if (!setupId || !code) { + sendError(res, Errors.BAD_REQUEST.code, Errors.BAD_REQUEST.message, req.requestId, 400) + return + } + + const setupHash = await hmacToken(setupId) + const record = await queryOne( + `SELECT metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'totp_setup' AND usedAt IS NULL AND expiresAt > ?`, + [setupHash, Date.now()], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'setupId 无效或已过期', req.requestId, 410) + return + } + + const meta = typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata + // Decrypt the encrypted secret stored during setup + const secret = await decryptSecret(meta.secret as string) + + // Verify the TOTP code against the generated secret + const candidateTimeStep = await verifyTotpCode(secret, code, null) + if (candidateTimeStep === null) { + metrics.mfaVerifyTotal["totp|invalid"] = (metrics.mfaVerifyTotal["totp|invalid"] || 0) + 1; sendError(res, "INVALID_TOTP_CODE", "验证码错误", req.requestId, 422) + return + } + + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE tokenHash = ?`, [Date.now(), setupHash]) + + // Encrypt secret per api.md 安全基线: KMS envelope encryption + const encryptedSecret = await encryptSecret(secret) + await exec( + `INSERT INTO mfa_totp_credentials (id, userId, secretEncrypted, status, lastUsedTimeStep, createdAt, enabledAt) + VALUES (?, ?, ?, 'active', ?, ?, ?)`, + [genId('totp_'), req.user!.userId, encryptedSecret, candidateTimeStep, Date.now(), Date.now()], + ) + + const recoveryCodes = Array.from({ length: 10 }, () => generateRecoveryCode()) + + // Store recovery codes hashed with argon2id per api.md §15.7 + for (const rc of recoveryCodes) { + const codeHash = await hashRecoveryCode(rc) + await exec( + `INSERT INTO mfa_recovery_codes (id, userId, codeHash, createdAt) + VALUES (?, ?, ?, ?)`, + [genId('mrc_'), req.user!.userId, codeHash, Date.now()], + ) + } + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'mfa.totp.enable', + resourceType: 'mfa_totp', + resourceId: req.user!.userId, + after: { status: 'active' }, + }) + + sendSuccess(res, { totpEnabled: true, recoveryCodes }, req.requestId) +}) + +// DELETE /me/mfa/totp — api.md §1.9.3 +// 使用单事务 + FOR UPDATE 确保并发安全 +// 同时注册 POST 路由以兼容前端(某些中间件可能拒绝 DELETE body) +async function handleTotpDisable(req: import('express').Request, res: import('express').Response): Promise { + const now = Date.now() + const conn = await getConnection() + try { + await conn.beginTransaction() + + // 锁定用户检查 + const [lockUser] = await conn.execute( + `SELECT id, status, passwordHash FROM users WHERE id = ? FOR UPDATE`, + [req.user!.userId], + ) + if (!lockUser[0] || lockUser[0].status !== 'active') { + await conn.rollback() + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + const { password, code } = req.body as { password?: string; code?: string } + if (!code) { + await conn.rollback() + sendError(res, Errors.BAD_REQUEST.code, '验证码必填', req.requestId, 400) + return + } + + // 锁定 TOTP 凭证行,防止并发消费同一 code + const [lockCreds] = await conn.execute( + `SELECT id, secretEncrypted, lastUsedTimeStep FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1 FOR UPDATE`, + [req.user!.userId], + ) + const existing = lockCreds[0] as { id: string; secretEncrypted: string; lastUsedTimeStep: number | null } | undefined + + if (!existing) { + await conn.rollback() + sendError(res, 'TOTP_NOT_ENABLED', 'TOTP 未启用', req.requestId, 404) + return + } + + // Verify password if set (读锁已获取的 userRow) + if (lockUser[0].passwordHash) { + if (!password) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, '密码不能为空', req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + const valid = await verifyPassword(lockUser[0].passwordHash as string, password) + if (!valid) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, '密码错误', req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + } + + // Verify TOTP code or recovery code + const encryptedSecret = existing.secretEncrypted + const secret = await decryptSecret(encryptedSecret) + const lastUsedTimeStep = existing.lastUsedTimeStep + const candidateTimeStep = await verifyTotpCode(secret, code, lastUsedTimeStep) + + if (candidateTimeStep !== null) { + // Valid TOTP code — consume and disable + await conn.execute( + `UPDATE mfa_totp_credentials SET lastUsedTimeStep = ? WHERE id = ?`, + [candidateTimeStep, existing.id], + ) + } else { + // Try as recovery code (argon2id scan per api.md §15.7) + const rcId = await findUnusedRecoveryCode(req.user!.userId, code) + if (!rcId) { + await conn.rollback() + metrics.mfaVerifyTotal["totp|invalid"] = (metrics.mfaVerifyTotal["totp|invalid"] || 0) + 1; sendError(res, 'INVALID_TOTP_CODE', '验证码或恢复码错误', req.requestId, 422) + return + } + // FOR UPDATE 检查防止并发消费同一恢复码 + const [rcLockRows] = await conn.execute( + `SELECT id FROM mfa_recovery_codes WHERE id = ? AND usedAt IS NULL FOR UPDATE`, + [rcId], + ) + if (!rcLockRows[0]) { + await conn.rollback() + metrics.mfaVerifyTotal["totp|invalid"] = (metrics.mfaVerifyTotal["totp|invalid"] || 0) + 1; sendError(res, 'INVALID_TOTP_CODE', '恢复码已被使用', req.requestId, 422) + return + } + await conn.execute(`UPDATE mfa_recovery_codes SET usedAt = ? WHERE id = ?`, [now, rcId]) + } + + // 原子禁用 TOTP + await conn.execute(`UPDATE mfa_totp_credentials SET status = 'disabled' WHERE userId = ?`, [req.user!.userId]) + + // 事务内写审计日志 + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'mfa.totp.disable', + resourceType: 'mfa_totp', + resourceId: req.user!.userId, + after: { status: 'disabled' }, + }) + + await conn.commit() + sendNoContent(res, req.requestId) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('totp disable error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '禁用 TOTP 失败', req.requestId, 500) + } finally { + conn.release() + } +} + +router.delete('/me/mfa/totp', requireAuth, handleTotpDisable) +router.post('/me/mfa/totp', requireAuth, handleTotpDisable) + +// POST /auth/mfa/totp/verify — api.md §1.9.4 +router.post('/auth/mfa/totp/verify', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { mfaChallengeToken, code } = req.body as { mfaChallengeToken?: string; code?: string } + + if (!mfaChallengeToken || !code) { + sendError(res, Errors.BAD_REQUEST.code, Errors.BAD_REQUEST.message, req.requestId, 400) + return + } + + // Find and lock challenge token — also read metadata for loginMethod per api.md §1.9.4 + const challengeHash = await hmacToken(mfaChallengeToken) + const record = await queryOne( + `SELECT id, userId, failedAttempts, metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'mfa_challenge' AND usedAt IS NULL AND expiresAt > ?`, + [challengeHash, Date.now()], + ) + + if (!record) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '挑战令牌无效或已过期', req.requestId, 410) + return + } + + const failedAttempts = (record.failedAttempts as number) || 0 + + // Check exhaustion (max 5 attempts) + if (failedAttempts >= 5) { + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), record.id]) + sendError(res, 'MFA_CHALLENGE_EXHAUSTED', '验证尝试次数过多', req.requestId, 429) + return + } + + // Try as TOTP code first + const cred = await queryOne( + `SELECT id, secretEncrypted, lastUsedTimeStep FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [record.userId], + ) + + let validCode = false + + if (cred) { + const encryptedSecret = cred.secretEncrypted as string + const secret = await decryptSecret(encryptedSecret) + const lastUsedTimeStep = cred.lastUsedTimeStep as number | null + // Single-pass TOTP verification with replay detection (api.md §1.9.4) + // First check all time steps without replay filter to determine if code is valid at all + const candidateTimeStep = await verifyTotpCode(secret, code, null) + + if (candidateTimeStep !== null) { + // Code matches a valid time step — now check replay + if (lastUsedTimeStep !== null && candidateTimeStep <= lastUsedTimeStep) { + // Replay detected: code is valid but was already used in a previous time step + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), record.id]) + metrics.mfaVerifyTotal["totp|replay"] = (metrics.mfaVerifyTotal["totp|replay"] || 0) + 1 + sendError(res, 'TOTP_CODE_REPLAY', '该验证码已被使用过', req.requestId, 422) + return + } + validCode = true + await exec( + `UPDATE mfa_totp_credentials SET lastUsedTimeStep = ?, lastUsedAt = ? WHERE id = ?`, + [candidateTimeStep, Date.now(), cred.id], + ) + } + } + + // Try as recovery code — use FOR UPDATE to prevent concurrent reuse (api.md §15.7) + if (!validCode) { + const [rcRows] = await pool.execute( + `SELECT id, codeHash FROM mfa_recovery_codes WHERE userId = ? AND usedAt IS NULL ORDER BY createdAt ASC LIMIT 10 FOR UPDATE`, + [record.userId], + ) + let rcId: string | null = null + if (rcRows.length > 0) { + for (const row of rcRows) { + if (await argon2.verify(row.codeHash as string, code)) { + rcId = row.id as string + break + } + } + } + if (rcId) { + validCode = true + await exec(`UPDATE mfa_recovery_codes SET usedAt = ? WHERE id = ? AND usedAt IS NULL`, [Date.now(), rcId]) + } + } + + if (!validCode) { + // Increment failed attempts + const newFailed = failedAttempts + 1 + if (newFailed >= 5) { + await exec(`UPDATE auth_tokens SET usedAt = ?, failedAttempts = ? WHERE id = ?`, [Date.now(), newFailed, record.id]) + sendError(res, 'MFA_CHALLENGE_EXHAUSTED', '验证尝试次数过多', req.requestId, 429) + } else { + await exec(`UPDATE auth_tokens SET failedAttempts = ? WHERE id = ?`, [newFailed, record.id]) + metrics.mfaVerifyTotal["totp|invalid"] = (metrics.mfaVerifyTotal["totp|invalid"] || 0) + 1; sendError(res, 'INVALID_TOTP_CODE', '验证码错误', req.requestId, 422) + } + return + } + + // Consume challenge token + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), record.id]) + + // Find user and create session + const { findUserById } = await import('../utils/users') + const user = await findUserById(record.userId) + if (!user) { + sendError(res, Errors.INTERNAL_ERROR.code, '用户不存在', req.requestId, 500) + return + } + + // Read loginMethod from mfaChallenge metadata (api.md §1.9.4), fallback to 'password' + const mfaMeta = (() => { + try { + const raw = (record as Record).metadata as string | undefined || '{}' + return typeof raw === 'string' ? JSON.parse(raw) : raw + } catch { return {} } + })() as Record + const mfaLoginMethod = (mfaMeta.loginMethod as string) || 'password' + + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const { sessionId, refreshToken } = await createSession(user.id, user.roles, mfaLoginMethod, ip, ua) + + const accessToken = await signJwt({ + sub: user.id, sid: sessionId, tokenVersion: user.tokenVersion, + roles: user.roles, aud: 'transcircle-web', iss: 'https://api.transcircle.org', + }) + + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', refreshToken, { + httpOnly: true, secure: true, + sameSite: 'lax', path: '/v1/auth', maxAge: maxAge * 1000, + }) + + // api.md §1.3: 浏览器场景通过 Cookie 下发时,响应体省略 refreshToken + const nativeClient = isNativeClient(req) + const totpResp: Record = { + mfaRequired: false, + accessToken, tokenType: 'Bearer', expiresIn: 900, + user: { + id: user.id, username: user.username, + displayName: user.displayName, avatarUrl: user.avatarUrl, + emailVerified: user.emailVerified, + }, + } + if (nativeClient) totpResp.refreshToken = refreshToken + sendSuccess(res, totpResp, req.requestId) +}) + +// POST /me/mfa/recovery-codes/regenerate — api.md §1.9.5 +router.post('/me/mfa/recovery-codes/regenerate', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const isActive = await queryOne(`SELECT id FROM users WHERE id = ? AND status = 'active'`, [req.user!.userId]) + if (!isActive) { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + const { code } = req.body as { code?: string } + if (!code) { + sendError(res, Errors.BAD_REQUEST.code, '验证码必填', req.requestId, 400) + return + } + + const cred = await queryOne( + `SELECT id, secretEncrypted, lastUsedTimeStep FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [req.user!.userId], + ) + + if (!cred) { + sendError(res, 'TOTP_NOT_ENABLED', 'TOTP 未启用', req.requestId, 404) + return + } + + // Verify current TOTP code + const encryptedSecret = cred.secretEncrypted as string + const secret = await decryptSecret(encryptedSecret) + const lastUsedTimeStep = cred.lastUsedTimeStep as number | null + const candidateTimeStep = await verifyTotpCode(secret, code, lastUsedTimeStep) + + if (candidateTimeStep === null) { + // Try as recovery code (argon2id scan per api.md §15.7) + const rcId = await findUnusedRecoveryCode(req.user!.userId, code) + if (!rcId) { + metrics.mfaVerifyTotal["totp|invalid"] = (metrics.mfaVerifyTotal["totp|invalid"] || 0) + 1; sendError(res, 'INVALID_TOTP_CODE', '验证码或恢复码错误', req.requestId, 422) + return + } + await exec(`UPDATE mfa_recovery_codes SET usedAt = ? WHERE id = ?`, [Date.now(), rcId]) + } else { + await exec( + `UPDATE mfa_totp_credentials SET lastUsedTimeStep = ? WHERE id = ?`, + [candidateTimeStep, cred.id], + ) + } + + // Generate new recovery codes FIRST to avoid losing access if crash occurs mid-operation + const recoveryCodes = Array.from({ length: 10 }, () => generateRecoveryCode()) + + for (const rc of recoveryCodes) { + const codeHash = await hashRecoveryCode(rc) + await exec( + `INSERT INTO mfa_recovery_codes (id, userId, codeHash, createdAt) + VALUES (?, ?, ?, ?)`, + [genId('mrc_'), req.user!.userId, codeHash, Date.now()], + ) + } + + // Then invalidate old recovery codes + await exec( + `UPDATE mfa_recovery_codes SET usedAt = ? WHERE userId = ? AND usedAt IS NULL`, + [Date.now(), req.user!.userId], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'mfa.recovery_codes.regenerate', + resourceType: 'mfa_totp', + resourceId: req.user!.userId, + after: { regenerated: true }, + }) + + sendSuccess(res, { recoveryCodes }, req.requestId) +}) + +export default router diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts new file mode 100644 index 0000000..d55effe --- /dev/null +++ b/functions/src/routes/auth.ts @@ -0,0 +1,1479 @@ +import { Router, type Router as RouterType } from 'express' +import { conf } from '../Config' +import { exec, queryOne } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { signJwt } from '../utils/jwt' +import { hashPassword } from '../utils/password' +import { sendSuccess, sendError, Errors, zodErrorsToDetails, sendNoContent } from '../utils/response' +import { createSession, rotateRefreshToken, revokeSession, hmacToken, generateHighEntropyToken } from '../utils/session' +import { findUserById } from '../utils/users' +import { loginCodeSchema, completeRegistrationSchema } from '../utils/validation' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { idempotencyKey } from '../middleware/idempotency' +import { writeAuditLog, sha256base64url } from '../utils/audit' +import { metrics } from '../utils/metrics' +import { isPasswordNotLeaked } from '../utils/hibp' +import { encryptOAuthPii, decryptOAuthPii } from '../utils/crypto' +import { sendEmail, buildVerificationEmail } from '../utils/mail' + +const router: RouterType = Router() + +/** Grant extra roles (e.g. 'reviewer' from GitHub org check) to a user within a transaction. */ +async function grantExtraRoles( + conn: import('mysql2/promise').PoolConnection, + userId: string, + extraRoles?: string[], + grantedBy?: string, +): Promise { + if (!extraRoles || extraRoles.length === 0) return + for (const roleName of extraRoles) { + const [roleRows] = await conn.execute( + `SELECT id FROM roles WHERE name = ?`, [roleName], + ) + if (roleRows[0]) { + await conn.execute( + `INSERT IGNORE INTO user_roles (id, userId, roleId, grantedBy, createdAt) VALUES (?, ?, ?, ?, ?)`, + [genId('ur_'), userId, roleRows[0].id, grantedBy || userId, Date.now()], + ) + } + } +} + +const oauthConf = conf.OAUTH as Record | undefined +const GITHUB_CLIENT_ID = oauthConf?.GITHUB_CLIENT_ID as string | undefined +const GITHUB_CLIENT_SECRET = oauthConf?.GITHUB_CLIENT_SECRET as string | undefined +const X_CLIENT_ID = oauthConf?.X_CLIENT_ID as string | undefined +const X_CLIENT_SECRET = oauthConf?.X_CLIENT_SECRET as string | undefined + +const SESSION_CONF = conf.SESSION as Record | undefined +const APP_URL = (conf.APP as Record | undefined)?.FRONTEND_URL || 'https://submit.transcircle.org' +const API_URL = (conf.APP as Record | undefined)?.API_URL || 'https://api.transcircle.org' + +/** + * Redirect with required security headers per api.md §1.6.2: + * Cache-Control: no-store, Referrer-Policy: no-referrer + */ +function safeRedirect(res: import('express').Response, url: string): void { + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Referrer-Policy', 'no-referrer') + res.redirect(url) +} + +/** Allowlist for redirectAfter in OAuth start per api.md §1.6.1 */ +const REDIRECT_ALLOWLIST = [ + '/dashboard', + '/settings', + '/settings/security', + '/settings/security/oauth-bind/confirm', + '/auth/callback', + '/auth/oauth/continue', + '/auth/oauth/merge', + '/auth/error', +] + +function validateRedirectAfter(url: string | undefined): string | null { + if (!url) return '/dashboard' + const pathOnly = url.split('?')[0]?.split('#')[0] ?? url + const allowed = REDIRECT_ALLOWLIST.includes(pathOnly) + if (!allowed) return null + return url +} + +// ────────────────────────────────────────────── +// POST /auth/refresh +// Refresh access token using HttpOnly refresh cookie or request body — api.md §1.11.2 +// Proper rotation with reuse detection in rotateRefreshToken() +// ────────────────────────────────────────────── +router.post('/refresh', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + // Support both Cookie (browser) and request body (native clients) per api.md §1.11.2 + // Spec: "两者择一,同时存在以 Cookie 为准" + const rawToken = (req.cookies?.refresh_token as string | undefined) ?? req.body?.refreshToken + if (!rawToken) { + metrics.authTokenRefreshTotal["expired"] = (metrics.authTokenRefreshTotal["expired"] || 0) + 1 + sendError(res, 'INVALID_REFRESH_TOKEN', '刷新令牌无效', req.requestId, 401) + return + } + + const tokenHash = await hmacToken(rawToken) + const result = await rotateRefreshToken(rawToken) + if (!result) { + // Per api.md §1.11.2: audit reuse detection events + const reuseEvent = await queryOne( + `SELECT rte.status, rte.sessionId, s.userId + FROM refresh_token_events rte + LEFT JOIN sessions s ON s.id = rte.sessionId + WHERE rte.tokenHash = ?`, + [tokenHash], + ) + if (reuseEvent && (reuseEvent.status === 'reused' || reuseEvent.status === 'revoked')) { + writeAuditLog(req, { + actorUserId: (reuseEvent.userId as string | null) || null, + action: 'session.refresh_reuse_detected', + resourceType: 'session', + resourceId: (reuseEvent.sessionId as string) || null, + after: { status: reuseEvent.status as string }, + }).catch((e: unknown) => console.error('audit error:', e)) + } + metrics.authTokenRefreshTotal["reuse_detected"] = (metrics.authTokenRefreshTotal["reuse_detected"] || 0) + 1 + sendError(res, 'REFRESH_TOKEN_REVOKED', '刷新令牌已被吊销', req.requestId, 401) + return + } + + // Audit successful refresh per api.md §8 + writeAuditLog(req, { + actorUserId: result.userId, + action: 'auth.token.refresh', + resourceType: 'session', + resourceId: result.id, + after: {}, + }).catch((e: unknown) => console.error('audit error:', e)) + + // Sign new access token using the existing session (no new session created) + const accessToken = await signJwt({ + sub: result.userId, + sid: result.id, + tokenVersion: result.tokenVersion, + roles: result.roles, + aud: 'transcircle-web', + iss: 'https://api.transcircle.org', + }) + + // Determine if client is native (refreshToken came from body, not cookie) + const isNativeClient = !!req.body?.refreshToken + + // Only set new refresh token cookie if race-grace returned a new one + // (race-grace hit returns empty newRefreshToken — client already has it) + if (result.newRefreshToken) { + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', result.newRefreshToken, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/v1/auth', + maxAge: maxAge * 1000, + }) + } + + // Per api.md §1.11.2: browser clients get refreshToken via Set-Cookie, + // but native clients (which passed refreshToken in request body) need + // the new refreshToken in the response body too. + const responseData: Record = { accessToken, tokenType: 'Bearer', expiresIn: 900 } + if (isNativeClient && result?.newRefreshToken) { + responseData.refreshToken = result.newRefreshToken + } + metrics.authTokenRefreshTotal["success"] = (metrics.authTokenRefreshTotal["success"] || 0) + 1 + sendSuccess(res, responseData, req.requestId) +}) + +// ────────────────────────────────────────────── +// POST /auth/logout — api.md §1.11.3 +// Revokes current session only, does NOT bump tokenVersion +// Returns 204 No Content +// ────────────────────────────────────────────── +router.post('/logout', requireAuth, async (req, res) => { + // Audit log per api.md §1.11.3: action = session.revoke + writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'session.revoke', + resourceType: 'session', + resourceId: req.user!.sessionId, + after: { revokedReason: 'logout', sessionId: req.user!.sessionId }, + }).catch((e: unknown) => console.error('audit error:', e)) + await revokeSession(req.user!.sessionId, 'logout') + await exec(`UPDATE refresh_token_events SET status = 'revoked' WHERE sessionId = ?`, [req.user!.sessionId]) + res.clearCookie('refresh_token', { path: '/v1/auth' }) + sendNoContent(res, req.requestId) +}) + +// ────────────────────────────────────────────── +// GET /auth/oauth/github/start — api.md §1.6.1 +// ────────────────────────────────────────────── +router.get('/oauth/github/start', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + if (!GITHUB_CLIENT_ID) { + sendError(res, Errors.OAUTH_PROVIDER_ERROR.code, 'GitHub OAuth 未配置', req.requestId, 503) + return + } + + const state = genId('ost_') + const callbackUrl = `${API_URL}/v1/auth/oauth/github/callback` + const redirectAfter = validateRedirectAfter(req.query.redirectAfter as string | undefined) + if (redirectAfter === null) { + sendError(res, Errors.BAD_REQUEST.code, 'redirectAfter 不在 allowlist 中', req.requestId, 400) + return + } + + const stateHash = await hmacToken(state) + const stateIp = req.ip || req.socket.remoteAddress || 'unknown' + const stateUa = req.headers['user-agent'] || 'unknown' + // Per api.md §1.6.1: state must bind provider, redirectAfter, ipHash, uaHash, mode + const stateMeta = JSON.stringify({ + provider: 'github', + mode: 'login', + redirectAfter, + ipHash: await hmacToken(stateIp), + uaHash: await hmacToken(stateUa), + }) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, NULL, 'oauth_state', ?, ?, ?, ?)`, + [ulid(), stateHash, stateMeta, Date.now() + 600_000, Date.now()], + ) + + res.cookie('oauth_state', state, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/v1/auth/oauth', + maxAge: 600_000, + }) + + const authorizationUrl = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=read:user+user:email&state=${state}` + + sendSuccess(res, { authorizationUrl, stateExpiresIn: 600 }, req.requestId) +}) + +// ────────────────────────────────────────────── +// POST /auth/oauth/complete-binding — api.md §1.6.5 +// Bind pending OAuth identity to current logged-in user +// ────────────────────────────────────────────── +router.post('/oauth/complete-binding', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + // requireAuth validates JWT, session, tokenVersion, and user status + // (allows active/pending_verification). Per api.md §1.6.5, binding + // requires active status, so reject pending_verification here. + const user = await findUserById(req.user!.userId) + if (!user || user.status === 'pending_verification') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证,禁止绑定', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + // 校验 step-up (5 分钟内) — api.md §1.6.5 + const session = await queryOne( + `SELECT lastStepUpAt FROM sessions WHERE id = ? AND userId = ? AND revokedAt IS NULL`, + [req.user!.sessionId, req.user!.userId], + ) + if (!session || !session.lastStepUpAt || Date.now() - session.lastStepUpAt > 300_000) { + sendError(res, 'STEP_UP_REQUIRED', '需要二次认证', req.requestId, 403) + return + } + + // 从 Cookie 读取 pending token + const provider = req.query.provider as string || 'github' + const pendingCookieName = `oauth_pending_${provider}` + const pendingToken = req.cookies?.[pendingCookieName] as string | undefined + if (!pendingToken) { + sendError(res, 'MISSING_OAUTH_PENDING', `缺少 ${pendingCookieName} Cookie`, req.requestId, 401) + return + } + + // CSRF 校验 + const csrfToken = req.headers['x-csrf-token'] as string | undefined + const csrfCookie = req.cookies?.oauth_pending_csrf as string | undefined + if (!csrfToken || csrfToken !== csrfCookie) { + sendError(res, Errors.CSRF_TOKEN_INVALID.code, Errors.CSRF_TOKEN_INVALID.message, req.requestId, Errors.CSRF_TOKEN_INVALID.status) + return + } + + // Find and consume pending registration token + const pendingHash = await hmacToken(pendingToken) + const pendingRecord = await queryOne( + `SELECT id, metadata FROM auth_tokens + WHERE tokenHash = ? AND type = 'oauth_pending_registration' AND usedAt IS NULL AND expiresAt > ?`, + [pendingHash, Date.now()], + ) + if (!pendingRecord) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'OAuth pending token 无效', req.requestId, 410) + return + } + + const meta = typeof pendingRecord.metadata === 'string' ? JSON.parse(pendingRecord.metadata) : pendingRecord.metadata + + // Decrypt PII from encrypted metadata blob (api.md §1.6.2) + if (meta.pii) { + try { + const pii = await decryptOAuthPii(meta.pii) + // Also decrypt csrfToken from pii for validation + Object.assign(meta, pii) + } catch { /* corrupted pii — proceed with nulls */ } + } + + // 原子检查 + 消费 + 写入 (事务 + FOR UPDATE 预防竞态, api.md §1.6.5) + const { getConnection } = await import('../Database') + const conn = await getConnection() + try { + await conn.beginTransaction() + + // 锁定 pending token + const [lockPending] = await conn.execute( + `SELECT id, metadata FROM auth_tokens + WHERE tokenHash = ? AND type = 'oauth_pending_registration' AND usedAt IS NULL AND expiresAt > ? + FOR UPDATE`, + [pendingHash, Date.now()], + ) + if ((lockPending as unknown[]).length === 0) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'OAuth pending token 无效', req.requestId, 410) + return + } + + // 检查当前用户在该 provider 下是否已有绑定 (行锁保护) + const [lockBind] = await conn.execute( + `SELECT id FROM oauth_accounts WHERE userId = ? AND provider = ? FOR UPDATE`, + [req.user!.userId, meta.provider], + ) + if ((lockBind as unknown[]).length > 0) { + await conn.rollback() + sendError(res, Errors.PROVIDER_ALREADY_BOUND.code, Errors.PROVIDER_ALREADY_BOUND.message, req.requestId, Errors.PROVIDER_ALREADY_BOUND.status) + return + } + + // 检查该 OAuth 身份是否已被其他账号绑定 + const [lockOa] = await conn.execute( + `SELECT id, userId FROM oauth_accounts WHERE provider = ? AND providerUserId = ? FOR UPDATE`, + [meta.provider, meta.providerUserId], + ) + if ((lockOa as unknown[]).length > 0) { + await conn.rollback() + const conflictUserId = lockOa[0].userId as string + // 生成 mergeToken per api.md §1.8 + const mergeToken = generateHighEntropyToken() + const mergeHash = await hmacToken(mergeToken) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'merge', ?, ?, ?, ?)`, + [ulid(), conflictUserId, mergeHash, + JSON.stringify({ primaryUserId: req.user!.userId, conflictUserId, provider: meta.provider }), + Date.now() + 600_000, Date.now()], + ) + sendError(res, Errors.OAUTH_ALREADY_LINKED.code, '该 OAuth 账号已关联到另一个账号,请走账号合并流程', req.requestId, 409, undefined, { + conflictUserId, + mergeToken, + mergeTokenExpiresIn: 600, + }) + return + } + + // 消费 pending token + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), lockPending[0].id]) + + // 写入 oauth_accounts (UNIQUE 约束做最终安全网) + try { + await conn.execute( + `INSERT INTO oauth_accounts (id, userId, provider, providerUserId, providerUsername, providerDisplayName, providerAvatarUrl, providerEmail, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [genId('oauth_'), req.user!.userId, meta.provider, meta.providerUserId, meta.providerUsername || null, meta.providerDisplayName || null, meta.providerAvatarUrl || null, meta.providerEmail || null, Date.now(), Date.now()], + ) + } catch (insertErr: unknown) { + const mysqlErr = insertErr as { code?: string } + if (mysqlErr.code === 'ER_DUP_ENTRY') { + await conn.rollback() + sendError(res, Errors.OAUTH_ALREADY_LINKED.code, '该 OAuth 身份已被绑定', req.requestId, 409) + return + } + throw insertErr + } + + // Audit log per api.md §1.6.5 + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'oauth.bind', + resourceType: 'oauth_account', + resourceId: req.user!.userId, + after: { provider: meta.provider }, + }) + + await conn.commit() + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('oauth complete-binding error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '绑定失败', req.requestId, 500) + return + } finally { + conn.release() + } + + // 清除 pending cookies + res.clearCookie(`oauth_pending_${provider}`, { path: '/v1/auth/oauth' }) + res.clearCookie('oauth_pending_csrf', { path: '/' }) + + sendSuccess(res, { + userId: req.user!.userId, + boundProvider: meta.provider, + providerUsername: meta.providerUsername || null, + boundAt: Date.now(), + }, req.requestId) +}) + +// ────────────────────────────────────────────── +// GET /auth/oauth/pending-profile — api.md §1.6.6 +// Fetch OAuth pending profile for pre-fill +// ────────────────────────────────────────────── +router.get('/oauth/pending-profile', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const provider = req.query.provider as string || 'github' + const csrfToken = req.headers['x-csrf-token'] as string | undefined + const csrfCookie = req.cookies?.oauth_pending_csrf as string | undefined + + if (!csrfToken || csrfToken !== csrfCookie) { + sendError(res, Errors.CSRF_TOKEN_INVALID.code, Errors.CSRF_TOKEN_INVALID.message, req.requestId, Errors.CSRF_TOKEN_INVALID.status) + return + } + + const pendingCookieName = `oauth_pending_${provider}` + const pendingToken = req.cookies?.[pendingCookieName] as string | undefined + if (!pendingToken) { + sendError(res, 'MISSING_OAUTH_PENDING', `缺少 ${pendingCookieName} Cookie`, req.requestId, 401) + return + } + + const pendingHash = await hmacToken(pendingToken) + const pendingRecord = await queryOne( + `SELECT metadata, expiresAt FROM auth_tokens + WHERE tokenHash = ? AND type = 'oauth_pending_registration' AND usedAt IS NULL`, + [pendingHash], + ) + + if (!pendingRecord || pendingRecord.expiresAt < Date.now()) { + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'Pending token 已过期', req.requestId, 410) + return + } + + const meta = typeof pendingRecord.metadata === 'string' ? JSON.parse(pendingRecord.metadata) : pendingRecord.metadata + const expiresIn = Math.max(0, Math.floor((pendingRecord.expiresAt - Date.now()) / 1000)) + + // Decrypt PII per api.md §1.6.2 + let pii: { providerEmail?: string | null; providerDisplayName?: string | null; providerEmailVerified?: boolean } = {} + if (meta.pii) { + try { pii = await decryptOAuthPii(meta.pii) } catch { /* corrupted pii — return nulls */ } + } + + sendSuccess(res, { + provider: meta.provider, + mode: meta.mode || 'registration', + suggestedEmail: pii.providerEmail || null, + suggestedDisplayName: pii.providerDisplayName || null, + providerEmailVerified: !!pii.providerEmailVerified, + expiresIn, + }, req.requestId) +}) + +// ────────────────────────────────────────────── +// OAuth 回调统一分流逻辑 — api.md §1.6.2 +// ────────────────────────────────────────────── +async function handleOAuthCallback( + provider: 'github' | 'x', + providerUserId: string, + providerData: { + username: string + displayName: string + avatarUrl: string | null + email: string | null + emailVerified: boolean + }, + req: import('express').Request, + res: import('express').Response, + extraRoles?: string[], +): Promise { + const state = req.query.state as string | undefined + const cookieState = req.cookies?.oauth_state + + if (cookieState !== state) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=bad_state`) + return + } + + // Verify state record + if (!state) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=bad_state`) + return + } + + const stateHash = await hmacToken(state) + const stateRecord = await queryOne( + `SELECT id, metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'oauth_state' AND expiresAt > ?`, + [stateHash, Date.now()], + ) + if (!stateRecord) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=bad_state`) + return + } + + const stateMeta = typeof stateRecord.metadata === 'string' ? JSON.parse(stateRecord.metadata) : stateRecord.metadata + const cbIp = req.ip || req.socket.remoteAddress || 'unknown' + const cbUa = req.headers['user-agent'] || 'unknown' + if ( + (stateMeta.ipHash && stateMeta.ipHash !== (await hmacToken(cbIp))) || + (stateMeta.uaHash && stateMeta.uaHash !== (await hmacToken(cbUa))) + ) { + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), stateRecord.id]) + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=bad_state`) + return + } + + // Consume state per api.md §1.6.2: mark usedAt, don't delete + await exec(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [Date.now(), stateRecord.id]) + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + + const mode = (stateMeta.mode as 'login' | 'bind') || 'login' + const redirectAfter = stateMeta.redirectAfter || '/dashboard' + + // Look up existing OAuth binding + const existing = await queryOne( + `SELECT oa.userId, u.status, u.tokenVersion + FROM oauth_accounts oa JOIN users u ON u.id = oa.userId + WHERE oa.provider = ? AND oa.providerUserId = ?`, + [provider, providerUserId], + ) + + const now = Date.now() + + if (existing) { + // OAuth 已绑定到本地账号 + if (mode === 'bind') { + if (stateMeta.userId && existing.userId === stateMeta.userId) { + safeRedirect(res, `${APP_URL}/settings/security?status=bind_already_self&provider=${provider}`) + return + } else { + // 已绑定到其他用户 → 合并流程 + const mergeToken = generateHighEntropyToken() + const mergeHash = await hmacToken(mergeToken) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'merge', ?, ?, ?, ?)`, + [ulid(), existing.userId, mergeHash, + JSON.stringify({ primaryUserId: stateMeta.userId, conflictUserId: existing.userId, provider }), + now + 600_000, now], + ) + safeRedirect(res, `${APP_URL}/auth/oauth/merge?status=bind_conflict_merge&mergeToken=${mergeToken}&conflictUserId=${existing.userId}`) + return + } + } + + // mode === 'login' + switch (existing.status) { + case 'active': + case 'pending_verification': { + // Audit login success per api.md §1.6.2 + writeAuditLog(req, { + actorUserId: existing.userId, + action: 'auth.login.success', + resourceType: 'user', + resourceId: existing.userId, + after: { method: `oauth:${provider}` }, + }).catch((e: unknown) => console.error('audit error:', e)) + + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + + // Fetch actual roles per api.md §15.10 + const userRoleRows = await import('../Database').then(d => d.query( + `SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ?`, + [existing.userId], + )) + const sessionRoles = (userRoleRows as Array<{ name: string }>).map(r => r.name) + + // Grant extraRoles (e.g. 'reviewer' from org check) if not already granted + if (extraRoles && extraRoles.length > 0) { + for (const roleName of extraRoles) { + if (!sessionRoles.includes(roleName)) { + const roleRow = await import('../Database').then(d => d.queryOne(`SELECT id FROM roles WHERE name = ?`, [roleName])) + if (roleRow) { + await exec( + `INSERT IGNORE INTO user_roles (id, userId, roleId, grantedBy, createdAt) VALUES (?, ?, ?, ?, ?)`, + [genId('ur_'), existing.userId, roleRow.id, existing.userId, now], + ) + } + } + } + } + + const { sessionId, refreshToken } = await createSession( + existing.userId, + sessionRoles, + `oauth:${provider}`, + ip, ua, + ) + + // Per api.md §1.6.2: login_ok 时 lastStepUpAt 必须为 NULL + await exec( + `UPDATE sessions SET lastStepUpAt = NULL WHERE id = ?`, + [sessionId], + ) + + // Create login code per api.md §1.6.2: + // auth_tokens.metadata MUST bind sessionId, requestIpHash, userAgentHash + const loginCode = genId('oa_lc_') + const loginCodeHash = await hmacToken(loginCode) + const loginCodeIpHash = await hmacToken(ip) + const loginCodeUaHash = await sha256base64url(ua) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'oauth_login_code', ?, + JSON_OBJECT('sessionId', ?, 'requestIpHash', ?, 'userAgentHash', ?, 'provider', ?), ?, ?)`, + [ulid(), existing.userId, loginCodeHash, + sessionId, loginCodeIpHash, loginCodeUaHash, provider, + now + 60_000, now], + ) + + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', refreshToken, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/v1/auth', + maxAge: maxAge * 1000, + }) + + safeRedirect(res, `${APP_URL}/auth/callback?status=login_ok&loginCode=${loginCode}&redirectAfter=${encodeURIComponent(redirectAfter)}`) + return + } + case 'banned': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'banned' } }).catch((e: unknown) => console.error('audit error:', e)) + safeRedirect(res, `${APP_URL}/auth/error?status=login_blocked&code=ACCOUNT_BANNED`) + return + case 'merged': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'merged' } }).catch((e: unknown) => console.error('audit error:', e)) + safeRedirect(res, `${APP_URL}/auth/error?status=login_blocked&code=ACCOUNT_MERGED`) + return + case 'pending_deletion': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'pending_deletion' } }).catch((e: unknown) => console.error('audit error:', e)) + safeRedirect(res, `${APP_URL}/auth/error?status=login_blocked&code=ACCOUNT_PENDING_DELETION`) + return + case 'deleted': + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'deleted' } }).catch((e: unknown) => console.error('audit error:', e)) + safeRedirect(res, `${APP_URL}/auth/error?status=login_blocked&code=ACCOUNT_DELETED`) + return + default: + writeAuditLog(req, { actorUserId: existing.userId, action: 'auth.login.fail', resourceType: 'user', resourceId: existing.userId, after: { reason: 'unknown_status' } }).catch((e: unknown) => console.error('audit error:', e)) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } + } + + // OAuth 未绑定 + if (mode === 'bind') { + // 检查该 provider 下是否已有绑定 + const providerBound = await queryOne( + `SELECT id FROM oauth_accounts WHERE userId = ? AND provider = ?`, + [stateMeta.userId, provider], + ) + if (providerBound) { + safeRedirect(res, `${APP_URL}/auth/error?status=bind_provider_taken&provider=${provider}`) + return + } + + // 生成 pending 注册 token(binding 模式) + const pendingToken = genId('opt_') + const pendingHash = await hmacToken(pendingToken) + const csrfToken = genId('ocs_') + + const bindPii = await encryptOAuthPii({ + providerUsername: providerData.username, + providerDisplayName: providerData.displayName, + providerAvatarUrl: providerData.avatarUrl, + providerEmail: providerData.email, + providerEmailVerified: providerData.emailVerified, + csrfToken, + }) + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'oauth_pending_registration', ?, ?, ?, ?)`, + [ulid(), stateMeta.userId, pendingHash, + JSON.stringify({ + provider, providerUserId, mode: 'binding', userId: stateMeta.userId, + extraRoles: extraRoles || [], + pii: bindPii, + requestIpHash: await hmacToken(cbIp), + userAgentHash: await hmacToken(cbUa), + }), + now + 600_000, now], + ) + + // 设置 oauth_pending_{provider} HttpOnly Cookie(per api.md §1.6.1) + res.cookie(`oauth_pending_${provider}`, pendingToken, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/v1/auth/oauth', + maxAge: 600_000, + }) + + // 设置 CSRF Cookie(非 HttpOnly,前端可读,Path=/ 以便前端 JS 可从任何页面读取) + res.cookie('oauth_pending_csrf', csrfToken, { + httpOnly: false, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 600_000, + }) + + // Audit log per api.md §15.13 + writeAuditLog(req, { + actorUserId: stateMeta.userId, + action: 'oauth.bind.callback_pending_binding', + resourceType: 'oauth_account', + resourceId: stateMeta.userId, + after: { provider, status: 'pending_binding' }, + }).catch((e: unknown) => console.error('audit error:', e)) + + safeRedirect(res, `${APP_URL}/settings/security/oauth-bind/confirm?status=pending_binding&provider=${provider}`) + return + } + + // mode === 'login' — 生成 pending 注册 token + const pendingToken = genId('opt_') + const pendingHash = await hmacToken(pendingToken) + const csrfToken = genId('ocs_') + + const loginPii = await encryptOAuthPii({ + providerUsername: providerData.username, + providerDisplayName: providerData.displayName, + providerAvatarUrl: providerData.avatarUrl, + providerEmail: providerData.email, + providerEmailVerified: providerData.emailVerified, + csrfToken, + }) + + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, NULL, 'oauth_pending_registration', ?, ?, ?, ?)`, + [ulid(), pendingHash, + JSON.stringify({ + provider, providerUserId, mode: 'registration', + extraRoles: extraRoles || [], + pii: loginPii, + requestIpHash: await hmacToken(cbIp), + userAgentHash: await hmacToken(cbUa), + }), + now + 600_000, now], + ) + + // Set oauth_pending_ HttpOnly cookie + res.cookie(`oauth_pending_${provider}`, pendingToken, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/v1/auth/oauth', + maxAge: 600_000, + }) + + // Set CSRF cookie + res.cookie('oauth_pending_csrf', csrfToken, { + httpOnly: false, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 600_000, + }) + + safeRedirect(res, `${APP_URL}/auth/oauth/continue?status=pending_registration&provider=${provider}`) +} + +// ────────────────────────────────────────────── +// GET /auth/oauth/github/callback — api.md §1.6.2 +// ────────────────────────────────────────────── +router.get('/oauth/github/callback', async (req, res) => { + // Handle OAuth provider error (user denied authorization) — api.md §1.6.2 + const oauthError = req.query.error as string | undefined + if (oauthError) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error&code=OAUTH_ERROR&reason=user_denied`) + return + } + const code = req.query.code as string | undefined + if (!code) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } + + try { + const tokenRes = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, client_secret: GITHUB_CLIENT_SECRET, code }), + }) + const tokenData = await tokenRes.json() as { access_token?: string; error?: string } + if (!tokenData.access_token) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } + + const userRes = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${tokenData.access_token}`, 'User-Agent': 'TransCircle' }, + }) + const githubUser = await userRes.json() as { + id: number; login: string; name: string | null; avatar_url: string | null; email: string | null + } + + let email = githubUser.email + if (!email) { + try { + const emailRes = await fetch('https://api.github.com/user/emails', { + headers: { Authorization: `Bearer ${tokenData.access_token}`, 'User-Agent': 'TransCircle', Accept: 'application/vnd.github.v3+json' }, + }) + const emails = await emailRes.json() as Array<{ email: string; primary: boolean; verified: boolean }> + const primary = emails.find((e) => e.primary && e.verified) + if (primary) email = primary.email + } catch { /* ignore */ } + } + + // Check GitHub org membership for automatic reviewer role (api.md §15.10) + const githubOrg = (conf.GITHUB as Record | undefined)?.GITHUB_ORG + const extraRoles: string[] = [] + if (githubOrg && tokenData.access_token) { + try { + const orgRes = await fetch(`https://api.github.com/orgs/${encodeURIComponent(githubOrg)}/members/${githubUser.login}`, { + headers: { Authorization: `Bearer ${tokenData.access_token}`, 'User-Agent': 'TransCircle', Accept: 'application/vnd.github.v3+json' }, + }) + if (orgRes.status === 204) extraRoles.push('reviewer') + } catch { /* best-effort */ } + } + + await handleOAuthCallback('github', String(githubUser.id), { + username: githubUser.login, + displayName: githubUser.name || githubUser.login, + avatarUrl: githubUser.avatar_url, + email: email || null, + emailVerified: !!email, + }, req, res, extraRoles) + + // api.md §1.6.2: 显式清理 provider access token,避免泄漏至错误追踪或堆栈 + writeAuditLog(req, { + actorUserId: null, + action: 'oauth.provider_token.discard', + resourceType: 'oauth_account', + resourceId: String(githubUser?.id ?? ''), + after: { provider: 'github', reason: 'callback_completed' }, + }).catch((e: unknown) => console.error('audit error:', e)) + tokenData.access_token = undefined + } catch (err) { + console.error('GitHub OAuth error:', err) + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } +}) + +// ────────────────────────────────────────────── +// GET /auth/oauth/x/start — api.md §1.6.1 +// ────────────────────────────────────────────── +router.get('/oauth/x/start', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + if (!X_CLIENT_ID) { + sendError(res, Errors.OAUTH_PROVIDER_ERROR.code, 'X OAuth 未配置', req.requestId, 503) + return + } + + const state = genId('ost_') + const codeVerifier = ulid() + ulid() + // S256 code challenge per api.md §1.6.7 + const codeChallengeEnc = new TextEncoder() + const codeChallengeDigest = await crypto.subtle.digest('SHA-256', codeChallengeEnc.encode(codeVerifier)) + const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(codeChallengeDigest))) + .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') + const callbackUrl = `${API_URL}/v1/auth/oauth/x/callback` + const redirectAfter = validateRedirectAfter(req.query.redirectAfter as string | undefined) + if (redirectAfter === null) { + sendError(res, Errors.BAD_REQUEST.code, 'redirectAfter 不在 allowlist 中', req.requestId, 400) + return + } + + const stateHash = await hmacToken(state) + const xStateIp = req.ip || req.socket.remoteAddress || 'unknown' + const xStateUa = req.headers['user-agent'] || 'unknown' + // Per api.md §1.6.1: state must bind provider, redirectAfter, ipHash, uaHash, mode + const xStateMeta = JSON.stringify({ + provider: 'x', + mode: 'login', + redirectAfter, + codeVerifier, + codeChallenge, + ipHash: await hmacToken(xStateIp), + uaHash: await hmacToken(xStateUa), + }) + await exec( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, NULL, 'oauth_state', ?, ?, ?, ?)`, + [ulid(), stateHash, xStateMeta, Date.now() + 600_000, Date.now()], + ) + + res.cookie('oauth_state', state, { + httpOnly: true, secure: true, + sameSite: 'lax', path: '/v1/auth/oauth', maxAge: 600_000, + }) + + const scopes = encodeURIComponent('tweet.read users.read') + const authorizationUrl = `https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${X_CLIENT_ID}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${scopes}&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256` + + sendSuccess(res, { authorizationUrl, stateExpiresIn: 600 }, req.requestId) +}) + +// ────────────────────────────────────────────── +// GET /auth/oauth/x/callback — api.md §1.6.2 +// ────────────────────────────────────────────── +router.get('/oauth/x/callback', async (req, res) => { + // Handle OAuth provider error (user denied authorization) — api.md §1.6.2 + const oauthError = req.query.error as string | undefined + if (oauthError) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error&code=OAUTH_ERROR&reason=user_denied`) + return + } + const { code, state } = req.query as { code?: string; state?: string } + if (!code) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } + + try { + // Verify state and code verifier from DB + const stateHash = await hmacToken(state || '') + const stateRecord = await queryOne( + `SELECT id, metadata FROM auth_tokens WHERE tokenHash = ? AND type = 'oauth_state' AND expiresAt > ?`, + [stateHash, Date.now()], + ) + if (!stateRecord) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=bad_state`) + return + } + + const meta = typeof stateRecord.metadata === 'string' ? JSON.parse(stateRecord.metadata) : stateRecord.metadata + const codeVerifier = meta.codeVerifier + + const tokenRes = await fetch('https://api.twitter.com/2/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa(`${X_CLIENT_ID}:${X_CLIENT_SECRET}`)}`, + }, + body: new URLSearchParams({ code, grant_type: 'authorization_code', redirect_uri: `${API_URL}/v1/auth/oauth/x/callback`, code_verifier: codeVerifier }), + }) + const tokenData = await tokenRes.json() as { access_token?: string; error?: string } + if (!tokenData.access_token) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } + + const userRes = await fetch('https://api.twitter.com/2/users/me', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }) + const userData = await userRes.json() as { data?: { id: string; username: string; name: string } } + if (!userData.data) { + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } + + const { id, username, name } = userData.data + + await handleOAuthCallback('x', id, { + username, + displayName: name || username, + avatarUrl: null, + email: null, + emailVerified: false, + }, req, res) + + // api.md §1.6.2: 清理 provider access token + writeAuditLog(req, { + actorUserId: null, + action: 'oauth.provider_token.discard', + resourceType: 'oauth_account', + resourceId: String(id ?? ''), + after: { provider: 'x', reason: 'callback_completed' }, + }).catch((e: unknown) => console.error('audit error:', e)) + if (typeof tokenData !== 'undefined') (tokenData as Record).access_token = undefined + } catch (err) { + console.error('X OAuth error:', err) + res.clearCookie('oauth_state', { path: '/v1/auth/oauth' }) + safeRedirect(res, `${APP_URL}/auth/error?status=oauth_error`) + return + } +}) + +// ────────────────────────────────────────────── +// POST /auth/oauth/complete-registration — api.md §1.6.4 +// Complete OAuth registration with username/password etc. +// ────────────────────────────────────────────── +router.post('/oauth/complete-registration', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, idempotencyKey, async (req, res) => { + const provider = req.query.provider as string || 'github' + + // CSRF validation: X-CSRF-Token must match oauth_pending_csrf cookie + const csrfToken = req.headers['x-csrf-token'] as string | undefined + const csrfCookie = req.cookies?.oauth_pending_csrf as string | undefined + if (!csrfToken || !csrfCookie || csrfToken !== csrfCookie) { + sendError(res, Errors.CSRF_TOKEN_INVALID.code, Errors.CSRF_TOKEN_INVALID.message, req.requestId, Errors.CSRF_TOKEN_INVALID.status) + return + } + + const pendingCookieName = `oauth_pending_${provider}` + const pendingToken = req.cookies?.[pendingCookieName] as string | undefined + if (!pendingToken) { + sendError(res, 'MISSING_OAUTH_PENDING', `缺少 ${pendingCookieName} Cookie`, req.requestId, 401) + return + } + + const parsed = completeRegistrationSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, 422, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const { username, password, displayName, emailMatchesProvider } = parsed.data + // Normalize email: lowercase per api.md §1.1 + const email = parsed.data.email.toLowerCase() + + // api.md §1.1: HIBP k-Anonymity leak check + if (!await isPasswordNotLeaked(password)) { + sendError(res, Errors.VALIDATION_ERROR.code, '该密码已在公开泄露中出现,请更换密码', req.requestId, 422) + return + } + + const now = Date.now() + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + + // Transaction with row-level lock + const { getConnection } = await import('../Database') + const conn = await getConnection() + try { + await conn.beginTransaction() + + // Look up oauth_pending_registration by pendingToken hash (contains actual provider data) + // IMPORTANT: filter usedAt IS NULL to prevent concurrent requests from both passing (api.md §1.6.4) + const pendingHash = await hmacToken(pendingToken) + const [lockRows] = await conn.execute( + `SELECT id, userId, metadata FROM auth_tokens + WHERE tokenHash = ? AND type = 'oauth_pending_registration' AND usedAt IS NULL + FOR UPDATE`, + [pendingHash], + ) + + if ((lockRows as unknown[]).length === 0) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'Pending token 无效或已过期', req.requestId, 410) + return + } + + const pendingRecord = lockRows[0] as { id: string; userId: string; metadata: string } + const meta = typeof pendingRecord.metadata === 'string' ? JSON.parse(pendingRecord.metadata) : pendingRecord.metadata + + // Decrypt PII from encrypted metadata blob (api.md §1.6.2) + if (meta.pii) { + try { + const pii = await decryptOAuthPii(meta.pii) + Object.assign(meta, pii) + } catch { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '加密数据损坏', req.requestId, 410) + return + } + } + + // Verify this is a registration-mode token with actual provider data (not a csrf-only record) + if (!meta.provider || !meta.providerUserId) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '无效的 pending token', req.requestId, 410) + return + } + + // Check uniqueness + const [existingUsername] = await conn.execute( + `SELECT id FROM users WHERE username = ? AND id != ?`, + [username, pendingRecord.userId || '_new'], + ) + if ((existingUsername as unknown[]).length > 0) { + await conn.rollback() + sendError(res, Errors.USERNAME_TAKEN.code, Errors.USERNAME_TAKEN.message, req.requestId, Errors.USERNAME_TAKEN.status) + return + } + + const [existingEmail] = await conn.execute( + `SELECT id FROM users WHERE email = ? AND id != ?`, + [email, pendingRecord.userId || '_new'], + ) + if ((existingEmail as unknown[]).length > 0) { + await conn.rollback() + sendError(res, Errors.EMAIL_TAKEN.code, Errors.EMAIL_TAKEN.message, req.requestId, Errors.EMAIL_TAKEN.status) + return + } + + // Hash password with argon2id (per api.md 安全基线) + const passwordHash = await hashPassword(password) + + // Mark pending token as used + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ? AND usedAt IS NULL`, [now, pendingRecord.id]) + + if (pendingRecord.userId) { + // Update existing stub user (created by OAuth callback) + const emailVerified = !!(emailMatchesProvider && meta.providerEmailVerified) + const emailVerifiedSource = emailVerified ? `oauth:${meta.provider}` : null + await conn.execute( + `UPDATE users SET username = ?, email = ?, emailVerified = ?, emailVerifiedSource = ?, + displayName = COALESCE(NULLIF(?, ''), displayName), + passwordHash = ?, passwordUpdatedAt = ?, status = 'active', updatedAt = ? WHERE id = ?`, + [username, email, emailVerified, emailVerifiedSource, displayName || username, passwordHash, now, now, pendingRecord.userId], + ) + + // Insert OAuth binding if not yet bound + await conn.execute( + `INSERT IGNORE INTO oauth_accounts (id, userId, provider, providerUserId, providerUsername, providerDisplayName, providerAvatarUrl, providerEmail, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [genId('oauth_'), pendingRecord.userId, meta.provider, meta.providerUserId, meta.providerUsername || null, meta.providerDisplayName || null, meta.providerAvatarUrl || null, meta.providerEmail || null, now, now], + ) + + // Grant extra roles from pending token metadata (e.g. 'reviewer' from org check) + await grantExtraRoles(conn, pendingRecord.userId, meta.extraRoles as string[] | undefined, pendingRecord.userId) + + const user = await findUserById(pendingRecord.userId) + if (!user) { + await conn.rollback() + sendError(res, Errors.INTERNAL_ERROR.code, '用户查询失败', req.requestId, 500) + return + } + + // Create session (use conn for transactional atomicity) + const { sessionId, refreshToken } = await createSession(user.id, user.roles, `oauth:${provider}`, ip, ua, conn) + + // Generate login code + const loginCode = genId('oa_lc_') + const loginCodeHash = await hmacToken(loginCode) + const loginCodeIpHash = await hmacToken(ip) + const loginCodeUaHash = await sha256base64url(ua) + await conn.execute( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'oauth_login_code', ?, JSON_OBJECT('sessionId', ?, 'requestIpHash', ?, 'userAgentHash', ?, 'provider', ?), ?, ?)`, + [ulid(), user.id, loginCodeHash, sessionId, loginCodeIpHash, loginCodeUaHash, provider, now + 60_000, now], + ) + + // Audit logs + await writeAuditLog(req, { + actorUserId: pendingRecord.userId, + action: 'auth.register', + resourceType: 'user', + resourceId: pendingRecord.userId, + after: { username, email, provider }, + }) + + await conn.commit() + + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', refreshToken, { + httpOnly: true, secure: true, + sameSite: 'lax', path: '/v1/auth', maxAge: maxAge * 1000, + }) + + res.clearCookie(`oauth_pending_${provider}`, { path: '/v1/auth/oauth' }) + res.clearCookie('oauth_pending_csrf', { path: '/' }) + + // Send verification email if needed per api.md §1.6.4 + let verificationEmailSent = false + if (!emailVerified && email) { + const rawVerifyToken = genId('ev_') + const verifyHash = await hmacToken(rawVerifyToken) + await conn.execute( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'email_verify', ?, '{}', ?, ?)`, + [ulid(), user.id, verifyHash, now + 86400_000, now], + ) + verificationEmailSent = await sendEmail(buildVerificationEmail(email, rawVerifyToken)) + } + + sendSuccess(res, { + user: { + id: user.id, username, email, + displayName: user.displayName, avatarUrl: user.avatarUrl, + emailVerified, + status: 'active', + createdAt: user.createdAt, + }, + boundProvider: provider as 'github' | 'x', + loginCode, + verificationEmailSent, + }, req.requestId, 201) + return + } else { + // Create new user + const userId = genId('usr_') + const emailVerified = !!(emailMatchesProvider && meta.providerEmailVerified) + const emailVerifiedSource = emailVerified ? `oauth:${meta.provider}` : null + await conn.execute( + `INSERT INTO users (id, username, email, emailVerified, emailVerifiedSource, displayName, avatarUrl, passwordHash, passwordUpdatedAt, passwordHistory, status, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, username, email, emailVerified, emailVerifiedSource, displayName || username, + meta.providerAvatarUrl || null, passwordHash, now, JSON.stringify([passwordHash]), + emailVerified ? 'active' : 'pending_verification', now, now], + ) + + // Insert OAuth binding + await conn.execute( + `INSERT IGNORE INTO oauth_accounts (id, userId, provider, providerUserId, providerUsername, providerDisplayName, providerAvatarUrl, providerEmail, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [genId('oauth_'), userId, meta.provider, meta.providerUserId, meta.providerUsername || null, meta.providerDisplayName || null, meta.providerAvatarUrl || null, meta.providerEmail || null, now, now], + ) + + // Grant extra roles from pending token metadata (e.g. 'reviewer' from org check) + await grantExtraRoles(conn, userId, meta.extraRoles as string[] | undefined, userId) + + // Use the new userId for session creation + const user = await findUserById(userId) + if (!user) { + await conn.rollback() + sendError(res, Errors.INTERNAL_ERROR.code, '用户创建失败', req.requestId, 500) + return + } + + // Create session (use conn for transactional atomicity) + const { sessionId, refreshToken } = await createSession(user.id, user.roles, `oauth:${provider}`, ip, ua, conn) + + // Generate login code + const loginCode = genId('oa_lc_') + const loginCodeHash = await hmacToken(loginCode) + const loginCodeIpHash = await hmacToken(ip) + const loginCodeUaHash = await sha256base64url(ua) + await conn.execute( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'oauth_login_code', ?, JSON_OBJECT('sessionId', ?, 'requestIpHash', ?, 'userAgentHash', ?, 'provider', ?), ?, ?)`, + [ulid(), user.id, loginCodeHash, sessionId, loginCodeIpHash, loginCodeUaHash, provider, now + 60_000, now], + ) + + // Audit logs + await writeAuditLog(req, { + actorUserId: userId, + action: 'auth.register', + resourceType: 'user', + resourceId: userId, + after: { username, email, provider }, + }) + await writeAuditLog(req, { + actorUserId: userId, + action: 'oauth.bind', + resourceType: 'user', + resourceId: userId, + after: { provider, bound: true }, + }) + + // Send verification email if needed per api.md §1.6.9 + let verificationEmailSent = false + if (!emailVerified && email) { + const rawVerifyToken = genId('ev_') + const verifyHash = await hmacToken(rawVerifyToken) + await conn.execute( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'email_verify', ?, '{}', ?, ?)`, + [ulid(), userId, verifyHash, now + 86400_000, now], + ) + verificationEmailSent = await sendEmail(buildVerificationEmail(email, rawVerifyToken)) + } + + await conn.commit() + + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', refreshToken, { + httpOnly: true, secure: true, + sameSite: 'lax', path: '/v1/auth', maxAge: maxAge * 1000, + }) + + res.clearCookie(`oauth_pending_${provider}`, { path: '/v1/auth/oauth' }) + res.clearCookie('oauth_pending_csrf', { path: '/' }) + + sendSuccess(res, { + user: { + id: user.id, username, email, + displayName: user.displayName, avatarUrl: user.avatarUrl, + emailVerified, status: emailVerified ? 'active' : 'pending_verification', + createdAt: user.createdAt, + }, + boundProvider: provider as 'github' | 'x', + loginCode, + verificationEmailSent, + }, req.requestId, 201) + return + } + + // (both branches above return — no additional commit needed here) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('complete-registration error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '注册失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +// ────────────────────────────────────────────── +// POST /auth/oauth/exchange — api.md §1.6.3 +// Exchange loginCode for access token +// ────────────────────────────────────────────── +router.post('/oauth/exchange', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const parsed = loginCodeSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, 'loginCode 不能为空', req.requestId, Errors.VALIDATION_ERROR.status) + return + } + + const { loginCode } = parsed.data + const loginCodeHash = await hmacToken(loginCode) + const { getConnection } = await import('../Database') + + let userId: string | null + let existingSessionId: string | undefined + let exchangeProvider: string | undefined + { + const conn = await getConnection() + try { + await conn.beginTransaction() + + const [lockRows] = await conn.execute( + `SELECT id, userId, expiresAt, metadata FROM auth_tokens + WHERE tokenHash = ? AND type = 'oauth_login_code' + FOR UPDATE`, + [loginCodeHash], + ) + const rows = lockRows as Array<{ id: string; userId: string; expiresAt: number; metadata: string }> + const token = rows[0] ?? null + + if (!token) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'loginCode 无效', req.requestId, 410) + return + } + + if (token.expiresAt < Date.now()) { + await conn.execute(`DELETE FROM auth_tokens WHERE id = ?`, [token.id]) + await conn.commit() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'loginCode 已过期', req.requestId, 410) + return + } + + // Extract metadata from token before consuming + const meta = (() => { + try { + return typeof token.metadata === 'string' ? JSON.parse(token.metadata) : token.metadata + } catch { return {} } + })() + existingSessionId = meta.sessionId as string | undefined + + // Extract provider from loginCode metadata for session creation fallback + exchangeProvider = meta.provider as string | undefined + + // Validate requestIpHash per api.md §1.6.2 + const metaIpHash = meta.requestIpHash as string | undefined + if (metaIpHash) { + const currentIp = req.ip || req.socket.remoteAddress || 'unknown' + const currentIpHash = await hmacToken(currentIp) + if (currentIpHash !== metaIpHash) { + await conn.rollback() + sendError(res, 'BAD_STATE', '请求 IP 与登录时不匹配', req.requestId, 400) + return + } + } + + // Validate userAgentHash per api.md §1.6.2 + const metaUaHash = meta.userAgentHash as string | undefined + if (metaUaHash) { + const currentUa = req.headers['user-agent'] || 'unknown' + const currentUaHash = await sha256base64url(currentUa) + if (currentUaHash !== metaUaHash) { + await conn.rollback() + sendError(res, 'BAD_STATE', 'User-Agent 与登录时不匹配', req.requestId, 400) + return + } + } + + // Validate refresh_token Cookie per api.md §1.6.3 + const refreshTokenCookieHeader = req.cookies?.refresh_token as string | undefined + if (!refreshTokenCookieHeader) { + await conn.rollback() + sendError(res, 'INVALID_REFRESH_TOKEN', '缺少 refresh_token Cookie', req.requestId, 401) + return + } + const rtCookieHash = await hmacToken(refreshTokenCookieHeader) + const [rtRows] = await conn.execute( + `SELECT sessionId FROM refresh_token_events WHERE tokenHash = ? AND status IN ('active', 'rotated')`, + [rtCookieHash], + ) + if (rtRows.length === 0 || rtRows[0]?.sessionId !== existingSessionId) { + await conn.rollback() + sendError(res, 'INVALID_REFRESH_TOKEN', 'refresh_token 与 loginCode 不匹配', req.requestId, 401) + return + } + + const [delRaw] = await conn.execute(`DELETE FROM auth_tokens WHERE id = ? AND tokenHash = ?`, [token.id, loginCodeHash]) + const delHeader = delRaw as { affectedRows: number } + + if (delHeader.affectedRows === 0) { + await conn.commit() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', 'loginCode 已被使用', req.requestId, 410) + return + } + + userId = token.userId + await conn.commit() + } finally { + conn.release() + } + } + + if (!userId) { + sendError(res, Errors.INTERNAL_ERROR.code, 'loginCode 兑换失败', req.requestId, 500) + return + } + + const user = await findUserById(userId) + if (!user) { + sendError(res, Errors.INTERNAL_ERROR.code, '用户不存在', req.requestId, 500) + return + } + + // OAuth callback already created the session — reuse sessionId (api.md §1.6.2) + // loginCode metadata stores the sessionId created by the callback + let refreshTokenCookie: string | undefined + if (!existingSessionId) { + // No existing session — create one (e.g. from complete-registration flow) + const { createSession: cs } = await import('../utils/session') + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const newSession = await cs(user.id, user.roles, `oauth:${exchangeProvider || 'github'}`, ip, ua) + existingSessionId = newSession.sessionId + refreshTokenCookie = newSession.refreshToken + } + + const accessToken = await signJwt({ + sub: user.id, + sid: existingSessionId!, + tokenVersion: user.tokenVersion, + roles: user.roles, + aud: 'transcircle-web', + iss: 'https://api.transcircle.org', + }) + + if (refreshTokenCookie) { + const maxAge = (SESSION_CONF?.SESS_MAXAGE as number) || 7 * 24 * 60 * 60 + res.cookie('refresh_token', refreshTokenCookie, { + httpOnly: true, secure: true, + sameSite: 'lax', path: '/v1/auth', maxAge: maxAge * 1000, + }) + } + + sendSuccess(res, { + accessToken, + tokenType: 'Bearer', + expiresIn: 900, + user: { + id: user.id, username: user.username, + displayName: user.displayName, avatarUrl: user.avatarUrl, + emailVerified: user.emailVerified, + }, + }, req.requestId) +}) + +export default router diff --git a/functions/src/routes/contributions.ts b/functions/src/routes/contributions.ts new file mode 100644 index 0000000..0272e40 --- /dev/null +++ b/functions/src/routes/contributions.ts @@ -0,0 +1,163 @@ +import { Router, type Router as RouterType } from 'express' +import { exec, queryOne } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { sendSuccess, sendError, Errors, zodErrorsToDetails } from '../utils/response' +import { requireAuth } from '../middleware/auth' +import { contributionSchema } from '../utils/validation' +import { rateLimitCheck } from '../middleware/rateLimit' +import { idempotencyKey } from '../middleware/idempotency' +import { writeAuditLog } from '../utils/audit' +import { markdownToHtml, plainTextToHtml } from '../utils/sanitize' +import { hmacToken } from '../utils/session' + +/** Pure SHA-256 hash (for submitterUserAgentHash per api.md §15.11) */ +async function sha256(input: string): Promise { + const enc = new TextEncoder() + const digest = await crypto.subtle.digest('SHA-256', enc.encode(input)) + return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('') +} + +const router: RouterType = Router() + +// ────────────────────────────────────────────── +// POST /contributions — api.md §3 提交投稿 +// ────────────────────────────────────────────── +router.post('/', requireAuth, (req, _res, next) => { req.rateLimitAction = 'submit'; next(); }, rateLimitCheck, idempotencyKey, async (req, res) => { + // Verify email per api.md §3.4 — 403 EMAIL_NOT_VERIFIED + const user = await queryOne(`SELECT status FROM users WHERE id = ?`, [req.user!.userId]) + if (!user || user.status !== 'active') { + if (user?.status === 'banned') { + sendError(res, Errors.ACCOUNT_BANNED.code, Errors.ACCOUNT_BANNED.message, req.requestId, Errors.ACCOUNT_BANNED.status) + return + } + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证,不能投稿', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + // Per-IP rate limit: 20/10min per api.md §3.1 + const now = Date.now() + const submitIpWindow = Math.floor(now / 600_000) * 600_000 + const submitIpKey = `submit:ip:${req.ip || 'unknown'}:${submitIpWindow}` + const submitIpCount = await queryOne(`SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, [submitIpKey, submitIpWindow]) + if ((submitIpCount?.count as number || 0) >= 20) { + const retryAfter = Math.ceil((submitIpWindow + 600_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + sendError(res, Errors.RATE_LIMITED.code, '同一 IP 投稿过于频繁,请稍后重试', req.requestId, Errors.RATE_LIMITED.status) + return + } + + // New-user daily submission limit (per api.md §3.1: 3/day for new users) + // "新注册用户" = within first 7 days of registration + const NEW_USER_PERIOD_MS = 7 * 86400_000 + const userReg = await queryOne(`SELECT createdAt FROM users WHERE id = ?`, [req.user!.userId]) + if (userReg) { + const userAge = Date.now() - (userReg.createdAt as number) + if (userAge < NEW_USER_PERIOD_MS) { + const todayStart = Math.floor(Date.now() / 86400_000) * 86400_000 + const todayCount = await queryOne( + `SELECT COUNT(*) as cnt FROM contributions WHERE authorUserId = ? AND createdAt >= ?`, + [req.user!.userId, todayStart], + ) + if ((todayCount?.cnt as number || 0) >= 3) { + const retryAfter = Math.ceil((todayStart + 86400_000 - Date.now()) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + sendError(res, Errors.RATE_LIMITED.code, '新用户每天最多投稿 3 次', req.requestId, Errors.RATE_LIMITED.status) + return + } + } + } + + // Honeypot check: if website field is filled, it's a bot (api.md §3 anti-spam) + if ((req.body as Record)?.website) { + sendError(res, Errors.VALIDATION_ERROR.code, '检测到垃圾投稿', req.requestId, 422) + return + } + + // Rejected submission cooling period (api.md §3.1): users with ≥3 rejected submissions + // in the last 7 days are cooled for 24 hours + const recentRejections = await queryOne( + `SELECT COUNT(*) as cnt FROM contribution_review_events e + JOIN contributions c ON c.id = e.contributionId + WHERE c.authorUserId = ? AND e.action = 'review' AND e.toStatus = 'rejected' AND e.createdAt >= ?`, + [req.user!.userId, now - 7 * 86400_000], + ) + if ((recentRejections?.cnt as number || 0) >= 3) { + sendError(res, Errors.RATE_LIMITED.code, '您的投稿多次被拒,请 24 小时后重试', req.requestId, 429) + return + } + + const parsed = contributionSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, Errors.VALIDATION_ERROR.status, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const data = parsed.data + + const id = genId('contrib_') + const ts = Date.now() + const submitMode = data.submitMode || 'submit' + const status = submitMode === 'draft' ? 'draft' : 'pending' + + const contentHtml = + data.contentFormat === 'plain_text' + ? plainTextToHtml(data.content) + : markdownToHtml(data.content) + + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const ipHash = await hmacToken(ip) + const uaHash = await sha256(ua) // sha256 per api.md §15.11 + + try { + await exec( + `INSERT INTO contributions (id, authorUserId, title, summary, contentRaw, contentFormat, contentHtml, rendererVersion, status, version, language, tags, idempotencyKey, submitterIpHash, submitterUserAgentHash, submittedAt, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, 'v1', ?, 1, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + req.user!.userId, + data.title.trim(), + data.summary?.trim() || null, + data.content, + data.contentFormat || 'markdown', + contentHtml, + status, + data.language || 'zh-CN', + JSON.stringify(data.tags || []), + (req.headers['idempotency-key'] as string) || null, + ipHash, + uaHash, + status === 'pending' ? ts : null, + ts, + ts, + ], + ) + } catch (insertErr: unknown) { + const mysqlErr = insertErr as { code?: string } + if (mysqlErr.code === 'ER_DUP_ENTRY') { + sendError(res, Errors.DUPLICATE_SUBMISSION.code, '重复提交', req.requestId, 409) + return + } + throw insertErr + } + + // Write audit log per api.md §3 + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.submit', + resourceType: 'contribution', + resourceId: id, + after: { title: data.title, status }, + }) + + // Increment IP rate limit counter + await exec( + `INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) VALUES (?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE count = count + 1`, + [ulid(), submitIpKey, submitIpWindow, ts], + ).catch(() => {}) + + sendSuccess(res, { id, status, createdAt: ts }, req.requestId, 201) +}) + +export default router diff --git a/functions/src/routes/edit-requests.ts b/functions/src/routes/edit-requests.ts new file mode 100644 index 0000000..508a37a --- /dev/null +++ b/functions/src/routes/edit-requests.ts @@ -0,0 +1,575 @@ +import { Router, type Request } from 'express' +import type mysql from 'mysql2' +import { exec, queryOne, query } from '../Database' +import { genId } from '../utils/ulid' +import { sendSuccess, sendError, Errors, zodErrorsToDetails } from '../utils/response' +import { requireAuth, requireReviewer } from '../middleware/auth' +import { writeAuditLog } from '../utils/audit' +import { markdownToHtml, plainTextToHtml } from '../utils/sanitize' +import { requirePerm } from '../utils/permissions' +import { z } from 'zod' + +/** Votes required for edit request resolution per api.md §10.6 */ +const REQUIRED_VOTES = 2 + +/** Helper to extract string param in Express 5 */ +function paramStr(req: Request, name: string): string { + const v = req.params[name] + return Array.isArray(v) ? v[0] ?? '' : v ?? '' +} + +const router: Router = Router() + +// ─── Validation schemas ──────────────────────────────────────────── + +const createEditRequestSchema = z.object({ + reason: z.string().min(1, '修改原因不能为空').refine((v) => [...v].length <= 500, '修改原因最多 500 个字符'), + proposedTitle: z.string().refine((v) => [...v].length <= 120, '标题最多 120 个字符').optional(), + proposedContent: z.string().refine((v) => [...v].length <= 50000, '正文最多 50000 字符').optional(), + proposedContentFormat: z.enum(['markdown', 'plain_text']).optional(), + proposedSummary: z.string().refine((v) => [...v].length <= 300, '摘要最多 300 个字符').optional(), + proposedTags: z.array(z.string()).max(8, '最多 8 个标签').optional(), +}).refine( + (data) => data.proposedTitle !== undefined || data.proposedContent !== undefined + || data.proposedSummary !== undefined || data.proposedTags !== undefined, + { message: '至少需要提供一项修改内容', path: ['_form'] }, +).refine( + (data) => !data.proposedContent || data.proposedContentFormat !== undefined, + { message: '提供 proposedContent 时 proposedContentFormat 必填', path: ['proposedContentFormat'] }, +) + +const withdrawEditRequestSchema = z.object({ + expectedVersion: z.number().int().positive('version 必须为正整数'), +}) + +const voteSchema = z.object({ + vote: z.enum(['approve', 'reject']), + note: z.string().refine((v) => [...v].length <= 500, '备注最多 500 个字符').nullable().optional(), + expectedVersion: z.number().int().positive('version 必须为正整数'), +}) + +// ─── 10.1 POST /v1/contributions/{id}/edit-requests — 提交修改申请 ── + +router.post('/contributions/:id/edit-requests', requireAuth, async (req, res) => { + // Check email verified per api.md §10.1 + const user = await queryOne(`SELECT status FROM users WHERE id = ?`, [req.user!.userId]) + if (!user || user.status !== 'active') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + const contributionId = paramStr(req, 'id') + + // Check contribution exists and is published per api.md §10.1 + const contrib = await queryOne( + `SELECT id, status FROM contributions WHERE id = ?`, + [contributionId], + ) + if (!contrib) { + sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, Errors.CONTRIBUTION_NOT_FOUND.status) + return + } + if (contrib.status !== 'published') { + sendError(res, 'CONTRIBUTION_NOT_EDITABLE', '只能对已发布的投稿发起修改申请', req.requestId, 409) + return + } + + const parsed = createEditRequestSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, Errors.VALIDATION_ERROR.status, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const { reason, proposedTitle, proposedContent, proposedContentFormat, proposedSummary, proposedTags } = parsed.data + const now = Date.now() + const editReqId = genId('edr_') + + // Check for pending edit request from same user on same contribution + const existingPending = await queryOne( + `SELECT id FROM contribution_edit_requests WHERE contributionId = ? AND requesterId = ? AND status = 'pending'`, + [contributionId, req.user!.userId], + ) + if (existingPending) { + sendError(res, Errors.CONFLICT.code, '您已有一项待处理的修改申请', req.requestId, Errors.CONFLICT.status) + return + } + + await exec( + `INSERT INTO contribution_edit_requests (id, contributionId, requesterId, reason, + proposedTitle, proposedContent, proposedContentFormat, proposedSummary, proposedTags, + status, version, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, ?, ?)`, + [editReqId, contributionId, req.user!.userId, reason, + proposedTitle || null, proposedContent || null, proposedContentFormat || null, + proposedSummary || null, proposedTags ? JSON.stringify(proposedTags) : null, + now, now], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.edit_request.create', + resourceType: 'contribution', + resourceId: contributionId, + after: { editRequestId: editReqId, reason }, + }) + + sendSuccess(res, { + id: editReqId, + contributionId, + status: 'pending', + createdAt: now, + }, req.requestId, 201) +}) + +// ─── 10.2 POST /v1/me/edit-requests/{id}/withdraw — 撤回修改申请 ── + +router.post('/me/edit-requests/:id/withdraw', requireAuth, async (req, res) => { + const id = paramStr(req, 'id') + const parsed = withdrawEditRequestSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, Errors.VALIDATION_ERROR.status, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const { expectedVersion } = parsed.data + const now = Date.now() + + const editReq = await queryOne( + `SELECT id, status, version FROM contribution_edit_requests WHERE id = ? AND requesterId = ?`, + [id, req.user!.userId], + ) + if (!editReq) { + sendError(res, Errors.EDIT_REQUEST_NOT_FOUND.code, '修改申请不存在', req.requestId, Errors.EDIT_REQUEST_NOT_FOUND.status) + return + } + if (editReq.status !== 'pending') { + sendError(res, Errors.INVALID_STATE_TRANSITION.code, '当前状态不可撤回', req.requestId, Errors.INVALID_STATE_TRANSITION.status) + return + } + if (editReq.version !== expectedVersion) { + sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status) + return + } + + const result = await exec( + `UPDATE contribution_edit_requests SET status = 'withdrawn', version = version + 1, updatedAt = ? + WHERE id = ? AND version = ? AND requesterId = ?`, + [now, id, expectedVersion, req.user!.userId], + ) + if (result.affectedRows === 0) { + sendError(res, Errors.VERSION_CONFLICT.code, '撤回失败', req.requestId, Errors.VERSION_CONFLICT.status) + return + } + + // Audit log per api.md §15.13 + writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.edit_request.withdraw', + resourceType: 'edit_request', + resourceId: id, + after: { status: 'withdrawn' }, + }).catch((e: unknown) => console.error('audit error:', e)) + + sendSuccess(res, { + id, + status: 'withdrawn', + version: (editReq.version as number) + 1, + updatedAt: now, + }, req.requestId) +}) + +// ─── 10.3 GET /v1/me/edit-requests — 查看我的修改申请列表 ── + +router.get('/me/edit-requests', requireAuth, async (req, res) => { + const status = req.query.status as string | undefined + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + + let whereClause = 'WHERE er.requesterId = ?' + const params: unknown[] = [req.user!.userId] + + if (status) { + whereClause += ' AND er.status = ?' + params.push(status) + } + if (cursor) { + whereClause += ' AND er.createdAt < ?' + params.push(parseInt(Buffer.from(cursor, 'base64url').toString('utf-8'), 10)) + } + + params.push(limit + 1) + + const rows = await query( + `SELECT er.id, er.contributionId, c.title as contributionTitle, er.reason, er.status, er.createdAt, er.updatedAt, + (SELECT COUNT(*) FROM edit_request_votes WHERE editRequestId = er.id AND vote = 'approve') as approveVotes, + (SELECT COUNT(*) FROM edit_request_votes WHERE editRequestId = er.id AND vote = 'reject') as rejectVotes + FROM contribution_edit_requests er + JOIN contributions c ON c.id = er.contributionId + ${whereClause} + ORDER BY er.createdAt DESC + LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + const data = (rows as Array>).map((r) => ({ + id: r.id, + contributionId: r.contributionId, + contributionTitle: r.contributionTitle, + reason: r.reason, + status: r.status, + votes: { + approve: (r.approveVotes as number) || 0, + reject: (r.rejectVotes as number) || 0, + total: ((r.approveVotes as number) || 0) + ((r.rejectVotes as number) || 0), + required: REQUIRED_VOTES, + }, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') + : null + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +// ─── 10.4 GET /v1/admin/edit-requests — 审核员查看修改申请列表 ── + +router.get('/admin/edit-requests', requireAuth, requireReviewer, requirePerm('contribution:edit-request:vote'), async (req, res) => { + const status = req.query.status as string | undefined + const contributionId = req.query.contributionId as string | undefined + const requesterId = req.query.requesterId as string | undefined + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + + let whereClause = 'WHERE 1=1' + const params: unknown[] = [] + + if (status) { whereClause += ' AND er.status = ?'; params.push(status) } + if (contributionId) { whereClause += ' AND er.contributionId = ?'; params.push(contributionId) } + if (requesterId) { whereClause += ' AND er.requesterId = ?'; params.push(requesterId) } + if (cursor) { + whereClause += ' AND er.createdAt < ?' + params.push(parseInt(Buffer.from(cursor, 'base64url').toString('utf-8'), 10)) + } + + params.push(limit + 1) + + const rows = await query( + `SELECT er.id, er.contributionId, c.title as contributionTitle, er.requesterId, u.displayName as requesterDisplayName, + er.reason, er.status, er.createdAt, + (SELECT COUNT(*) FROM edit_request_votes WHERE editRequestId = er.id AND vote = 'approve') as approveVotes, + (SELECT COUNT(*) FROM edit_request_votes WHERE editRequestId = er.id AND vote = 'reject') as rejectVotes + FROM contribution_edit_requests er + JOIN contributions c ON c.id = er.contributionId + JOIN users u ON u.id = er.requesterId + ${whereClause} + ORDER BY er.createdAt DESC + LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + const data = (rows as Array>).map((r) => ({ + id: r.id, + contributionId: r.contributionId, + contributionTitle: r.contributionTitle, + requester: { + id: r.requesterId, + displayName: r.requesterDisplayName, + }, + reason: r.reason, + status: r.status, + votes: { + approve: (r.approveVotes as number) || 0, + reject: (r.rejectVotes as number) || 0, + total: ((r.approveVotes as number) || 0) + ((r.rejectVotes as number) || 0), + required: REQUIRED_VOTES, + }, + createdAt: r.createdAt, + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') + : null + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +// ─── 10.5 GET /v1/admin/edit-requests/{id} — 审核员查看详情 ── + +router.get('/admin/edit-requests/:id', requireAuth, requireReviewer, requirePerm('contribution:edit-request:vote'), async (req, res) => { + const id = paramStr(req, 'id') + + const row = await queryOne( + `SELECT er.*, c.title as origTitle, c.summary as origSummary, c.contentRaw as origContent, + c.contentFormat as origContentFormat, c.tags as origTags, + u.displayName as requesterDisplayName + FROM contribution_edit_requests er + JOIN contributions c ON c.id = er.contributionId + JOIN users u ON u.id = er.requesterId + WHERE er.id = ?`, + [id], + ) + + if (!row) { + sendError(res, Errors.EDIT_REQUEST_NOT_FOUND.code, '修改申请不存在', req.requestId, Errors.EDIT_REQUEST_NOT_FOUND.status) + return + } + + // Get votes and history + const votes = await query( + `SELECT reviewerUserId, vote, note, createdAt FROM edit_request_votes WHERE editRequestId = ? ORDER BY createdAt ASC`, + [id], + ) as Array> + + const approveVotes = votes.filter((v) => v.vote === 'approve').length + const rejectVotes = votes.filter((v) => v.vote === 'reject').length + + // Check if current admin has voted + const myVote = votes.find((v) => v.reviewerUserId === req.user!.userId) + + sendSuccess(res, { + id: row.id, + contribution: { + id: row.contributionId, + title: row.origTitle, + summary: row.origSummary || null, + contentRaw: row.origContent, + contentFormat: row.origContentFormat, + tags: typeof row.origTags === 'string' ? JSON.parse(row.origTags as string) : row.origTags || [], + }, + requester: { + id: row.requesterId, + displayName: row.requesterDisplayName, + }, + reason: row.reason, + proposed: { + title: row.proposedTitle || null, + summary: row.proposedSummary || null, + content: row.proposedContent || null, + contentFormat: row.proposedContentFormat || null, + tags: row.proposedTags ? (typeof row.proposedTags === 'string' ? JSON.parse(row.proposedTags as string) : row.proposedTags) : null, + }, + status: row.status, + votes: { + approve: approveVotes, + reject: rejectVotes, + total: approveVotes + rejectVotes, + required: REQUIRED_VOTES, + history: votes.map((v) => ({ + reviewerId: v.reviewerUserId, + vote: v.vote, + note: v.note || null, + createdAt: v.createdAt, + })), + }, + myVote: myVote?.vote || null, + version: row.version, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, req.requestId) +}) + +// ─── 10.6 POST /v1/admin/edit-requests/{id}/vote — 审核员投票 ── + +router.post('/admin/edit-requests/:id/vote', requireAuth, requireReviewer, requirePerm('contribution:edit-request:vote'), async (req, res) => { + const id = paramStr(req, 'id') + const parsed = voteSchema.safeParse(req.body) + if (!parsed.success) { + sendError(res, Errors.VALIDATION_ERROR.code, '请求数据校验失败', req.requestId, Errors.VALIDATION_ERROR.status, zodErrorsToDetails(parsed.error.flatten())) + return + } + + const { vote, note, expectedVersion } = parsed.data + const now = Date.now() + + // Use transaction + SELECT FOR UPDATE for concurrency safety per api.md §10.6 + const { getConnection } = await import('../Database') + const conn = await getConnection() + try { + await conn.beginTransaction() + + const [editReqRows] = await conn.execute( + `SELECT id, contributionId, requesterId, status, version FROM contribution_edit_requests WHERE id = ? FOR UPDATE`, + [id], + ) + + const editReq = (editReqRows as unknown as Array<{ id: string; contributionId: string; requesterId: string; status: string; version: number }>)[0] ?? null + + if (!editReq) { + await conn.rollback() + sendError(res, Errors.EDIT_REQUEST_NOT_FOUND.code, '修改申请不存在', req.requestId, Errors.EDIT_REQUEST_NOT_FOUND.status) + conn.release() + return + } + if (editReq.status !== 'pending' && editReq.status !== 'in_review') { + await conn.rollback() + sendError(res, Errors.INVALID_STATE_TRANSITION.code, '申请已结束', req.requestId, Errors.INVALID_STATE_TRANSITION.status) + conn.release() + return + } + if (editReq.requesterId === req.user!.userId) { + await conn.rollback() + sendError(res, 'SELF_VOTE_FORBIDDEN', '不可对自己的申请投票', req.requestId, 409) + conn.release() + return + } + if (editReq.version !== expectedVersion) { + await conn.rollback() + sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status) + conn.release() + return + } + + // Check for duplicate vote + const [existingVoteRows] = await conn.execute( + `SELECT id FROM edit_request_votes WHERE editRequestId = ? AND reviewerUserId = ? FOR UPDATE`, + [id, req.user!.userId], + ) + const existingVote = (existingVoteRows as Array<{ id: string }>)[0] ?? null + if (existingVote) { + await conn.rollback() + sendError(res, 'ALREADY_VOTED', '已投过票', req.requestId, 409) + conn.release() + return + } + + // Cast vote + await conn.execute( + `INSERT INTO edit_request_votes (id, editRequestId, reviewerUserId, vote, note, createdAt) + VALUES (?, ?, ?, ?, ?, ?)`, + [genId('erv_'), id, req.user!.userId, vote, note || null, now], + ) + + // Optimistic version bump — only succeeds if version hasn't changed since our FOR UPDATE + const [versionResult] = await conn.execute( + `UPDATE contribution_edit_requests SET version = version + 1, updatedAt = ? WHERE id = ? AND version = ?`, + [now, id, editReq.version], + ) + if (versionResult.affectedRows === 0) { + await conn.rollback() + sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突,请刷新后重试', req.requestId, Errors.VERSION_CONFLICT.status) + conn.release() + return + } + + const newVersion = editReq.version + 1 + + // Check resolution: 2 approve or 2 reject + const [approveRows] = await conn.execute( + `SELECT COUNT(*) as cnt FROM edit_request_votes WHERE editRequestId = ? AND vote = 'approve'`, + [id], + ) + const [rejectRows] = await conn.execute( + `SELECT COUNT(*) as cnt FROM edit_request_votes WHERE editRequestId = ? AND vote = 'reject'`, + [id], + ) + + const approve = Number(approveRows[0]?.cnt ?? 0) + const reject = Number(rejectRows[0]?.cnt ?? 0) + + let resolvedStatus: string | null = null + + if (approve >= 2) { + resolvedStatus = 'approved' + + // Apply the edit to the original contribution + const [editRows] = await conn.execute( + `SELECT contributionId, proposedTitle, proposedContent, proposedContentFormat, proposedSummary, proposedTags FROM contribution_edit_requests WHERE id = ? FOR UPDATE`, + [id], + ) + const editRow = (editRows as Array<{ contributionId: string; proposedTitle: string | null; proposedContent: string | null; proposedContentFormat: string | null; proposedSummary: string | null; proposedTags: string | null }>)[0] ?? null + if (editRow) { + const updates: string[] = ['version = version + 1', 'updatedAt = ?'] + const updateParams: unknown[] = [now] + + if (editRow.proposedTitle) { updates.push('title = ?'); updateParams.push(editRow.proposedTitle) } + if (editRow.proposedContent) { + updates.push('contentRaw = ?') + updateParams.push(editRow.proposedContent) + const contentFormat = (editRow.proposedContentFormat as string) || 'markdown' + const contentHtml = contentFormat === 'plain_text' + ? plainTextToHtml(editRow.proposedContent as string) + : markdownToHtml(editRow.proposedContent as string) + updates.push('contentHtml = ?') + updateParams.push(contentHtml) + updates.push('contentFormat = ?') + updateParams.push(contentFormat) + } + if (editRow.proposedSummary !== null) { updates.push('summary = ?'); updateParams.push(editRow.proposedSummary) } + if (editRow.proposedTags) { updates.push('tags = ?'); updateParams.push(JSON.stringify(editRow.proposedTags)) } + + updateParams.push(editRow.contributionId) + + await conn.execute( + `UPDATE contributions SET ${updates.join(', ')} WHERE id = ?`, + updateParams as mysql.ExecuteValues, + ) + } + } else if (reject >= 2) { + resolvedStatus = 'rejected' + } + + // Update edit request status if resolved + if (resolvedStatus) { + await conn.execute( + `UPDATE contribution_edit_requests SET status = ?, updatedAt = ? WHERE id = ?`, + [resolvedStatus, now, id], + ) + + await conn.execute( + `INSERT INTO contribution_review_events (id, contributionId, reviewerUserId, action, fromStatus, toStatus, createdAt, requestId) + VALUES (?, ?, ?, 'edit_request_resolve', ?, ?, ?, ?)`, + [genId('rev_'), editReq.contributionId, req.user!.userId, editReq.status, resolvedStatus, now, req.requestId], + ) + } + + await conn.commit() + conn.release() + + // Audit logs (outside transaction — fire-and-forget safe after commit) + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.edit_request.vote', + resourceType: 'edit_request', + resourceId: id, + after: { vote, status: resolvedStatus || editReq.status }, + }) + + if (resolvedStatus) { + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'contribution.edit_request.resolve', + resourceType: 'edit_request', + resourceId: id, + after: { resolution: resolvedStatus }, + }) + } + + sendSuccess(res, { + id, + status: resolvedStatus || editReq.status, + version: newVersion, + votes: { + approve, + reject, + total: approve + reject, + required: REQUIRED_VOTES, + }, + }, req.requestId) + } catch (err) { + try { await conn.rollback() } catch { /* ignore rollback error */ } + conn.release() + throw err + } +}) + +export default router diff --git a/functions/src/routes/images.ts b/functions/src/routes/images.ts new file mode 100644 index 0000000..66b13cb --- /dev/null +++ b/functions/src/routes/images.ts @@ -0,0 +1,624 @@ +import { Router } from 'express' +import multer from 'multer' +import { exec, queryOne } from '../Database' +import { ulid, genId } from '../utils/ulid' +import { sendSuccess, sendError, Errors } from '../utils/response' +import { metrics } from '../utils/metrics' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { writeAuditLog } from '../utils/audit' +import { createHash, createHmac } from 'node:crypto' +import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { conf } from '../Config' + +const router: Router = Router() + +// Multer setup: in-memory buffer, 2 MiB limit, single field "file" (api.md §11.1 multipart/form-data) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 2_097_152, files: 1 }, +}) + +const IMAGE_STORAGE = process.env.IMAGE_STORAGE_DIR || join(process.cwd(), 'storage', 'images') +const MAGIC_BYTES: Record = { + 'image/jpeg': new Uint8Array([0xFF, 0xD8, 0xFF]), + 'image/png': new Uint8Array([0x89, 0x50, 0x4E, 0x47]), + 'image/gif': new Uint8Array([0x47, 0x49, 0x46]), + 'image/webp': new Uint8Array([0x52, 0x49, 0x46, 0x46]), // RIFF...WEBP +} +const MAX_FILE_SIZE = 2_097_152 // 2 MiB +const MAX_DIMENSION = 4096 +const MAX_PIXELS = 16_777_216 + +/** + * Detect MIME type from file header (magic bytes) per api.md §11.1. + */ +function detectMimeType(buffer: Buffer): string | null { + for (const [mime, magic] of Object.entries(MAGIC_BYTES)) { + const matches = magic.every((byte, i) => buffer[i] === byte) + if (matches) { + // WebP needs additional check for "WEBP" at offset 8 + if (mime === 'image/webp') { + const webpMarker = buffer.slice(8, 12).toString('ascii') + if (webpMarker !== 'WEBP') continue + } + return mime + } + } + return null +} + +/** + * Parse image dimensions from buffer (minimal implementation). + * Returns { width, height } or null if parsing fails. + */ +function parseImageDimensions(buffer: Buffer, mimeType: string): { width: number; height: number } | null { + try { + if (mimeType === 'image/png') { + // PNG: IHDR chunk at offset 16 + const width = buffer.readUInt32BE(16) + const height = buffer.readUInt32BE(20) + return { width, height } + } + if (mimeType === 'image/jpeg') { + // JPEG: scan for all standard SOF markers (api.md §11.1: must decode image integrity) + // SOF markers: 0xC0(SOF0 baseline), 0xC1(SOF1 extended), 0xC2(SOF2 progressive), + // 0xC3(SOF3 lossless), 0xC5(SOF5 differential), 0xC6(SOF6 differential progressive), + // 0xC7(SOF7 differential lossless), 0xC9-0xCB(arithmetic variants), 0xCD-0xCF(differential arithmetic) + const SOF_MARKERS = new Set([0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF]) + let offset = 2 + while (offset < buffer.length - 1) { + if (buffer[offset] !== 0xFF) break + const marker = buffer[offset + 1] + if (SOF_MARKERS.has(marker)) { + const height = buffer.readUInt16BE(offset + 5) + const width = buffer.readUInt16BE(offset + 7) + return { width, height } + } + const segLen = buffer.readUInt16BE(offset + 2) + if (segLen < 2) break + offset += 2 + segLen + } + return null + } + if (mimeType === 'image/gif') { + // GIF: width/height at offset 6/8 (little-endian) + const width = buffer.readUInt16LE(6) + const height = buffer.readUInt16LE(8) + return { width, height } + } + if (mimeType === 'image/webp') { + // WebP: width/height at offset 24 (VP8/VP8L) + const format = buffer.slice(12, 16).toString('ascii') + if (format === 'VP8 ') { + // VP8 keyframe + const w = buffer.readUInt16LE(26) & 0x3FFF + const h = buffer.readUInt16LE(28) & 0x3FFF + return { width: w, height: h } + } + if (format === 'VP8L') { + const bits = buffer.readUInt32LE(21) + return { width: (bits & 0x3FFF) + 1, height: ((bits >> 14) & 0x3FFF) + 1 } + } + return null + } + } catch { + return null + } + return null +} + +/** + * Detect polyglot files: trailing data after valid image content. + * Per api.md §11.1: must detect appended non-image data. + * + * JPEG: scan to EOI marker (0xFF 0xD9), check for data after it. + * PNG: IEND chunk (0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82), check for data after. + * GIF: trailer byte (0x3B), check for data after. + * WebP: file size from RIFF header, verify total length matches. + */ +function detectPolyglot(buffer: Buffer, mimeType: string): boolean { + try { + if (mimeType === 'image/jpeg') { + // Scan for EOI marker (FF D9) from the end + for (let i = buffer.length - 2; i >= 0; i--) { + if (buffer[i] === 0xFF && buffer[i + 1] === 0xD9) { + // If there's data after EOI, it's a polyglot + return i + 2 < buffer.length + } + } + return false // No EOI found — truncated + } + + if (mimeType === 'image/png') { + // IEND chunk: 00 00 00 00 49 45 4E 44 AE 42 60 82 (12 bytes) + const iend = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]) + const iendIdx = buffer.lastIndexOf(iend) + if (iendIdx === -1) return false // No IEND — truncated + return iendIdx + iend.length < buffer.length + } + + if (mimeType === 'image/gif') { + // GIF trailer byte: 0x3B + if (buffer[buffer.length - 1] === 0x3B) { + return false // Clean end + } + // Some GIFs have multiple extensions — check for 0x3B anywhere near end + const trailerIdx = buffer.lastIndexOf(0x3B) + if (trailerIdx === -1) return false // No trailer — truncated + return trailerIdx + 1 < buffer.length + } + + if (mimeType === 'image/webp') { + // RIFF header: file size at offset 4 (little-endian) + if (buffer.length < 12) return false + const riffSize = buffer.readUInt32LE(4) + 8 // RIFF size field excludes first 8 bytes + return buffer.length > riffSize + } + } catch { + return false // Conservative: assume clean on parse error + } + return false +} + +/** + * Minimal EXIF GPS/device fingerprint removal for JPEG images. + * Scans for APP1 (EXIF) marker and nulls out GPS IFD tags. + * Production should use a library like `sharp` or `exifr` for full stripping. + */ +function removeExifGps(buffer: Buffer): Buffer { + // Only process JPEG + if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) return buffer + + let offset = 2 + while (offset < buffer.length - 1) { + if (buffer[offset] !== 0xFF) break + const marker = buffer[offset + 1] + + // APP1 marker (0xE1) — EXIF data + if (marker === 0xE1) { + const segLen = buffer.readUInt16BE(offset + 2) + const segEnd = offset + 2 + segLen + + if (segEnd <= buffer.length) { + const exifSegment = buffer.subarray(offset, segEnd) + + // Check for "Exif\0\0" header + if (exifSegment.length >= 8 && exifSegment[4] === 0x45 && + exifSegment[5] === 0x78 && exifSegment[6] === 0x69 && + exifSegment[7] === 0x66 && exifSegment[8] === 0x00 && + exifSegment[9] === 0x00) { + + // TIFF header starts at offset 10 within APP1 + const tiffOffset = offset + 10 + if (tiffOffset + 8 <= buffer.length) { + const endian = buffer.readUInt16BE(tiffOffset) === 0x4D4D ? 'BE' : 'LE' + const readU16 = (pos: number) => endian === 'BE' ? buffer.readUInt16BE(pos) : buffer.readUInt16LE(pos) + const readU32 = (pos: number) => endian === 'BE' ? buffer.readUInt32BE(pos) : buffer.readUInt32LE(pos) + + // IFD0 offset + const ifd0Offset = tiffOffset + readU32(tiffOffset + 4) + if (ifd0Offset + 2 <= buffer.length) { + const numEntries = readU16(ifd0Offset) + + for (let i = 0; i < numEntries && i < 50; i++) { + const entryOffset = ifd0Offset + 2 + i * 12 + if (entryOffset + 12 > buffer.length) break + const tag = readU16(entryOffset) + + // GPS IFD tag (0x8825) + if (tag === 0x8825) { + // Zero out the entire value/data field (8 bytes starting at offset + 4) + buffer.fill(0, entryOffset + 4, entryOffset + 12) + } + } + } + } + } + } + break // Only first APP1 matters + } + + // Skip to next marker + const segLen = buffer.readUInt16BE(offset + 2) + offset += 2 + segLen + } + + return buffer +} + +/** + * POST /v1/images — api.md §11.1 上传图片 + * Uses multer for proper multipart/form-data handling + */ +router.post('/', + (req, _res, next) => { req.rateLimitAction = 'image'; next(); }, + rateLimitCheck, + requireAuth, upload.single('file'), async (req, res) => { + // Check email verified + const user = await queryOne(`SELECT status FROM users WHERE id = ?`, [req.user!.userId]) + if (!user || user.status !== 'active') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + // Image upload rate limits per api.md §11.1: 20/hour per user, 100/hour per IP + const now = Date.now() + const hourWindow = Math.floor(now / 3600_000) * 3600_000 + const userRateKey = `image:upload:user:${req.user!.userId}:${hourWindow}` + const ipRateKey = `image:upload:ip:${req.ip || 'unknown'}:${hourWindow}` + + const userUploads = await queryOne( + `SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, + [userRateKey, hourWindow], + ) + if ((userUploads?.count as number || 0) >= 20) { + const retryAfter = Math.ceil((hourWindow + 3600_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + sendError(res, Errors.RATE_LIMITED.code, '每用户每小时最多上传 20 张图片', req.requestId, 429) + return + } + + const ipUploads = await queryOne( + `SELECT count FROM rate_limits WHERE bucketKey = ? AND windowStart = ?`, + [ipRateKey, hourWindow], + ) + if ((ipUploads?.count as number || 0) >= 100) { + const retryAfter = Math.ceil((hourWindow + 3600_000 - now) / 1000) + res.setHeader('Retry-After', String(retryAfter)) + sendError(res, Errors.RATE_LIMITED.code, '每 IP 每小时最多上传 100 张图片', req.requestId, 429) + return + } + + if (!req.file) { + sendError(res, Errors.BAD_REQUEST.code, '未提供文件(需 multipart/form-data,字段名 "file")', req.requestId, 400) + return + } + + const fileBuffer = req.file.buffer + const originalFilename = req.file.originalname || 'upload.bin' + + // File size check + if (fileBuffer.length > MAX_FILE_SIZE) { + sendError(res, Errors.CONTENT_TOO_LARGE.code, '文件超过 2 MiB 限制', req.requestId, 413) + return + } + + // Detect MIME type from magic bytes + const detectedMime = detectMimeType(fileBuffer) + if (!detectedMime) { + sendError(res, 'UNSUPPORTED_MEDIA_TYPE', '不支持的图片类型', req.requestId, 415) + return + } + + // Verify integrity by parsing dimensions + const dims = parseImageDimensions(fileBuffer, detectedMime) + if (!dims) { + metrics.imageUploadTotal["invalid|image"] = (metrics.imageUploadTotal["invalid|image"] || 0) + 1; sendError(res, 'INVALID_IMAGE', '图片损坏或解码失败', req.requestId, 422) + return + } + + if (dims.width > MAX_DIMENSION || dims.height > MAX_DIMENSION) { + metrics.imageUploadTotal["invalid|image"] = (metrics.imageUploadTotal["invalid|image"] || 0) + 1; sendError(res, 'INVALID_IMAGE', '图片尺寸超过 4096 像素限制', req.requestId, 422) + return + } + + if (dims.width * dims.height > MAX_PIXELS) { + metrics.imageUploadTotal["invalid|image"] = (metrics.imageUploadTotal["invalid|image"] || 0) + 1; sendError(res, 'INVALID_IMAGE', '图片总像素数超过限制', req.requestId, 422) + return + } + + // Polyglot detection: check for trailing data after valid image end (api.md §11.1) + if (detectPolyglot(fileBuffer, detectedMime)) { + metrics.imageUploadTotal["invalid|image"] = (metrics.imageUploadTotal["invalid|image"] || 0) + 1; sendError(res, 'INVALID_IMAGE', '图片包含多余数据(polyglot 检测失败)', req.requestId, 422) + return + } + + // EXIF GPS/device fingerprint removal per api.md §11.1 + const cleanedBuffer = removeExifGps(fileBuffer) + + // SHA-256 for dedup (after EXIF cleaning so same image → same hash) + const sha256 = createHash('sha256').update(cleanedBuffer).digest('hex') + + // Check dedup + const existing = await queryOne( + `SELECT id, createdAt FROM images WHERE sha256 = ? AND status = 'active'`, + [sha256], + ) + if (existing) { + let existingId = existing.id as string + if (!existingId.startsWith('img_')) existingId = `img_${existingId}` + metrics.imageUploadTotal[`success|${detectedMime}`] = (metrics.imageUploadTotal[`success|${detectedMime}`] || 0) + 1 + const appUrl = (conf.APP as Record | undefined)?.API_URL || 'https://api.transcircle.org' + sendSuccess(res, { + id: existingId, + url: `${appUrl}/v1/images/${existingId}`, + mimeType: detectedMime, + size: cleanedBuffer.length, + width: dims.width, + height: dims.height, + sha256, + createdAt: existing.createdAt, + }, req.requestId, 201) + return + } + + const imgId = genId('img_') + const datePath = new Date(now).toISOString().slice(0, 7).replace('-', '') // yyyymm + const ext = detectedMime.split('/')[1] + const storageKey = `images/${datePath}/${sha256}.${ext}` + + // Write to storage (EXIF-cleaned version) via backend abstraction + const store = await getStorageBackend() + await store.write(storageKey, cleanedBuffer, detectedMime) + + // Insert record + await exec( + `INSERT INTO images (id, uploaderId, originalFilename, mimeType, size, width, height, sha256, storageKey, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [imgId, req.user!.userId, originalFilename.slice(0, 255), detectedMime, + cleanedBuffer.length, dims.width, dims.height, sha256, storageKey, now], + ) + + // Audit log + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'image.upload', + resourceType: 'image', + resourceId: imgId, + after: { mimeType: detectedMime, size: cleanedBuffer.length, sha256 }, + }) + + // Increment rate limit counters — atomic per api.md §13.3 + for (const rateKey of [userRateKey, ipRateKey]) { + await exec( + `INSERT INTO rate_limits (id, bucketKey, windowStart, count, createdAt) VALUES (?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE count = count + 1`, + [ulid(), rateKey, hourWindow, now], + ) + } + + metrics.imageUploadTotal[`success|${detectedMime}`] = (metrics.imageUploadTotal[`success|${detectedMime}`] || 0) + 1 + const appUrl = (conf.APP as Record | undefined)?.API_URL || 'https://api.transcircle.org' + sendSuccess(res, { + id: imgId, + url: `${appUrl}/v1/images/${imgId}`, + mimeType: detectedMime, + size: cleanedBuffer.length, + width: dims.width, + height: dims.height, + sha256, + createdAt: now, + }, req.requestId, 201) +}) + +/** + * GET /v1/images/{id} — api.md §11.2 查看图片 + */ +router.get('/:id', async (req, res) => { + const { id } = req.params + + const image = await queryOne( + `SELECT id, mimeType, sha256, storageKey, status FROM images WHERE id = ?`, + [id], + ) + + if (!image || image.status === 'deleted') { + sendError(res, Errors.IMAGE_NOT_FOUND.code, '图片不存在', req.requestId, Errors.IMAGE_NOT_FOUND.status) + return + } + + const storageKey = image.storageKey as string + const store = await getStorageBackend() + const fileBuffer = await store.read(storageKey) + + if (!fileBuffer) { + sendError(res, Errors.IMAGE_NOT_FOUND.code, '图片文件不存在', req.requestId, Errors.IMAGE_NOT_FOUND.status) + return + } + const etag = `"${image.sha256 as string}"` + + // Check If-None-Match + const ifNoneMatch = req.headers['if-none-match'] + if (ifNoneMatch === etag) { + res.status(304).end() + return + } + + res.setHeader('Content-Type', image.mimeType as string) + res.setHeader('Content-Length', fileBuffer.length) + res.setHeader('ETag', etag) + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Content-Disposition', 'inline') + res.status(200).end(fileBuffer) +}) + +/** + * ── Image Storage Backend Abstraction ────────────────────────── + * + * 生产环境升级路径: + * 1. 设置 S3_BUCKET / R2_BUCKET / R2_ACCOUNT_ID 等环境变量 → 自动使用 S3 存储 + * 2. 未设置时 → 默认文件系统存储(当前实现) + * + * 设计目标:ImageStorage 接口统一 read/write/delete, + * Router 层代码不需关心底层存储。 + */ + +interface ImageStorageBackend { + write(key: string, buffer: Buffer, mimeType: string): Promise + read(key: string): Promise + delete(key: string): Promise +} + +/** Filesystem-based storage (dev/sandbox default) */ +const fsStorage: ImageStorageBackend = { + async write(key: string, buffer: Buffer): Promise { + const storagePath = join(IMAGE_STORAGE, key.replace('images/', '')) + const dir = join(storagePath, '..') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(storagePath, buffer) + }, + async read(key: string): Promise { + const storagePath = join(IMAGE_STORAGE, key.replace('images/', '')) + if (!existsSync(storagePath)) return null + return readFileSync(storagePath) + }, + async delete(key: string): Promise { + const storagePath = join(IMAGE_STORAGE, key.replace('images/', '')) + if (existsSync(storagePath)) unlinkSync(storagePath) + }, +} + +/** + * AWS Signature V4 helpers for S3-compatible storage. + * Uses Node.js crypto for HMAC-SHA256 signing. + */ +function sha256Hash(data: string): string { + return createHash('sha256').update(data, 'utf-8').digest('hex') +} + +function hmacSha256(key: Uint8Array | string, data: string): Uint8Array { + return createHmac('sha256', key).update(data, 'utf-8').digest() +} + +async function s3Sign( + method: string, + host: string, + path: string, + queryString: string, + headers: Record, + payloadHash: string, + region: string, + accessKey: string, + secretKey: string, +): Promise { + const amzDate = headers['x-amz-date'] + const dateStamp = amzDate.slice(0, 8) + const credentialScope = `${dateStamp}/${region}/s3/aws4_request` + + // Canonical request + const canonicalHeaders = Object.entries(headers) + .map(([k, v]) => `${k.toLowerCase()}:${v}\n`) + .sort(([a], [b]) => a.localeCompare(b)) + .join('') + const signedHeaders = Object.keys(headers).map(k => k.toLowerCase()).sort().join(';') + const canonicalRequest = [ + method, + path, + queryString, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n') + + // String to sign + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + sha256Hash(canonicalRequest), + ].join('\n') + + // Signing key + const dateKey = hmacSha256('AWS4' + secretKey, dateStamp) + const dateRegionKey = hmacSha256(dateKey, region) + const dateRegionServiceKey = hmacSha256(dateRegionKey, 's3') + const signingKey = hmacSha256(dateRegionServiceKey, 'aws4_request') + + const signature = Buffer.from(hmacSha256(signingKey, stringToSign)).toString('hex') + + return `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope},SignedHeaders=${signedHeaders},Signature=${signature}` +} + + + +/** S3-compatible storage (R2 / MinIO / AWS S3) — enabled when S3_BUCKET is set */ +let _s3Storage: ImageStorageBackend | null = null + +async function getS3Storage(): Promise { + if (_s3Storage) return _s3Storage + + const bucket = process.env.S3_BUCKET || process.env.R2_BUCKET + if (!bucket) return null + + const accessKey = process.env.S3_ACCESS_KEY || process.env.R2_ACCESS_KEY + const secretKey = process.env.S3_SECRET_KEY || process.env.R2_SECRET_KEY + if (!accessKey || !secretKey) { + console.warn('[images] S3 storage requires S3_ACCESS_KEY and S3_SECRET_KEY') + return null + } + + const region = process.env.S3_REGION || 'us-east-1' + const s3Endpoint = process.env.S3_ENDPOINT || `https://${bucket}.s3.${region}.amazonaws.com` + + async function s3Send( + method: string, + path: string, + body?: BodyInit, + extraHeaders?: Record, + ): Promise { + const host = new URL(s3Endpoint).host + const amzDate = new Date().toISOString().replace(/[:-]/g, '').slice(0, 15) + 'Z' + const payloadHash = body ? createHash('sha256').update(Buffer.from(await new Response(body).arrayBuffer())).digest('hex') : sha256Hash('') + + const headers: Record = { + 'host': host, + 'x-amz-date': amzDate, + 'x-amz-content-sha256': payloadHash, + ...extraHeaders, + } + + const url = `${s3Endpoint}${path}` + const authorization = await s3Sign( + method, host, path, '', headers, payloadHash, + region, accessKey!, secretKey!, + ) + headers['authorization'] = authorization + + return fetch(url, { + method, + headers, + body: method === 'PUT' || method === 'POST' ? body : undefined, + }) + } + + _s3Storage = { + async write(key: string, buffer: Buffer, mimeType: string): Promise { + const encodedKey = encodeURIComponent(key) + const res = await s3Send('PUT', `/${encodedKey}`, buffer, { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }) + if (!res.ok) throw new Error(`S3 PUT ${res.status}`) + }, + async read(key: string): Promise { + try { + const encodedKey = encodeURIComponent(key) + const res = await s3Send('GET', `/${encodedKey}`) + if (!res.ok) return null + return Buffer.from(await res.arrayBuffer()) + } catch { + return null + } + }, + async delete(key: string): Promise { + const encodedKey = encodeURIComponent(key) + await s3Send('DELETE', `/${encodedKey}`) + }, + } + return _s3Storage +} + +/** Resolve storage backend based on environment */ +let _storageBackend: ImageStorageBackend | null = null + +async function getStorageBackend(): Promise { + if (_storageBackend) return _storageBackend + _storageBackend = (await getS3Storage()) || fsStorage + return _storageBackend +} + +export { getStorageBackend, type ImageStorageBackend } +export default router diff --git a/functions/src/routes/me.ts b/functions/src/routes/me.ts new file mode 100644 index 0000000..f5b5684 --- /dev/null +++ b/functions/src/routes/me.ts @@ -0,0 +1,972 @@ +import { Router, type Router as RouterType } from 'express' +import { sendSuccess, sendError, Errors } from '../utils/response' +import { query, queryOne, exec } from '../Database' +import { requireAuth } from '../middleware/auth' +import { rateLimitCheck } from '../middleware/rateLimit' +import { findUserById } from '../utils/users' +import { ulid, genId } from '../utils/ulid' +import { conf } from '../Config' +import { hmacToken } from '../utils/session' +import { writeAuditLog, canonicalJson, sha256base64url } from '../utils/audit' +import { findUnusedRecoveryCode } from '../utils/password' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const router: RouterType = Router() + +// ────────────────────────────────────────────── +// GET /me — api.md §2.1 获取当前用户资料 +// ────────────────────────────────────────────── +router.get('/', requireAuth, async (req, res) => { + const user = await findUserById(req.user!.userId) + if (!user) { + sendError(res, Errors.USER_NOT_FOUND.code, '用户不存在', req.requestId, Errors.USER_NOT_FOUND.status) + return + } + + // Get OAuth providers bound to this account + const oauthAccounts = await queryOne( + `SELECT GROUP_CONCAT(provider) as providers FROM oauth_accounts WHERE userId = ?`, + [req.user!.userId], + ) + + // Check if user has a password set + const userRow = await queryOne(`SELECT passwordHash FROM users WHERE id = ?`, [req.user!.userId]) + const hasPassword = !!(userRow as Record | null)?.passwordHash + + // Count active passkeys + const passkeyRow = await queryOne( + `SELECT COUNT(*) as count FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) + const passkeyCount = (passkeyRow?.count as number) || 0 + + // Check TOTP status + const totpRow = await queryOne( + `SELECT id FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [req.user!.userId], + ) + + const oauthProviders = oauthAccounts?.providers + ? (oauthAccounts.providers as string).split(',') + : [] + + // Send response per api.md §2.1 format + sendSuccess( + res, + { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + emailVerified: user.emailVerified, + status: user.status, + roles: user.roles, + security: { + hasPassword, + totpEnabled: !!totpRow, + passkeyCount, + oauthProviders, + }, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + }, + req.requestId, + ) +}) + +// ────────────────────────────────────────────── +// PATCH /me — api.md §2.2 更新当前用户资料 +// ────────────────────────────────────────────── +router.patch('/', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { displayName, avatarUrl } = req.body as { + displayName?: string + avatarUrl?: string | null + } + + const updates: string[] = [] + const params: unknown[] = [] + + if (displayName !== undefined) { + const dn = displayName.trim() + if ([...dn].length < 1 || [...dn].length > 50) { + sendError(res, Errors.VALIDATION_ERROR.code, '显示名称需 1-50 个字符', req.requestId, 422) + return + } + updates.push('displayName = ?') + params.push(dn) + } + + if (avatarUrl !== undefined) { + // Must be a platform image URL or OAuth avatar URL; null = remove avatar + if (avatarUrl !== null) { + const apiBase = (conf.APP as Record | undefined)?.API_URL || 'https://api.transcircle.org' + const allowedPrefixes = [`${apiBase}/v1/images/`, 'https://avatars.githubusercontent.com/', 'https://pbs.twimg.com/'] + const allowed = allowedPrefixes.some((p) => avatarUrl.startsWith(p)) + if (!allowed) { + sendError(res, Errors.VALIDATION_ERROR.code, 'avatarUrl 不在 allowlist 内', req.requestId, 422) + return + } + } + updates.push('avatarUrl = ?') + params.push(avatarUrl) + } + + if (updates.length === 0) { + sendError(res, Errors.VALIDATION_ERROR.code, '没有要更新的字段', req.requestId, 422) + return + } + + const now = Date.now() + updates.push('updatedAt = ?') + params.push(now) + params.push(req.user!.userId) + + await exec(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params) + + const updatedUser = await findUserById(req.user!.userId) + if (!updatedUser) { + sendError(res, Errors.USER_NOT_FOUND.code, '用户不存在', req.requestId, 404) + return + } + + // Re-fetch security info + const oauthRow = await queryOne( + `SELECT GROUP_CONCAT(provider) as providers FROM oauth_accounts WHERE userId = ?`, + [req.user!.userId], + ) + const userRow = await queryOne(`SELECT passwordHash FROM users WHERE id = ?`, [req.user!.userId]) + const hasPassword = !!(userRow as Record | null)?.passwordHash + const passkeyRow = await queryOne( + `SELECT COUNT(*) as count FROM passkeys WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) + const totpRow = await queryOne( + `SELECT id FROM mfa_totp_credentials WHERE userId = ? AND status = 'active' LIMIT 1`, + [req.user!.userId], + ) + + sendSuccess(res, { + id: updatedUser.id, + username: updatedUser.username, + email: updatedUser.email, + displayName: updatedUser.displayName, + avatarUrl: updatedUser.avatarUrl, + emailVerified: updatedUser.emailVerified, + status: updatedUser.status, + roles: updatedUser.roles, + security: { + hasPassword, + totpEnabled: !!totpRow, + passkeyCount: (passkeyRow?.count as number) || 0, + oauthProviders: oauthRow?.providers ? (oauthRow.providers as string).split(',') : [], + }, + createdAt: updatedUser.createdAt, + lastLoginAt: updatedUser.lastLoginAt, + updatedAt: now, + }, req.requestId) +}) + +// ══════════════════════════════════════════════ +// My Contributions — api.md §4 +// ══════════════════════════════════════════════ + +// 4.1 GET /me/contributions — 获取我的投稿列表 +router.get('/contributions', requireAuth, async (req, res) => { + const status = req.query.status as string | undefined + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + const sort = (req.query.sort as string) || 'createdAt_desc' + + let whereClause = 'WHERE c.authorUserId = ?' + const params: unknown[] = [req.user!.userId] + + if (status) { + whereClause += ' AND c.status = ?' + params.push(status) + } + if (cursor) { + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf-8') + whereClause += ' AND c.createdAt < ?' + params.push(parseInt(decoded, 10)) + } catch { + sendError(res, Errors.VALIDATION_ERROR.code, '无效的 cursor', req.requestId, 422) + return + } + } + + const orderBy = sort === 'createdAt_asc' ? 'ORDER BY c.createdAt ASC' + : sort === 'updatedAt_desc' ? 'ORDER BY c.updatedAt DESC' + : sort === 'updatedAt_asc' ? 'ORDER BY c.updatedAt ASC' + : 'ORDER BY c.createdAt DESC' + + params.push(limit + 1) + + const rows = await query( + `SELECT c.id, c.title, c.status, c.createdAt, c.updatedAt + FROM contributions c ${whereClause} ${orderBy} LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + // Fetch latest review event for each contribution + const data = await Promise.all((rows as Array>).map(async (r) => { + const review = await queryOne( + `SELECT publicNote, createdAt as reviewedAt FROM contribution_review_events + WHERE contributionId = ? AND action = 'review' ORDER BY createdAt DESC LIMIT 1`, + [r.id], + ) + return { + id: r.id, + title: r.title, + status: r.status, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + review: { + publicNote: review?.publicNote || null, + reviewedAt: review?.reviewedAt || null, + }, + } + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).createdAt)).toString('base64url') + : null + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +// 4.2 GET /me/contributions/:id — 获取我的投稿详情 +router.get('/contributions/:id', requireAuth, async (req, res) => { + const { id } = req.params + + const row = await queryOne( + `SELECT c.* FROM contributions c WHERE c.id = ? AND c.authorUserId = ?`, + [id, req.user!.userId], + ) + + if (!row) { + sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404) + return + } + + // Get latest review — use toStatus not action per api.md §4.2 + const latestReview = await queryOne( + `SELECT reviewerUserId, createdAt as reviewedAt, toStatus as decision, publicNote + FROM contribution_review_events WHERE contributionId = ? AND action = 'review' + ORDER BY createdAt DESC LIMIT 1`, + [id], + ) + + const reviewerName = latestReview + ? await queryOne(`SELECT displayName FROM users WHERE id = ?`, [latestReview.reviewerUserId]) + : null + + sendSuccess(res, { + id: row.id, + title: row.title, + summary: row.summary || null, + contentRaw: row.contentRaw, + contentFormat: row.contentFormat, + tags: typeof row.tags === 'string' ? JSON.parse(row.tags as string) : row.tags || [], + language: row.language, + status: row.status, + version: row.version, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + submittedAt: row.submittedAt || null, + publishedAt: row.publishedAt || null, + review: latestReview ? { + reviewerDisplayName: reviewerName?.displayName || null, + reviewedAt: latestReview.reviewedAt, + decision: latestReview.decision, + publicNote: latestReview.publicNote || null, + } : { + reviewerDisplayName: null, + reviewedAt: null, + decision: null, + publicNote: null, + }, + }, req.requestId) +}) + +// 4.3 PATCH /me/contributions/:id — 修改草稿 +router.patch('/contributions/:id', requireAuth, async (req, res) => { + const { id } = req.params + const { title, content, contentFormat, summary, tags, language, expectedVersion } = req.body as { + title?: string; content?: string; contentFormat?: string; summary?: string | null + tags?: string[]; language?: string; expectedVersion: number + } + + if (!expectedVersion) { + sendError(res, Errors.VALIDATION_ERROR.code, 'expectedVersion 必填', req.requestId, 422) + return + } + + const contrib = await queryOne( + `SELECT id, status, version FROM contributions WHERE id = ? AND authorUserId = ?`, + [id, req.user!.userId], + ) + if (!contrib) { + sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404) + return + } + + const allowedStatuses = ['draft', 'rejected', 'withdrawn'] + if (!allowedStatuses.includes(contrib.status as string)) { + sendError(res, 'INVALID_STATE_TRANSITION', '当前状态不允许修改', req.requestId, 409) + return + } + if (contrib.version !== expectedVersion) { + sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status) + return + } + + const updates: string[] = [] + const params: unknown[] = [] + + if (title !== undefined) { updates.push('title = ?'); params.push(title.trim()) } + if (content !== undefined) { + // Per api.md §4.3: providing content requires contentFormat + if (!contentFormat) { + sendError(res, Errors.VALIDATION_ERROR.code, '提供正文时必须指定 contentFormat', req.requestId, 422) + return + } + updates.push('contentRaw = ?'); params.push(content) + } + if (contentFormat !== undefined) { updates.push('contentFormat = ?'); params.push(contentFormat) } + if (summary !== undefined) { updates.push('summary = ?'); params.push(summary || null) } + if (tags !== undefined) { updates.push('tags = ?'); params.push(JSON.stringify(tags)) } + if (language !== undefined) { updates.push('language = ?'); params.push(language) } + + if (updates.length === 0) { + sendError(res, Errors.VALIDATION_ERROR.code, '没有要更新的字段', req.requestId, 422) + return + } + + updates.push('version = version + 1') + updates.push('updatedAt = ?') + const now = Date.now() + params.push(now) + + const result = await exec( + `UPDATE contributions SET ${updates.join(', ')} WHERE id = ? AND version = ? AND authorUserId = ?`, + [...params, id, expectedVersion, req.user!.userId], + ) + + if (result.affectedRows === 0) { + sendError(res, Errors.VERSION_CONFLICT.code, '更新失败,请刷新后重试', req.requestId, 409) + return + } + + const updated = await queryOne( + `SELECT * FROM contributions WHERE id = ?`, [id], + ) + if (!updated) { + sendError(res, Errors.INTERNAL_ERROR.code, '查询失败', req.requestId, 500) + return + } + + sendSuccess(res, { + id: updated.id, title: updated.title, summary: updated.summary || null, + contentRaw: updated.contentRaw, contentFormat: updated.contentFormat, + tags: typeof updated.tags === 'string' ? JSON.parse(updated.tags as string) : updated.tags || [], + language: updated.language, status: updated.status, version: updated.version, + createdAt: updated.createdAt, updatedAt: updated.updatedAt, + submittedAt: updated.submittedAt || null, publishedAt: updated.publishedAt || null, + review: { reviewerDisplayName: null, reviewedAt: null, decision: null, publicNote: null }, + }, req.requestId) +}) + +// 4.4 POST /me/contributions/:id/submit — 提交草稿 +router.post('/contributions/:id/submit', requireAuth, async (req, res) => { + const { id } = req.params + const { expectedVersion } = req.body as { expectedVersion?: number } + if (!expectedVersion) { + sendError(res, Errors.VALIDATION_ERROR.code, 'expectedVersion 必填', req.requestId, 422) + return + } + + const contrib = await queryOne( + `SELECT id, status, version FROM contributions WHERE id = ? AND authorUserId = ?`, + [id, req.user!.userId], + ) + if (!contrib) { sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404); return } + + const allowedStatuses = ['draft', 'rejected', 'withdrawn'] + if (!allowedStatuses.includes(contrib.status as string)) { + sendError(res, 'INVALID_STATE_TRANSITION', '当前状态不可提交', req.requestId, 409); return + } + if (contrib.version !== expectedVersion) { + sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status); return + } + + // Require email verification per spec + const user = await queryOne(`SELECT status FROM users WHERE id = ?`, [req.user!.userId]) + if (user?.status !== 'active') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证,不能投稿', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + const now = Date.now() + const result = await exec( + `UPDATE contributions SET status = 'pending', version = version + 1, submittedAt = ?, updatedAt = ? + WHERE id = ? AND version = ? AND authorUserId = ?`, + [now, now, id, expectedVersion, req.user!.userId], + ) + if (result.affectedRows === 0) { + sendError(res, Errors.VERSION_CONFLICT.code, '提交失败', req.requestId, 409); return + } + + sendSuccess(res, { id, status: 'pending', version: (contrib.version as number) + 1, submittedAt: now }, req.requestId) +}) + +// 4.5 POST /me/contributions/:id/withdraw — 撤回投稿 +router.post('/contributions/:id/withdraw', requireAuth, async (req, res) => { + const { id } = req.params + const { expectedVersion } = req.body as { expectedVersion?: number } + if (!expectedVersion) { + sendError(res, Errors.VALIDATION_ERROR.code, 'expectedVersion 必填', req.requestId, 422); return + } + + const contrib = await queryOne( + `SELECT id, status, version FROM contributions WHERE id = ? AND authorUserId = ?`, + [id, req.user!.userId], + ) + if (!contrib) { sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在', req.requestId, 404); return } + + const allowedStatuses = ['pending', 'in_review'] + if (!allowedStatuses.includes(contrib.status as string)) { + sendError(res, 'INVALID_STATE_TRANSITION', '当前状态不可撤回', req.requestId, 409); return + } + if (contrib.version !== expectedVersion) { + sendError(res, Errors.VERSION_CONFLICT.code, '版本冲突', req.requestId, Errors.VERSION_CONFLICT.status); return + } + + const now = Date.now() + const result = await exec( + `UPDATE contributions SET status = 'withdrawn', version = version + 1, updatedAt = ? + WHERE id = ? AND version = ? AND authorUserId = ?`, + [now, id, expectedVersion, req.user!.userId], + ) + if (result.affectedRows === 0) { + sendError(res, Errors.VERSION_CONFLICT.code, '撤回失败', req.requestId, 409); return + } + + sendSuccess(res, { id, status: 'withdrawn', version: (contrib.version as number) + 1, updatedAt: now }, req.requestId) +}) + +// ══════════════════════════════════════════════ +// GDPR 接口 — api.md §2 +// ══════════════════════════════════════════════ + +// ────────────────────────────────────────────── +// 2.3 POST /me/export — 导出我的数据 (GDPR) +// ────────────────────────────────────────────── +router.post('/export', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const now = Date.now() + + // 校验邮箱已验证 + status=active + const user = await queryOne( + `SELECT status, emailVerified FROM users WHERE id = ?`, + [req.user!.userId], + ) + if (!user || user.status !== 'active') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + if (!user.emailVerified) { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + return + } + + // 7 天内最多 2 次 + const sevenDaysAgo = now - 7 * 86400_000 + const recentExports = await queryOne( + `SELECT COUNT(*) as cnt, MIN(createdAt) as firstExport FROM audit_logs WHERE actorUserId = ? AND action = 'me.export' AND createdAt >= ?`, + [req.user!.userId, sevenDaysAgo], + ) + if ((recentExports?.cnt as number || 0) >= 2) { + const firstExport = recentExports?.firstExport as number | undefined + const retryAfter = firstExport + ? Math.ceil(Math.max(0, firstExport + 7 * 86400_000 - now) / 1000) + : 7 * 86400 + res.setHeader('Retry-After', String(retryAfter)) + sendError(res, Errors.RATE_LIMITED.code, '7 天内最多导出 2 次', req.requestId, 429) + return + } + + const exportId = genId('exp_') + + // 写入 audit_logs(含导出数据范围,用于异步 worker 追踪) + await writeAuditLog(req, { + actorUserId: req.user!.userId, + action: 'me.export', + resourceType: 'user', + resourceId: req.user!.userId, + after: { + exportId, + status: 'queued', + scope: ['profile', 'contributions', 'edit_requests', 'images', 'oauth_accounts', 'sessions'], + }, + }) + + // ── 内联导出: 对小型用户(投稿 < 100)直接在请求线程中收集并生成 JSON 导出 ── + try { + const contribCount = await queryOne( + `SELECT COUNT(*) as cnt FROM contributions WHERE authorUserId = ?`, + [req.user!.userId], + ) + const totalContribs = (contribCount?.cnt as number) || 0 + + if (totalContribs < 100) { + // Collect profile + const profile = await queryOne( + `SELECT id, username, email, emailVerified, displayName, avatarUrl, status, createdAt, lastLoginAt FROM users WHERE id = ?`, + [req.user!.userId], + ) + + // Collect contributions (no internalNote/internal content) + const contributions = await query( + `SELECT id, title, summary, contentFormat, language, status, version, createdAt, updatedAt, submittedAt, publishedAt + FROM contributions WHERE authorUserId = ? ORDER BY createdAt DESC`, + [req.user!.userId], + ) + + // Collect edit requests + const editRequests = await query( + `SELECT id, contributionId, reason, proposedTitle, proposedSummary, proposedContentFormat, status, createdAt + FROM contribution_edit_requests WHERE requesterId = ? ORDER BY createdAt DESC`, + [req.user!.userId], + ) + + // Collect image metadata + const images = await query( + `SELECT id, mimeType, size, width, height, sha256, createdAt FROM images WHERE uploaderId = ? ORDER BY createdAt DESC`, + [req.user!.userId], + ) + + // Collect OAuth bindings + const oauthAccounts = await query( + `SELECT provider, providerUsername, providerDisplayName, createdAt as boundAt + FROM oauth_accounts WHERE userId = ?`, + [req.user!.userId], + ) + + // Collect session summary + const sessions = await query( + `SELECT id, loginMethod, ipPrefix, createdAt, lastUsedAt FROM sessions WHERE userId = ? AND revokedAt IS NULL ORDER BY createdAt DESC`, + [req.user!.userId], + ) + + const exportData = { + exportedAt: now, + exportId, + profile: profile + ? { id: profile.id, username: profile.username, email: profile.email, emailVerified: !!profile.emailVerified, displayName: profile.displayName, avatarUrl: profile.avatarUrl, status: profile.status, createdAt: profile.createdAt, lastLoginAt: profile.lastLoginAt } + : null, + contributions: (contributions as Array>).map((c) => ({ + id: c.id, title: c.title, summary: c.summary || null, contentFormat: c.contentFormat, + language: c.language, status: c.status, version: c.version, + createdAt: c.createdAt, updatedAt: c.updatedAt, submittedAt: c.submittedAt || null, publishedAt: c.publishedAt || null, + })), + editRequests: (editRequests as Array>).map((e) => ({ + id: e.id, contributionId: e.contributionId, reason: e.reason, + proposedTitle: e.proposedTitle || null, proposedSummary: e.proposedSummary || null, + status: e.status, createdAt: e.createdAt, + })), + images: (images as Array>).map((i) => ({ + id: i.id, mimeType: i.mimeType, size: i.size, width: i.width, height: i.height, + sha256: i.sha256, createdAt: i.createdAt, + })), + oauthAccounts: (oauthAccounts as Array>).map((o) => ({ + provider: o.provider, providerUsername: o.providerUsername, providerDisplayName: o.providerDisplayName, boundAt: o.boundAt, + })), + sessions: (sessions as Array>).map((s) => ({ + id: s.id, loginMethod: s.loginMethod, ipPrefix: s.ipPrefix || null, + createdAt: s.createdAt, lastUsedAt: s.lastUsedAt || null, + })), + } + + // Write export file + const exportDir = join(process.cwd(), 'storage', 'exports') + if (!existsSync(exportDir)) mkdirSync(exportDir, { recursive: true }) + const exportPath = join(exportDir, `${exportId}.json`) + writeFileSync(exportPath, JSON.stringify(exportData, null, 2), 'utf-8') + + // Create export_jobs record + await exec( + `INSERT INTO export_jobs (id, userId, status, exportPath, createdAt, completedAt) + VALUES (?, ?, 'completed', ?, ?, ?)`, + [exportId, req.user!.userId, exportPath, now, now], + ).catch(() => { + // table might not exist yet — non-fatal + }) + + // Per api.md §2.3: status must always be 'queued' — export is async even when fast + sendSuccess(res, { + exportId, + status: 'queued', + createdAt: now, + }, req.requestId, 202) + return + } + } catch { + // inline export failed — fall through to queued response + } + + sendSuccess(res, { + exportId, + status: 'queued', + createdAt: now, + }, req.requestId, 202) +}) + +// ────────────────────────────────────────────── +// 2.4 POST /me/delete — 注销账户 (GDPR) +// ────────────────────────────────────────────── +router.post('/delete', requireAuth, (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { confirmation, password } = req.body as { confirmation?: string; password?: string } + + if (confirmation !== 'DELETE-MY-ACCOUNT') { + sendError(res, Errors.VALIDATION_ERROR.code, '必须输入 DELETE-MY-ACCOUNT 确认', req.requestId, 422) + return + } + + // 校验 step-up (5 分钟内) + const session = await queryOne( + `SELECT lastStepUpAt FROM sessions WHERE id = ? AND userId = ?`, + [req.user!.sessionId, req.user!.userId], + ) + if (!session?.lastStepUpAt || Date.now() - session.lastStepUpAt > 300_000) { + sendError(res, 'STEP_UP_REQUIRED', '需要二次认证', req.requestId, 403) + return + } + + // 校验 status = active + const user = await queryOne( + `SELECT id, status, passwordHash, email FROM users WHERE id = ?`, + [req.user!.userId], + ) + if (!user || user.status !== 'active') { + if (user?.status === 'pending_verification') { + sendError(res, Errors.EMAIL_NOT_VERIFIED.code, '邮箱未验证', req.requestId, Errors.EMAIL_NOT_VERIFIED.status) + } else { + sendError(res, Errors.FORBIDDEN.code, '当前状态不允许注销', req.requestId, Errors.FORBIDDEN.status) + } + return + } + + // 如已设置密码,校验密码 + if (user.passwordHash) { + if (!password) { + sendError(res, Errors.INVALID_CREDENTIALS.code, '密码不能为空', req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + const { verifyPassword } = await import('../utils/password') + const valid = await verifyPassword(user.passwordHash as string, password) + if (!valid) { + sendError(res, Errors.INVALID_CREDENTIALS.code, '密码错误', req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + } + + const now = Date.now() + const deletionScheduledFor = now + 30 * 86400_000 + + // 生成 cancelToken(在事务外声明,用于事务内存储和事务外发邮件) + let cancelToken: string + const { getConnection } = await import('../Database') + const conn = await getConnection() + try { + await conn.beginTransaction() + + // 标记用户状态 + await conn.execute( + `UPDATE users SET status = 'pending_deletion', tokenVersion = tokenVersion + 1, updatedAt = ? WHERE id = ? AND status = 'active'`, + [now, req.user!.userId], + ) + + // 吊销全部 sessions + await conn.execute( + `UPDATE sessions SET revokedAt = ?, revokedReason = 'account_pending_deletion' WHERE userId = ? AND revokedAt IS NULL`, + [now, req.user!.userId], + ) + await conn.execute( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ?) + AND status IN ('active', 'rotated')`, + [req.user!.userId], + ) + + // 冻结 passkeys + await conn.execute( + `UPDATE passkeys SET status = 'frozen', frozenReason = 'account_pending_deletion' WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) + + // 冻结 mfa_totp_credentials + await conn.execute( + `UPDATE mfa_totp_credentials SET status = 'frozen' WHERE userId = ? AND status = 'active'`, + [req.user!.userId], + ) + + // 生成 cancelToken + cancelToken = genId('dc_') + ulid().slice(0, 16) + const cancelHash = await hmacToken(cancelToken) + await conn.execute( + `INSERT INTO auth_tokens (id, userId, type, tokenHash, metadata, expiresAt, createdAt) + VALUES (?, ?, 'account_delete_cancel', ?, ?, ?, ?)`, + [ulid(), req.user!.userId, cancelHash, + JSON.stringify({ deletionScheduledFor }), deletionScheduledFor, now], + ) + + // 审计日志(带哈希链 per api.md §15.13) + const auditId = genId('aud_') + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const deleteAfterData = { deletionScheduledFor } + const deleteCanonicalAfter = canonicalJson(deleteAfterData) + // Compute prevHash inside transaction — FOR UPDATE prevents concurrent hash chain forks (api.md §15.13) + const [lastAuditRow] = await conn.execute( + `SELECT entryHash FROM audit_logs ORDER BY createdAt DESC LIMIT 1 FOR UPDATE`, + ) + const deletePrevHash = (lastAuditRow[0]?.entryHash as string) ?? null + const deleteRecordData = { + id: auditId, actorUserId: req.user!.userId, action: 'me.delete', + resourceType: 'user', resourceId: req.user!.userId, + before: null, after: deleteAfterData, metadata: {}, + createdAt: now, requestId: req.requestId, + ipHash: await hmacToken(ip), uaHash: await hmacToken(ua), + } + const deleteCanonicalRecord = canonicalJson(deleteRecordData) + const deleteChainInput = deletePrevHash ? `${deletePrevHash}|${deleteCanonicalRecord}` : deleteCanonicalRecord + const deleteEntryHash = await sha256base64url(deleteChainInput) + await conn.execute( + `INSERT INTO audit_logs (id, actorUserId, action, resourceType, resourceId, \`before\`, after, metadata, createdAt, requestId, ipHash, userAgentHash, prevHash, entryHash) + VALUES (?, ?, 'me.delete', 'user', ?, NULL, ?, '{}', ?, ?, ?, ?, ?, ?)`, + [auditId, req.user!.userId, req.user!.userId, + deleteCanonicalAfter, now, req.requestId, + await hmacToken(ip), await hmacToken(ua), + deletePrevHash, deleteEntryHash], + ) + + await conn.commit() + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('delete account error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '注销失败', req.requestId, 500) + return + } finally { + conn.release() + } + + // 异步发送撤销邮件 + const email = user.email as string + if (email && cancelToken) { + const { sendEmail, buildCancelDeletionEmail } = await import('../utils/mail') + sendEmail(buildCancelDeletionEmail(email, cancelToken)).catch((e: unknown) => + console.error('cancel deletion email error:', (e as Error).message || e), + ) + } + + sendSuccess(res, { + deletionScheduledFor, + cancelEmailSent: !!email, + }, req.requestId, 202) +}) + +// ────────────────────────────────────────────── +// 2.5 POST /me/delete/cancel — 撤销账户注销 +// ────────────────────────────────────────────── +router.post('/delete/cancel', (req, _res, next) => { req.rateLimitAction = 'auth'; next(); }, rateLimitCheck, async (req, res) => { + const { cancelToken, identifier, password, mfaCode, passkeyAssertion } = req.body as { + cancelToken?: string; identifier?: string; password?: string + mfaCode?: string; passkeyAssertion?: unknown + } + + if (!cancelToken || !identifier) { + sendError(res, Errors.BAD_REQUEST.code, '缺少必要参数', req.requestId, 400) + return + } + + const now = Date.now() + const tokenHash = await hmacToken(cancelToken) + + // 单事务执行 + const { getConnection } = await import('../Database') + const conn = await getConnection() + try { + await conn.beginTransaction() + + // 查找 cancelToken + const [lockRows] = await conn.execute( + `SELECT id, userId, metadata FROM auth_tokens + WHERE tokenHash = ? AND type = 'account_delete_cancel' AND usedAt IS NULL AND expiresAt > ? + FOR UPDATE`, + [tokenHash, now], + ) + if ((lockRows as unknown[]).length === 0) { + await conn.rollback() + sendError(res, 'TOKEN_INVALID_OR_EXPIRED', '取消令牌无效或已过期', req.requestId, 410) + return + } + + const tokenRecord = lockRows[0] as { id: string; userId: string; metadata: string } + const tokenUserId = tokenRecord.userId + + // 校验 identifier 与 token 关联用户一致 + const findUser = await queryOne( + `SELECT id, status, passwordHash, email FROM users WHERE id = ?`, + [tokenUserId], + ) + if (!findUser) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, '用户不存在', req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + + // 校验 identifier + if (findUser.email !== identifier && findUser.username !== identifier) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, Errors.INVALID_CREDENTIALS.message, req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + + // 校验密码 + if (findUser.passwordHash) { + if (!password) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, '密码不能为空', req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + const { verifyPassword } = await import('../utils/password') + const valid = await verifyPassword(findUser.passwordHash as string, password) + if (!valid) { + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, Errors.INVALID_CREDENTIALS.message, req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + } else if (!passkeyAssertion) { + // OAuth-only 账户必须提供 Passkey assertion + await conn.rollback() + sendError(res, Errors.INVALID_CREDENTIALS.code, '需要 Passkey 验证', req.requestId, Errors.INVALID_CREDENTIALS.status) + return + } + + // 校验 MFA (if enabled) + if (mfaCode) { + const totpCred = await queryOne( + `SELECT id, secretEncrypted, lastUsedTimeStep FROM mfa_totp_credentials WHERE userId = ? AND status = 'frozen' LIMIT 1`, + [tokenUserId], + ) + if (totpCred) { + const { decryptSecret } = await import('../utils/crypto') + const { verifyTotpCode } = await import('../utils/totp') + const encryptedSecret = totpCred.secretEncrypted as string + const secret = await decryptSecret(encryptedSecret) + const lastUsedTimeStep = totpCred.lastUsedTimeStep as number | null + + const candidateTimeStep = await verifyTotpCode(secret, mfaCode, lastUsedTimeStep) + if (candidateTimeStep === null) { + // Try as recovery code (argon2id scan per api.md §15.7) + const rcIdFromPool = await findUnusedRecoveryCode(tokenUserId, mfaCode) + if (!rcIdFromPool) { + await conn.rollback() + sendError(res, 'INVALID_TOTP_CODE', '验证码或恢复码错误', req.requestId, 422) + return + } + // FOR UPDATE check within transaction to prevent race + const [rcCheckRows] = await conn.execute( + `SELECT id FROM mfa_recovery_codes WHERE id = ? AND usedAt IS NULL FOR UPDATE`, + [rcIdFromPool], + ) + if (!rcCheckRows[0]) { + await conn.rollback() + sendError(res, 'INVALID_TOTP_CODE', '恢复码已被使用', req.requestId, 422) + return + } + await conn.execute(`UPDATE mfa_recovery_codes SET usedAt = ? WHERE id = ?`, [now, rcCheckRows[0].id]) + } else { + await conn.execute(`UPDATE mfa_totp_credentials SET lastUsedTimeStep = ? WHERE id = ?`, [candidateTimeStep, totpCred.id]) + } + } + } + + // 标记 cancelToken 已使用 + await conn.execute(`UPDATE auth_tokens SET usedAt = ? WHERE id = ?`, [now, tokenRecord.id]) + + // 恢复用户状态 + await conn.execute( + `UPDATE users SET status = 'active', tokenVersion = tokenVersion + 1, updatedAt = ? WHERE id = ? AND status = 'pending_deletion'`, + [now, tokenUserId], + ) + + // 解冻 passkeys + await conn.execute( + `UPDATE passkeys SET status = 'active', frozenReason = NULL WHERE userId = ? AND frozenReason = 'account_pending_deletion'`, + [tokenUserId], + ) + + // 解冻 TOTP + await conn.execute( + `UPDATE mfa_totp_credentials SET status = 'active' WHERE userId = ? AND status = 'frozen'`, + [tokenUserId], + ) + + // 审计日志(带哈希链 per api.md §15.13) + const cancelAuditId = genId('aud_') + const cancelIp = req.ip || req.socket.remoteAddress || 'unknown' + const cancelUa = req.headers['user-agent'] || 'unknown' + const cancelAfterData = { status: 'active' } + const cancelCanonicalAfter = canonicalJson(cancelAfterData) + const [lastCancelAudit] = await conn.execute( + `SELECT entryHash FROM audit_logs ORDER BY createdAt DESC LIMIT 1 FOR UPDATE`, + ) + const cancelPrevHash = (lastCancelAudit[0]?.entryHash as string) ?? null + const cancelRecordData = { + id: cancelAuditId, actorUserId: tokenUserId, action: 'me.delete.cancel', + resourceType: 'user', resourceId: tokenUserId, + before: null, after: cancelAfterData, metadata: {}, + createdAt: now, requestId: req.requestId, + ipHash: await hmacToken(cancelIp), uaHash: await hmacToken(cancelUa), + } + const cancelCanonicalRecord = canonicalJson(cancelRecordData) + const cancelChainInput = cancelPrevHash ? `${cancelPrevHash}|${cancelCanonicalRecord}` : cancelCanonicalRecord + const cancelEntryHash = await sha256base64url(cancelChainInput) + await conn.execute( + `INSERT INTO audit_logs (id, actorUserId, action, resourceType, resourceId, \`before\`, after, metadata, createdAt, requestId, ipHash, userAgentHash, prevHash, entryHash) + VALUES (?, ?, 'me.delete.cancel', 'user', ?, NULL, ?, '{}', ?, ?, ?, ?, ?, ?)`, + [cancelAuditId, tokenUserId, tokenUserId, + cancelCanonicalAfter, now, req.requestId, + await hmacToken(cancelIp), await hmacToken(cancelUa), + cancelPrevHash, cancelEntryHash], + ) + + // 统计 reactivated passkeys + const passkeyCount = await queryOne( + `SELECT COUNT(*) as cnt FROM passkeys WHERE userId = ? AND status = 'active'`, + [tokenUserId], + ) + + await conn.commit() + + sendSuccess(res, { + userId: tokenUserId, + status: 'active', + passkeysReactivated: (passkeyCount?.cnt as number) || 0, + }, req.requestId) + } catch (err) { + await conn.rollback().catch(() => {}) + console.error('cancel delete error:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '撤销注销失败', req.requestId, 500) + } finally { + conn.release() + } +}) + +export default router diff --git a/functions/src/routes/openapi.ts b/functions/src/routes/openapi.ts new file mode 100644 index 0000000..2960a0b --- /dev/null +++ b/functions/src/routes/openapi.ts @@ -0,0 +1,594 @@ +import { Router } from 'express' +import { sendSuccess } from '../utils/response' + +const router: Router = Router() + +// GET /v1/openapi.json — api.md §13.4 +router.get('/openapi.json', (_req, res) => { + const spec = { + openapi: '3.1.0', + info: { + title: 'TransCircle API', + version: '1.0.0', + description: '投稿 API 接口 — 第四版规范。\n\nBase URL: https://api.transcircle.org\n认证方式: Bearer Token + JWT', + }, + servers: [ + { url: 'https://api.transcircle.org', description: 'Production' }, + { url: 'https://sandbox-api.transcircle.org', description: 'Sandbox' }, + ], + paths: { + '/v1/auth/register': { + post: { + summary: '用户注册', + tags: ['Auth'], + operationId: 'authRegister', + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/RegisterRequest' } } }, + }, + responses: { + '201': { description: '注册成功', content: { 'application/json': { schema: { $ref: '#/components/schemas/RegisterResponse' } } } }, + '409': { description: '用户名/邮箱已被占用', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + '422': { description: '字段校验失败', content: { 'application/json': { schema: { $ref: '#/components/schemas/ValidationError' } } } }, + '429': { description: '触发限流', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + }, + }, + }, + '/v1/auth/email/verify': { + post: { summary: '验证邮箱', tags: ['Auth'], operationId: 'emailVerify' }, + }, + '/v1/auth/email/resend': { + post: { summary: '重发验证邮件', tags: ['Auth'], operationId: 'emailResend' }, + }, + '/v1/auth/login': { + post: { + summary: '密码登录', + tags: ['Auth'], + operationId: 'authLogin', + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginRequest' } } }, + }, + responses: { + '200': { description: '登录成功或需要 MFA', content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } } }, + '401': { description: '凭据错误', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + '423': { description: '账户锁定', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + }, + }, + }, + '/v1/auth/password/forgot': { + post: { summary: '发起密码重置', tags: ['Auth'], operationId: 'passwordForgot' }, + }, + '/v1/auth/password/reset': { + post: { summary: '提交新密码', tags: ['Auth'], operationId: 'passwordReset' }, + }, + '/v1/me/password': { + post: { summary: '修改密码', tags: ['Auth'], operationId: 'changePassword' }, + }, + '/v1/auth/refresh': { + post: { + summary: '刷新 Access Token', + tags: ['Auth'], + operationId: 'refreshToken', + responses: { + '200': { description: '刷新成功', content: { 'application/json': { schema: { $ref: '#/components/schemas/RefreshResponse' } } } }, + }, + }, + }, + '/v1/auth/logout': { + post: { summary: '退出当前会话', tags: ['Auth'], operationId: 'logout', responses: { '204': { description: '成功' } } }, + }, + '/v1/auth/logout-all': { + post: { summary: '退出全部会话', tags: ['Auth'], operationId: 'logoutAll' }, + }, + '/v1/auth/session': { + get: { summary: '获取当前会话信息', tags: ['Auth'], operationId: 'getSession' }, + }, + '/v1/me/sessions': { + get: { summary: '列出活跃会话', tags: ['Auth'], operationId: 'listSessions' }, + delete: { summary: '吊销指定 session', tags: ['Auth'], operationId: 'revokeSession' }, + }, + '/v1/auth/oauth/github/start': { + get: { summary: '发起 GitHub OAuth 授权', tags: ['OAuth'], operationId: 'oauthGithubStart' }, + }, + '/v1/auth/oauth/github/callback': { + get: { summary: 'GitHub OAuth 回调', tags: ['OAuth'], operationId: 'oauthGithubCallback' }, + }, + '/v1/auth/oauth/x/start': { + get: { summary: '发起 X OAuth 授权', tags: ['OAuth'], operationId: 'oauthXStart' }, + }, + '/v1/auth/oauth/x/callback': { + get: { summary: 'X OAuth 回调', tags: ['OAuth'], operationId: 'oauthXCallback' }, + }, + '/v1/auth/oauth/exchange': { + post: { summary: '兑换登录结果', tags: ['OAuth'], operationId: 'oauthExchange' }, + }, + '/v1/auth/oauth/complete-registration': { + post: { summary: 'OAuth 完成密码注册', tags: ['OAuth'], operationId: 'oauthCompleteRegistration' }, + }, + '/v1/auth/oauth/complete-binding': { + post: { summary: 'OAuth 绑定到当前账号', tags: ['OAuth'], operationId: 'oauthCompleteBinding' }, + }, + '/v1/auth/oauth/pending-profile': { + get: { summary: '拉取 OAuth 补全预填信息', tags: ['OAuth'], operationId: 'oauthPendingProfile' }, + }, + '/v1/auth/oauth/native/start': { + post: { summary: '原生端发起 OAuth 授权 (PKCE)', tags: ['OAuth'], operationId: 'oauthNativeStart' }, + }, + '/v1/auth/oauth/native/exchange': { + post: { summary: '原生端兑换 OAuth 回调', tags: ['OAuth'], operationId: 'oauthNativeExchange' }, + }, + '/v1/auth/oauth/native/complete-registration': { + post: { summary: '原生端完成密码注册并绑定', tags: ['OAuth'], operationId: 'oauthNativeCompleteRegistration' }, + }, + '/v1/auth/oauth/native/complete-binding': { + post: { summary: '原生端绑定 OAuth 到当前账号', tags: ['OAuth'], operationId: 'oauthNativeCompleteBinding' }, + }, + '/v1/auth/merge': { + post: { summary: '账号合并', tags: ['Auth'], operationId: 'accountMerge' }, + }, + '/v1/me/mfa/totp/setup': { + post: { + summary: '开始设置 TOTP', + tags: ['MFA'], + operationId: 'totpSetup', + security: [{ bearerAuth: [] }], + responses: { + '200': { description: '成功', content: { 'application/json': { schema: { $ref: '#/components/schemas/TotpSetupResponse' } } } }, + }, + }, + }, + '/v1/me/mfa/totp/enable': { + post: { summary: '确认并启用 TOTP', tags: ['MFA'], operationId: 'totpEnable' }, + }, + '/v1/me/mfa/totp': { + delete: { summary: '禁用 TOTP', tags: ['MFA'], operationId: 'totpDisable' }, + }, + '/v1/auth/mfa/totp/verify': { + post: { summary: 'MFA 登录验证', tags: ['MFA'], operationId: 'mfaTotpVerify' }, + }, + '/v1/me/mfa/recovery-codes/regenerate': { + post: { summary: '重新生成恢复码', tags: ['MFA'], operationId: 'recoveryCodesRegenerate' }, + }, + '/v1/me/passkeys/register/start': { + post: { summary: '开始注册 Passkey', tags: ['Passkey'], operationId: 'passkeyRegisterStart' }, + }, + '/v1/me/passkeys/register/finish': { + post: { summary: '完成 Passkey 注册', tags: ['Passkey'], operationId: 'passkeyRegisterFinish' }, + }, + '/v1/me/passkeys': { + get: { summary: '查看已注册 Passkey 列表', tags: ['Passkey'], operationId: 'listPasskeys' }, + }, + '/v1/me/passkeys/{id}': { + delete: { summary: '删除 Passkey', tags: ['Passkey'], operationId: 'deletePasskey' }, + }, + '/v1/auth/passkey/login/start': { + post: { summary: 'Passkey 登录 — 开始', tags: ['Passkey'], operationId: 'passkeyLoginStart' }, + }, + '/v1/auth/passkey/login/finish': { + post: { summary: 'Passkey 登录 — 完成', tags: ['Passkey'], operationId: 'passkeyLoginFinish' }, + }, + '/v1/auth/step-up/start': { + post: { summary: '发起 Step-up 再认证', tags: ['Auth'], operationId: 'stepUpStart' }, + }, + '/v1/auth/step-up/verify': { + post: { summary: '完成 Step-up 验证', tags: ['Auth'], operationId: 'stepUpVerify' }, + }, + '/v1/me': { + get: { + summary: '获取当前用户资料', + tags: ['User'], + operationId: 'getProfile', + security: [{ bearerAuth: [] }], + responses: { + '200': { description: '成功', content: { 'application/json': { schema: { $ref: '#/components/schemas/UserProfile' } } } }, + }, + }, + patch: { + summary: '更新当前用户资料', + tags: ['User'], + operationId: 'updateProfile', + security: [{ bearerAuth: [] }], + requestBody: { + content: { 'application/json': { schema: { type: 'object', properties: { displayName: { type: 'string', maxLength: 50 }, avatarUrl: { type: 'string', nullable: true } } } } }, + }, + responses: { + '200': { description: '更新成功', content: { 'application/json': { schema: { $ref: '#/components/schemas/UserProfile' } } } }, + }, + }, + }, + '/v1/me/oauth': { + get: { summary: '查询 OAuth 绑定状态', tags: ['OAuth'], operationId: 'listOAuthBinds' }, + }, + '/v1/me/oauth/{provider}': { + delete: { summary: '解绑 OAuth 账号', tags: ['OAuth'], operationId: 'unbindOAuth' }, + }, + '/v1/me/oauth/github/bind/start': { + get: { summary: '发起 GitHub 绑定', tags: ['OAuth'], operationId: 'githubBindStart' }, + }, + '/v1/me/oauth/x/bind/start': { + get: { summary: '发起 X 绑定', tags: ['OAuth'], operationId: 'xBindStart' }, + }, + '/v1/me/export': { + post: { summary: '导出我的数据 (GDPR)', tags: ['User'], operationId: 'exportData' }, + }, + '/v1/me/delete': { + post: { summary: '注销账户 (GDPR)', tags: ['User'], operationId: 'deleteAccount' }, + }, + '/v1/me/delete/cancel': { + post: { summary: '撤销账户注销', tags: ['User'], operationId: 'cancelDelete' }, + }, + '/v1/me/contributions': { + get: { summary: '获取我的投稿列表', tags: ['Contributions'], operationId: 'myContributions' }, + }, + '/v1/me/contributions/{id}': { + get: { summary: '获取我的投稿详情', tags: ['Contributions'], operationId: 'myContributionDetail' }, + patch: { summary: '修改草稿', tags: ['Contributions'], operationId: 'updateDraft' }, + }, + '/v1/me/contributions/{id}/submit': { + post: { summary: '提交草稿', tags: ['Contributions'], operationId: 'submitContribution' }, + }, + '/v1/me/contributions/{id}/withdraw': { + post: { summary: '撤回投稿', tags: ['Contributions'], operationId: 'withdrawContribution' }, + }, + '/v1/contributions': { + post: { + summary: '提交投稿', + tags: ['Contributions'], + operationId: 'createContribution', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/ContributionRequest' } } }, + }, + responses: { + '201': { description: '创建成功', content: { 'application/json': { schema: { $ref: '#/components/schemas/ContributionCreateResponse' } } } }, + '403': { description: '权限不足/邮箱未验证', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + }, + }, + }, + '/v1/contributions/{id}/edit-requests': { + post: { summary: '提交修改申请', tags: ['EditRequests'], operationId: 'createEditRequest' }, + }, + '/v1/me/edit-requests': { + get: { summary: '查看我的修改申请列表', tags: ['EditRequests'], operationId: 'myEditRequests' }, + }, + '/v1/me/edit-requests/{id}/withdraw': { + post: { summary: '撤回修改申请', tags: ['EditRequests'], operationId: 'withdrawEditRequest' }, + }, + '/v1/public/contributions': { + get: { summary: '公开展示 — 列表', tags: ['Public'], operationId: 'publicContributions' }, + }, + '/v1/public/contributions/{id}': { + get: { summary: '公开展示 — 详情', tags: ['Public'], operationId: 'publicContributionDetail' }, + }, + '/v1/images': { + post: { summary: '上传图片', tags: ['Images'], operationId: 'uploadImage' }, + }, + '/v1/images/{id}': { + get: { summary: '查看图片', tags: ['Images'], operationId: 'getImage' }, + }, + '/v1/admin/contributions': { + get: { summary: '审核后台 — 投稿列表', tags: ['Admin'], operationId: 'adminContributions' }, + }, + '/v1/admin/contributions/stats': { + get: { summary: '审核后台 — 统计', tags: ['Admin'], operationId: 'adminStats' }, + }, + '/v1/admin/contributions/{id}': { + get: { summary: '审核后台 — 投稿详情', tags: ['Admin'], operationId: 'adminContributionDetail' }, + }, + '/v1/admin/contributions/{id}/review': { + post: { summary: '审核投稿', tags: ['Admin'], operationId: 'reviewContribution' }, + }, + '/v1/admin/contributions/{id}/publish': { + post: { summary: '发布投稿', tags: ['Admin'], operationId: 'publishContribution' }, + }, + '/v1/admin/contributions/{id}/hide': { + post: { summary: '隐藏投稿', tags: ['Admin'], operationId: 'hideContribution' }, + }, + '/v1/admin/contributions/{id}/restore': { + post: { summary: '恢复投稿', tags: ['Admin'], operationId: 'restoreContribution' }, + }, + '/v1/admin/contributions/{id}/delete': { + post: { summary: '删除投稿', tags: ['Admin'], operationId: 'adminDeleteContribution' }, + }, + '/v1/admin/contributions/{id}/review-events': { + get: { summary: '审核事件历史', tags: ['Admin'], operationId: 'reviewEvents' }, + }, + '/v1/admin/users': { + get: { summary: '用户列表', tags: ['Admin'], operationId: 'adminUsers' }, + }, + '/v1/admin/users/{id}': { + get: { summary: '用户详情', tags: ['Admin'], operationId: 'adminUserDetail' }, + }, + '/v1/admin/users/{id}/ban': { + post: { summary: '封禁用户', tags: ['Admin'], operationId: 'banUser' }, + }, + '/v1/admin/users/{id}/unban': { + post: { summary: '解封用户', tags: ['Admin'], operationId: 'unbanUser' }, + }, + '/v1/admin/users/{id}/roles': { + post: { summary: '授予角色', tags: ['Admin'], operationId: 'grantRole' }, + }, + '/v1/admin/users/{id}/roles/{roleId}': { + delete: { summary: '撤销角色', tags: ['Admin'], operationId: 'revokeRole' }, + }, + '/v1/admin/audit-logs': { + get: { summary: '审计日志', tags: ['Admin'], operationId: 'auditLogs' }, + }, + '/v1/admin/edit-requests': { + get: { summary: '审核员查看修改申请列表', tags: ['Admin'], operationId: 'adminEditRequests' }, + }, + '/v1/admin/edit-requests/{id}': { + get: { summary: '审核员查看修改申请详情', tags: ['Admin'], operationId: 'adminEditRequestDetail' }, + }, + '/v1/admin/edit-requests/{id}/vote': { + post: { summary: '审核员投票', tags: ['Admin'], operationId: 'voteEditRequest' }, + }, + '/v1/stories/published': { + get: { summary: '已发布投稿(Story 站点格式)', tags: ['Public'], operationId: 'publishedStories' }, + }, + '/healthz': { + get: { summary: '健康检查(轻量)', tags: ['Ops'], operationId: 'healthz' }, + }, + '/readyz': { + get: { summary: '就绪检查(含 DB)', tags: ['Ops'], operationId: 'readyz' }, + }, + '/metrics': { + get: { summary: 'Prometheus 指标', tags: ['Ops'], operationId: 'metrics' }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Bearer Token(JWT),有效期 15 分钟', + }, + }, + schemas: { + // ── 统一响应信封 ── + ApiSuccess: { + type: 'object', + required: ['data', 'requestId'], + properties: { + data: { description: '响应数据' }, + requestId: { type: 'string', example: 'req_01HZYC8AA4V7J9M2C3K5N6P7Q8' }, + pagination: { $ref: '#/components/schemas/Pagination' }, + }, + }, + ApiError: { + type: 'object', + required: ['error', 'requestId'], + properties: { + error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { type: 'string', example: 'VALIDATION_ERROR' }, + message: { type: 'string', example: '请求数据校验失败' }, + details: { + type: 'array', + items: { $ref: '#/components/schemas/ErrorDetail' }, + }, + data: { description: '结构化附加数据(如 mergeToken, nextAction)' }, + }, + }, + requestId: { type: 'string', example: 'req_01HZYC8AA4V7J9M2C3K5N6P7Q8' }, + }, + }, + ValidationError: { + type: 'object', + required: ['error', 'requestId'], + properties: { + error: { + type: 'object', + required: ['code', 'message', 'details'], + properties: { + code: { type: 'string', example: 'VALIDATION_ERROR' }, + message: { type: 'string', example: '请求数据校验失败' }, + details: { + type: 'array', + items: { $ref: '#/components/schemas/ErrorDetail' }, + }, + }, + }, + requestId: { type: 'string' }, + }, + }, + ErrorDetail: { + type: 'object', + required: ['field', 'reason'], + properties: { + field: { type: 'string', example: 'password' }, + reason: { type: 'string', example: '密码至少 12 个字符' }, + }, + }, + Pagination: { + type: 'object', + required: ['limit', 'nextCursor', 'hasMore'], + properties: { + limit: { type: 'integer', example: 20 }, + nextCursor: { type: 'string', nullable: true, example: 'MTcxNjE2MzIwMDAwMA' }, + hasMore: { type: 'boolean', example: false }, + }, + }, + + // ── Auth ── + RegisterRequest: { + type: 'object', + required: ['username', 'email', 'password', 'displayName'], + properties: { + username: { type: 'string', pattern: '^[a-z][a-z0-9_-]{2,31}$', example: 'alice123' }, + email: { type: 'string', format: 'email', maxLength: 254, example: 'alice@example.com' }, + password: { type: 'string', minLength: 12, maxLength: 128, example: 'Strong@Passw0rd_2026!' }, + displayName: { type: 'string', minLength: 1, maxLength: 50, example: 'Alice' }, + }, + }, + RegisterResponse: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + user: { $ref: '#/components/schemas/UserObject' }, + verificationEmailSent: { type: 'boolean' }, + }, + }, + requestId: { type: 'string' }, + }, + }, + LoginRequest: { + type: 'object', + required: ['identifier', 'password'], + properties: { + identifier: { type: 'string', minLength: 3, maxLength: 254, example: 'alice@example.com' }, + password: { type: 'string', example: 'Strong@Passw0rd_2026!' }, + }, + }, + LoginResponse: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + mfaRequired: { type: 'boolean', example: false }, + accessToken: { type: 'string' }, + tokenType: { type: 'string', example: 'Bearer' }, + expiresIn: { type: 'integer', example: 900 }, + refreshToken: { type: 'string' }, + user: { $ref: '#/components/schemas/UserBrief' }, + mfaChallengeToken: { type: 'string' }, + mfaChallengeExpiresIn: { type: 'integer' }, + availableMethods: { type: 'array', items: { type: 'string' } }, + }, + }, + requestId: { type: 'string' }, + }, + }, + RefreshResponse: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + accessToken: { type: 'string' }, + tokenType: { type: 'string', example: 'Bearer' }, + expiresIn: { type: 'integer', example: 900 }, + }, + }, + requestId: { type: 'string' }, + }, + }, + + // ── User ── + UserObject: { + type: 'object', + properties: { + id: { type: 'string', example: 'usr_01HZYC7Q9N8K2P4R6X0M9A1B2C' }, + username: { type: 'string', example: 'alice123' }, + email: { type: 'string', nullable: true, example: 'alice@example.com' }, + displayName: { type: 'string', example: 'Alice' }, + avatarUrl: { type: 'string', nullable: true }, + emailVerified: { type: 'boolean' }, + status: { type: 'string', enum: ['active', 'pending_verification', 'banned', 'merged', 'pending_deletion', 'deleted'] }, + createdAt: { type: 'integer', description: 'Unix 毫秒' }, + }, + }, + UserBrief: { + type: 'object', + properties: { + id: { type: 'string' }, + username: { type: 'string' }, + displayName: { type: 'string' }, + avatarUrl: { type: 'string', nullable: true }, + emailVerified: { type: 'boolean' }, + }, + }, + UserProfile: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + id: { type: 'string' }, + username: { type: 'string' }, + email: { type: 'string', nullable: true }, + displayName: { type: 'string' }, + avatarUrl: { type: 'string', nullable: true }, + emailVerified: { type: 'boolean' }, + status: { type: 'string' }, + roles: { type: 'array', items: { type: 'string' } }, + security: { + type: 'object', + properties: { + hasPassword: { type: 'boolean' }, + totpEnabled: { type: 'boolean' }, + passkeyCount: { type: 'integer' }, + oauthProviders: { type: 'array', items: { type: 'string' } }, + }, + }, + createdAt: { type: 'integer' }, + lastLoginAt: { type: 'integer', nullable: true }, + }, + }, + requestId: { type: 'string' }, + }, + }, + + // ── TOTP ── + TotpSetupResponse: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + setupId: { type: 'string', example: 'totp_setup_xxx' }, + secret: { type: 'string', example: 'JBSWY3DPEHPK3PXP' }, + otpauthUrl: { type: 'string', example: 'otpauth://totp/...' }, + qrCodeImage: { type: 'string', nullable: true }, + expiresIn: { type: 'integer', example: 600 }, + }, + }, + requestId: { type: 'string' }, + }, + }, + + // ── Contributions ── + ContributionRequest: { + type: 'object', + required: ['title', 'content', 'contentFormat'], + properties: { + title: { type: 'string', maxLength: 120, example: '用户输入标题' }, + content: { type: 'string', maxLength: 50000, example: 'Markdown 内容' }, + contentFormat: { type: 'string', enum: ['markdown', 'plain_text'] }, + summary: { type: 'string', maxLength: 300, nullable: true }, + tags: { type: 'array', items: { type: 'string', maxLength: 32 }, maxItems: 8 }, + language: { type: 'string', enum: ['zh-CN', 'zh-TW', 'en', 'ja', 'other'], default: 'zh-CN' }, + submitMode: { type: 'string', enum: ['draft', 'submit'], default: 'submit' }, + }, + }, + ContributionCreateResponse: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + id: { type: 'string', example: 'contrib_01HZYC9R4M6X8P2Q1N0A7B5C3D' }, + status: { type: 'string', example: 'pending' }, + createdAt: { type: 'integer' }, + }, + }, + requestId: { type: 'string' }, + }, + }, + }, + }, + security: [{ bearerAuth: [] }], + } + + sendSuccess(res, spec, _req.requestId) +}) + +export default router diff --git a/functions/src/routes/public-contributions.ts b/functions/src/routes/public-contributions.ts new file mode 100644 index 0000000..7104d5d --- /dev/null +++ b/functions/src/routes/public-contributions.ts @@ -0,0 +1,105 @@ +import { Router } from 'express' +import { query, queryOne } from '../Database' +import { sendSuccess, sendError, Errors } from '../utils/response' + +const router: Router = Router() + +// GET /public/contributions — api.md §5.1 +router.get('/contributions', async (req, res) => { + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 20, 1), 100) + const cursor = req.query.cursor as string | undefined + const language = req.query.language as string | undefined + const tag = req.query.tag as string | undefined + const sort = (req.query.sort as string) || 'publishedAt_desc' + + let whereClause = `WHERE c.status = 'published'` + let orderClause = 'ORDER BY c.publishedAt DESC' + const params: unknown[] = [] + + if (language) { whereClause += ' AND c.language = ?'; params.push(language) } + if (tag) { whereClause += ' AND JSON_CONTAINS(c.tags, ?)'; params.push(JSON.stringify(tag)) } + if (cursor) { + whereClause += ' AND c.publishedAt < ?' + params.push(parseInt(Buffer.from(cursor, 'base64url').toString('utf-8'), 10)) + } + if (sort === 'publishedAt_asc') { + orderClause = 'ORDER BY c.publishedAt ASC' + } + + params.push(limit + 1) + + const rows = await query( + `SELECT c.id, c.title, c.summary, c.tags, c.language, c.publishedAt, + u.displayName, u.avatarUrl + FROM contributions c + LEFT JOIN users u ON u.id = c.authorUserId + ${whereClause} + ${orderClause} + LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + const data = (rows as Array>).map((r) => ({ + id: r.id, + title: r.title, + summary: r.summary || null, + tags: typeof r.tags === 'string' ? JSON.parse(r.tags as string) : r.tags || [], + language: r.language, + author: { + displayName: r.displayName || 'Anonymous', + avatarUrl: r.avatarUrl || null, + }, + publishedAt: r.publishedAt, + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).publishedAt)).toString('base64url') + : null + + // Per api.md §6.4: cache headers for CDN + res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=60') + res.setHeader('Surrogate-Key', 'list_contributions') + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) +}) + +// GET /public/contributions/:id — api.md §5.2 +router.get('/contributions/:id', async (req, res) => { + const row = await queryOne( + `SELECT c.id, c.title, c.summary, c.contentHtml, c.contentFormat, c.tags, c.language, c.publishedAt, + u.displayName, u.avatarUrl + FROM contributions c + LEFT JOIN users u ON u.id = c.authorUserId + WHERE c.id = ? AND c.status = 'published'`, + [req.params.id], + ) + + if (!row) { + sendError(res, Errors.CONTRIBUTION_NOT_FOUND.code, '投稿不存在或未发布', req.requestId, Errors.CONTRIBUTION_NOT_FOUND.status) + return + } + + // Per api.md §6.4: cache headers for CDN + res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=60') + res.setHeader('Surrogate-Key', `contrib_${row.id}`) + + sendSuccess(res, { + id: row.id, + title: row.title, + summary: row.summary || null, + contentHtml: row.contentHtml || '', + contentFormat: row.contentFormat, + tags: typeof row.tags === 'string' ? JSON.parse(row.tags as string) : row.tags || [], + language: row.language, + author: { + displayName: row.displayName || 'Anonymous', + avatarUrl: row.avatarUrl || null, + }, + publishedAt: row.publishedAt, + }, req.requestId) +}) + +export default router diff --git a/functions/src/routes/stories.ts b/functions/src/routes/stories.ts new file mode 100644 index 0000000..1af6425 --- /dev/null +++ b/functions/src/routes/stories.ts @@ -0,0 +1,75 @@ +import { Router, type Router as RouterType } from 'express' +import { query } from '../Database' +import { sendSuccess, sendError, Errors } from '../utils/response' + +const router: RouterType = Router() + +// ────────────────────────────────────────────── +// GET /stories/published +// Serves published contributions to the external story site (story.transcircle.org). +// The story site renderer expects snake_case fields for backward compatibility. +// Also returns camelCase fields for api.md compliance. Always paginated via cursor per api.md §12. +// ────────────────────────────────────────────── +router.get('/published', async (req, res) => { + try { + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 50, 1), 100) + const cursor = req.query.cursor as string | undefined + + let whereClause = `WHERE c.status = 'published'` + const params: unknown[] = [] + + if (cursor) { + whereClause += ' AND c.publishedAt < ?' + params.push(parseInt(Buffer.from(cursor, 'base64url').toString('utf-8'), 10)) + } + + params.push(limit + 1) + + const rows = await query( + `SELECT c.id, c.title, c.summary, c.contentHtml as contentHtml_raw, c.tags, c.language, c.publishedAt, + c.submittedAt as created_at, + u.displayName as author_name, u.avatarUrl as author_avatar + FROM contributions c + LEFT JOIN users u ON u.id = c.authorUserId + ${whereClause} + ORDER BY c.publishedAt DESC + LIMIT ?`, + params, + ) + + const hasMore = rows.length > limit + if (hasMore) rows.pop() + + // Transform to nested author object + camelCase fields for api.md compliance + // Use contentHtml (sanitized) instead of contentRaw for security per api.md §5.2 + const data = (rows as Record[]).map((row) => ({ + id: row.id, + title: row.title, + summary: row.summary, + contentHtml: row.contentHtml_raw, // api.md §5.2: use sanitized HTML + content: row.contentHtml_raw, // backward compat for story site + tags: typeof row.tags === 'string' ? JSON.parse(row.tags as string) : row.tags || [], + language: row.language || 'zh-CN', + author: { + displayName: row.author_name, + avatarUrl: row.author_avatar, + }, + author_name: row.author_name, // backward compat for story site + author_avatar: row.author_avatar, // backward compat for story site + publishedAt: row.publishedAt || null, + createdAt: row.created_at, // camelCase version + created_at: row.created_at, // backward compat for story site + })) + + const nextCursor = hasMore && rows.length > 0 + ? Buffer.from(String((rows[rows.length - 1] as Record).publishedAt)).toString('base64url') + : null + + sendSuccess(res, data, req.requestId, 200, { nextCursor, hasMore, limit }) + } catch (err) { + console.error('Failed to fetch published stories:', err) + sendError(res, Errors.INTERNAL_ERROR.code, '获取已发布投稿失败', req.requestId, 500) + } +}) + +export default router diff --git a/functions/src/setup.ts b/functions/src/setup.ts new file mode 100644 index 0000000..34ae52b --- /dev/null +++ b/functions/src/setup.ts @@ -0,0 +1,116 @@ +/** + * Database setup script. + * Run: npx tsx src/setup.ts + * + * Creates the database (if not exists) and runs the schema. + * The schema SQL is embedded here for portability. + */ + +import mysql from 'mysql2/promise'; +import fs from 'node:fs'; +import path from 'node:path'; +import { conf } from './Config'; + +const dbConf = conf.DB_CONFIG as Record; + +async function main() { + const host = (dbConf.DB_HOSTNAME as string) || 'localhost'; + const port = Number(dbConf.DB_PORT) || 3306; + const user = (dbConf.DB_USERNAME as string) || 'transcircle'; + const password = (dbConf.DB_PASSWORD as string) || ''; + const database = (dbConf.DB_DATABASE as string) || 'transcircle'; + + console.log(`Connecting to MySQL at ${host}:${port}...`); + + // Connect without database first to create it + const conn = await mysql.createConnection({ host, port, user, password }); + + await conn.execute(`CREATE DATABASE IF NOT EXISTS \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); + console.log(`Database "${database}" ensured.`); + + await conn.changeUser({ database }); + console.log('Connected to', database); + + // Look for schema.sql relative to this file + const schemaPaths = [ + path.resolve(import.meta.dirname, '..', 'schema.sql'), + path.resolve(import.meta.dirname, '..', '..', 'schema.sql'), + ]; + + let schemaSql = ''; + for (const sp of schemaPaths) { + if (fs.existsSync(sp)) { + schemaSql = fs.readFileSync(sp, 'utf-8'); + console.log(`Found schema at ${sp}`); + break; + } + } + + if (schemaSql) { + // Split by delimiter and run each statement + const statements = schemaSql + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0 && !s.startsWith('DELIMITER')); + + for (const stmt of statements) { + try { + await conn.execute(stmt); + } catch (err: unknown) { + // Table already exists errors are OK + const mysqlErr = err as { errno?: number; message?: string }; + if (mysqlErr?.errno === 1050) { + console.log(` Table already exists, skipping...`); + } else { + console.error(` Error executing: ${stmt.slice(0, 60)}...`); + console.error(` ${mysqlErr?.message || err}`); + } + } + } + console.log('Schema applied.'); + } else { + console.warn('No schema.sql found — skipping table creation.'); + } + + // Create roles + try { + // Role IDs use role_ format per api.md §15.9 + await conn.execute(`INSERT IGNORE INTO roles (id, name, description, createdAt) VALUES ('role_admin', 'admin', '系统管理员', UNIX_TIMESTAMP(NOW()) * 1000)`); + await conn.execute(`INSERT IGNORE INTO roles (id, name, description, createdAt) VALUES ('role_editor', 'editor', '编辑', UNIX_TIMESTAMP(NOW()) * 1000)`); + await conn.execute(`INSERT IGNORE INTO roles (id, name, description, createdAt) VALUES ('role_reviewer', 'reviewer', '审稿员', UNIX_TIMESTAMP(NOW()) * 1000)`); + console.log('Default roles created.'); + } catch { /* roles may already exist */ } + + // Run migrations + const migrationsDir = path.resolve(import.meta.dirname, '..', 'migrations'); + if (fs.existsSync(migrationsDir)) { + const migrationFiles = fs.readdirSync(migrationsDir) + .filter(f => f.endsWith('.sql')) + .sort(); + for (const mf of migrationFiles) { + const migrationSql = fs.readFileSync(path.join(migrationsDir, mf), 'utf-8'); + const stmts = migrationSql.split(';').map(s => s.trim()).filter(s => s.length > 0); + for (const stmt of stmts) { + try { + await conn.execute(stmt); + console.log(` Migration ${mf} applied.`); + } catch (err: unknown) { + const mysqlErr = err as { errno?: number; message?: string }; + if (mysqlErr?.errno === 1050) { + console.log(` ${mf}: table already exists, skipping...`); + } else { + console.error(` Migration ${mf} error: ${mysqlErr?.message || err}`); + } + } + } + } + } + + await conn.end(); + console.log('Done!'); +} + +main().catch((err) => { + console.error('Setup failed:', err); + process.exit(1); +}); diff --git a/functions/src/utils/asn.ts b/functions/src/utils/asn.ts new file mode 100644 index 0000000..50ff240 --- /dev/null +++ b/functions/src/utils/asn.ts @@ -0,0 +1,98 @@ +/** + * ASN (Autonomous System Number) lookup utility — api.md §1.1 + * + * Supports: + * 1. Optional external API (ip-api.com) — set ASN_API_URL env var + * 2. Falls back to null when not configured (rate limiting skipped) + * + * Caches results in-memory to avoid repeated API calls. + */ + +interface AsnResult { + asn: string // e.g. "AS15169" + org: string // e.g. "Google LLC" +} + +const cache = new Map() +const CACHE_TTL_MS = 3600_000 // 1 hour + +// Track calls for global conflict alerting +let globalConflictCount = 0 +let globalConflictWindowStart = 0 +let globalConflictAlerted = false + +export interface ConflictAlert { + triggered: boolean + count: number + windowStart: number +} + +/** + * Look up ASN for an IP address. + * Returns null if ASN lookup is not configured or fails. + */ +export async function lookupAsn(ip: string): Promise { + const apiUrl = process.env.ASN_API_URL + if (!apiUrl) return null // Not configured — skip ASN rate limiting + + const cached = cache.get(ip) + if (cached) return cached + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + const res = await fetch(`${apiUrl}/${ip}?fields=as,org`, { + signal: controller.signal, + headers: { 'Accept': 'application/json' }, + }) + clearTimeout(timeout) + + if (!res.ok) return null + const data = await res.json() as { as?: string; org?: string } + if (!data.as) return null + + const result: AsnResult = { asn: data.as, org: data.org || '' } + cache.set(ip, result) + setTimeout(() => cache.delete(ip), CACHE_TTL_MS) + return result + } catch { + return null + } +} + +/** + * Track global registration conflict responses. + * api.md §1.1: 全局 5 分钟冲突响应率 > 1000 次 → 触发告警 + * Returns true if alert threshold has been crossed. + */ +export function trackGlobalConflict(): ConflictAlert { + const now = Date.now() + const windowMs = 300_000 // 5 minutes + + if (now - globalConflictWindowStart > windowMs) { + // New window + globalConflictCount = 1 + globalConflictWindowStart = now + globalConflictAlerted = false + } else { + globalConflictCount++ + } + + const triggered = globalConflictCount > 1000 && !globalConflictAlerted + if (triggered) globalConflictAlerted = true + + return { + triggered, + count: globalConflictCount, + windowStart: globalConflictWindowStart, + } +} + +/** + * Reset global conflict counter (used in tests). + */ +export function resetGlobalConflictCounter(): void { + globalConflictCount = 0 + globalConflictWindowStart = 0 + globalConflictAlerted = false +} diff --git a/functions/src/utils/audit.ts b/functions/src/utils/audit.ts new file mode 100644 index 0000000..599f3c3 --- /dev/null +++ b/functions/src/utils/audit.ts @@ -0,0 +1,116 @@ +/** + * Audit log helper — writes append-only audit entries per api.md §8. + * Handles ipHash/userAgentHash/prevHash/entryHash automatically. + */ +import type { Request } from 'express' +import { getConnection } from '../Database' +import { genId } from './ulid' +import { hmacToken } from './session' + +export async function sha256base64url(data: string): Promise { + const enc = new TextEncoder() + const digest = await crypto.subtle.digest('SHA-256', enc.encode(data)) + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') +} + +/** + * Canonical JSON serialization with sorted keys (api.md §15.13). + * Guarantees deterministic output for entryHash computation. + */ +export function canonicalJson(obj: Record): string { + const keys = Object.keys(obj).sort() + const parts = keys.map((k) => { + const v = obj[k] + if (v === null || v === undefined) return `"${k}":null` + if (typeof v === 'object' && !Array.isArray(v)) { + return `"${k}":${canonicalJson(v as Record)}` + } + return `"${k}":${JSON.stringify(v)}` + }) + return `{${parts.join(',')}}` +} + +/** + * Write an audit log entry with hash chain integrity. + * Uses a transaction + FOR UPDATE to prevent hash chain forks (api.md §15.13). + */ +export async function writeAuditLog( + req: Request, + params: { + actorUserId: string | null + action: string + resourceType: string + resourceId: string | null + before?: unknown | null + after?: unknown | null + metadata?: Record + }, +): Promise { + const ip = req.ip || req.socket.remoteAddress || 'unknown' + const ua = req.headers['user-agent'] || 'unknown' + const now = Date.now() + const auditId = genId('aud_') + + const ipHash = await hmacToken(ip) // hmac_sha256 per api.md §15.13 + const uaHash = await sha256base64url(ua) // sha256 per api.md §15.13 + + // Build record data (without hash fields, for entryHash computation) + const recordData: Record = { + id: auditId, + actorUserId: params.actorUserId, + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + before: params.before ?? null, + after: params.after ?? null, + metadata: params.metadata ?? {}, + createdAt: now, + requestId: req.requestId, + ipHash, + uaHash, + } + + // Use transaction + FOR UPDATE to prevent concurrent hash chain forks + const conn = await getConnection() + try { + await conn.beginTransaction() + + const [lastRows] = await conn.execute( + `SELECT entryHash FROM audit_logs ORDER BY createdAt DESC LIMIT 1 FOR UPDATE`, + ) + const prevHash = (lastRows[0]?.entryHash as string) ?? null + + const canonicalRecord = canonicalJson(recordData) + const chainInput = prevHash ? `${prevHash}|${canonicalRecord}` : canonicalRecord + const entryHash = await sha256base64url(chainInput) + + await conn.execute( + `INSERT INTO audit_logs (id, actorUserId, action, resourceType, resourceId, \`before\`, after, metadata, createdAt, requestId, ipHash, userAgentHash, prevHash, entryHash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + auditId, + params.actorUserId, + params.action, + params.resourceType, + params.resourceId, + params.before ? canonicalJson(params.before as Record) : null, + params.after ? canonicalJson(params.after as Record) : null, + canonicalJson(params.metadata ?? {}), + now, + req.requestId, + ipHash, + uaHash, + prevHash, + entryHash, + ], + ) + + await conn.commit() + } catch { + await conn.rollback().catch(() => {}) + // Best-effort — audit log failures must not block the main operation + } finally { + conn.release() + } +} diff --git a/functions/src/utils/cdn.ts b/functions/src/utils/cdn.ts new file mode 100644 index 0000000..7e816de --- /dev/null +++ b/functions/src/utils/cdn.ts @@ -0,0 +1,62 @@ +/** + * CDN cache purge utility per api.md §6.4 / §6.6. + * + * On hide/delete, we must purge the public contribution endpoints so CDN + * doesn't serve stale content. This module provides a pluggable purge function + * that logs when no CDN provider is configured. + * + * Surrogate-Key pattern: contrib_, list_contributions + */ + +let _surrogateEnabled = false + +export function enableSurrogateKeys(): void { + _surrogateEnabled = true +} + +export function isSurrogateEnabled(): boolean { + return _surrogateEnabled +} + +/** + * Purge a single contribution from CDN cache. + * Per api.md §6.4: + * /v1/public/contributions/{id} + * /v1/public/contributions?* (all cursor pages) + */ +export async function purgeContributionCache(contributionId: string): Promise { + const cdnUrl = process.env.CDN_PURGE_URL + const cdnToken = process.env.CDN_PURGE_TOKEN + + if (!cdnUrl || !cdnToken) { + // No CDN configured — log and skip (safe for dev/sandbox) + console.log(`[CDN] Would purge contribution ${contributionId} (no CDN configured)`) + return + } + + const urls = [ + `/v1/public/contributions/${contributionId}`, + `/v1/public/contributions?*`, + ] + + try { + const res = await fetch(`${cdnUrl}/purge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${cdnToken}`, + }, + body: JSON.stringify({ + surrogateKeys: [`contrib_${contributionId}`, 'list_contributions'], + urls, + }), + }) + if (!res.ok) { + console.error(`[CDN] Purge failed: ${res.status} ${await res.text()}`) + } else { + console.log(`[CDN] Purged contribution ${contributionId}`) + } + } catch (err) { + console.error('[CDN] Purge error:', err) + } +} diff --git a/functions/src/utils/crypto.ts b/functions/src/utils/crypto.ts new file mode 100644 index 0000000..7f955ba --- /dev/null +++ b/functions/src/utils/crypto.ts @@ -0,0 +1,95 @@ +/** + * KMS-like envelope encryption for sensitive data. + * Uses AES-256-GCM with a key derived from SESSION_SECRET via PBKDF2. + * + * 生产环境升级路径(按优先级): + * 1. 设置 AWS KMS / GCP Cloud KMS — 在 Config 中配置 KMS_KEY_ID / KMS_REGION, + * 用 KMS GenerateDataKey 派生每次加密的 data key,主 key 由云平台托管 + * 2. 设置 HASHI_Vault Transit 引擎 — 在 Config 中配置 VAULT_TRANSIT_KEY_PATH, + * 所有加密/解密操作委托给 Vault,支持 key rotation 和 audit + * 3. 维持当前 PBKDF2 + AES-256-GCM 方案,但将 SESSION_SECRET 替换为 + * 由 KMS 托管的加密密钥文件(JWT_KEY_DIR/encryption-key.bin) + * + * api.md 安全基线: "TOTP secret、Passkey 公钥等均以 hmac_sha256 或 KMS 加密后入库" + */ + +import { conf } from '../Config' + +const SESSION_SECRET = + ((conf.SESSION as Record)?.SESS_SECRET) || 'default-secret-change-me' + +async function deriveKey(salt: Uint8Array): Promise { + const enc = new TextEncoder() + const secretBytes = new Uint8Array(enc.encode(SESSION_SECRET)) + const keyMaterial = await crypto.subtle.importKey( + 'raw', + secretBytes, + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ) + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ) +} + +/** Encrypt plaintext → base64url( salt || iv || ciphertext ) */ +export async function encryptSecret(plaintext: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const key = await deriveKey(salt) + const enc = new TextEncoder() + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + enc.encode(plaintext), + ) + const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength) + combined.set(salt, 0) + combined.set(iv, salt.length) + combined.set(new Uint8Array(ciphertext), salt.length + iv.length) + + return btoa(String.fromCharCode(...combined)) + .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') +} + +/** Decrypt base64url( salt || iv || ciphertext ) → plaintext */ +export async function decryptSecret(encoded: string): Promise { + // Convert base64url → base64 + let b64 = encoded.replace(/-/g, '+').replace(/_/g, '/') + while (b64.length % 4) b64 += '=' + const raw = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) + + const salt = raw.slice(0, 16) + const iv = raw.slice(16, 28) + const ciphertext = raw.slice(28) + const key = await deriveKey(salt) + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext) + return new TextDecoder().decode(decrypted) +} + +/** + * Encrypt OAuth PII fields into a single encrypted JSON blob (api.md §1.6.2). + * Stores providerEmail, providerDisplayName, providerUsername, providerAvatarUrl securely. + */ +export interface OAuthPii { + providerEmail?: string | null + providerDisplayName?: string | null + providerUsername?: string | null + providerAvatarUrl?: string | null + providerEmailVerified?: boolean + csrfToken?: string +} + +export async function encryptOAuthPii(pii: OAuthPii): Promise { + return encryptSecret(JSON.stringify(pii)) +} + +export async function decryptOAuthPii(encrypted: string): Promise { + const json = await decryptSecret(encrypted) + return JSON.parse(json) as OAuthPii +} diff --git a/functions/src/utils/hibp.ts b/functions/src/utils/hibp.ts new file mode 100644 index 0000000..6ac9346 --- /dev/null +++ b/functions/src/utils/hibp.ts @@ -0,0 +1,52 @@ +/** + * HIBP k-Anonymity password leak check (api.md §1.1). + * + * Uses the Have I Been Pwned k-Anonymity API so the full SHA-1 hash + * is never sent to the remote server — only the first 5 hex chars. + * + * https://haveibeenpwned.com/API/v3#SearchingPwnedPasswordsByRange + */ + +const HIBP_API = 'https://api.pwnedpasswords.com/range/' + +/** + * Check whether a password appears in known data breaches. + * Returns true if the password was NOT found (safe), false if leaked. + */ +export async function isPasswordNotLeaked(password: string): Promise { + try { + // SHA-1 hash the password + const enc = new TextEncoder() + const digest = await crypto.subtle.digest('SHA-1', enc.encode(password)) + const hex = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase() + + const prefix = hex.slice(0, 5) + const suffix = hex.slice(5) + + const res = await fetch(`${HIBP_API}${prefix}`, { + headers: { 'User-Agent': 'TransCircle' }, + signal: AbortSignal.timeout(3000), + }) + + if (!res.ok) { + // API unavailable — allow the password through (fail open) + return true + } + + const body = await res.text() + for (const line of body.split('\n')) { + const [hashSuffix] = line.split(':') + if (hashSuffix === suffix) { + return false // password found in breaches + } + } + + return true // not found + } catch { + // Network error — fail open + return true + } +} diff --git a/functions/src/utils/jwt.ts b/functions/src/utils/jwt.ts new file mode 100644 index 0000000..6e3460e --- /dev/null +++ b/functions/src/utils/jwt.ts @@ -0,0 +1,117 @@ +import { genId } from './ulid' +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' + +// Ed25519 密钥对(持久化到文件,支持多实例部署) +// 生产环境应从 KMS 加载 +const KEY_DIR = process.env.JWT_KEY_DIR || join(process.cwd(), '.jwt-keys') +const PRIV_KEY_PATH = join(KEY_DIR, 'ed25519.priv.bin') +const PUB_KEY_PATH = join(KEY_DIR, 'ed25519.pub.bin') + +let cachedPrivateKey: Uint8Array | null = null +let cachedPublicKey: Uint8Array | null = null + +async function getKeyPair(): Promise<{ privateKey: Uint8Array; publicKey: Uint8Array }> { + if (cachedPrivateKey && cachedPublicKey) { + return { privateKey: cachedPrivateKey, publicKey: cachedPublicKey } + } + + // Try loading from persistent file + if (existsSync(PRIV_KEY_PATH) && existsSync(PUB_KEY_PATH)) { + cachedPrivateKey = new Uint8Array(readFileSync(PRIV_KEY_PATH)) + cachedPublicKey = new Uint8Array(readFileSync(PUB_KEY_PATH)) + return { privateKey: cachedPrivateKey, publicKey: cachedPublicKey } + } + + // Generate and persist + const ed = await import('@noble/ed25519') + const key = await ed.keygenAsync() + + // Ensure directory exists + if (!existsSync(KEY_DIR)) { + mkdirSync(KEY_DIR, { recursive: true }) + } + + writeFileSync(PRIV_KEY_PATH, Buffer.from(key.secretKey)) + writeFileSync(PUB_KEY_PATH, Buffer.from(key.publicKey)) + + cachedPrivateKey = key.secretKey + cachedPublicKey = key.publicKey + return { privateKey: key.secretKey, publicKey: key.publicKey } +} + +export interface JwtPayload { + iss: string + aud: string + sub: string + jti: string + sid: string + roles: string[] + tokenVersion: number + iat: number + exp: number +} + +function base64url(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') +} + +function decodeBase64url(s: string): Uint8Array { + let str = s.replace(/-/g, '+').replace(/_/g, '/') + while (str.length % 4) str += '=' + return Uint8Array.from(atob(str), (c) => c.charCodeAt(0)) +} + +/** Sign a JWT with Ed25519. Returns the full JWT string. */ +export async function signJwt( + payload: Omit, +): Promise { + const ed = await import('@noble/ed25519') + const { privateKey } = await getKeyPair() + const now = Math.floor(Date.now() / 1000) + + const jwtPayload: JwtPayload = { + ...payload, + jti: genId('jwt_'), + iat: now, + exp: now + 900, // 15 min + } + + const header = base64url( + new TextEncoder().encode(JSON.stringify({ alg: 'EdDSA', typ: 'JWT', kid: 'k1' })), + ) + const body = base64url( + new TextEncoder().encode(JSON.stringify(jwtPayload)), + ) + const message = `${header}.${body}` + const signature = await ed.signAsync(new TextEncoder().encode(message), privateKey) + return `${message}.${base64url(signature)}` +} + +/** Verify and decode a JWT. Returns null if invalid/expired. */ +export async function verifyJwt(token: string): Promise { + const parts = token.split('.') + if (parts.length !== 3) return null + + try { + const ed = await import('@noble/ed25519') + const { publicKey } = await getKeyPair() + const valid = await ed.verifyAsync( + decodeBase64url(parts[2]), + new TextEncoder().encode(`${parts[0]}.${parts[1]}`), + publicKey, + ) + if (!valid) return null + + const payload = JSON.parse( + new TextDecoder().decode(decodeBase64url(parts[1])), + ) as JwtPayload + if (payload.exp < Math.floor(Date.now() / 1000)) return null + return payload + } catch { + return null + } +} diff --git a/functions/src/utils/mail.ts b/functions/src/utils/mail.ts new file mode 100644 index 0000000..5e4c126 --- /dev/null +++ b/functions/src/utils/mail.ts @@ -0,0 +1,116 @@ +/** + * Email sending utility. + * - Dev mode: logs to console. + * - Production: uses Resend if RESEND_API_KEY is set; falls back to console log. + * + * api.md requires: verification email (1.2.2), password reset email (1.4.1), + * GDPR cancellation email (2.4), and export ready notification (2.3). + */ +import { conf } from '../Config' +import { log } from '../Logger' + +const DEV_MODE = process.env.NODE_ENV !== 'production' +const DEV_FRONTEND = process.env.DEV_FRONTEND_URL || 'http://localhost:5173' +const FRONTEND_URL = DEV_MODE + ? DEV_FRONTEND + : (conf.APP as Record | undefined)?.FRONTEND_URL || 'https://submit.transcircle.org' +const RESEND_API_KEY = process.env.RESEND_API_KEY +const EMAIL_FROM = process.env.EMAIL_FROM || 'noreply@transcircle.org' + +export interface EmailOptions { + to: string + subject: string + text: string + html?: string +} + +/** + * Send an email. + * + * Production path (priority): + * 1. RESEND_API_KEY env var set → use Resend REST API + * 2. SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS env vars set → use SMTP + * 3. Otherwise → log warning and return false + * + * Dev path: logs to console. + */ +export async function sendEmail(options: EmailOptions): Promise { + if (DEV_MODE) { + log(`[mail] Dev mode — logging email to ${options.to}`) + console.log('═══════════════════════════════════════════') + console.log(`📧 To: ${options.to}`) + console.log(`📧 Subject: ${options.subject}`) + console.log(`📧 Body:\n${options.text}`) + console.log('═══════════════════════════════════════════') + return true + } + + // Resend API (https://resend.com) + if (RESEND_API_KEY) { + try { + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: EMAIL_FROM, + to: options.to, + subject: options.subject, + text: options.text, + html: options.html, + }), + }) + if (res.ok) { + log(`[mail] Sent via Resend to ${options.to}`) + return true + } + const errBody = await res.text() + console.error(`[mail] Resend error (${res.status}): ${errBody}`) + return false + } catch (err) { + console.error(`[mail] Resend request failed: ${(err as Error).message}`) + return false + } + } + + console.error(`[mail] No email provider configured (set RESEND_API_KEY) — cannot send to ${options.to}`) + return false +} + +/** + * Build a verification email with the token link. + */ +export function buildVerificationEmail(email: string, token: string): EmailOptions { + return { + to: email, + subject: '验证您的邮箱 — TransCircle', + text: `请点击以下链接验证您的邮箱(24 小时内有效):\n\n${FRONTEND_URL}/auth/email/verify?token=${token}`, + html: `

请点击以下链接验证您的邮箱(24 小时内有效):

验证邮箱

`, + } +} + +/** + * Build a password reset email with the token link. + */ +export function buildPasswordResetEmail(email: string, token: string): EmailOptions { + return { + to: email, + subject: '重置密码 — TransCircle', + text: `请点击以下链接重置密码(1 小时内有效):\n\n${FRONTEND_URL}/auth/reset-password?token=${token}`, + html: `

请点击以下链接重置密码(1 小时内有效):

重置密码

`, + } +} + +/** + * Build a cancellation email for GDPR account deletion (api.md §2.4). + */ +export function buildCancelDeletionEmail(email: string, token: string): EmailOptions { + return { + to: email, + subject: '撤销账户注销 — TransCircle', + text: `您的账户已进入 30 天冷静期。如要撤销注销,请点击以下链接(30 天内有效):\n\n${FRONTEND_URL}/auth/cancel-deletion?token=${token}\n\n如果这不是您本人的操作,请立即联系支持团队。`, + html: `

您的账户已进入 30 天冷静期。

如要撤销注销,请点击以下链接(30 天内有效):

撤销注销

如果这不是您本人的操作,请立即联系支持团队。

`, + } +} diff --git a/functions/src/utils/metrics.ts b/functions/src/utils/metrics.ts new file mode 100644 index 0000000..cd04f51 --- /dev/null +++ b/functions/src/utils/metrics.ts @@ -0,0 +1,49 @@ +/** + * Business metrics counters per api.md §13.2.2. + * Shared across route modules; exported singleton so all imports + * share the same counters. + */ + +export const metrics = { + // ── HTTP counters (from Router.ts) ── + httpTotal: 0, + httpByStatus: {} as Record, + httpByPath: {} as Record, + httpByMethodPathStatus: {} as Record, + + // ── Business metrics (api.md §13.2.2) ── + authLoginTotal: {} as Record, // result → count + authTokenRefreshTotal: {} as Record, // result → count + authOauthCallbackTotal: {} as Record, // provider|status → count + mfaVerifyTotal: {} as Record, // method|result → count + passkeyReplayDetectedTotal: 0, + rateLimitedTotal: {} as Record, // route|dimension → count + imageUploadTotal: {} as Record, // result|mime → count + contributionReviewLatencySeconds: [] as number[], // sample array for histogram (max 10000 entries) + httpRequestDurationSeconds: [] as number[], // timing samples for api.md §13.2.2 (max 10000 entries) + MAX_HISTOGRAM_SAMPLES: 10000, +} + +/** Push a value into a histogram array, capping at MAX_HISTOGRAM_SAMPLES to prevent memory leak */ +export function pushHistogramSample(arr: number[], value: number, max = 10000): void { + if (arr.length >= max) { + arr.shift() + } + arr.push(value) +} + +export function resetMetrics(): void { + metrics.httpTotal = 0 + metrics.httpByStatus = {} + metrics.httpByPath = {} + metrics.httpByMethodPathStatus = {} + metrics.authLoginTotal = {} + metrics.authTokenRefreshTotal = {} + metrics.authOauthCallbackTotal = {} + metrics.mfaVerifyTotal = {} + metrics.passkeyReplayDetectedTotal = 0 + metrics.rateLimitedTotal = {} + metrics.imageUploadTotal = {} + metrics.contributionReviewLatencySeconds = [] + metrics.httpRequestDurationSeconds = [] +} diff --git a/functions/src/utils/password.ts b/functions/src/utils/password.ts new file mode 100644 index 0000000..003304c --- /dev/null +++ b/functions/src/utils/password.ts @@ -0,0 +1,62 @@ +import argon2 from 'argon2' +import { query } from '../Database' + +/** + * Hash a password using argon2id per api.md 安全基线: + * - type: argon2id + * - memoryCost: 64 MiB (65536 KiB) + * - timeCost: 3 iterations + */ +export async function hashPassword(password: string): Promise { + return argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MiB + timeCost: 3, + parallelism: 1, + }) +} + +/** Verify a password against an argon2id hash. */ +export async function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password) +} + +/** + * Hash a recovery code using argon2id (api.md §15.7). + * Recovery codes are stored as argon2id hashes, not HMAC, + * so a compromised DB dump doesn't reveal the plaintext codes. + */ +export async function hashRecoveryCode(code: string): Promise { + return hashPassword(code) +} + +/** + * Find and consume an unused recovery code for a user. + * Scans all unused codes and tries argon2id.verify on each. + * Returns the matching record id, or null if none match. + * + * Limits the number of verification attempts to prevent DOS via + * argon2's expensive verification loop. + * + * Caller must mark usedAt in a transaction after this returns a match. + */ +const MAX_RECOVERY_VERIFY_ATTEMPTS = 3 + +export async function findUnusedRecoveryCode(userId: string, code: string): Promise { + const rows = await query( + `SELECT id, codeHash FROM mfa_recovery_codes WHERE userId = ? AND usedAt IS NULL ORDER BY createdAt ASC`, + [userId], + ) as unknown as Array<{ id: string; codeHash: string }> + + if (!rows || rows.length === 0) return null + + let attempts = 0 + for (const row of rows) { + if (attempts >= MAX_RECOVERY_VERIFY_ATTEMPTS) break + attempts++ + if (await argon2.verify(row.codeHash, code)) { + return row.id + } + } + return null +} diff --git a/functions/src/utils/permissions.ts b/functions/src/utils/permissions.ts new file mode 100644 index 0000000..fc8cde8 --- /dev/null +++ b/functions/src/utils/permissions.ts @@ -0,0 +1,96 @@ +import { queryOne } from '../Database' +import type { Request, Response, NextFunction } from 'express' +import { sendError, Errors } from './response' + +/** + * Granular permission definitions per api.md §15.10. + * + * Each role maps to a set of permissions. The mapping is stored in-memory + * for performance (role names are low-cardinality and change infrequently). + */ +const ROLE_PERMISSIONS: Record = { + admin: [ + 'contribution:read', + 'contribution:review', + 'contribution:publish', + 'contribution:hide', + 'contribution:restore', + 'contribution:delete', + 'contribution:audit:read', + 'contribution:internal-note:read', + 'contribution:edit-request:vote', + 'user:read', + 'user:ban', + 'role:grant', + 'role:revoke', + 'audit:read', + ], + editor: [ + 'contribution:read', + 'contribution:review', + 'contribution:publish', + 'contribution:hide', + 'contribution:restore', + 'contribution:delete', + 'contribution:audit:read', + 'contribution:internal-note:read', + 'contribution:edit-request:vote', + 'user:read', + 'audit:read', + ], + reviewer: [ + 'contribution:read', + 'contribution:review', + 'contribution:audit:read', + 'contribution:internal-note:read', + 'contribution:edit-request:vote', + ], +} + +/** + * Check if a user has a specific permission by looking up their role(s) + * and mapping to the granular permission list (api.md §15.10). + */ +export async function requirePermission(userId: string, permission: string): Promise { + const rows = await queryOne( + `SELECT GROUP_CONCAT(DISTINCT r.name) as roleNames + FROM user_roles ur + JOIN roles r ON r.id = ur.roleId + WHERE ur.userId = ?`, + [userId], + ) + if (!rows?.roleNames) return false + + const roleNames = (rows.roleNames as string).split(',') + for (const role of roleNames) { + const perms = ROLE_PERMISSIONS[role] + if (perms?.includes(permission)) return true + } + return false +} + +/** + * Express middleware factory: requires the authenticated user to have all specified permissions. + * Must be used after requireAuth (so req.user is set). + * + * Usage: router.get('/path', requirePerm('contribution:read'), handler) + */ +export function requirePerm(...permissions: string[]) { + return async (req: Request, res: Response, next: NextFunction) => { + // Check JWT roles first (covers temp admin tokens, DB-only roles, etc.) + const userRoles = req.user!.roles + for (const perm of permissions) { + // Quick check from JWT roles (in-memory, no DB hit) + const hasFromJwt = userRoles.some(r => ROLE_PERMISSIONS[r]?.includes(perm)) + if (hasFromJwt) continue + + // Fallback to DB check (for roles granted after token was issued) + const hasFromDb = await requirePermission(req.user!.userId, perm) + if (!hasFromDb) { + sendError(res, Errors.FORBIDDEN.code, `缺少权限: ${perm}`, req.requestId, Errors.FORBIDDEN.status) + return + } + } + next() + } +} diff --git a/functions/src/utils/response.ts b/functions/src/utils/response.ts new file mode 100644 index 0000000..34caf04 --- /dev/null +++ b/functions/src/utils/response.ts @@ -0,0 +1,195 @@ +import type { Response } from 'express'; + +export interface ApiSuccess { + data: T; + requestId: string; +} + +export interface ApiError { + error: { + code: string; + message: string; + details?: unknown; + data?: unknown; // per api.md §12.3: structured metadata (mergeToken, nextAction, etc.) + }; + requestId: string; +} + +/** + * Send a success response with data. + * Matches contract: { data, requestId } + * If pagination is provided, also includes { pagination } at the top level. + */ +export function sendSuccess( + res: Response, + data: T, + requestId: string, + status = 200, + pagination?: Pagination, + deprecation?: DeprecationInfo, +): void { + res.setHeader('X-Request-Id', requestId); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (deprecation) { + res.setHeader('Deprecation', 'true'); + res.setHeader('Sunset', deprecation.sunset); + res.setHeader('Link', `<${deprecation.migrationLink}>; rel="deprecation"`); + } + const body: Record = { data, requestId }; + if (pagination !== undefined) { + body.pagination = pagination; + } + res.status(status).json(body); +} + +/** + * Send an error response with optional details and/or extra data. + * Matches contract: { error: { code, message, details?, data? }, requestId } + */ +export function sendError( + res: Response, + code: string, + message: string, + requestId: string, + status = 400, + details?: unknown, + data?: unknown, +): void { + res.setHeader('X-Request-Id', requestId); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + const body: ApiError = { + error: { code, message }, + requestId, + }; + if (details !== undefined) body.error.details = details; + if (data !== undefined) body.error.data = data; + res.status(status).json(body); +} + +/** + * Send a 204 No Content response. + * Removes Content-Type header since 204 has no body (per HTTP semantics). + */ +export function sendNoContent(res: Response, requestId: string): void { + res.removeHeader('Content-Type') + res.setHeader('X-Request-Id', requestId) + res.status(204).end() +} + +/** + * List pagination envelope (per api.md §12.2) + */ +export interface Pagination { + limit: number; + nextCursor: string | null; + hasMore: boolean; +} + +/** + * Deprecation info for sunset endpoints (api.md §13.5). + * When set, adds Deprecation, Sunset, and Link response headers. + */ +export interface DeprecationInfo { + sunset: string; // HTTP-date, e.g. "Wed, 20 Nov 2026 00:00:00 GMT" + migrationLink: string; // e.g. "/docs/migration/v2" +} + +/** + * Convert Zod flattened errors to api.md §12.3 spec format: + * `[{ field: "title", reason: "错误描述" }]` + */ +export function zodErrorsToDetails(errors: ReturnType): Array<{ field: string; reason: string }> { + const details: Array<{ field: string; reason: string }> = [] + for (const [field, reasons] of Object.entries(errors.fieldErrors)) { + if (reasons) { + for (const reason of reasons) { + details.push({ field, reason: reason as string }) + } + } + } + if (errors.formErrors.length > 0) { + for (const reason of errors.formErrors) { + details.push({ field: '_form', reason: reason as string }) + } + } + return details +} + +/** + * Common error presets — spec-specific codes (per api.md §12.4) + */ +export const Errors = { + // ── Generic ── + UNAUTHORIZED: { code: 'UNAUTHORIZED', message: '请先登录', status: 401 }, + FORBIDDEN: { code: 'FORBIDDEN', message: '权限不足', status: 403 }, + VALIDATION_ERROR: { code: 'VALIDATION_ERROR', message: '请求数据校验失败', status: 422 }, + RATE_LIMITED: { code: 'RATE_LIMITED', message: '请求过于频繁,请稍后重试', status: 429 }, + INTERNAL_ERROR: { code: 'INTERNAL_ERROR', message: '服务器内部错误', status: 500 }, + BAD_REQUEST: { code: 'BAD_REQUEST', message: '请求格式错误', status: 400 }, + CONTENT_TOO_LARGE: { code: 'CONTENT_TOO_LARGE', message: '内容过大', status: 413 }, + UNSUPPORTED_MEDIA_TYPE: { code: 'UNSUPPORTED_MEDIA_TYPE', message: '不支持的媒体类型', status: 415 }, + + // ── Auth / Token ── + INVALID_CREDENTIALS: { code: 'INVALID_CREDENTIALS', message: '用户名/邮箱或密码错误', status: 401 }, + INVALID_REFRESH_TOKEN: { code: 'INVALID_REFRESH_TOKEN', message: '刷新令牌无效', status: 401 }, + REFRESH_TOKEN_REVOKED: { code: 'REFRESH_TOKEN_REVOKED', message: '刷新令牌已被吊销', status: 401 }, + TOKEN_INVALID_OR_EXPIRED: { code: 'TOKEN_INVALID_OR_EXPIRED', message: '令牌无效或已过期', status: 410 }, + CSRF_TOKEN_INVALID: { code: 'CSRF_TOKEN_INVALID', message: 'CSRF 校验失败', status: 403 }, + MISSING_OAUTH_PENDING: { code: 'MISSING_OAUTH_PENDING', message: '缺少 OAuth pending cookie', status: 401 }, + STEP_UP_REQUIRED: { code: 'STEP_UP_REQUIRED', message: '需要二次认证', status: 403 }, + EMAIL_NOT_VERIFIED: { code: 'EMAIL_NOT_VERIFIED', message: '邮箱未验证', status: 403 }, + ACCOUNT_BANNED: { code: 'ACCOUNT_BANNED', message: '账户已被封禁', status: 403 }, + ACCOUNT_MERGED: { code: 'ACCOUNT_MERGED', message: '账户已合并', status: 403 }, + ACCOUNT_PENDING_DELETION: { code: 'ACCOUNT_PENDING_DELETION', message: '账户正在注销', status: 403 }, + ACCOUNT_DELETED: { code: 'ACCOUNT_DELETED', message: '账户已被注销', status: 404 }, + ACCOUNT_LOCKED: { code: 'ACCOUNT_LOCKED', message: '账户已锁定,请 15 分钟后重试', status: 423 }, + PASSKEY_VERIFICATION_FAILED: { code: 'PASSKEY_VERIFICATION_FAILED', message: 'Passkey 验证失败', status: 422 }, + PASSKEY_ALREADY_REGISTERED: { code: 'PASSKEY_ALREADY_REGISTERED', message: 'Passkey 已注册', status: 409 }, + PASSKEY_NAME_TAKEN: { code: 'PASSKEY_NAME_TAKEN', message: 'Passkey 名称已存在', status: 409 }, + PASSKEY_REPLAY_DETECTED: { code: 'PASSKEY_REPLAY_DETECTED', message: 'Passkey 回放检测', status: 422 }, + PASSKEY_FROZEN: { code: 'PASSKEY_FROZEN', message: 'Passkey 已冻结', status: 403 }, + INVALID_TOTP_CODE: { code: 'INVALID_TOTP_CODE', message: '验证码错误', status: 422 }, + TOTP_CODE_REPLAY: { code: 'TOTP_CODE_REPLAY', message: '验证码已被使用', status: 422 }, + TOTP_ALREADY_ENABLED: { code: 'TOTP_ALREADY_ENABLED', message: 'TOTP 已启用', status: 409 }, + TOTP_NOT_ENABLED: { code: 'TOTP_NOT_ENABLED', message: 'TOTP 未启用', status: 404 }, + MFA_CHALLENGE_EXHAUSTED: { code: 'MFA_CHALLENGE_EXHAUSTED', message: '验证尝试次数过多', status: 429 }, + BAD_STATE: { code: 'BAD_STATE', message: 'OAuth state 校验失败', status: 400 }, + OAUTH_ERROR: { code: 'OAUTH_ERROR', message: 'OAuth 提供商返回错误', status: 400 }, + PKCE_VERIFICATION_FAILED: { code: 'PKCE_VERIFICATION_FAILED', message: 'PKCE 验证失败', status: 422 }, + + // ── Resource ── + NOT_FOUND: { code: 'NOT_FOUND', message: '资源不存在', status: 404 }, + CONTRIBUTION_NOT_FOUND: { code: 'CONTRIBUTION_NOT_FOUND', message: '投稿不存在', status: 404 }, + EDIT_REQUEST_NOT_FOUND: { code: 'EDIT_REQUEST_NOT_FOUND', message: '修改申请不存在', status: 404 }, + USER_NOT_FOUND: { code: 'USER_NOT_FOUND', message: '用户不存在', status: 404 }, + SESSION_NOT_FOUND: { code: 'SESSION_NOT_FOUND', message: '会话不存在', status: 404 }, + PASSKEY_NOT_FOUND: { code: 'PASSKEY_NOT_FOUND', message: 'Passkey 不存在', status: 404 }, + IMAGE_NOT_FOUND: { code: 'IMAGE_NOT_FOUND', message: '图片不存在', status: 404 }, + ROLE_NOT_FOUND: { code: 'ROLE_NOT_FOUND', message: '角色不存在', status: 404 }, + EMAIL_NOT_FOUND: { code: 'EMAIL_NOT_FOUND', message: '邮箱未注册', status: 404 }, + EMAIL_ALREADY_VERIFIED: { code: 'EMAIL_ALREADY_VERIFIED', message: '邮箱已验证', status: 409 }, + OAUTH_NOT_BOUND: { code: 'OAUTH_NOT_BOUND', message: '该 provider 未绑定', status: 404 }, + INVALID_IMAGE: { code: 'INVALID_IMAGE', message: '图片无效', status: 422 }, + + // ── Conflict / State ── + CONFLICT: { code: 'CONFLICT', message: '资源冲突', status: 409 }, + VERSION_CONFLICT: { code: 'VERSION_CONFLICT', message: '版本冲突,请刷新后重试', status: 409 }, + INVALID_STATE_TRANSITION: { code: 'INVALID_STATE_TRANSITION', message: '当前状态不允许该操作', status: 409 }, + USERNAME_TAKEN: { code: 'USERNAME_TAKEN', message: '用户名已被使用', status: 409 }, + EMAIL_TAKEN: { code: 'EMAIL_TAKEN', message: '该邮箱已被注册', status: 409 }, + OAUTH_ALREADY_LINKED: { code: 'OAUTH_ALREADY_LINKED', message: '该 OAuth 账号已被其他账号绑定', status: 409 }, + ALREADY_BOUND_TO_SELF: { code: 'ALREADY_BOUND_TO_SELF', message: '该 OAuth 账号已绑定到当前用户', status: 409 }, + PROVIDER_ALREADY_BOUND: { code: 'PROVIDER_ALREADY_BOUND', message: '当前账户在该 provider 下已有绑定', status: 409 }, + IDEMPOTENCY_KEY_MISMATCH: { code: 'IDEMPOTENCY_KEY_MISMATCH', message: 'Idempotency-Key 冲突', status: 409 }, + DUPLICATE_SUBMISSION: { code: 'DUPLICATE_SUBMISSION', message: '重复提交', status: 409 }, + LAST_LOGIN_METHOD: { code: 'LAST_LOGIN_METHOD', message: '解绑后无登录方式', status: 409 }, + ROLE_ALREADY_GRANTED: { code: 'ROLE_ALREADY_GRANTED', message: '用户已拥有该角色', status: 409 }, + ALREADY_VOTED: { code: 'ALREADY_VOTED', message: '已投过票', status: 409 }, + SELF_VOTE_FORBIDDEN: { code: 'SELF_VOTE_FORBIDDEN', message: '不可对自己的申请投票', status: 409 }, + CONTRIBUTION_NOT_EDITABLE: { code: 'CONTRIBUTION_NOT_EDITABLE', message: '投稿当前状态不可被申请修改', status: 409 }, + + // ── External ── + OAUTH_PROVIDER_ERROR: { code: 'OAUTH_PROVIDER_ERROR', message: 'OAuth 提供商不可用', status: 502 }, +} as const; diff --git a/functions/src/utils/sanitize.ts b/functions/src/utils/sanitize.ts new file mode 100644 index 0000000..31f29d9 --- /dev/null +++ b/functions/src/utils/sanitize.ts @@ -0,0 +1,84 @@ +/** + * Per api.md §9: Markdown 内容安全清洗 + * 使用 marked + DOMPurify 实现工业级安全渲染 + */ + +import { marked } from 'marked' +import { JSDOM } from 'jsdom' +import DOMPurify from 'dompurify' + +const dom = new JSDOM('') +const purify = DOMPurify(dom.window as unknown as Parameters[0]) + +const ALLOWED_IMAGE_DOMAINS = [ + 'api.transcircle.org', + 'avatars.githubusercontent.com', +] + +/** + * Render Markdown to sanitized HTML per api.md §9 rules: + * - All links get rel="nofollow noopener noreferrer" + * - Image domains restricted to allowlist + * - No script, iframe, onerror, onclick, javascript: URLs + */ +export function markdownToHtml(md: string): string { + // Configure marked with safe options + const raw = marked.parse(md, { async: false }) as string + + // Post-process links for security + const processed = raw + .replace(/]*)>/gi, (match: string, src: string) => { + try { + const url = new URL(src) + const allowed = ALLOWED_IMAGE_DOMAINS.some((d) => url.hostname.endsWith(d)) + if (!allowed) { + return `${src}` + } + } catch { + if (!src.startsWith('/v1/images/')) { + return `${src}` + } + } + return match + }) + + return purify.sanitize(processed, { + ALLOWED_TAGS: [ + 'p', 'br', 'strong', 'em', 'u', 's', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr', 'a', 'img', 'table', + 'thead', 'tbody', 'tr', 'th', 'td', 'del', 'ins', 'sup', 'sub', 'dl', 'dt', 'dd', + ], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'rel', 'target', 'class', 'id'], + ALLOW_DATA_ATTR: false, + }) +} + +/** Plain text → escaped HTML (no Markdown rendering) */ +export function plainTextToHtml(text: string): string { + const escaped = text + .replace(/&/g, '&') + .replace(//g, '>') + return purify.sanitize(`

${escaped.replace(/\n\n/g, '

').replace(/\n/g, '
')}

`, { + ALLOWED_TAGS: ['p', 'br'], + ALLOWED_ATTR: [], + ALLOW_DATA_ATTR: false, + }) +} + +/** + * Lightweight HTML sanitizer for non-Markdown scenarios. + * Also available as standalone. + */ +export function sanitizeHtml(html: string): string { + return purify.sanitize(html, { + ALLOWED_TAGS: [ + 'p', 'br', 'strong', 'em', 'u', 's', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr', 'a', 'img', 'table', + 'thead', 'tbody', 'tr', 'th', 'td', + ], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'rel', 'target'], + ALLOW_DATA_ATTR: false, + }) +} diff --git a/functions/src/utils/session.ts b/functions/src/utils/session.ts new file mode 100644 index 0000000..dd4cf47 --- /dev/null +++ b/functions/src/utils/session.ts @@ -0,0 +1,321 @@ +import { query, queryOne, exec } from '../Database' +import { genId } from './ulid' +import { conf } from '../Config' +import { parseUserAgent, type DeviceInfo } from './user-agent' + +const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000 // 7 days +const REFRESH_TOKEN_BYTES = 32 +const SESSION_SECRET = + ((conf.SESSION as Record)?.SESS_SECRET) || 'default-secret-change-me' + +function randomToken(): string { + const buf = crypto.getRandomValues(new Uint8Array(REFRESH_TOKEN_BYTES)) + return Array.from(buf) + .map((b) => b.toString(36).padStart(2, '0')) + .join('') +} + +/** HMAC-SHA256 for token hashing. */ +export async function hmacToken(data: string): Promise { + const enc = new TextEncoder() + const key = await crypto.subtle.importKey('raw', enc.encode(SESSION_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data)) + return btoa(String.fromCharCode(...new Uint8Array(sig))) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') +} + +/** Generate a high-entropy token (256 bits) using crypto random values. */ +export function generateHighEntropyToken(): string { + const buf = crypto.getRandomValues(new Uint8Array(32)) + return Array.from(buf) + .map((b) => b.toString(36).padStart(2, '0')) + .join('') +} + +/** SHA-256 for fingerprinting. */ +async function sha256(data: string): Promise { + const enc = new TextEncoder() + const digest = await crypto.subtle.digest('SHA-256', enc.encode(data)) + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') +} + +export interface SessionInfo { + id: string + userId: string + tokenVersion: number + roles: string[] +} + +export interface RotateResult extends SessionInfo { + newRefreshToken: string +} + +/** + * Create a new session with refresh token for a user. + * Returns the session record info and the raw refresh token string. + * If `conn` is provided, uses it for transactional atomicity. + */ +export async function createSession( + userId: string, + roles: string[], + loginMethod: string, + ip: string, + ua: string, + conn?: import('mysql2/promise').PoolConnection, +): Promise<{ sessionId: string; refreshToken: string }> { + const sessionId = genId('sess_') + const refreshToken = randomToken() + const tokenPrefix = refreshToken.slice(0, 8) + const [tokenHash, ipHash, uaHash] = await Promise.all([ + hmacToken(refreshToken), + hmacToken(ip || 'unknown'), // hmac_sha256 per api.md §15.4 (was sha256) + sha256(ua || 'unknown'), + ]) + const ipStr = ip || 'unknown' + const ipPrefix = ipStr.includes(':') + ? ipStr.split(':').slice(0, 4).join(':') + '::/48' + : ipStr.split('.').slice(0, 3).join('.') + '.0' + const deviceInfo: DeviceInfo = parseUserAgent(ua || 'unknown') + const now = Date.now() + + const run = conn ? conn.execute.bind(conn) : exec + + await run( + `INSERT INTO sessions (id, userId, createdAt, lastUsedAt, expiresAt, ipHash, ipPrefix, userAgentHash, deviceSummary, loginMethod) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [sessionId, userId, now, now, now + SESSION_MAX_AGE, ipHash, ipPrefix, uaHash, JSON.stringify(deviceInfo), loginMethod], + ) + + await run( + `INSERT INTO refresh_token_events (id, sessionId, tokenHash, tokenPrefix, status, createdAt, expiresAt) + VALUES (?, ?, ?, ?, 'active', ?, ?)`, + [genId('rte_'), sessionId, tokenHash, tokenPrefix, now, now + SESSION_MAX_AGE], + ) + + return { sessionId, refreshToken } +} + +/** + * Verify and rotate a refresh token. + * Returns null on invalid/reused token, or RotateResult on success. + * + * Atomic rotation with reuse detection: + * - UPDATE ... WHERE status='active' eliminates TOCTOU race + * - race-grace window (2s) handles concurrent refresh requests + */ +export async function rotateRefreshToken(rawToken: string): Promise { + const [tokenHash] = await Promise.all([hmacToken(rawToken)]) + const prefix = rawToken.slice(0, 8) + + const event = await queryOne( + `SELECT id, sessionId, status, rotatedToHash + FROM refresh_token_events + WHERE tokenHash = ? AND tokenPrefix = ?`, + [tokenHash, prefix], + ) + + if (!event) return null + + const now = Date.now() + const newToken = randomToken() + const newPrefix = newToken.slice(0, 8) + const newHash = await hmacToken(newToken) + + // Atomic claim: only 'active' tokens can be rotated + const claimed = await exec( + `UPDATE refresh_token_events + SET status = 'rotated', rotatedToHash = ?, usedAt = ? + WHERE id = ? AND status = 'active'`, + [newHash, now, event.id], + ) + + if (claimed.affectedRows === 1) { + // Won the race — token is atomically ours + const session = await queryOne( + `SELECT s.id, s.userId, s.expiresAt, u.tokenVersion + FROM sessions s + JOIN users u ON u.id = s.userId + WHERE s.id = ?`, + [event.sessionId], + ) + + if (!session || (session.expiresAt as number) < now) { + await exec(`UPDATE refresh_token_events SET status = 'revoked' WHERE tokenHash = ?`, [newHash]) + return null + } + + // Insert new active token event + await exec( + `INSERT INTO refresh_token_events (id, sessionId, tokenHash, tokenPrefix, status, createdAt, expiresAt) + VALUES (?, ?, ?, ?, 'active', ?, ?)`, + [genId('rte_'), event.sessionId, newHash, newPrefix, now, now + SESSION_MAX_AGE], + ) + + await exec(`UPDATE sessions SET lastUsedAt = ? WHERE id = ?`, [now, event.sessionId]) + + // Fetch actual roles from DB (api.md §15.10) + let roles: string[] = [] + try { + const roleRows = await query( + `SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ?`, + [session.userId], + ) + roles = (roleRows as Array<{ name: string }>).map(r => r.name) + } catch { /* no roles */ } + + return { + id: session.id, + userId: session.userId, + tokenVersion: session.tokenVersion, + roles, + newRefreshToken: newToken, + } + } + + // Token already consumed — potential replay attack per api.md §1.11.2 + const oldRecord = await queryOne( + `SELECT usedAt FROM refresh_token_events WHERE id = ?`, + [event.id], + ) + const usedAt = oldRecord?.usedAt as number | undefined + + if (event.status === 'active') { + // Was active at read time but someone else rotated it concurrently + if (usedAt && now - usedAt <= 2000) { + // race-grace window (≤2s): concurrent refresh from network jitter/retry + // Follow rotation chain to find the active session (api.md §1.11.2) + if (event.rotatedToHash) { + const rotatedEvent = await queryOne( + `SELECT rte.sessionId, s.userId, u.tokenVersion + FROM refresh_token_events rte + JOIN sessions s ON s.id = rte.sessionId + JOIN users u ON u.id = s.userId + WHERE rte.tokenHash = ? AND rte.status = 'active'`, + [event.rotatedToHash], + ) + if (rotatedEvent) { + let roles: string[] = [] + try { + const roleRows = await query( + `SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ?`, + [rotatedEvent.userId], + ) + roles = (roleRows as Array<{ name: string }>).map(r => r.name) + } catch { /* no roles */ } + return { + id: rotatedEvent.sessionId, + userId: rotatedEvent.userId, + tokenVersion: rotatedEvent.tokenVersion, + roles, + newRefreshToken: '', // client already has it from first response + } + } + } + } else { + // Outside race-grace window (>2s): real reuse — revoke session + all tokens + bump tokenVersion + await exec( + `UPDATE sessions SET revokedAt = ?, revokedReason = 'refresh_reuse_detected' WHERE id = ?`, + [now, event.sessionId], + ) + await exec( + `UPDATE refresh_token_events SET status = 'revoked' WHERE sessionId = ? AND status IN ('active', 'rotated')`, + [event.sessionId], + ) + } + } else if (event.status === 'rotated' && event.rotatedToHash) { + // Reuse of an already-rotated token outside race-grace — chase the chain + const rotatedEvent = await queryOne( + `SELECT sessionId FROM refresh_token_events WHERE tokenHash = ?`, + [event.rotatedToHash], + ) + if (rotatedEvent && (!usedAt || now - usedAt > 2000)) { + await exec( + `UPDATE sessions SET revokedAt = ?, revokedReason = 'refresh_reuse_detected' WHERE id = ?`, + [now, rotatedEvent.sessionId], + ) + await exec( + `UPDATE refresh_token_events SET status = 'revoked' WHERE sessionId = ? AND status IN ('active', 'rotated')`, + [rotatedEvent.sessionId], + ) + } + } + + // Mark token as reused and bump tokenVersion only outside race-grace window + if (!usedAt || now - usedAt > 2000) { + await exec( + `UPDATE refresh_token_events SET status = 'reused', usedAt = COALESCE(usedAt, ?) WHERE id = ?`, + [now, event.id], + ) + + // Bump tokenVersion per api.md §1.11.2 reuse handling + const reuseSession = await queryOne( + `SELECT userId FROM sessions WHERE id = ?`, + [event.sessionId], + ) + if (reuseSession) { + await exec(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [reuseSession.userId]) + } + } + + return null +} + +/** Revoke all sessions for a user, including their refresh token events. */ +export async function revokeUserSessions(userId: string, reason: string, exceptSessionId?: string): Promise { + const now = Date.now() + if (exceptSessionId) { + await exec(`UPDATE sessions SET revokedAt = ?, revokedReason = ? WHERE userId = ? AND id != ? AND revokedAt IS NULL`, [ + now, reason, userId, exceptSessionId, + ]) + await exec( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ? AND id != ? AND revokedAt = ?) + AND status IN ('active', 'rotated')`, + [userId, exceptSessionId, now], + ) + } else { + await exec(`UPDATE sessions SET revokedAt = ?, revokedReason = ? WHERE userId = ? AND revokedAt IS NULL`, [ + now, reason, userId, + ]) + await exec( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId IN (SELECT id FROM sessions WHERE userId = ? AND revokedAt = ?) + AND status IN ('active', 'rotated')`, + [userId, now], + ) + } +} + +/** Revoke a specific session, including its refresh token events. */ +/** + * Detect if the current request originates from a native/mobile client + * (as opposed to a browser). Per api.md §1.3: + * - Browser: refreshToken delivered via Set-Cookie (omit from response body) + * - Native: refreshToken delivered via response body + * + * Priority: explicit X-Client-Type header > UA heuristic fallback. + */ +export function isNativeClient(req: import('express').Request): boolean { + if (req.headers['x-client-type'] === 'native') return true + const ua = req.headers['user-agent'] + if (!ua) return false + // Fallback: browsers typically include these keywords; native SDKs don't + return !/Mozilla|Chrome\b|Safari\b|Firefox\b|Edge\b|Opera\b/i.test(ua) +} + +export async function revokeSession(sessionId: string, reason: string): Promise { + const now = Date.now() + await exec(`UPDATE sessions SET revokedAt = ?, revokedReason = ? WHERE id = ? AND revokedAt IS NULL`, [ + now, reason, sessionId, + ]) + await exec( + `UPDATE refresh_token_events SET status = 'revoked' + WHERE sessionId = ? AND status IN ('active', 'rotated')`, + [sessionId], + ) +} diff --git a/functions/src/utils/totp.ts b/functions/src/utils/totp.ts new file mode 100644 index 0000000..722de33 --- /dev/null +++ b/functions/src/utils/totp.ts @@ -0,0 +1,88 @@ +/** + * Minimal TOTP (RFC 6238) implementation using Web Crypto API. + * Supports verification with replay prevention via lastUsedTimeStep. + */ + +const TOTP_PERIOD = 30 +const TOTP_DIGITS = 6 + +const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + +function base32Decode(encoded: string): Uint8Array { + const cleaned = encoded.replace(/=+$/, '').toUpperCase() + const bytes: number[] = [] + let buffer = 0 + let bitsLeft = 0 + for (const ch of cleaned) { + const idx = BASE32.indexOf(ch) + if (idx === -1) throw new Error(`Invalid Base32 character: ${ch}`) + buffer = (buffer << 5) | idx + bitsLeft += 5 + if (bitsLeft >= 8) { + bitsLeft -= 8 + bytes.push((buffer >> bitsLeft) & 0xff) + } + } + return new Uint8Array(bytes) +} + +async function hmacSha1(key: Uint8Array, data: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey( + 'raw', key as BufferSource, { name: 'HMAC', hash: 'SHA-1' }, + false, ['sign'], + ) + const sig = await crypto.subtle.sign('HMAC', cryptoKey, data as BufferSource) + return new Uint8Array(sig) +} + +function truncate(hmacResult: Uint8Array): number { + const offset = hmacResult[hmacResult.length - 1]! & 0xf + const code = + ((hmacResult[offset]! & 0x7f) << 24) | + ((hmacResult[offset + 1]! & 0xff) << 16) | + ((hmacResult[offset + 2]! & 0xff) << 8) | + (hmacResult[offset + 3]! & 0xff) + return code % Math.pow(10, TOTP_DIGITS) +} + +export function getCurrentTimeStep(): number { + return Math.floor(Date.now() / 1000 / TOTP_PERIOD) +} + +export async function generateTotpCode(secret: string, timeStep?: number): Promise { + let ts = timeStep ?? getCurrentTimeStep() + const key = base32Decode(secret) + + // Big-endian 8-byte counter + const counter = new Uint8Array(8) + for (let i = 7; i >= 0; i--) { + counter[i] = ts & 0xff + ts >>>= 8 + } + + const hmac = await hmacSha1(key, counter) + const code = truncate(hmac) + return String(code).padStart(TOTP_DIGITS, '0') +} + +/** + * Verify a TOTP code with ±1 time-step window and replay prevention. + * Returns the candidateTimeStep on success, or null on failure. + */ +export async function verifyTotpCode( + secret: string, + code: string, + lastUsedTimeStep: number | null, +): Promise { + const currentTs = getCurrentTimeStep() + + // Check current, current-1, current+1 + for (const candidate of [currentTs, currentTs - 1, currentTs + 1]) { + if (lastUsedTimeStep !== null && candidate <= lastUsedTimeStep) continue + + const expected = await generateTotpCode(secret, candidate) + if (expected === code) return candidate + } + + return null +} diff --git a/functions/src/utils/ulid.ts b/functions/src/utils/ulid.ts new file mode 100644 index 0000000..1717584 --- /dev/null +++ b/functions/src/utils/ulid.ts @@ -0,0 +1,63 @@ +/** + * ULID generator: 26-char Crockford Base32, 48-bit timestamp + 80-bit random. + * Sorted by timestamp for rough chronological ordering. + */ + +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const CROCKFORD_MAP: Record = {}; +for (let i = 0; i < 32; i++) { + CROCKFORD_MAP[CROCKFORD[i]] = CROCKFORD[i]; +} + +/** Generate a ULID string */ +export function ulid(): string { + return _ulid(); +} + +/** + * Generate a prefixed ID: prefix + 26-char Crockford Base32 ULID. + * E.g., genId('usr_') → "usr_01HZYC7Q9N8K2P4R6X0M9A1B2C" + * + * Per api.md resource ID convention: prefix + ULID. + */ +export function genId(prefix: string): string { + return prefix + _ulid(); +} + +function _ulid(): string { + const now = Date.now(); + const ts = now.toString(16).padStart(12, '0'); + const random = crypto.getRandomValues(new Uint8Array(16)); + const hex = ts + Array.from(random).map(b => b.toString(16).padStart(2, '0')).join(''); + + // Encode hex to Crockford Base32 (first 48 bits = 10 chars, remaining 80 bits = 16 chars) + let result = ''; + let bits = 0; + let value = 0; + + for (let i = 0; i < hex.length; i++) { + value = (value << 4) | parseInt(hex[i], 16); + bits += 4; + if (bits >= 5) { + bits -= 5; + result += CROCKFORD[(value >> bits) & 31]; + value &= (1 << bits) - 1; + } + } + if (bits > 0) { + result += CROCKFORD[(value << (5 - bits)) & 31]; + } + + return result.padEnd(26, CROCKFORD[0]).slice(0, 26); +} + +/** Extract timestamp from a ULID (first 10 chars encode the 48-bit timestamp) */ +export function ulidTimestamp(id: string): number { + let ts = 0; + for (let i = 0; i < 10; i++) { + const idx = CROCKFORD.indexOf(id[i]?.toUpperCase() ?? '0'); + if (idx === -1) continue; + ts = (ts << 5) | idx; + } + return ts; +} diff --git a/functions/src/utils/user-agent.ts b/functions/src/utils/user-agent.ts new file mode 100644 index 0000000..a38ed55 --- /dev/null +++ b/functions/src/utils/user-agent.ts @@ -0,0 +1,72 @@ +/** + * Minimal User-Agent parser for session device info. + * Returns browser name, OS name, and device type from the raw UA string. + */ + +export interface DeviceInfo { + browser: string | null + os: string | null + type: 'desktop' | 'mobile' | 'tablet' | 'bot' | 'unknown' +} + +export function parseUserAgent(ua: string): DeviceInfo { + const lower = ua.toLowerCase() + + // Bot detection + if (/bot|crawl|spider|scraper|curl|wget|headless/i.test(ua)) { + return { browser: null, os: null, type: 'bot' } + } + + // Browser detection + let browser: string | null = null + if (lower.includes('edg/') || lower.includes('edge/')) { + const m = /edge\/(\d+)/i.exec(ua) + browser = `Edge${m ? ' ' + m[1] : ''}` + } else if (lower.includes('chrome/') && !lower.includes('chromium')) { + const m = /chrome\/(\d+)/i.exec(ua) + browser = `Chrome${m ? ' ' + m[1] : ''}` + } else if (lower.includes('safari/') && !lower.includes('chrome')) { + const m = /version\/(\d+)/i.exec(ua) + browser = `Safari${m ? ' ' + m[1] : ''}` + } else if (lower.includes('firefox/')) { + const m = /firefox\/(\d+)/i.exec(ua) + browser = `Firefox${m ? ' ' + m[1] : ''}` + } else if (lower.includes('msie') || lower.includes('trident')) { + browser = 'Internet Explorer' + } else if (lower.includes('opera')) { + browser = 'Opera' + } + + // OS detection + let os: string | null = null + if (lower.includes('windows')) { + const m = /windows nt (\d+\.?\d*)/i.exec(ua) + const versions: Record = { '10.0': '10', '6.3': '8.1', '6.2': '8', '6.1': '7', '6.0': 'Vista', '5.2': 'XP', '5.1': 'XP' } + os = `Windows${m ? ' ' + (versions[m[1]] || m[1]) : ''}` + } else if (lower.includes('mac os x') || lower.includes('macintosh')) { + const m = /mac os x (\d+[._\d]*)/i.exec(ua) + os = `macOS${m ? ' ' + m[1].replace(/_/g, '.') : ''}` + } else if (lower.includes('linux') && !lower.includes('android')) { + os = 'Linux' + } else if (lower.includes('android')) { + const m = /android (\d+\.?\d*)/i.exec(ua) + os = `Android${m ? ' ' + m[1] : ''}` + } else if (lower.includes('ios') || lower.includes('iphone os') || lower.includes('ipad')) { + const m = /os (\d+[._\d]*)/i.exec(ua) + os = `iOS${m ? ' ' + m[1].replace(/_/g, '.') : ''}` + } else if (lower.includes('crkeyboard')) { + os = 'ChromeOS' + } + + // Device type detection + let type: DeviceInfo['type'] = 'desktop' + if (lower.includes('ipad') || lower.includes('tablet') || lower.includes('playbook') || lower.includes('silk')) { + type = 'tablet' + } else if (lower.includes('mobile') || lower.includes('iphone') || lower.includes('ipod') || + /android.*mobile/.test(lower) || lower.includes('blackberry') || + (lower.includes('windows phone') && lower.includes('mobile'))) { + type = 'mobile' + } + + return { browser, os, type } +} diff --git a/functions/src/utils/users.ts b/functions/src/utils/users.ts new file mode 100644 index 0000000..d52f91e --- /dev/null +++ b/functions/src/utils/users.ts @@ -0,0 +1,124 @@ +import { queryOne, exec } from '../Database' +import { genId } from './ulid' + +export interface UserRecord { + id: string + username: string + email: string | null + emailVerified: boolean + displayName: string + avatarUrl: string | null + tokenVersion: number + status: string + createdAt: number + updatedAt: number + lastLoginAt: number | null + roles: string[] +} + +export interface OAuthAccountRecord { + id: string + userId: string + provider: string + providerUserId: string + providerUsername: string | null + providerDisplayName: string | null + providerAvatarUrl: string | null +} + +/** Build roles list from subquery result. */ +async function fetchRoles(userId: string): Promise { + try { + const rows = await import('../Database').then(d => d.query( + `SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.roleId WHERE ur.userId = ?`, + [userId], + )) + return (rows as Array<{ name: string }>).map(r => r.name) + } catch { + return [] + } +} + +/** Find a user by OAuth provider + providerUserId. */ +export async function findUserByOAuth(provider: string, providerUserId: string): Promise { + const oauth = await queryOne(`SELECT userId FROM oauth_accounts WHERE provider = ? AND providerUserId = ?`, [ + provider, + providerUserId, + ]) + if (!oauth) return null + return findUserById(oauth.userId) +} + +/** Find a user by ID. */ +export async function findUserById(id: string): Promise { + const user = await queryOne( + `SELECT * FROM users WHERE id = ?`, + [id], + ) + if (!user) return null + return { + ...user, + roles: await fetchRoles(id), + } as unknown as UserRecord +} + +/** Find a user by username. */ +export async function findUserByUsername(username: string): Promise { + const user = await queryOne( + `SELECT * FROM users WHERE username = ?`, + [username], + ) + if (!user) return null + return { + ...user, + roles: await fetchRoles(user.id as string), + } as unknown as UserRecord +} + +/** Find a user by email. */ +export async function findUserByEmail(email: string): Promise { + const user = await queryOne( + `SELECT * FROM users WHERE email = ?`, + [email], + ) + if (!user) return null + return { + ...user, + roles: await fetchRoles(user.id as string), + } as unknown as UserRecord +} + + +/** Set user admin status. */ +export async function setUserAdmin(userId: string, grantorUserId: string, isAdmin: boolean): Promise { + const role = await queryOne(`SELECT id FROM roles WHERE name = 'admin'`) + if (!role) { + const roleId = genId('role_') + await exec(`INSERT INTO roles (id, name, description, createdAt) VALUES (?, 'admin', '系统管理员', ?)`, [roleId, Date.now()]) + if (isAdmin) { + await exec(`INSERT INTO user_roles (id, userId, roleId, grantedBy, createdAt) VALUES (?, ?, ?, ?, ?)`, [ + genId('ur_'), + userId, + roleId, + grantorUserId, + Date.now(), + ]) + } + await exec(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [userId]) + return + } + + if (isAdmin) { + await exec(`INSERT IGNORE INTO user_roles (id, userId, roleId, grantedBy, createdAt) VALUES (?, ?, ?, ?, ?)`, [ + genId('ur_'), + userId, + role.id, + grantorUserId, + Date.now(), + ]) + } else { + await exec(`DELETE FROM user_roles WHERE userId = ? AND roleId = ?`, [userId, role.id]) + } + + await exec(`UPDATE users SET tokenVersion = tokenVersion + 1 WHERE id = ?`, [userId]) +} diff --git a/functions/src/utils/validation.ts b/functions/src/utils/validation.ts new file mode 100644 index 0000000..442dad3 --- /dev/null +++ b/functions/src/utils/validation.ts @@ -0,0 +1,125 @@ +import { z } from 'zod' + +/** + * Password validation per api.md §1.1: + * - Length 12-128 Unicode characters + * - At least 3 of 4 categories: uppercase, lowercase, digits, symbols + * - Must not contain username or email local part (case-insensitive) + */ +export function validatePassword(password: string, username?: string, email?: string): string | null { + if ([...password].length < 12) return '密码至少 12 个字符' + if ([...password].length > 128) return '密码最多 128 个字符' + + const hasUpper = /[A-Z]/.test(password) + const hasLower = /[a-z]/.test(password) + const hasDigit = /[0-9]/.test(password) + const hasSymbol = /[^a-zA-Z0-9]/.test(password) + + const categories = [hasUpper, hasLower, hasDigit, hasSymbol].filter(Boolean).length + if (categories < 3) { + return '密码必须包含大写字母、小写字母、数字、符号中的至少 3 类' + } + + if (username) { + const lowerPwd = password.toLowerCase() + if (lowerPwd.includes(username.toLowerCase())) { + return '密码不能包含用户名' + } + } + + if (email) { + const localPart = email.split('@')[0] + if (localPart) { + const lowerPwd = password.toLowerCase() + if (lowerPwd.includes(localPart.toLowerCase())) { + return '密码不能包含邮箱地址' + } + } + } + + return null +} + +/** Shared base for register/complete-registration schemas. */ +const usernameField = z + .string() + .regex(/^[a-z][a-z0-9_-]{2,31}$/, '用户名须为3-32位,小写字母开头,仅含小写字母/数字/下划线/短横线') +const emailField = z.string().email('邮箱格式不正确').refine((v) => [...v].length <= 254, '邮箱最多 254 个字符') +const displayNameField = z + .string() + .refine((v) => [...v].length >= 1 && [...v].length <= 50, '显示名称需 1-50 个字符') + +export const loginCodeSchema = z.object({ + loginCode: z.string().min(1, 'loginCode 不能为空'), +}) + +// api.md §3.1 — 投稿字段 +export const contributionSchema = z.object({ + title: z.string().min(1, '标题不能为空').refine((v) => [...v].length <= 120, '标题最多 120 个字符'), + content: z.string().min(1, '正文不能为空').refine((v) => [...v].length <= 50000, '正文最多 50000 字符'), + contentFormat: z.enum(['markdown', 'plain_text']), + summary: z.string().refine((v) => [...v].length <= 300, '摘要最多 300 个字符').optional(), + tags: z + .array(z.string().refine((v) => [...v].length >= 1 && [...v].length <= 32, '标签须 1-32 个字符')) + .max(8, '最多 8 个标签') + .default([]), + language: z.enum(['zh-CN', 'zh-TW', 'en', 'ja', 'other']).default('zh-CN'), + submitMode: z.enum(['draft', 'submit']).default('submit'), +}) + +// api.md §6.3 — 审核操作(支持 approved / rejected / in_review 过渡) +export const reviewSchema = z.object({ + decision: z.enum(['approved', 'rejected']).optional(), + toStatus: z.enum(['in_review']).optional(), + expectedVersion: z.number().int().positive('version 必须为正整数'), + internalNote: z.string().refine((v) => [...v].length <= 1000, '最多 1000 个字符').nullable().optional(), + publicNote: z.string().refine((v) => [...v].length <= 500, '最多 500 个字符').nullable().optional(), +}).refine( + (data) => data.decision !== undefined || data.toStatus !== undefined, + { message: 'decision 或 toStatus 必填一项', path: ['decision'] }, +) + +/** Password field with base validation (Unicode char count per api.md). */ +const passwordField = z.string() + .refine((v) => [...v].length >= 12, '密码至少 12 个字符') + .refine((v) => [...v].length <= 128, '密码最多 128 个字符') + +// api.md §1.6.4 — OAuth 完成注册 +export const completeRegistrationSchema = z.object({ + username: usernameField, + email: emailField, + password: passwordField, + displayName: displayNameField, + emailMatchesProvider: z.boolean().optional().default(true), +}).superRefine((data, ctx) => { + const err = validatePassword(data.password, data.username, data.email) + if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err, path: ['password'] }) +}) + +// api.md §1.1 — 注册 +export const registerSchema = z.object({ + username: usernameField, + email: emailField, + password: passwordField, + displayName: displayNameField, +}).superRefine((data, ctx) => { + const err = validatePassword(data.password, data.username, data.email) + if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err, path: ['password'] }) +}) + +// api.md §1.3 — 登录 +export const loginSchema = z.object({ + identifier: z.string().min(3).max(254), + password: z.string() + .refine((v) => [...v].length >= 12, '密码至少 12 个字符') + .refine((v) => [...v].length <= 128, '密码最多 128 个字符'), +}) + +// api.md §1.4.2 — 密码重置(含密码复杂规则校验) +export const passwordResetSchema = z.object({ + token: z.string().min(1, '重置令牌不能为空'), + newPassword: passwordField, +}).superRefine((data, ctx) => { + const err = validatePassword(data.newPassword) + if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err, path: ['newPassword'] }) +}) diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 0000000..4ba3ea1 --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/index.html b/index.html index cfa36d1..0659328 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,15 @@ TransCircleProject +
diff --git a/package.json b/package.json index d3c0437..e47327b 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,41 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@11.4.0", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "dev:backend": "cd functions && npx tsx watch src/Index.ts", + "dev:all": "concurrently \"npm run dev\" \"npm run dev:backend\"", + "build": "tsc -b tsconfig.app.json tsconfig.node.json && vite build", "lint": "eslint .", - "preview": "vite preview" + "lint:fix": "eslint . --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\" \"functions/src/**/*.ts\"", + "typecheck": "tsc -b tsconfig.app.json tsconfig.node.json --noEmit", + "test": "echo \"no tests configured\"", + "preview": "vite preview", + "db:setup": "cd functions && npx tsx src/setup.ts", + "prepare": "simple-git-hooks" + }, + "simple-git-hooks": { + "pre-commit": "pnpm lint-staged", + "pre-push": "pnpm typecheck", + "commit-msg": "node .git-hooks/commit-msg.mjs \"$1\"" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx,mjs,cjs}": [ + "eslint --fix" + ], + "*.{json,md,yml,yaml}": [ + "prettier --write" + ] }, "dependencies": { + "dompurify": "^3.2.4", + "i18next": "^26.3.0", + "md-editor-rt": "^6.5.1", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-i18next": "^17.0.8", "react-router-dom": "^7.15.1" }, "devDependencies": { @@ -20,10 +46,14 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^10.0.3", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "lint-staged": "^15.5.1", + "prettier": "^3.5.3", + "simple-git-hooks": "^2.12.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 350fa33..f5b8b02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,15 +5,26 @@ settings: excludeLinksFromLockfile: false importers: - .: dependencies: + dompurify: + specifier: ^3.2.4 + version: 3.4.10 + i18next: + specifier: ^26.3.0 + version: 26.3.0(typescript@6.0.3) + md-editor-rt: + specifier: ^6.5.1 + version: 6.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: specifier: ^19.2.6 version: 19.2.6 react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.0(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react-router-dom: specifier: ^7.15.1 version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -32,7 +43,10 @@ importers: version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.2(vite@8.0.13(@types/node@24.12.4)) + version: 6.0.2(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)) + concurrently: + specifier: ^10.0.3 + version: 10.0.3 eslint: specifier: ^10.3.0 version: 10.4.0 @@ -45,6 +59,15 @@ importers: globals: specifier: ^17.6.0 version: 17.6.0 + lint-staged: + specifier: ^15.5.1 + version: 15.5.2 + prettier: + specifier: ^3.5.3 + version: 3.8.3 + simple-git-hooks: + specifier: ^2.12.1 + version: 2.13.1 typescript: specifier: ~6.0.2 version: 6.0.3 @@ -53,111 +76,579 @@ importers: version: 8.59.4(eslint@10.4.0)(typescript@6.0.3) vite: specifier: ^8.0.12 - version: 8.0.13(@types/node@24.12.4) + version: 8.0.13(@types/node@24.12.4)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0) + + functions: + dependencies: + '@noble/ed25519': + specifier: ^3.1.0 + version: 3.1.0 + '@simplewebauthn/server': + specifier: ^13.3.1 + version: 13.3.1 + argon2: + specifier: ^0.44.0 + version: 0.44.0 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + dompurify: + specifier: ^3.4.7 + version: 3.4.10 + express: + specifier: ^5.2.1 + version: 5.2.1 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + marked: + specifier: ^18.0.4 + version: 18.0.5 + multer: + specifier: ^2.1.1 + version: 2.1.1 + mysql2: + specifier: ^3.14.1 + version: 3.22.5(@types/node@22.19.21) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + toml: + specifier: ^4.1.1 + version: 4.1.1 + zod: + specifier: ^3.24.4 + version: 3.24.4 + devDependencies: + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.10(@types/express@5.0.6) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/jsdom': + specifier: ^28.0.3 + version: 28.0.3 + '@types/multer': + specifier: ^2.1.0 + version: 2.1.0 + '@types/node': + specifier: ^22.15.17 + version: 22.19.21 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + tsx: + specifier: ^4.20.0 + version: 4.22.4 + typescript: + specifier: ~5.8.3 + version: 5.8.3 packages: + '@asamuzakjp/css-color@5.1.11': + resolution: + { integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg== } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + + '@asamuzakjp/dom-selector@7.1.1': + resolution: + { integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ== } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + + '@asamuzakjp/generational-cache@1.0.1': + resolution: + { integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg== } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + + '@asamuzakjp/nwsapi@2.3.9': + resolution: + { integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q== } '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== } + engines: { node: '>=6.9.0' } '@babel/compat-data@7.29.3': - resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg== } + engines: { node: '>=6.9.0' } '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== } + engines: { node: '>=6.9.0' } '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== } + engines: { node: '>=6.9.0' } '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== } + engines: { node: '>=6.9.0' } '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== } + engines: { node: '>=6.9.0' } '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== } + engines: { node: '>=6.9.0' } '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== } + engines: { node: '>=6.9.0' } peerDependencies: '@babel/core': ^7.0.0 '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== } + engines: { node: '>=6.9.0' } '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== } + engines: { node: '>=6.9.0' } '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== } + engines: { node: '>=6.9.0' } '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw== } + engines: { node: '>=6.9.0' } '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA== } + engines: { node: '>=6.0.0' } hasBin: true + '@babel/runtime@7.29.7': + resolution: + { integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== } + engines: { node: '>=6.9.0' } + '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== } + engines: { node: '>=6.9.0' } '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== } + engines: { node: '>=6.9.0' } '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} + resolution: + { integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== } + engines: { node: '>=6.9.0' } + + '@bramus/specificity@2.4.2': + resolution: + { integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw== } + hasBin: true + + '@codemirror/autocomplete@6.20.2': + resolution: + { integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ== } + + '@codemirror/commands@6.10.3': + resolution: + { integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q== } + + '@codemirror/lang-angular@0.1.4': + resolution: + { integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g== } + + '@codemirror/lang-cpp@6.0.3': + resolution: + { integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA== } + + '@codemirror/lang-css@6.3.1': + resolution: + { integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg== } + + '@codemirror/lang-go@6.0.1': + resolution: + { integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg== } + + '@codemirror/lang-html@6.4.11': + resolution: + { integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw== } + + '@codemirror/lang-java@6.0.2': + resolution: + { integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ== } + + '@codemirror/lang-javascript@6.2.5': + resolution: + { integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A== } + + '@codemirror/lang-jinja@6.0.1': + resolution: + { integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw== } + + '@codemirror/lang-json@6.0.2': + resolution: + { integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ== } + + '@codemirror/lang-less@6.0.2': + resolution: + { integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ== } + + '@codemirror/lang-liquid@6.3.2': + resolution: + { integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw== } + + '@codemirror/lang-markdown@6.5.0': + resolution: + { integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw== } + + '@codemirror/lang-php@6.0.2': + resolution: + { integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA== } + + '@codemirror/lang-python@6.2.1': + resolution: + { integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw== } + + '@codemirror/lang-rust@6.0.2': + resolution: + { integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA== } + + '@codemirror/lang-sass@6.0.2': + resolution: + { integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q== } + + '@codemirror/lang-sql@6.10.0': + resolution: + { integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w== } + + '@codemirror/lang-vue@0.1.3': + resolution: + { integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug== } + + '@codemirror/lang-wast@6.0.2': + resolution: + { integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q== } + + '@codemirror/lang-xml@6.1.0': + resolution: + { integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg== } + + '@codemirror/lang-yaml@6.1.3': + resolution: + { integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ== } + + '@codemirror/language-data@6.5.2': + resolution: + { integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg== } + + '@codemirror/language@6.12.3': + resolution: + { integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA== } + + '@codemirror/legacy-modes@6.5.3': + resolution: + { integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg== } + + '@codemirror/lint@6.9.6': + resolution: + { integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A== } + + '@codemirror/search@6.7.0': + resolution: + { integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg== } + + '@codemirror/state@6.6.0': + resolution: + { integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ== } + + '@codemirror/view@6.43.0': + resolution: + { integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA== } + + '@csstools/color-helpers@6.0.2': + resolution: + { integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q== } + engines: { node: '>=20.19.0' } + + '@csstools/css-calc@3.2.1': + resolution: + { integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg== } + engines: { node: '>=20.19.0' } + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.3': + resolution: + { integrity: sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q== } + engines: { node: '>=20.19.0' } + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: + { integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w== } + engines: { node: '>=20.19.0' } + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: + { integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A== } + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: + { integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== } + engines: { node: '>=20.19.0' } '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + resolution: + { integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== } '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + resolution: + { integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== } '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + resolution: + { integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== } + + '@epic-web/invariant@1.0.0': + resolution: + { integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== } + + '@esbuild/aix-ppc64@0.28.1': + resolution: + { integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== } + engines: { node: '>=18' } + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: + { integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== } + engines: { node: '>=18' } + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: + { integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== } + engines: { node: '>=18' } + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: + { integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== } + engines: { node: '>=18' } + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: + { integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== } + engines: { node: '>=18' } + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: + { integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== } + engines: { node: '>=18' } + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: + { integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== } + engines: { node: '>=18' } + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: + { integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== } + engines: { node: '>=18' } + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: + { integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== } + engines: { node: '>=18' } + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: + { integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== } + engines: { node: '>=18' } + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: + { integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== } + engines: { node: '>=18' } + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: + { integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== } + engines: { node: '>=18' } + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: + { integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== } + engines: { node: '>=18' } + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: + { integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== } + engines: { node: '>=18' } + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: + { integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== } + engines: { node: '>=18' } + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: + { integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== } + engines: { node: '>=18' } + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: + { integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== } + engines: { node: '>=18' } + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: + { integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== } + engines: { node: '>=18' } + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: + { integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== } + engines: { node: '>=18' } + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: + { integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== } + engines: { node: '>=18' } + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: + { integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== } + engines: { node: '>=18' } + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: + { integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== } + engines: { node: '>=18' } + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: + { integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== } + engines: { node: '>=18' } + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: + { integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== } + engines: { node: '>=18' } + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: + { integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== } + engines: { node: '>=18' } + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: + { integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== } + engines: { node: '>=18' } + cpu: [x64] + os: [win32] '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + resolution: + { integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } peerDependencies: eslint: ^10.0.0 peerDependenciesMeta: @@ -165,241 +656,532 @@ packages: optional: true '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } '@eslint/plugin-kit@0.7.1': - resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@exodus/bytes@1.15.1': + resolution: + { integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q== } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@hexagon/base64@1.1.28': + resolution: + { integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw== } '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} + resolution: + { integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== } + engines: { node: '>=18.18.0' } '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} + resolution: + { integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== } + engines: { node: '>=18.18.0' } '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} + resolution: + { integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== } + engines: { node: '>=18.18.0' } '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} + resolution: + { integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== } + engines: { node: '>=12.22' } '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} + resolution: + { integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== } + engines: { node: '>=18.18' } '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + resolution: + { integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== } '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + resolution: + { integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== } '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== } + engines: { node: '>=6.0.0' } '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + resolution: + { integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== } '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + resolution: + { integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== } + + '@levischuck/tiny-cbor@0.2.2': + resolution: + { integrity: sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw== } + + '@lezer/common@1.5.2': + resolution: + { integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ== } + + '@lezer/cpp@1.1.6': + resolution: + { integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA== } + + '@lezer/css@1.3.3': + resolution: + { integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg== } + + '@lezer/go@1.0.1': + resolution: + { integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ== } + + '@lezer/highlight@1.2.3': + resolution: + { integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g== } + + '@lezer/html@1.3.13': + resolution: + { integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg== } + + '@lezer/java@1.1.3': + resolution: + { integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw== } + + '@lezer/javascript@1.5.4': + resolution: + { integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA== } + + '@lezer/json@1.0.3': + resolution: + { integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ== } + + '@lezer/lr@1.4.10': + resolution: + { integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A== } + + '@lezer/markdown@1.6.4': + resolution: + { integrity: sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA== } + + '@lezer/php@1.0.5': + resolution: + { integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA== } + + '@lezer/python@1.1.19': + resolution: + { integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ== } + + '@lezer/rust@1.0.2': + resolution: + { integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg== } + + '@lezer/sass@1.1.0': + resolution: + { integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ== } + + '@lezer/xml@1.0.6': + resolution: + { integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww== } + + '@lezer/yaml@1.0.4': + resolution: + { integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw== } + + '@marijn/find-cluster-break@1.0.2': + resolution: + { integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== } '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + resolution: + { integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== } peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ed25519@3.1.0': + resolution: + { integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g== } + '@oxc-project/types@0.130.0': - resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + resolution: + { integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q== } + + '@peculiar/asn1-android@2.8.0': + resolution: + { integrity: sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA== } + + '@peculiar/asn1-cms@2.8.0': + resolution: + { integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA== } + + '@peculiar/asn1-csr@2.8.0': + resolution: + { integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg== } + + '@peculiar/asn1-ecc@2.8.0': + resolution: + { integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ== } + + '@peculiar/asn1-pfx@2.8.0': + resolution: + { integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg== } + + '@peculiar/asn1-pkcs8@2.8.0': + resolution: + { integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA== } + + '@peculiar/asn1-pkcs9@2.8.0': + resolution: + { integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ== } + + '@peculiar/asn1-rsa@2.8.0': + resolution: + { integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg== } + + '@peculiar/asn1-schema@2.8.0': + resolution: + { integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q== } + + '@peculiar/asn1-x509-attr@2.8.0': + resolution: + { integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA== } + + '@peculiar/asn1-x509@2.8.0': + resolution: + { integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg== } + + '@peculiar/utils@2.0.3': + resolution: + { integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ== } + + '@peculiar/x509@1.14.3': + resolution: + { integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA== } + engines: { node: '>=20.0.0' } + + '@phc/format@1.0.0': + resolution: + { integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== } + engines: { node: '>=10' } '@rolldown/binding-android-arm64@1.0.1': - resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [android] '@rolldown/binding-darwin-arm64@1.0.1': - resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [darwin] '@rolldown/binding-darwin-x64@1.0.1': - resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [darwin] '@rolldown/binding-freebsd-x64@1.0.1': - resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [freebsd] '@rolldown/binding-linux-arm-gnueabihf@1.0.1': - resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm] os: [linux] '@rolldown/binding-linux-arm64-gnu@1.0.1': - resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.1': - resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.1': - resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [ppc64] os: [linux] libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.1': - resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [s390x] os: [linux] libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.1': - resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.1': - resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.1': - resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [openharmony] '@rolldown/binding-wasm32-wasi@1.0.1': - resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [wasm32] '@rolldown/binding-win32-arm64-msvc@1.0.1': - resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [win32] '@rolldown/binding-win32-x64-msvc@1.0.1': - resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ== } + engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [win32] '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + resolution: + { integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== } + + '@simplewebauthn/server@13.3.1': + resolution: + { integrity: sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w== } + engines: { node: '>=20.0.0' } '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + resolution: + { integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== } + + '@types/body-parser@1.19.6': + resolution: + { integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== } + + '@types/connect@3.4.38': + resolution: + { integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== } + + '@types/cookie-parser@1.4.10': + resolution: + { integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg== } + peerDependencies: + '@types/express': '*' '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + resolution: + { integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== } '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + resolution: + { integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== } + + '@types/express-serve-static-core@5.1.1': + resolution: + { integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A== } + + '@types/express@5.0.6': + resolution: + { integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== } + + '@types/http-errors@2.0.5': + resolution: + { integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== } + + '@types/jsdom@28.0.3': + resolution: + { integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ== } '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + resolution: + { integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== } + + '@types/linkify-it@5.0.0': + resolution: + { integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== } + + '@types/markdown-it@14.1.2': + resolution: + { integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== } + + '@types/mdurl@2.0.0': + resolution: + { integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== } + + '@types/multer@2.1.0': + resolution: + { integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA== } + + '@types/node@22.19.21': + resolution: + { integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA== } '@types/node@24.12.4': - resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + resolution: + { integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA== } + + '@types/qrcode@1.5.6': + resolution: + { integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw== } + + '@types/qs@6.15.1': + resolution: + { integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw== } + + '@types/range-parser@1.2.7': + resolution: + { integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== } '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + resolution: + { integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== } peerDependencies: '@types/react': ^19.2.0 '@types/react@19.2.15': - resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} + resolution: + { integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q== } + + '@types/send@1.2.1': + resolution: + { integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ== } + + '@types/serve-static@2.2.0': + resolution: + { integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ== } + + '@types/tough-cookie@4.0.5': + resolution: + { integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== } + + '@types/trusted-types@2.0.7': + resolution: + { integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== } '@typescript-eslint/eslint-plugin@8.59.4': - resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: '@typescript-eslint/parser': ^8.59.4 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/parser@8.59.4': - resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/project-service@8.59.4': - resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/scope-manager@8.59.4': - resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@typescript-eslint/tsconfig-utils@8.59.4': - resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/type-utils@8.59.4': - resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@8.59.4': - resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } '@typescript-eslint/typescript-estree@8.59.4': - resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/utils@8.59.4': - resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/visitor-keys@8.59.4': - resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@vavt/copy2clipboard@1.0.3': + resolution: + { integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA== } + + '@vavt/util@2.1.2': + resolution: + { integrity: sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw== } '@vitejs/plugin-react@6.0.2': - resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg== } + engines: { node: ^20.19.0 || >=22.12.0 } peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 @@ -410,107 +1192,435 @@ packages: babel-plugin-react-compiler: optional: true + accepts@2.0.0: + resolution: + { integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== } + engines: { node: '>= 0.6' } + acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + resolution: + { integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== } peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} + resolution: + { integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== } + engines: { node: '>=0.4.0' } hasBin: true ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + resolution: + { integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== } + + ansi-escapes@7.3.0: + resolution: + { integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== } + engines: { node: '>=18' } + + ansi-regex@5.0.1: + resolution: + { integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== } + engines: { node: '>=8' } + + ansi-regex@6.2.2: + resolution: + { integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== } + engines: { node: '>=12' } + + ansi-styles@4.3.0: + resolution: + { integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== } + engines: { node: '>=8' } + + ansi-styles@6.2.3: + resolution: + { integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== } + engines: { node: '>=12' } + + append-field@1.0.0: + resolution: + { integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== } + + argon2@0.44.0: + resolution: + { integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig== } + engines: { node: '>=16.17.0' } + + argparse@2.0.1: + resolution: + { integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== } + + asn1js@3.0.10: + resolution: + { integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg== } + engines: { node: '>=12.0.0' } + + aws-ssl-profiles@1.1.2: + resolution: + { integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== } + engines: { node: '>= 6.0.0' } balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} + resolution: + { integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== } + engines: { node: 18 || 20 || >=22 } baseline-browser-mapping@2.10.31: - resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} - engines: {node: '>=6.0.0'} + resolution: + { integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q== } + engines: { node: '>=6.0.0' } hasBin: true + bidi-js@1.0.3: + resolution: + { integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== } + + body-parser@2.2.2: + resolution: + { integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== } + engines: { node: '>=18' } + brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + resolution: + { integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== } + engines: { node: 18 || 20 || >=22 } + + braces@3.0.3: + resolution: + { integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== } + engines: { node: '>=8' } browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + resolution: + { integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== } + engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true + buffer-from@1.1.2: + resolution: + { integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== } + + busboy@1.6.0: + resolution: + { integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== } + engines: { node: '>=10.16.0' } + + bytes@3.1.2: + resolution: + { integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== } + engines: { node: '>= 0.8' } + + call-bind-apply-helpers@1.0.2: + resolution: + { integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== } + engines: { node: '>= 0.4' } + + call-bound@1.0.4: + resolution: + { integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== } + engines: { node: '>= 0.4' } + + camelcase@5.3.1: + resolution: + { integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== } + engines: { node: '>=6' } + caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + resolution: + { integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA== } + + chalk@5.6.2: + resolution: + { integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== } + engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } + + cli-cursor@5.0.0: + resolution: + { integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw== } + engines: { node: '>=18' } + + cli-truncate@4.0.0: + resolution: + { integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA== } + engines: { node: '>=18' } + + cliui@6.0.0: + resolution: + { integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== } + + cliui@9.0.1: + resolution: + { integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== } + engines: { node: '>=20' } + + codemirror@6.0.2: + resolution: + { integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw== } + + 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== } + + colorette@2.0.20: + resolution: + { integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== } + + commander@13.1.0: + resolution: + { integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw== } + engines: { node: '>=18' } + + commander@2.20.3: + resolution: + { integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== } + + concat-stream@2.0.0: + resolution: + { integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== } + engines: { '0': node >= 6.0 } + + concurrently@10.0.3: + resolution: + { integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA== } + engines: { node: '>=22' } + hasBin: true + + content-disposition@1.1.0: + resolution: + { integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== } + engines: { node: '>=18' } + + content-type@1.0.5: + resolution: + { integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== } + engines: { node: '>= 0.6' } + + content-type@2.0.0: + resolution: + { integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== } + engines: { node: '>=18' } convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + resolution: + { integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== } + + cookie-parser@1.4.7: + resolution: + { integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw== } + engines: { node: '>= 0.8.0' } + + cookie-signature@1.0.6: + resolution: + { integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== } + + cookie-signature@1.2.2: + resolution: + { integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== } + engines: { node: '>=6.6.0' } + + cookie@0.7.2: + resolution: + { integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== } + engines: { node: '>= 0.6' } cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== } + engines: { node: '>=18' } + + crelt@1.0.6: + resolution: + { integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== } + + 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'} + resolution: + { integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== } + engines: { node: '>= 8' } + + css-tree@3.2.1: + resolution: + { integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA== } + engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0 } + + cssfilter@0.0.10: + resolution: + { integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw== } csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + resolution: + { integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== } + + data-urls@7.0.0: + resolution: + { integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA== } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} + resolution: + { integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== } + engines: { node: '>=6.0' } peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true + decamelize@1.2.0: + resolution: + { integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== } + engines: { node: '>=0.10.0' } + + decimal.js@10.6.0: + resolution: + { integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== } + deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + resolution: + { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== } + + denque@2.1.0: + resolution: + { integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== } + engines: { node: '>=0.10' } + + depd@2.0.0: + resolution: + { integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== } + engines: { node: '>= 0.8' } detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== } + engines: { node: '>=8' } + + dijkstrajs@1.0.3: + resolution: + { integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== } + + dompurify@3.4.10: + resolution: + { integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w== } + + dunder-proto@1.0.1: + resolution: + { integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== } + engines: { node: '>= 0.4' } + + ee-first@1.1.1: + resolution: + { integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== } electron-to-chromium@1.5.360: - resolution: {integrity: sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==} + resolution: + { integrity: sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA== } + + emoji-regex@10.6.0: + resolution: + { integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== } + + emoji-regex@8.0.0: + resolution: + { integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== } + + encodeurl@2.0.0: + resolution: + { integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== } + engines: { node: '>= 0.8' } + + entities@4.5.0: + resolution: + { integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== } + engines: { node: '>=0.12' } + + entities@8.0.0: + resolution: + { integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA== } + engines: { node: '>=20.19.0' } + + environment@1.1.0: + resolution: + { integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== } + engines: { node: '>=18' } + + es-define-property@1.0.1: + resolution: + { integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== } + engines: { node: '>= 0.4' } + + es-errors@1.3.0: + resolution: + { integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== } + engines: { node: '>= 0.4' } + + es-object-atoms@1.1.2: + resolution: + { integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== } + engines: { node: '>= 0.4' } + + esbuild@0.28.1: + resolution: + { integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== } + engines: { node: '>=18' } + hasBin: true escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== } + engines: { node: '>=6' } + + escape-html@1.0.3: + resolution: + { integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== } escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== } + engines: { node: '>=10' } eslint-plugin-react-hooks@7.1.1: - resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g== } + engines: { node: '>=18' } peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react-refresh@0.5.2: - resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + resolution: + { integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA== } peerDependencies: eslint: ^9 || ^10 eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + resolution: + { integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } eslint@10.4.0: - resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } hasBin: true peerDependencies: jiti: '*' @@ -519,37 +1629,65 @@ packages: optional: true espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + resolution: + { integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} + resolution: + { integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== } + engines: { node: '>=0.10' } esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} + resolution: + { integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== } + engines: { node: '>=4.0' } estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} + resolution: + { integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== } + engines: { node: '>=4.0' } esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== } + engines: { node: '>=0.10.0' } + + etag@1.8.1: + resolution: + { integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== } + engines: { node: '>= 0.6' } + + eventemitter3@5.0.4: + resolution: + { integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== } + + execa@8.0.1: + resolution: + { integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg== } + engines: { node: '>=16.17' } + + express@5.2.1: + resolution: + { integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== } + engines: { node: '>= 18' } fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + resolution: + { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== } fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + resolution: + { integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== } fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + resolution: + { integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== } fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== } + engines: { node: '>=12.0.0' } peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -557,248 +1695,793 @@ packages: optional: true file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} + resolution: + { integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== } + engines: { node: '>=16.0.0' } + + fill-range@7.1.1: + resolution: + { integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== } + engines: { node: '>=8' } + + finalhandler@2.1.1: + resolution: + { integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== } + engines: { node: '>= 18.0.0' } + + find-up@4.1.0: + resolution: + { integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== } + engines: { node: '>=8' } find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== } + engines: { node: '>=10' } flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} + resolution: + { integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== } + engines: { node: '>=16' } flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + resolution: + { integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== } + + forwarded@0.2.0: + resolution: + { integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== } + engines: { node: '>= 0.6' } + + fresh@2.0.0: + resolution: + { integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== } + engines: { node: '>= 0.8' } fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + resolution: + { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } os: [darwin] + function-bind@1.1.2: + resolution: + { integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== } + + generate-function@2.3.1: + resolution: + { integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== } + gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + 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-east-asian-width@1.6.0: + resolution: + { integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== } + engines: { node: '>=18' } + + get-intrinsic@1.3.0: + resolution: + { integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== } + engines: { node: '>= 0.4' } + + get-proto@1.0.1: + resolution: + { integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== } + engines: { node: '>= 0.4' } + + get-stream@8.0.1: + resolution: + { integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== } + engines: { node: '>=16' } glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} + resolution: + { integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== } + engines: { node: '>=10.13.0' } globals@17.6.0: - resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} - engines: {node: '>=18'} + resolution: + { integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA== } + engines: { node: '>=18' } + + gopd@1.2.0: + resolution: + { integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== } + engines: { node: '>= 0.4' } + + has-symbols@1.1.0: + resolution: + { integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== } + engines: { node: '>= 0.4' } + + hasown@2.0.4: + resolution: + { integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== } + engines: { node: '>= 0.4' } hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + resolution: + { integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== } hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + resolution: + { integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== } + + html-encoding-sniffer@6.0.0: + resolution: + { integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg== } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + + html-parse-stringify@3.0.1: + resolution: + { integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== } + + http-errors@2.0.1: + resolution: + { integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== } + engines: { node: '>= 0.8' } + + human-signals@5.0.0: + resolution: + { integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== } + engines: { node: '>=16.17.0' } + + i18next@26.3.0: + resolution: + { integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA== } + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + iconv-lite@0.7.2: + resolution: + { integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== } + engines: { node: '>=0.10.0' } ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + resolution: + { integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== } + engines: { node: '>= 4' } ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} + resolution: + { integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== } + engines: { node: '>= 4' } imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} + resolution: + { integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== } + engines: { node: '>=0.8.19' } + + inherits@2.0.4: + resolution: + { integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== } + + ipaddr.js@1.9.1: + resolution: + { integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== } + engines: { node: '>= 0.10' } is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== } + engines: { node: '>=0.10.0' } + + is-fullwidth-code-point@3.0.0: + resolution: + { integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== } + engines: { node: '>=8' } + + is-fullwidth-code-point@4.0.0: + resolution: + { integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== } + engines: { node: '>=12' } + + is-fullwidth-code-point@5.1.0: + resolution: + { integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== } + engines: { node: '>=18' } is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== } + engines: { node: '>=0.10.0' } + + is-number@7.0.0: + resolution: + { integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== } + engines: { node: '>=0.12.0' } + + is-potential-custom-element-name@1.0.1: + resolution: + { integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== } + + is-promise@4.0.0: + resolution: + { integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== } + + is-property@1.0.2: + resolution: + { integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== } + + is-stream@3.0.0: + resolution: + { integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + resolution: + { integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== } js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + resolution: + { integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== } + + jsdom@29.1.1: + resolution: + { integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q== } + engines: { node: ^20.19.0 || ^22.13.0 || >=24.0.0 } + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== } + engines: { node: '>=6' } hasBin: true json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + resolution: + { integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== } json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + resolution: + { integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== } json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + resolution: + { integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== } json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== } + engines: { node: '>=6' } hasBin: true keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + resolution: + { integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== } levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== } + engines: { node: '>= 0.8.0' } lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== } + engines: { node: '>= 12.0.0' } cpu: [arm64] os: [android] lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== } + engines: { node: '>= 12.0.0' } cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== } + engines: { node: '>= 12.0.0' } cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== } + engines: { node: '>= 12.0.0' } cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== } + engines: { node: '>= 12.0.0' } cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== } + engines: { node: '>= 12.0.0' } cpu: [arm64] os: [linux] libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== } + engines: { node: '>= 12.0.0' } cpu: [arm64] os: [linux] libc: [musl] lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== } + engines: { node: '>= 12.0.0' } cpu: [x64] os: [linux] libc: [glibc] lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== } + engines: { node: '>= 12.0.0' } cpu: [x64] os: [linux] libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== } + engines: { node: '>= 12.0.0' } cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== } + engines: { node: '>= 12.0.0' } cpu: [x64] os: [win32] lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} + resolution: + { integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== } + engines: { node: '>= 12.0.0' } + + lilconfig@3.1.3: + resolution: + { integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== } + engines: { node: '>=14' } + + linkify-it@5.0.1: + resolution: + { integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg== } + + lint-staged@15.5.2: + resolution: + { integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w== } + engines: { node: '>=18.12.0' } + hasBin: true - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} + listr2@8.3.3: + resolution: + { integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ== } + engines: { node: '>=18.0.0' } - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + locate-path@5.0.0: + resolution: + { integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== } + engines: { node: '>=8' } - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} + locate-path@6.0.0: + resolution: + { integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== } + engines: { node: '>=10' } - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + log-update@6.1.0: + resolution: + { integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w== } + engines: { node: '>=18' } - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true + long@5.3.2: + resolution: + { integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== } + + lru-cache@11.5.1: + resolution: + { integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A== } + engines: { node: 20 || >=22 } + + lru-cache@5.1.1: + resolution: + { integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== } + + lru.min@1.1.4: + resolution: + { integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== } + engines: { bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0' } + + lucide-react@1.17.0: + resolution: + { integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w== } + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + markdown-it-image-figures@2.1.1: + resolution: + { integrity: sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g== } + engines: { node: '>=12.0.0' } + peerDependencies: + markdown-it: '*' + + markdown-it-sub@2.0.0: + resolution: + { integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA== } + + markdown-it-sup@2.0.0: + resolution: + { integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA== } + + markdown-it@14.2.0: + resolution: + { integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ== } + hasBin: true + + marked@18.0.5: + resolution: + { integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w== } + engines: { node: '>= 20' } + hasBin: true + + math-intrinsics@1.1.0: + resolution: + { integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== } + engines: { node: '>= 0.4' } + + md-editor-rt@6.5.1: + resolution: + { integrity: sha512-k7tJT374Ww23yabLEt4wRaoU8SndeIypAcpTfG+bfcnxzwyLTkgcNCU20Gck+V5pHHQFRoqEStUqlviQFW4pjg== } + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + mdn-data@2.27.1: + resolution: + { integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ== } + + mdurl@2.0.0: + resolution: + { integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== } + + media-typer@0.3.0: + resolution: + { integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== } + engines: { node: '>= 0.6' } + + media-typer@1.1.0: + resolution: + { integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== } + engines: { node: '>= 0.8' } + + medium-zoom@1.1.0: + resolution: + { integrity: sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ== } + + merge-descriptors@2.0.0: + resolution: + { integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== } + engines: { node: '>=18' } + + merge-stream@2.0.0: + resolution: + { integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== } + + micromatch@4.0.8: + resolution: + { integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== } + engines: { node: '>=8.6' } + + mime-db@1.40.0: + resolution: + { integrity: sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== } + engines: { node: '>= 0.6' } + + mime-db@1.54.0: + resolution: + { integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== } + engines: { node: '>= 0.6' } + + mime-types@2.1.24: + resolution: + { integrity: sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== } + engines: { node: '>= 0.6' } + + mime-types@3.0.2: + resolution: + { integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== } + engines: { node: '>=18' } + + mimic-fn@4.0.0: + resolution: + { integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== } + engines: { node: '>=12' } + + mimic-function@5.0.1: + resolution: + { integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== } + engines: { node: '>=18' } + + minimatch@10.2.5: + resolution: + { integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== } + engines: { node: 18 || 20 || >=22 } + + ms@2.1.3: + resolution: + { integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== } + + multer@2.1.1: + resolution: + { integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A== } + engines: { node: '>= 10.16.0' } + + mysql2@3.22.5: + resolution: + { integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ== } + engines: { node: '>= 8.0' } + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: + { integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w== } + engines: { node: '>=8.0.0' } + + nanoid@3.3.12: + resolution: + { integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + resolution: + { integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== } + + negotiator@1.0.0: + resolution: + { integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== } + engines: { node: '>= 0.6' } + + node-addon-api@8.8.0: + resolution: + { integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA== } + 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.44: - resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + resolution: + { integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ== } + + npm-run-path@5.3.0: + resolution: + { integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ== } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + + object-inspect@1.13.4: + resolution: + { integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== } + engines: { node: '>= 0.4' } + + on-finished@2.4.1: + resolution: + { integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== } + engines: { node: '>= 0.8' } + + once@1.4.0: + resolution: + { integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== } + + onetime@6.0.0: + resolution: + { integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== } + engines: { node: '>=12' } + + onetime@7.0.0: + resolution: + { integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== } + engines: { node: '>=18' } optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== } + engines: { node: '>= 0.8.0' } + + p-limit@2.2.0: + resolution: + { integrity: sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ== } + engines: { node: '>=6' } p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== } + engines: { node: '>=10' } + + p-locate@4.1.0: + resolution: + { integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== } + engines: { node: '>=8' } p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== } + engines: { node: '>=10' } + + p-try@2.2.0: + resolution: + { integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== } + engines: { node: '>=6' } + + parse5@8.0.1: + resolution: + { integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw== } + + parseurl@1.3.3: + resolution: + { integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== } + engines: { node: '>= 0.8' } path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== } + engines: { node: '>=8' } path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== } + engines: { node: '>=8' } + + path-key@4.0.0: + resolution: + { integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== } + engines: { node: '>=12' } + + path-to-regexp@8.4.2: + resolution: + { integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== } picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + resolution: + { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== } + + picomatch@2.3.2: + resolution: + { integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== } + engines: { node: '>=8.6' } picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} + resolution: + { integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== } + engines: { node: '>=12' } + + pidtree@0.6.0: + resolution: + { integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== } + engines: { node: '>=0.10' } + hasBin: true + + pngjs@5.0.0: + resolution: + { integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== } + engines: { node: '>=10.13.0' } postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} + resolution: + { integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== } + engines: { node: ^10 || ^12 || >=14 } prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== } + engines: { node: '>= 0.8.0' } + + prettier@3.8.3: + resolution: + { integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== } + engines: { node: '>=14' } + hasBin: true + + proxy-addr@2.0.7: + resolution: + { integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== } + engines: { node: '>= 0.10' } + + punycode.js@2.3.1: + resolution: + { integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== } + engines: { node: '>=6' } punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} + resolution: + { integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== } + engines: { node: '>=6' } + + pvtsutils@1.3.6: + resolution: + { integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg== } + + pvutils@1.1.5: + resolution: + { integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA== } + engines: { node: '>=16.0.0' } + + qrcode@1.5.4: + resolution: + { integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg== } + engines: { node: '>=10.13.0' } + hasBin: true + + qs@6.15.2: + resolution: + { integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw== } + engines: { node: '>=0.6' } + + range-parser@1.2.1: + resolution: + { integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== } + engines: { node: '>= 0.6' } + + raw-body@3.0.2: + resolution: + { integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== } + engines: { node: '>= 0.10' } react-dom@19.2.6: - resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + resolution: + { integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g== } peerDependencies: react: ^19.2.6 + react-i18next@17.0.8: + resolution: + { integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw== } + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-router-dom@7.15.1: - resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} - engines: {node: '>=20.0.0'} + resolution: + { integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg== } + engines: { node: '>=20.0.0' } peerDependencies: react: '>=18' react-dom: '>=18' react-router@7.15.1: - resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} - engines: {node: '>=20.0.0'} + resolution: + { integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A== } + engines: { node: '>=20.0.0' } peerDependencies: react: '>=18' react-dom: '>=18' @@ -807,85 +2490,394 @@ packages: optional: true react@19.2.6: - resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q== } + engines: { node: '>=0.10.0' } + + readable-stream@3.6.2: + resolution: + { integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== } + engines: { node: '>= 6' } + + reflect-metadata@0.2.2: + resolution: + { integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== } + + 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' } + + require-main-filename@2.0.0: + resolution: + { integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== } + + restore-cursor@5.1.0: + resolution: + { integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA== } + engines: { node: '>=18' } + + rfdc@1.4.1: + resolution: + { integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== } rolldown@1.0.1: - resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ== } + engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true + router@2.2.0: + resolution: + { integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== } + engines: { node: '>= 18' } + + rxjs@7.8.2: + resolution: + { integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== } + + safe-buffer@5.2.1: + resolution: + { integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== } + + safer-buffer@2.1.2: + resolution: + { integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== } + + saxes@6.0.0: + resolution: + { integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== } + engines: { node: '>=v12.22.7' } + scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + resolution: + { integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== } semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + resolution: + { integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== } hasBin: true semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA== } + engines: { node: '>=10' } hasBin: true + send@1.2.1: + resolution: + { integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== } + engines: { node: '>= 18' } + + serve-static@2.2.1: + resolution: + { integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== } + engines: { node: '>= 18' } + + set-blocking@2.0.0: + resolution: + { integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== } + set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + resolution: + { integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== } + + setprototypeof@1.2.0: + resolution: + { integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== } shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== } + engines: { node: '>=8' } shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + resolution: + { integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== } + engines: { node: '>=8' } + + shell-quote@1.8.4: + resolution: + { integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ== } + engines: { node: '>= 0.4' } + + side-channel-list@1.0.1: + resolution: + { integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== } + engines: { node: '>= 0.4' } + + side-channel-map@1.0.1: + resolution: + { integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== } + engines: { node: '>= 0.4' } + + side-channel-weakmap@1.0.2: + resolution: + { integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== } + engines: { node: '>= 0.4' } + + side-channel@1.1.1: + resolution: + { integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== } + engines: { node: '>= 0.4' } + + signal-exit@4.1.0: + resolution: + { integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== } + engines: { node: '>=14' } + + simple-git-hooks@2.13.1: + resolution: + { integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ== } + hasBin: true + + slice-ansi@5.0.0: + resolution: + { integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== } + engines: { node: '>=12' } + + slice-ansi@7.1.2: + resolution: + { integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w== } + engines: { node: '>=18' } source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== } + engines: { node: '>=0.10.0' } + + sql-escaper@1.3.3: + resolution: + { integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw== } + engines: { bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0' } + + statuses@2.0.2: + resolution: + { integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== } + engines: { node: '>= 0.8' } + + streamsearch@1.1.0: + resolution: + { integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== } + engines: { node: '>=10.0.0' } + + string-argv@0.3.2: + resolution: + { integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== } + engines: { node: '>=0.6.19' } + + string-width@4.2.0: + resolution: + { integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== } + engines: { node: '>=8' } + + string-width@7.2.0: + resolution: + { integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== } + engines: { node: '>=18' } + + string_decoder@1.3.0: + resolution: + { integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== } + + strip-ansi@6.0.0: + resolution: + { integrity: sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== } + engines: { node: '>=8' } + + strip-ansi@7.2.0: + resolution: + { integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== } + engines: { node: '>=12' } + + strip-final-newline@3.0.0: + resolution: + { integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== } + engines: { node: '>=12' } + + style-mod@4.1.3: + resolution: + { integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ== } + + supports-color@10.2.2: + resolution: + { integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g== } + engines: { node: '>=18' } + + symbol-tree@3.2.4: + resolution: + { integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== } tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} + resolution: + { integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== } + engines: { node: '>=12.0.0' } + + tldts-core@7.4.2: + resolution: + { integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA== } + + tldts@7.4.2: + resolution: + { integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw== } + hasBin: true + + to-regex-range@5.0.1: + resolution: + { integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== } + engines: { node: '>=8.0' } + + toidentifier@1.0.1: + resolution: + { integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== } + engines: { node: '>=0.6' } + + toml@4.1.1: + resolution: + { integrity: sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw== } + engines: { node: '>=20' } + + tough-cookie@6.0.1: + resolution: + { integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw== } + engines: { node: '>=16' } + + tr46@6.0.0: + resolution: + { integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw== } + engines: { node: '>=20' } + + tree-kill@1.2.2: + resolution: + { integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== } + hasBin: true ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} + resolution: + { integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== } + engines: { node: '>=18.12' } peerDependencies: typescript: '>=4.8.4' + tslib@1.14.1: + resolution: + { integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== } + tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + resolution: + { integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== } + + tsx@4.22.4: + resolution: + { integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg== } + engines: { node: '>=18.0.0' } + hasBin: true + + tsyringe@4.10.0: + resolution: + { integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw== } + engines: { node: '>= 6.0.0' } type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + resolution: + { integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== } + engines: { node: '>= 0.8.0' } + + type-is@1.6.18: + resolution: + { integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== } + engines: { node: '>= 0.6' } + + type-is@2.1.0: + resolution: + { integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== } + engines: { node: '>= 18' } + + typedarray@0.0.6: + resolution: + { integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== } typescript-eslint@8.59.4: - resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + resolution: + { integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ== } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript@5.8.3: + resolution: + { integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== } + engines: { node: '>=14.17' } + hasBin: true + typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} + resolution: + { integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== } + engines: { node: '>=14.17' } hasBin: true + uc.micro@2.1.0: + resolution: + { integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== } + + undici-types@6.21.0: + resolution: + { integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== } + undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + resolution: + { integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== } + + undici-types@7.27.2: + resolution: + { integrity: sha512-cH9f42mHuljpNuoS47sWDDWXVxWnJgYCzHVUlr3tn7+HVx0L6QSO+VG5qgzT4kXkR2K8ZsReaT5bupam6RNAEQ== } + + undici@7.27.2: + resolution: + { integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA== } + engines: { node: '>=20.18.1' } + + unpipe@1.0.0: + resolution: + { integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== } + engines: { node: '>= 0.8' } update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + resolution: + { integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== } hasBin: true peerDependencies: browserslist: '>= 4.21.0' uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + resolution: + { integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== } + + use-sync-external-store@1.6.0: + resolution: + { integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: + { integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== } + + vary@1.1.2: + resolution: + { integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== } + engines: { node: '>= 0.8' } vite@8.0.13: - resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw== } + engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 @@ -926,32 +2918,158 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: + { integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== } + engines: { node: '>=0.10.0' } + + w3c-keyname@2.2.8: + resolution: + { integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== } + + w3c-xmlserializer@5.0.0: + resolution: + { integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== } + engines: { node: '>=18' } + + webidl-conversions@8.0.1: + resolution: + { integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ== } + engines: { node: '>=20' } + + whatwg-mimetype@5.0.0: + resolution: + { integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw== } + engines: { node: '>=20' } + + whatwg-url@16.0.1: + resolution: + { integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw== } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + + which-module@2.0.1: + resolution: + { integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== } + which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + resolution: + { integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== } + engines: { node: '>= 8' } hasBin: true word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + resolution: + { integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== } + engines: { node: '>=0.10.0' } + + wrap-ansi@6.2.0: + resolution: + { integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== } + engines: { node: '>=8' } + + wrap-ansi@9.0.2: + resolution: + { integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== } + engines: { node: '>=18' } + + wrappy@1.0.2: + resolution: + { integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== } + + xml-name-validator@5.0.0: + resolution: + { integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== } + engines: { node: '>=18' } + + xmlchars@2.2.0: + resolution: + { integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== } + + xss@1.0.15: + resolution: + { integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg== } + engines: { node: '>= 0.10.0' } + hasBin: true + + y18n@4.0.3: + resolution: + { integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== } + + y18n@5.0.8: + resolution: + { integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== } + engines: { node: '>=10' } yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + resolution: + { integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== } + + yaml@2.9.0: + resolution: + { integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== } + engines: { node: '>= 14.6' } + hasBin: true + + yargs-parser@18.1.3: + resolution: + { integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== } + engines: { node: '>=6' } + + yargs-parser@22.0.0: + resolution: + { integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== } + engines: { node: ^20.19.0 || ^22.12.0 || >=23 } + + yargs@15.3.1: + resolution: + { integrity: sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA== } + engines: { node: '>=8' } + + yargs@18.0.0: + resolution: + { integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg== } + engines: { node: ^20.19.0 || ^22.12.0 || >=23 } yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + resolution: + { integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== } + engines: { node: '>=10' } zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} + resolution: + { integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== } + engines: { node: '>=18.0.0' } peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@3.24.4: + resolution: + { integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg== } + zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + resolution: + { integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== } snapshots: + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} '@babel/code-frame@7.29.0': dependencies: @@ -1030,6 +3148,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -1053,6 +3173,285 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@codemirror/autocomplete@6.20.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/cpp': 1.1.6 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.6.4 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.19 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.3 + '@codemirror/legacy-modes': 6.5.3 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.3 + + '@codemirror/lint@6.9.6': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + crelt: 1.0.6 + + '@codemirror/search@6.7.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.43.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1069,6 +3468,86 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0)': dependencies: eslint: 10.4.0 @@ -1103,6 +3582,10 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -1138,38 +3621,239 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true + '@levischuck/tiny-cbor@0.2.2': {} - '@oxc-project/types@0.130.0': {} + '@lezer/common@1.5.2': {} - '@rolldown/binding-android-arm64@1.0.1': - optional: true + '@lezer/cpp@1.1.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 - '@rolldown/binding-darwin-arm64@1.0.1': - optional: true + '@lezer/css@1.3.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 - '@rolldown/binding-darwin-x64@1.0.1': - optional: true + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 - '@rolldown/binding-freebsd-x64@1.0.1': - optional: true + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 - '@rolldown/binding-linux-arm-gnueabihf@1.0.1': - optional: true + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 - '@rolldown/binding-linux-arm64-gnu@1.0.1': - optional: true + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 - '@rolldown/binding-linux-arm64-musl@1.0.1': - optional: true + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 - '@rolldown/binding-linux-ppc64-gnu@1.0.1': - optional: true + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.6.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@marijn/find-cluster-break@1.0.2': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@noble/ed25519@3.1.0': {} + + '@oxc-project/types@0.130.0': {} + + '@peculiar/asn1-android@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pfx': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-csr': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-pkcs9': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + + '@phc/format@1.0.0': {} + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true '@rolldown/binding-linux-s390x-gnu@1.0.1': optional: true @@ -1198,21 +3882,92 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@simplewebauthn/server@13.3.1': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.2 + '@peculiar/asn1-android': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/x509': 1.14.3 + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.12.4 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.4 + + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 24.12.4 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/jsdom@28.0.3': + dependencies: + '@types/node': 24.12.4 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 7.27.2 + '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@types/multer@2.1.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@22.19.21': + dependencies: + undici-types: 6.21.0 + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.12.4 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: '@types/react': 19.2.15 @@ -1221,6 +3976,20 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/send@1.2.1': + dependencies: + '@types/node': 24.12.4 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.12.4 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1312,10 +4081,19 @@ snapshots: '@typescript-eslint/types': 8.59.4 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@24.12.4))': + '@vavt/copy2clipboard@1.0.3': {} + + '@vavt/util@2.1.2': {} + + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.13(@types/node@24.12.4) + vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0) + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -1330,14 +4108,69 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + append-field@1.0.0: {} + + argon2@0.44.0: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.8.0 + node-gyp-build: 4.8.4 + + argparse@2.0.1: {} + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + aws-ssl-profiles@1.1.2: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.31: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.31 @@ -1346,32 +4179,224 @@ snapshots: node-releases: 2.0.44 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + caniuse-lite@1.0.30001793: {} + chalk@5.6.2: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + cliui@6.0.0: + dependencies: + string-width: 4.2.0 + strip-ansi: 6.0.0 + wrap-ansi: 6.2.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + commander@13.1.0: {} + + commander@2.20.3: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + concurrently@10.0.3: + dependencies: + chalk: 5.6.2 + rxjs: 7.8.2 + shell-quote: 1.8.4 + supports-color: 10.2.2 + tree-kill: 1.2.2 + yargs: 18.0.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} + crelt@1.0.6: {} + + 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 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssfilter@0.0.10: {} + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + denque@2.1.0: {} + + depd@2.0.0: {} + detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + + dompurify@3.4.10: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.360: {} + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + entities@4.5.0: {} + + entities@8.0.0: {} + + environment@1.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-plugin-react-hooks@7.1.1(eslint@10.4.0): @@ -1453,6 +4478,55 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1467,6 +4541,26 @@ snapshots: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -1479,16 +4573,58 @@ snapshots: flatted@3.4.2: {} + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true - gensync@1.0.0-beta.2: {} + function-bind@1.1.2: {} - glob-parent@6.0.2: + generate-function@2.3.1: dependencies: - is-glob: 4.0.3 + is-property: 1.0.2 - globals@17.6.0: {} + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@8.0.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.6.0: {} + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 hermes-estree@0.25.1: {} @@ -1496,22 +4632,98 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.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 + + human-signals@5.0.0: {} + + i18next@26.3.0(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} imurmurhash@0.1.4: {} + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-property@1.0.2: {} + + is-stream@3.0.0: {} + isexe@2.0.0: {} js-tokens@4.0.0: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -1580,26 +4792,210 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} + + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + + lint-staged@15.5.2: + dependencies: + chalk: 5.6.2 + commander: 13.1.0 + debug: 4.4.3 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.3 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + listr2@8.3.3: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + long@5.3.2: {} + + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru.min@1.1.4: {} + + lucide-react@1.17.0(react@19.2.6): + dependencies: + react: 19.2.6 + + markdown-it-image-figures@2.1.1(markdown-it@14.2.0): + dependencies: + markdown-it: 14.2.0 + + markdown-it-sub@2.0.0: {} + + markdown-it-sup@2.0.0: {} + + markdown-it@14.2.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@18.0.5: {} + + math-intrinsics@1.1.0: {} + + md-editor-rt@6.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/commands': 6.10.3 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/language': 6.12.3 + '@codemirror/language-data': 6.5.2 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/highlight': 1.2.3 + '@types/markdown-it': 14.1.2 + '@vavt/copy2clipboard': 1.0.3 + '@vavt/util': 2.1.2 + codemirror: 6.0.2 + lucide-react: 1.17.0(react@19.2.6) + markdown-it: 14.2.0 + markdown-it-image-figures: 2.1.1(markdown-it@14.2.0) + markdown-it-sub: 2.0.0 + markdown-it-sup: 2.0.0 + medium-zoom: 1.1.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + xss: 1.0.15 + + mdn-data@2.27.1: {} + + mdurl@2.0.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + medium-zoom@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.40.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.24: + dependencies: + mime-db: 1.40.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 ms@2.1.3: {} + multer@2.1.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + mysql2@3.22.5(@types/node@22.19.21): + dependencies: + '@types/node': 22.19.21 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.12: {} natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + node-addon-api@8.8.0: {} + + node-gyp-build@4.8.4: {} + node-releases@2.0.44: {} + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1609,22 +5005,48 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.2.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.2.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-key@4.0.0: {} + + path-to-regexp@8.4.2: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} + pidtree@0.6.0: {} + + pngjs@5.0.0: {} + postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -1633,13 +5055,58 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.3: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode.js@2.3.1: {} + punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.3.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 scheduler: 0.27.0 + react-i18next@17.0.8(i18next@26.3.0(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.0(typescript@6.0.3) + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + typescript: 6.0.3 + react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -1656,6 +5123,27 @@ snapshots: react@19.2.6: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + reflect-metadata@0.2.2: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rfdc@1.4.1: {} + rolldown@1.0.1: dependencies: '@oxc-project/types': 0.130.0 @@ -1677,38 +5165,221 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.1 '@rolldown/binding-win32-x64-msvc': 1.0.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} semver@7.8.0: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + shell-quote@1.8.4: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + simple-git-hooks@2.13.1: {} + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} + sql-escaper@1.3.3: {} + + statuses@2.0.2: {} + + streamsearch@1.1.0: {} + + string-argv@0.3.2: {} + + string-width@4.2.0: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.0: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@3.0.0: {} + + style-mod@4.1.3: {} + + supports-color@10.2.2: {} + + symbol-tree@3.2.4: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tldts-core@7.4.2: {} + + tldts@7.4.2: + dependencies: + tldts-core: 7.4.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toml@4.1.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.2 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 - tslib@2.8.1: - optional: true + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.24 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + typescript-eslint@8.59.4(eslint@10.4.0)(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3) @@ -1720,10 +5391,22 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.8.3: {} + typescript@6.0.3: {} + uc.micro@2.1.0: {} + + undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici-types@7.27.2: {} + + undici@7.27.2: {} + + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -1734,7 +5417,15 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.0.13(@types/node@24.12.4): + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -1743,7 +5434,32 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 24.12.4 + esbuild: 0.28.1 fsevents: 2.3.3 + tsx: 4.22.4 + yaml: 2.9.0 + + void-elements@3.1.0: {} + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which-module@2.0.1: {} which@2.0.2: dependencies: @@ -1751,12 +5467,73 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.0 + strip-ansi: 6.0.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + + y18n@4.0.3: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} + yaml@2.9.0: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@22.0.0: {} + + yargs@15.3.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.0 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 + zod@3.24.4: {} + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..82c43b9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - 'functions' +allowBuilds: + argon2: true + esbuild: true + simple-git-hooks: set this to true or false +onlyBuiltDependencies: + - simple-git-hooks + - argon2 +preferOffline: true diff --git a/src/App.module.css b/src/App.module.css index d66901e..673d150 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -111,14 +111,14 @@ text-decoration: none; font-size: 0.875rem; font-weight: 500; - box-shadow: 0 2px 8px rgba(240, 98, 146, 0.25); + box-shadow: 0 2px 8px var(--shadow-color); transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } .ctaPrimary:hover { - background-color: #cc537c; + background-color: var(--cta-hover); transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(240, 98, 146, 0.4); + box-shadow: 0 4px 12px var(--shadow-color-hover); } @supports (background-color: color-mix(in srgb, red 85%, black)) { diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..50bcb54 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,490 @@ +/** + * Unified API client for TransCircle Frontend. + * + * 设计目标: + * - 内存中管理 access token(不写 localStorage / sessionStorage,遵守 api.md JWT 存储建议) + * - 401 时自动 refresh → retry(refresh token rotation 由后端保障) + * - 统一的请求拦截(自动注入 Authorization / Content-Type / Idempotency-Key / X-CSRF-Token) + * - 统一的错误解析 + * - 类型安全的辅助方法 + */ + +import { API_BASE as _API_BASE } from '@/config' + +/** Re-export for use by pages that need direct fetch (e.g. DELETE with body) */ +export const API_BASE = _API_BASE + +// ─── Token Management ────────────────────────────────────────── + +let _memoryToken: string | null = null +let _loginProvider: string | null = null // 'github' | 'x' | null + +export function setAccessToken(token: string | null): void { + _memoryToken = token +} + +export function getAccessToken(): string | null { + return _memoryToken +} + +export function setLoginProvider(provider: string | null): void { + _loginProvider = provider +} + +export function getLoginProvider(): string | null { + return _loginProvider +} + +// ─── Refresh Token Rotation ──────────────────────────────────── + +let _refreshPromise: Promise | null = null + +/** + * Attempt to refresh the access token via POST /v1/auth/refresh. + * Uses a promise queue so concurrent callers share one in-flight request. + */ +async function doRefresh(): Promise { + if (_refreshPromise) return _refreshPromise + + _refreshPromise = (async () => { + try { + const res = await fetch(`${API_BASE}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }) + + if (res.status === 401) { + // REFRESH_TOKEN_REVOKED or INVALID_REFRESH_TOKEN + _memoryToken = null + return null + } + + if (!res.ok) return null + + const body = (await res.json()) as { + data?: { accessToken?: string } + requestId?: string + } + if (body.data?.accessToken) { + _memoryToken = body.data.accessToken + return _memoryToken + } + return null + } catch { + return null + } finally { + _refreshPromise = null + } + })() + + return _refreshPromise +} + +// ─── CSRF Token Helper ───────────────────────────────────────── + +/** + * Read oauth_pending_csrf cookie for OAuth flows (api.md §1.6.2). + * Falls back to sessionStorage for cross-page navigation resilience. + */ +export function getCsrfToken(): string { + const match = document.cookie.match(/oauth_pending_csrf=([^;]+)/) + if (match?.[1]) return match[1] + return sessionStorage.getItem('oauth_pending_csrf') || '' +} + +/** Persist CSRF token to sessionStorage so it survives page navigation */ +export function saveCsrfToken(token: string): void { + sessionStorage.setItem('oauth_pending_csrf', token) +} + +/** Clean up CSRF token after use */ +export function clearCsrfToken(): void { + sessionStorage.removeItem('oauth_pending_csrf') +} + +// ─── Idempotency-Key Helper ──────────────────────────────────── + +/** + * Generate a UUID v4 Idempotency-Key per api.md §12. + * UUID v4 matches the required format (16-64 chars, UUID v4 or ULID). + */ +export function newIdempotencyKey(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID() + // Safari 15.3- fallback: UUID v4 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0 + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) + }) +} + +// ─── Per-Intent Idempotency-Key ────────────────────────────── + +let _intentKey: string | null = null + +/** + * Set an idempotency-key for the current business intent. + * The key persists until explicitly cleared, so retries (e.g. after 401 refresh + * or network timeout) reuse the same key — matching api.md's requirement that + * keys are generated "per business intent" and reused across retries. + */ +export function setIntentKey(key: string | null): void { + _intentKey = key +} + +export function clearIntentKey(): void { + _intentKey = null +} + +// ─── API Response Types ──────────────────────────────────────── + +export interface ApiSuccess { + data: T + requestId: string +} + +export interface ApiErrorBody { + error: { + code: string + message: string + details?: Array<{ field: string; reason: string }> + data?: Record + } + requestId: string +} + +export interface RateLimitInfo { + limit: number + remaining: number + reset: number + retryAfter?: number +} + +type ApiResultBase = { + requestId: string + status: number + rateLimit?: RateLimitInfo +} + +export type ApiResult = ApiResultBase & ({ + ok: true + data: T + pagination?: { + limit: number + nextCursor: string | null + hasMore: boolean + } +} | { + ok: false + error: ApiErrorBody['error'] +}) + +// ─── Request Options ─────────────────────────────────────────── + +export interface ApiRequestOptions { + /** Skip auto-injecting Authorization header */ + noAuth?: boolean + /** Custom headers to merge */ + headers?: Record + /** Include Idempotency-Key header (UUID v4) */ + idempotent?: boolean + /** Include X-CSRF-Token header */ + csrf?: boolean + /** Don't attempt refresh on 401 */ + skipRefresh?: boolean + /** AbortSignal for cancellation */ + signal?: AbortSignal +} + +// ─── Conditional log helper ──────────────────────────────────── + +function logRequestId(label: string, body: { requestId?: string }): void { + if (body.requestId) { + console.debug(`[api] ${label} requestId=${body.requestId}`) + } +} + +// ─── Core Request Function ───────────────────────────────────── + +const EMPTY_HEADERS = {} as const + +/** + * Generate an X-Request-Id for client-side request tracing (api.md §12). + * ULID-like format: short random hex string, ≤ 64 chars. + */ +function newRequestId(): string { + return `req_fe_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}` +} + +/** + * Core `fetch` wrapper. + * + * 1. Builds headers (Content-Type, Authorization, X-CSRF-Token, Idempotency-Key, X-Request-Id) + * 2. Sends request + * 3. On 401 + valid token → attempts refresh → retries once + * 4. Parses JSON body into `ApiResult` + */ +export async function apiRequest( + method: string, + path: string, + body?: unknown, + options: ApiRequestOptions = {}, +): Promise> { + const url = `${API_BASE}${path}` + const headers = new Headers(options.headers || EMPTY_HEADERS) + + // Content-Type + if (body !== undefined && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json; charset=utf-8') + } + + // Authorization + if (!options.noAuth) { + const tk = _memoryToken + if (tk) headers.set('Authorization', `Bearer ${tk}`) + } + + // CSRF — only set when token is non-empty to avoid sending a blank header + if (options.csrf) { + const csrfToken = getCsrfToken() + if (csrfToken) headers.set('X-CSRF-Token', csrfToken) + } + + // Idempotency-Key — 提升到业务意图层,超时重试复用同一 key(M9) + const idempotencyKey = options.idempotent + ? (_intentKey || newIdempotencyKey()) + : undefined + if (idempotencyKey && !_intentKey) { + _intentKey = idempotencyKey + } + if (idempotencyKey) { + headers.set('Idempotency-Key', idempotencyKey) + } + + // X-Request-Id — 客户端请求追踪(api.md §12 通用请求头) + if (!headers.has('X-Request-Id')) { + headers.set('X-Request-Id', newRequestId()) + } + + const init: RequestInit = { + method, + headers, + credentials: 'include', + signal: options.signal, + } + if (body !== undefined) { + if (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || + body instanceof URLSearchParams || body instanceof ReadableStream) { + init.body = body + if (body instanceof FormData) { + headers.delete('Content-Type') + } + } else { + init.body = JSON.stringify(body) + } + } + + let res = await fetch(url, init) + + // ── Auto-refresh on 401 ── + if (res.status === 401 && !options.skipRefresh && _memoryToken) { + const newToken = await doRefresh() + if (newToken) { + headers.set('Authorization', `Bearer ${newToken}`) + res = await fetch(url, { ...init, headers }) + } else { + setAccessToken(null) + } + } + + // ── Parse response ── + // Clear intent key only after idempotent requests to prevent + // non-idempotent calls from accidentally wiping a key set for an upcoming + // idempotent request (M9 / N1). + if (options.idempotent) { + _intentKey = null + } + const status = res.status + const contentType = res.headers.get('content-type') || '' + + // Parse rate limit headers (api.md §12 通用响应头) + const rateLimit: RateLimitInfo | undefined = (() => { + const limit = res.headers.get('X-RateLimit-Limit') + const remaining = res.headers.get('X-RateLimit-Remaining') + const reset = res.headers.get('X-RateLimit-Reset') + const retryAfter = res.headers.get('Retry-After') + if (limit && remaining && reset) { + return { + limit: Number(limit), + remaining: Number(remaining), + reset: Number(reset), + ...(retryAfter ? { retryAfter: Number(retryAfter) } : {}), + } + } + return undefined + })() + + if (status === 204) { + return { ok: true, data: undefined as T, requestId: res.headers.get('X-Request-Id') || '', status, rateLimit } + } + + if (contentType.includes('application/json')) { + const json = (await res.json()) as Record + const requestId = (json.requestId as string) || res.headers.get('X-Request-Id') || '' + + // Log rate limit info for 429 responses (L1) + if (status === 429 && rateLimit?.retryAfter) { + console.warn(`[api] Rate limited: retry after ${rateLimit.retryAfter}s (${rateLimit.limit} req/window)`) + try { window.dispatchEvent(new CustomEvent('api:rate-limit', { detail: rateLimit })) } catch {/* noop */} + } + + if (status >= 200 && status < 300) { + // path 可能含敏感 query(如 /admin/users?keyword=email),仅记录路径部分(api.md 安全基线) + logRequestId(`${method} ${path.split('?')[0]!}`, json) + // Persist CSRF token from response body if present (H1 — supports cross-origin OAuth flows) + const responseCsrf = json.csrfToken as string | undefined + if (responseCsrf) saveCsrfToken(responseCsrf) + const base = { requestId, status, rateLimit } + const pagination = json.pagination as { limit: number; nextCursor: string | null; hasMore: boolean } | undefined + const result: ApiResult = pagination + ? { ...base, ok: true as const, data: json.data as T, pagination } + : { ...base, ok: true as const, data: json.data as T } + return result + } + + // Error response — api.md §12 format: { error: { code, message, details?, data? }, requestId } + // Also extract CSRF token from error responses (H1) + const errorCsrf = json.csrfToken as string | undefined + if (errorCsrf) saveCsrfToken(errorCsrf) + const errorData = json.error as { code: string; message: string; details?: Array<{ field: string; reason: string }>; data?: Record } | undefined + + // Append retry-after info to rate-limited error messages so pages display it automatically (L1) + if (status === 429 && rateLimit?.retryAfter && errorData?.message) { + errorData.message += ` (请在 ${rateLimit.retryAfter} 秒后重试)` + } + + return { + ok: false, + error: errorData || { code: 'UNKNOWN', message: 'Unknown error' }, + requestId: (json.requestId as string) || requestId, + status, + rateLimit, + } + } + + // Non-JSON response (e.g. image, plain text) + if (status >= 200 && status < 300) { + return { ok: true, data: res as unknown as T, requestId: '', status, rateLimit } + } + + return { ok: false, error: { code: 'HTTP_ERROR', message: `HTTP ${status}` }, requestId: '', status, rateLimit } +} + +// ─── HTTP Verb Helpers ───────────────────────────────────────── + +export function get(path: string, options?: ApiRequestOptions): Promise> { + return apiRequest('GET', path, undefined, options) +} + +export function post(path: string, body?: unknown, options?: ApiRequestOptions): Promise> { + return apiRequest('POST', path, body, options) +} + +export function patch(path: string, body?: unknown, options?: ApiRequestOptions): Promise> { + return apiRequest('PATCH', path, body, options) +} + +export function del(path: string, body?: unknown, options?: ApiRequestOptions): Promise> { + return apiRequest('DELETE', path, body, options) +} + +/** + * Upload a file via multipart/form-data (api.md §11.1). + */ +export async function uploadFile( + file: File, + signal?: AbortSignal, +): Promise> { + const formData = new FormData() + formData.append('file', file) + + const headers = new Headers() + const tk = _memoryToken + if (tk) headers.set('Authorization', `Bearer ${tk}`) + headers.set('X-Request-Id', newRequestId()) + + let res = await fetch(`${API_BASE}/images`, { + method: 'POST', + headers, + credentials: 'include', + body: formData, + signal, + }) + + if (res.status === 401 && _memoryToken) { + const newToken = await doRefresh() + if (newToken) { + headers.set('Authorization', `Bearer ${newToken}`) + res = await fetch(`${API_BASE}/images`, { + method: 'POST', headers, credentials: 'include', body: formData, signal, + }) + } else { + setAccessToken(null) + } + } + + const status = res.status + const contentType = res.headers.get('content-type') || '' + const requestId = res.headers.get('X-Request-Id') || '' + + if (status >= 200 && status < 300) { + if (!contentType.includes('application/json')) { + return { ok: true, data: res as unknown as T, requestId, status } + } + const json = await res.json() as Record + return { ok: true, data: json.data as T, requestId: (json.requestId as string) || requestId, status } + } + + if (contentType.includes('application/json')) { + const json = await res.json() as Record + return { + ok: false, + error: (json as { error?: { code: string; message: string } }).error || { code: 'UNKNOWN', message: 'Upload failed' }, + requestId: (json.requestId as string) || requestId, status, + } + } + + return { + ok: false, + error: { code: 'UPLOAD_ERROR', message: `HTTP ${status}` }, + requestId, status, + } +} + +/** + * Convenience: refresh the access token at app init. + * Returns the new access token or null. + */ +export async function tryRefreshToken(): Promise { + const token = await doRefresh() + return token +} + +/** + * Clear all auth state (on logout or session expiry). + */ +export function clearAuth(): void { + _memoryToken = null + _loginProvider = null + _refreshPromise = null + _intentKey = null +} diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 0000000..be512c2 --- /dev/null +++ b/src/api/errors.ts @@ -0,0 +1,87 @@ +/** + * API 错误码常量 — 对应 api.md §12.4 完整错误码清单。 + * + * 用法: + * const result = await post('/auth/login', { ... }) + * if (!result.ok) { + * if (result.error.code === ERRORS.ACCOUNT_BANNED) { ... } + * } + */ + +export const ERRORS = { + // ── Generic ── + BAD_REQUEST: 'BAD_REQUEST', + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + NOT_FOUND: 'NOT_FOUND', + VALIDATION_ERROR: 'VALIDATION_ERROR', + RATE_LIMITED: 'RATE_LIMITED', + INTERNAL_ERROR: 'INTERNAL_ERROR', + CONTENT_TOO_LARGE: 'CONTENT_TOO_LARGE', + UNSUPPORTED_MEDIA_TYPE: 'UNSUPPORTED_MEDIA_TYPE', + + // ── Auth / Token ── + INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', + INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN', + REFRESH_TOKEN_REVOKED: 'REFRESH_TOKEN_REVOKED', + TOKEN_INVALID_OR_EXPIRED: 'TOKEN_INVALID_OR_EXPIRED', + CSRF_TOKEN_INVALID: 'CSRF_TOKEN_INVALID', + MISSING_OAUTH_PENDING: 'MISSING_OAUTH_PENDING', + STEP_UP_REQUIRED: 'STEP_UP_REQUIRED', + EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED', + ACCOUNT_BANNED: 'ACCOUNT_BANNED', + ACCOUNT_MERGED: 'ACCOUNT_MERGED', + ACCOUNT_PENDING_DELETION: 'ACCOUNT_PENDING_DELETION', + ACCOUNT_DELETED: 'ACCOUNT_DELETED', + ACCOUNT_LOCKED: 'ACCOUNT_LOCKED', + BAD_STATE: 'BAD_STATE', + OAUTH_ERROR: 'OAUTH_ERROR', + OAUTH_PROVIDER_ERROR: 'OAUTH_PROVIDER_ERROR', + PKCE_VERIFICATION_FAILED: 'PKCE_VERIFICATION_FAILED', + + // ── MFA ── + INVALID_TOTP_CODE: 'INVALID_TOTP_CODE', + TOTP_CODE_REPLAY: 'TOTP_CODE_REPLAY', + TOTP_ALREADY_ENABLED: 'TOTP_ALREADY_ENABLED', + TOTP_NOT_ENABLED: 'TOTP_NOT_ENABLED', + MFA_CHALLENGE_EXHAUSTED: 'MFA_CHALLENGE_EXHAUSTED', + + // ── Passkey ── + PASSKEY_VERIFICATION_FAILED: 'PASSKEY_VERIFICATION_FAILED', + PASSKEY_ALREADY_REGISTERED: 'PASSKEY_ALREADY_REGISTERED', + PASSKEY_NAME_TAKEN: 'PASSKEY_NAME_TAKEN', + PASSKEY_REPLAY_DETECTED: 'PASSKEY_REPLAY_DETECTED', + PASSKEY_FROZEN: 'PASSKEY_FROZEN', + + // ── Conflict / Resource ── + USERNAME_TAKEN: 'USERNAME_TAKEN', + EMAIL_TAKEN: 'EMAIL_TAKEN', + OAUTH_ALREADY_LINKED: 'OAUTH_ALREADY_LINKED', + /** @deprecated api.md §12.4 已不再返回此错误码;bind_already_self 通过重定向 status 表达 */ + ALREADY_BOUND_TO_SELF: 'ALREADY_BOUND_TO_SELF', + PROVIDER_ALREADY_BOUND: 'PROVIDER_ALREADY_BOUND', + IDEMPOTENCY_KEY_MISMATCH: 'IDEMPOTENCY_KEY_MISMATCH', + VERSION_CONFLICT: 'VERSION_CONFLICT', + INVALID_STATE_TRANSITION: 'INVALID_STATE_TRANSITION', + LAST_LOGIN_METHOD: 'LAST_LOGIN_METHOD', + ALREADY_VOTED: 'ALREADY_VOTED', + SELF_VOTE_FORBIDDEN: 'SELF_VOTE_FORBIDDEN', + CONTRIBUTION_NOT_EDITABLE: 'CONTRIBUTION_NOT_EDITABLE', + DUPLICATE_SUBMISSION: 'DUPLICATE_SUBMISSION', + + // ── Resources ── + USER_NOT_FOUND: 'USER_NOT_FOUND', + CONTRIBUTION_NOT_FOUND: 'CONTRIBUTION_NOT_FOUND', + EDIT_REQUEST_NOT_FOUND: 'EDIT_REQUEST_NOT_FOUND', + SESSION_NOT_FOUND: 'SESSION_NOT_FOUND', + PASSKEY_NOT_FOUND: 'PASSKEY_NOT_FOUND', + IMAGE_NOT_FOUND: 'IMAGE_NOT_FOUND', + OAUTH_NOT_BOUND: 'OAUTH_NOT_BOUND', + EMAIL_NOT_FOUND: 'EMAIL_NOT_FOUND', + EMAIL_ALREADY_VERIFIED: 'EMAIL_ALREADY_VERIFIED', + INVALID_IMAGE: 'INVALID_IMAGE', + + // ── Roles ── + ROLE_NOT_FOUND: 'ROLE_NOT_FOUND', + ROLE_ALREADY_GRANTED: 'ROLE_ALREADY_GRANTED', +} as const diff --git a/src/api/permissions.ts b/src/api/permissions.ts new file mode 100644 index 0000000..bcda80c --- /dev/null +++ b/src/api/permissions.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' + +export const PERMISSIONS = { + CONTRIBUTION_REVIEW: 'contribution:review', + CONTRIBUTION_PUBLISH: 'contribution:publish', + CONTRIBUTION_HIDE: 'contribution:hide', + CONTRIBUTION_RESTORE: 'contribution:restore', + CONTRIBUTION_DELETE: 'contribution:delete', + USER_READ: 'user:read', + AUDIT_READ: 'audit:read', +} as const + +export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS] + +const ROLE_PERMISSIONS: Record = { + admin: Object.values(PERMISSIONS), + reviewer: [ + PERMISSIONS.CONTRIBUTION_REVIEW, + PERMISSIONS.CONTRIBUTION_PUBLISH, + ], +} + +export function computePermissions(roles: string[]): Permission[] { + const result: Permission[] = [] + for (const role of roles) { + const perms = ROLE_PERMISSIONS[role] + if (perms) result.push(...perms) + } + return [...new Set(result)] +} + +export function usePermissions(roles: string[]): Permission[] { + return useMemo(() => computePermissions(roles), [roles]) +} + +export function hasPermission(permissions: Permission[], permission: Permission): boolean { + return permissions.includes(permission) +} diff --git a/src/components/FieldError.tsx b/src/components/FieldError.tsx new file mode 100644 index 0000000..8680249 --- /dev/null +++ b/src/components/FieldError.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type ReactNode } from 'react' + +/* eslint-disable react-refresh/only-export-components */ + +/** Context for exposing the errorId to deeply nested form controls */ +const FieldErrorContext = createContext(null) + +/** Hook for consuming field error context inside FormField children */ +export function useFieldError(): string | null { + return useContext(FieldErrorContext) +} + +/** + * Render-prop wrapper for consuming field error context. + * Use inside FormField children when the actual form control is + * nested deeper than the direct child (e.g. inside a wrapper div). + */ +export function FieldErrorConsumer({ children }: { children: (errorId: string | null) => ReactNode }): ReactNode { + const errorId = useContext(FieldErrorContext) + return <>{children(errorId)} +} + +export { FieldErrorContext } diff --git a/src/components/FloatingTOC.tsx b/src/components/FloatingTOC.tsx index 6e9ee92..98abc4e 100644 --- a/src/components/FloatingTOC.tsx +++ b/src/components/FloatingTOC.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import styles from './FloatingTOC.module.css'; export interface TOCItem { @@ -10,17 +11,20 @@ interface FloatingTOCProps { label?: string; } -const FloatingTOC = ({ items, label = '目录' }: FloatingTOCProps) => ( - -); +export const FloatingTOC = ({ items, label }: FloatingTOCProps) => { + const { t } = useTranslation() + const ariaLabel = label || t('toc.label') -export default FloatingTOC; + return ( + + ); +}; diff --git a/src/components/FormField.module.css b/src/components/FormField.module.css new file mode 100644 index 0000000..37b2cb5 --- /dev/null +++ b/src/components/FormField.module.css @@ -0,0 +1,22 @@ +.fieldWrapper { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-main); +} + +.required { + color: var(--accent-pink); + margin-left: 2px; +} + +.error { + font-size: 0.8rem; + color: var(--error-color); + margin: 0; +} diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx new file mode 100644 index 0000000..865fe60 --- /dev/null +++ b/src/components/FormField.tsx @@ -0,0 +1,86 @@ +import { useId, Children, isValidElement, cloneElement, type ReactNode, type ReactElement } from 'react' +import { FieldErrorContext } from './FieldError' +import styles from './FormField.module.css' + +interface FormFieldProps { + label: string + required?: boolean + error?: string + children: ReactNode + htmlFor?: string +} + +/** Elements that accept `htmlFor` / are "labelable" per HTML spec */ +function isLabelableElement(child: ReactElement): boolean { + const { type } = child + // ,