diff --git a/.env.example b/.env.example index ccf3517..7242184 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,55 @@ -# The browser uses the same-origin API path in development and production. -# Override only when a reverse proxy exposes this service at another path. +# Frontend uses the same-origin API path behind Vercel. VITE_API_BASE_URL=/api/v1 -# API server settings. The database and uploads stay local unless explicitly changed. +# Fastify / Railway +NODE_ENV=development HOST=127.0.0.1 PORT=7777 -DATABASE_PATH=.data/platform.db -UPLOAD_DIR=.data/uploads -# Per-account quota defaults to 250 MiB; the platform cap defaults to 5 GiB. +DATABASE_URL=postgresql://platform:platform@127.0.0.1:5432/platform +DATABASE_SSL=false +DATABASE_POOL_MAX=10 +APP_ORIGINS=http://localhost:5173,http://localhost:7777 +PUBLIC_WEBSOCKET_ORIGIN=ws://localhost:7777 +SESSION_TTL_HOURS=12 +VERIFICATION_CODE_SECRET= +SECURE_COOKIES=false +ALLOW_BEARER_AUTH=true +# Use 1 behind Railway; keep false only when the API is reached directly. +TRUST_PROXY=false +LOGGER=true +SEED_ON_START=true + +# Persistent object storage (MinIO locally, Cloudflare R2 in production). +S3_ENDPOINT=http://127.0.0.1:9000 +S3_REGION=us-east-1 +S3_BUCKET=platform-files +S3_ACCESS_KEY_ID=platform +S3_SECRET_ACCESS_KEY=platform-development-secret +S3_FORCE_PATH_STYLE=true UPLOAD_OWNER_QUOTA_BYTES=262144000 UPLOAD_TOTAL_QUOTA_BYTES=5368709120 UPLOAD_MAX_CONCURRENT=4 -APP_ORIGIN=http://localhost:5173 -SESSION_TTL_HOURS=12 -VERIFICATION_CODE_SECRET= + +# Production Railway Hobby uses an HMAC-authenticated HTTPS relay hosted by Vercel. +# Configure the same random secret (at least 32 characters) in Railway and Vercel. +MAIL_RELAY_URL= +MAIL_RELAY_SECRET= + +# Gmail SMTP belongs to the Vercel relay in production. These variables can also +# be used directly by the API during local development when MAIL_RELAY_URL is empty. +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER= +SMTP_PASS= +MAIL_FROM=International Chinese Platform # One-time production administrator bootstrap (pnpm admin:bootstrap). -ADMIN_EMAIL= +ADMIN_EMAIL=yanghanyu2023@gmail.com ADMIN_PASSWORD= ADMIN_DISPLAY_NAME=平台管理员 -# Optional external adapters. Core demo flows work without these values. -SMTP_URL= -MAIL_FROM=International Chinese Platform +# Optional adapters; deterministic local dialogue remains available without AI. AI_API_URL= AI_API_KEY= TURN_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9e67ba..1faa033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,20 @@ jobs: name: Validate runs-on: ubuntu-latest timeout-minutes: 15 + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: platform + POSTGRES_USER: platform + POSTGRES_PASSWORD: platform + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U platform -d platform" + --health-interval 5s + --health-timeout 3s + --health-retries 20 steps: - name: Check out repository @@ -40,6 +54,7 @@ jobs: env: NODE_ENV: test CI: true + TEST_DATABASE_URL: postgresql://platform:platform@127.0.0.1:5432/platform - name: Upload production bundle uses: actions/upload-artifact@v4 @@ -53,6 +68,20 @@ jobs: name: Cross-role browser E2E runs-on: ubuntu-latest timeout-minutes: 20 + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: platform + POSTGRES_USER: platform + POSTGRES_PASSWORD: platform + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U platform -d platform" + --health-interval 5s + --health-timeout 3s + --health-retries 20 steps: - name: Check out repository @@ -83,6 +112,30 @@ jobs: - name: Build production application run: pnpm build + - name: Start isolated MinIO object storage + run: | + docker run --detach --name icp-minio \ + --publish 9000:9000 \ + --env MINIO_ROOT_USER=platform \ + --env MINIO_ROOT_PASSWORD=platform-development-secret \ + minio/minio:latest server /data + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:9000/minio/health/live; then break; fi + if [ "$attempt" -eq 30 ]; then exit 1; fi + sleep 1 + done + docker run --rm --network host --entrypoint /bin/sh minio/mc:latest -c \ + "mc alias set local http://127.0.0.1:9000 platform platform-development-secret && mc mb --ignore-existing local/platform-files" + + - name: Initialize E2E database + env: + NODE_ENV: test + DATABASE_URL: postgresql://platform:platform@127.0.0.1:5432/platform + DATABASE_SSL: 'false' + run: | + pnpm db:migrate + pnpm db:seed + - name: Install browser test dependencies run: | python -m pip install --requirement e2e/requirements.txt @@ -91,14 +144,22 @@ jobs: - name: Start isolated production service env: NODE_ENV: production - SEED_ON_START: 'true' + SEED_ON_START: 'false' SECURE_COOKIES: 'false' - APP_ORIGIN: http://localhost:7777 + ALLOW_BEARER_AUTH: 'false' + DATABASE_URL: postgresql://platform:platform@127.0.0.1:5432/platform + DATABASE_SSL: 'false' + APP_ORIGINS: http://localhost:7777 + PUBLIC_WEBSOCKET_ORIGIN: ws://localhost:7777 VERIFICATION_CODE_SECRET: e2e-only-verification-secret-32-characters - DATABASE_PATH: .data/e2e.db + S3_ENDPOINT: http://127.0.0.1:9000 + S3_REGION: us-east-1 + S3_BUCKET: platform-files + S3_ACCESS_KEY_ID: platform + S3_SECRET_ACCESS_KEY: platform-development-secret + S3_FORCE_PATH_STYLE: 'true' run: | - mkdir -p test-results/e2e .data - rm -f .data/e2e.db .data/e2e.db-shm .data/e2e.db-wal + mkdir -p test-results/e2e pnpm start > test-results/e2e/service.log 2>&1 & echo $! > test-results/e2e/service.pid @@ -114,7 +175,6 @@ jobs: name: international-chinese-platform-e2e-failure path: | test-results/e2e - .data/e2e.db if-no-files-found: warn retention-days: 7 @@ -124,3 +184,4 @@ jobs: if [ -f test-results/e2e/service.pid ]; then kill "$(cat test-results/e2e/service.pid)" || true fi + docker rm --force icp-minio || true diff --git a/.gitignore b/.gitignore index 34c45d1..2d750fe 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ __pycache__/ *.sw? *.tsbuildinfo + +.vercel/ + +.vercel +.env* diff --git a/Dockerfile b/Dockerfile index f1c0374..c7ecef6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1.7 - FROM node:24-bookworm-slim AS build ARG PNPM_VERSION=11.9.0 @@ -18,8 +16,7 @@ RUN corepack enable \ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ - pnpm install --frozen-lockfile +RUN pnpm install --frozen-lockfile COPY . . RUN pnpm build && pnpm prune --prod @@ -34,14 +31,22 @@ LABEL org.opencontainers.image.title="International Chinese Platform" \ ENV NODE_ENV=production \ HOST=0.0.0.0 \ PORT=7777 \ - DATABASE_PATH=/app/data/platform.db \ - UPLOAD_DIR=/app/uploads \ DIST_DIR=/app/dist \ SEED_ON_START=false -RUN groupadd --system --gid 10001 platform \ +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl \ + && install -d /usr/share/postgresql-common/pgdg \ + && curl --fail --silent --show-error \ + --output /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ + https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install --yes --no-install-recommends postgresql-client-18 \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 platform \ && useradd --system --uid 10001 --gid platform --home-dir /app --shell /usr/sbin/nologin platform \ - && mkdir -p /app/data /app/uploads \ + && mkdir -p /app \ && chown -R platform:platform /app WORKDIR /app @@ -54,7 +59,6 @@ COPY --from=build --chown=platform:platform /app/dist ./dist USER 10001:10001 EXPOSE 7777 -VOLUME ["/app/data", "/app/uploads"] STOPSIGNAL SIGTERM HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ diff --git a/README.en.md b/README.en.md index 4034b80..de57ffb 100644 --- a/README.en.md +++ b/README.en.md @@ -1,465 +1,94 @@ - +# International Chinese Platform -
- International Chinese Platform logo +A public-beta full-stack platform for international Chinese education. Students, teachers, and administrators share persistent workflows for registration, teacher verification, courses, appointments, live classrooms, assignments, notifications, and dialogue practice. -

International Chinese Platform

+## Implemented capabilities -

A self-contained, full-stack teaching workspace for international Chinese education

-

Student learning, teacher delivery, and platform review in one runnable, testable, deployable workflow.

- -

- Overview - · - Completeness - · - Quick Start - · - Documentation - · - 中文 -

- -

- - CI - - API tests: 53 passing - Vue 3.5 - Fastify 5 - SQLite - Node.js 24 - pnpm 11.9.0 - Docker Compose -

-
- ---- - - - -## Overview - -International Chinese Platform is a modular monolith built around real teaching collaboration. Its Vue 3 client, Fastify API, SQLite persistence, file service, and real-time classroom protocol all live in this repository. The backend is in [`server/`](./server/), and [`server/app.js`](./server/app.js) registers every domain route. No unknown or obsolete backend from an earlier repository is required. - -The product contains separate student, teacher, and administrator workspaces. Course moderation, teacher appointments, and homework grading move between those roles through database-backed workflows. Notifications and audit records are persisted with business transitions instead of being simulated in the browser. - -> [!NOTE] -> In this repository, “complete” means that the core teaching workflows can be installed, migrated, started, demonstrated, tested, backed up, and restored within a single-instance deployment boundary. It does not claim to be a commercial LMS with payments, formal enrollment, lesson recording, or unlimited horizontal scaling. No hosted demo or screenshots are invented here. - -### Portfolio Highlights - -- **More than a frontend shell** — Authentication, authorization, domain APIs, migrations, demo data, uploads, email integration, WebSockets, tests, and a production container are maintained together. -- **Explicit workflow state machines** — A rejected course can be revised and resubmitted; an accepted appointment creates a classroom after conflict checks; homework moves from draft through submission to grading under server-side rules. -- **Consistent cross-role data** — Students, teachers, and administrators operate on the same SQLite records. Notifications, review history, and important audit events are written with state changes. -- **Explainable security defaults** — Server-side opaque sessions, HttpOnly cookies, scrypt password hashing, origin checks, role and ownership checks, rate limiting, and file-signature validation are built in. -- **Bounded external dependencies** — SMTP, AI, and TURN are adapters. If external AI is absent or fails, a deterministic local dialogue provider keeps the teaching flow usable. -- **Operationally inspectable** — Ordered migrations, one-time administrator bootstrap, a multi-stage Docker image, health probes, persistent volumes, backup instructions, and recovery procedures are included. - - - -## Completeness Definition - -| Category | Status | Repository commitment | -| --------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Core teaching domain | ✅ Complete | Three-role authentication and authorization, teacher verification, course review, appointments and classrooms, homework grading, notifications, profiles, files, and dialogues have frontend, backend, and persistence | -| Engineering and operations | ✅ Complete | Reproducible installation, automatic migrations, demo data, API tests, production build, CI, Docker Compose, health checks, admin bootstrap, and backup/recovery guidance | -| SMTP email | ◇ Conditional | Local development can return a development verification code; enabling production registration requires `SMTP_URL`, `MAIL_FROM`, and a stable verification secret | -| AI dialogue | ◇ Optional adapter | A compatible external JSON API may be configured; timeout, failure, or no configuration falls back to deterministic local teaching dialogue | -| TURN relay | ◇ Conditional | WebRTC can work when peers are directly reachable; strict NAT, enterprise networks, and dependable production classrooms require TURN | -| Commercial LMS capabilities | — Out of scope | Payments, formal enrollment and capacity allocation, issuer-backed certificate verification/OCR, classroom recording, content-rights workflows, and multi-node WebSockets are not implemented | - -Price and capacity are informational course fields; they do not represent payment or seat allocation. Until a formal enrollment model is added, assignments for published courses are visible and submittable by every authenticated student. The SQLite topology is intentionally one process and one application replica. Horizontal scaling first requires a new persistence layer and a distributed room-coordination design. - -## Role Workspaces - -### Student - -- Register, sign in, restore a session, update a profile, and change a password. -- Discover published courses and professional profiles for platform-verified teachers, create or cancel an appointment, and receive state notifications. -- Enter a classroom during its join window, use chat, hand raising, and WebRTC audio/video signaling, and confirm classroom completion. -- View published assignments, save server-side drafts, submit answers, and read teacher scores and feedback. -- Create persistent Chinese dialogue exercises; local teaching feedback remains available without external AI. -- Follow notification deep links back to the relevant appointment, classroom, or assignment context. - -### Teacher - -- Maintain school, title, experience, specialties, languages, teaching style, and other professional profile fields. -- Enter the administrator verification queue after registration; editing personal or professional data resets an existing verification for review. -- Create and edit courses, submit them for review, and revise rejected courses before resubmission. -- Accept or reject appointments while the server checks schedule conflicts for both teacher and student. -- Join and complete classrooms, read message history, and participate in live interaction. -- Create, edit, publish, and close assignments; inspect submissions and record grades and feedback. - -### Administrator - -- Review pending courses, approve publication, or reject with a required explanation. -- Review submitted teacher institution, title, and certificate data; approve or revoke platform verification with an audit note. -- Inspect aggregate user, course, appointment, assignment, submission, and review metrics. -- Review recent activity generated by important domain audit events. -- Maintain the administrator session, password, and notifications. The first production administrator is created through a one-time bootstrap command. - -## Four End-to-End State Loops - -```mermaid -flowchart TB - subgraph Verification["Teacher verification loop"] - T1["pending · registration or profile edit"] -->|administrator approves after manual review| T2["verified · publicly discoverable and bookable"] - T2 -->|administrator revokes or teacher edits profile| T1 - end - - subgraph Course["Course review loop"] - C1["draft · teacher creates"] -->|submit| C2["pending"] - C2 -->|administrator approves| C3["published · visible to students"] - C2 -->|administrator rejects with reason| C4["rejected"] - C4 -->|teacher revises and resubmits| C2 - end - - subgraph Appointment["Appointment and classroom loop"] - A1["pending · student requests"] -->|teacher accepts + both schedules checked| A2["accepted + scheduled classroom"] - A1 -->|teacher rejects| A3["rejected"] - A1 -->|participant cancels| A4["cancelled"] - A2 -->|join window + one-time ticket| A5["WebSocket / WebRTC classroom"] - A2 -->|participant cancels| A4 - A5 -->|participant completes lesson| A6["completed + closed classroom"] - end - - subgraph Homework["Homework submission and grading loop"] - H1["draft · teacher authors"] -->|publish after deadline validation| H2["published"] - H2 -->|student saves| H3["draft submission"] - H3 -->|submit before deadline| H4["submitted"] - H4 -->|teacher grades| H5["graded · score and feedback"] - H2 -->|teacher closes| H6["closed · no new writes"] - end -``` - -The server validates these transitions and combines transactions, uniqueness constraints, notification deduplication, and audit records to prevent browser bypasses and partial writes. +- Email-code registration, login/logout, database sessions, and HttpOnly cookies +- Student, teacher, and administrator authorization with ownership checks +- Teacher applications, administrator review, and public teacher discovery +- Course draft, review, revision, publishing, and ratings +- Appointment acceptance/rejection, classroom creation, and completion +- Assignment questions, submissions, grading, and results +- Notifications, persistent dialogue sessions, and audit logs +- One-time WebSocket tickets, classroom chat, presence, and WebRTC signalling +- PostgreSQL numbered migrations, transactions, and concurrency guards +- Private R2/MinIO storage with upload intents, quota reservations, content validation, conditional promotion, and signed downloads +- One-time production administrator bootstrap with mandatory first-login password reset ## Architecture ```mermaid flowchart LR - Browser["Browser · student / teacher / administrator"] -->|"same-origin pages and /api/v1"| App["Fastify 5 · server/app.js"] - Browser <-->|"/ws/classroom · ticketed WebSocket"| App - - App --> Static["production assets · dist/"] - App --> Routes["auth, courses, appointments, homework, notifications, files, dialogues"] - App --> Realtime["class chat, presence, hand raising, RTC signaling"] - - Routes --> DB[("SQLite · WAL + ordered migrations")] - Realtime --> DB - Routes --> Files["persistent local upload directory"] - - Routes -. "optional" .-> SMTP["SMTP email"] - Routes -. "optional + local fallback" .-> AI["external AI"] - Realtime -. "strict NAT" .-> TURN["TURN relay"] + Browser["Vue 3 browser client"] -->|"HTTPS / page"| Vercel[Vercel] + Vercel -->|"/api/* rewrite"| API["Railway · Fastify"] + Browser -->|"WSS"| API + Browser -->|"10-minute presigned PUT"| R2["Cloudflare R2 · private"] + API --> PG[(Railway PostgreSQL)] + API --> R2 + API --> Gmail["Gmail SMTP"] ``` -During development, Vite serves the client at `http://localhost:5173` and proxies `/api` and `/ws` to `http://127.0.0.1:7777`. In production, one Fastify process on port `7777` serves `dist/`, `/api/v1` (including access-controlled file content), and WebSockets from the same origin. A reverse proxy should terminate public TLS. - -## Technology Stack - -| Layer | Technology | -| --------------------------- | ---------------------------------------------------------------------------- | -| Web client | Vue 3.5, Vite 6, Vue Router 4, Pinia 2 | -| UI and styling | Element Plus 2, Tailwind CSS 3, Sass | -| API | Fastify 5, Zod 4, Node.js ESM | -| Data | SQLite, better-sqlite3, WAL, ordered migration ledger | -| Authentication and security | scrypt, opaque server-side sessions, HttpOnly cookies, Helmet, rate limiting | -| Live classroom | WebSocket, WebRTC signaling, one-time short-lived classroom tickets | -| External adapters | Nodemailer / SMTP, HTTP AI provider, TURN | -| Quality and delivery | Node test runner, ESLint 9, Prettier 3, GitHub Actions, Docker Compose | +The Beta keeps one backend instance because live-room membership is held in process memory. Vercel proxies `/api/*` so browser sessions remain first-party cookies; classroom WebSockets connect directly to Railway. - +## Local development -## Quick Start - -### Prerequisites - -- Node.js 24 is recommended; the minimum in `package.json` is Node.js 22. -- pnpm `11.9.0`. -- Python 3.12 and Playwright Chromium are additionally required for browser E2E. - -### 1. Clone and install +Requires Node.js 24, pnpm 11, and Docker Desktop / Docker Engine. ```bash -git clone https://github.com/computersciencefreshmen/International_Chinese_Platform.git -cd International_Chinese_Platform -corepack enable -corepack prepare pnpm@11.9.0 --activate pnpm install --frozen-lockfile -``` - -### 2. Create a local configuration - -macOS / Linux: - -```bash -cp .env.example .env.local -``` - -Windows PowerShell: - -```powershell -Copy-Item .env.example .env.local -``` - -The defaults are enough for the complete local workflow. SMTP, external AI, and TURN are not prerequisites for the local demonstration. - -### 3. Initialize the demo database - -```bash -pnpm db:reset -``` - -> [!CAUTION] -> `db:reset` deletes the SQLite file at `DATABASE_PATH`, including its WAL/SHM companions, before rebuilding demo data. Use it only for a local development database. Production releases must migrate after taking a backup. - -### 4. Start the web client and API - -```bash +docker compose up -d postgres minio minio-init +pnpm db:migrate +pnpm db:seed pnpm dev ``` -- Web: -- API: -- Health: - -### Demo Accounts - -| Role | Email | Password | Default entry | -| ------------- | --------------------- | ---------- | ------------------------------ | -| Student | `student@example.com` | `Demo123!` | `/student/home` | -| Teacher | `teacher@example.com` | `Demo123!` | `/teacher/home` | -| Administrator | `admin@example.com` | `Demo123!` | `/administrator/courseDocking` | - -These accounts are created by the local seed and must not be used in production. The production image fixes `SEED_ON_START=false`. +- Frontend: `http://localhost:5173` +- API: `http://localhost:7777/api/v1` +- MinIO console: `http://localhost:9001` -## Environment Variables +See [`.env.example`](./.env.example). Local development and CI use PostgreSQL as well, eliminating SQLite/production drift. -See [`.env.example`](./.env.example) for the full local template and [`docs/operations.md`](./docs/operations.md) for production values and secret-handling requirements. - -| Variable | Purpose | Default or boundary | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `VITE_API_BASE_URL` | Browser API prefix | `/api/v1`; same-origin is recommended | -| `HOST` / `PORT` | API bind address and port | `127.0.0.1` / `7777` | -| `DATABASE_PATH` | SQLite file | `.data/platform.db` | -| `UPLOAD_DIR` | Uploaded-file directory | `.data/uploads` | -| `UPLOAD_OWNER_QUOTA_BYTES` / `UPLOAD_TOTAL_QUOTA_BYTES` | Per-account and platform upload quotas | 250 MiB / 5 GiB by default, reserved atomically inside one instance | -| `UPLOAD_MAX_CONCURRENT` | Global concurrent upload slots | `4` by default, maximum `32` | -| `APP_ORIGIN` | Exact origin allowed for cookie-authenticated writes | `http://localhost:5173` in development; the public HTTPS origin in production | -| `SESSION_TTL_HOURS` | Session lifetime | `12` | -| `VERIFICATION_CODE_SECRET` | Registration-code HMAC secret | A stable, high-entropy value is required in production | -| `SMTP_URL` / `MAIL_FROM` | Registration-code delivery | Required when production registration is enabled | -| `AI_API_URL` / `AI_API_KEY` | Optional external dialogue generator | Deterministic local implementation on absence or failure | -| `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` | Optional WebRTC relay | Required for strict NAT and dependable production classrooms | -| `SEED_ON_START` | Insert demo data at startup | Enabled by default in development; fixed off in production Compose | -| `SECURE_COOKIES` / `ALLOW_BEARER_AUTH` / `TRUST_PROXY` | Cookies, bearer compatibility, and proxy trust boundary | Production Compose enables secure cookies and disables bearer auth; proxy trust must match the topology | -| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_DISPLAY_NAME` | One-time first-admin bootstrap | Inject only while running the command; do not store in the production environment file | - -Every `VITE_` variable is embedded in the browser bundle and must never contain a secret. Production registration requires both SMTP and a verification secret. In development only, the API returns a local verification code when SMTP is absent. - -## Migrations and Administrator Bootstrap - -When the database opens, it reads the `schema_migrations` ledger and applies migrations from [`server/db/database.js`](./server/db/database.js) in order and inside transactions. They can also be run explicitly: +## Commands ```bash +pnpm dev +pnpm build pnpm db:migrate -``` - -Production does not seed a demo administrator. After the first deployment, inject `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and `ADMIN_DISPLAY_NAME` temporarily, then run: - -```bash +pnpm db:seed pnpm admin:bootstrap -``` - -The password must be at least 12 characters and contain uppercase, lowercase, numeric, and special characters. The command creates only the first administrator and refuses to overwrite an existing one. See the [operations manual](./docs/operations.md) for secure Docker injection, backups, migrations, and recovery. - -## Tests and Quality Gates - -```bash pnpm test:api pnpm check +pnpm backup:create +pnpm backup:restore ``` -| Layer | Current coverage | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| API integration | **53 / 53 passing**; authentication, teacher review, courses, appointments and classrooms, assignments, migrations, email, streamed uploads and quotas, notifications, profiles, dialogues, and real-time isolation | -| Browser E2E | **4 cross-role workflows passing**; real UI login for every role, then teacher verification revoke/restore, course review, appointment/classroom completion, and homework submission/grading | -| Static quality | `pnpm check` runs ESLint, Prettier, API tests, and the production build in sequence | -| Continuous integration | Separate Validate and Cross-role browser E2E jobs with Node 24, pnpm 11.9.0, Python 3.12, a frozen lockfile, and an isolated database | - -The browser suite uses a pinned Python Playwright version. Install its dependencies and build the production client first: - -```bash -python -m pip install --requirement e2e/requirements.txt -python -m playwright install chromium -pnpm build -``` - -Start an isolated production-mode service in the first terminal. On macOS / Linux: - -```bash -mkdir -p .data -rm -f .data/e2e.db .data/e2e.db-shm .data/e2e.db-wal -NODE_ENV=production \ -SEED_ON_START=true \ -SECURE_COOKIES=false \ -APP_ORIGIN=http://localhost:7777 \ -VERIFICATION_CODE_SECRET=e2e-only-verification-secret-32-characters \ -DATABASE_PATH=.data/e2e.db \ -pnpm start -``` - -On Windows PowerShell: - -```powershell -New-Item -ItemType Directory -Force .data | Out-Null -Remove-Item -Force -ErrorAction SilentlyContinue .data/e2e.db, .data/e2e.db-shm, .data/e2e.db-wal -$env:NODE_ENV = 'production' -$env:SEED_ON_START = 'true' -$env:SECURE_COOKIES = 'false' -$env:APP_ORIGIN = 'http://localhost:7777' -$env:VERIFICATION_CODE_SECRET = 'e2e-only-verification-secret-32-characters' -$env:DATABASE_PATH = '.data/e2e.db' -pnpm start -``` - -Run the suite from a second terminal: - -```bash -python e2e/test_workflows.py -``` - -The default target is `http://localhost:7777`. Set `E2E_BASE_URL` to override it or `E2E_BROWSER_EXECUTABLE` to use a local Chrome binary. On failure, screenshots, traces, and browser logs are written to `test-results/e2e/`. CI builds the app, installs Chromium, starts an isolated seeded database, and uploads failure artifacts automatically. - -API tests use temporary databases and do not depend on the demo database. The project does not claim a publicly hosted demo; reproducible verification is provided through local startup, automated tests, and Docker deployment. - -## Same-Origin Docker Deployment - -The production image uses a Node 24 multi-stage build and runs as a non-root user. After preparing a restricted `.env.production` file: - -```bash -docker compose --env-file .env.production build --pull -docker compose --env-file .env.production up -d -docker compose --env-file .env.production ps -``` - -Compose binds the app to host `127.0.0.1:7777` by default and stores the database and uploads in separate persistent volumes. The reverse proxy must expose the page, `/api/v1`, uploaded content, and `/ws` under one public origin. Do not publish the API on a separate domain. - -Read the [production operations manual](./docs/operations.md) before deployment. It covers TLS proxying, administrator bootstrap, migration, cold backups, recovery drills, monitoring, and incident response so that “the image built” is not treated as “production ready.” - -## Project Structure - -```text -International_Chinese_Platform/ -├── src/ # Vue 3 client and three role workspaces -│ ├── api/ # Same-origin API client -│ ├── components/ # Account, service, and base components -│ ├── router/ # Session restoration and role guards -│ ├── stores/ # Pinia state -│ └── views/ # Student, teacher, administrator, classroom views -├── server/ # Fastify backend, not an external dependency -│ ├── app.js # Composition root and route registration -│ ├── routes/ # Authentication, domain APIs, and WebSocket -│ ├── db/ # Schema, migrations, seed, reset, admin bootstrap -│ ├── services/ # SMTP and AI adapters -│ └── test/ # 53 API integration tests -├── e2e/ # Cross-role Python Playwright tests -├── docs/ -│ ├── adr/ # Architecture decision records -│ ├── plans/ # Completion design and implementation plans -│ └── operations.md # Production, backup, and recovery manual -├── .github/workflows/ci.yml # Quality gates and browser E2E -├── Dockerfile # Node 24 multi-stage production image -├── docker-compose.yml # Single-instance deployment with two persistent volumes -├── SECURITY.md # Vulnerability reporting and security expectations -└── .env.example # Local configuration template -``` - -## API Groups +Production configuration rejects `SEED_ON_START=true`; demo users and courses are never imported automatically. -All REST endpoints use the `/api/v1` prefix and return a consistent `{ code, msg, data }` envelope. [`server/app.js`](./server/app.js) is the source of truth for registered modules. +## File security model -| Group | Main paths | Capability | -| ------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| System | `/health`, `/ready` | Liveness and database readiness | -| Auth | `/auth/verification-code`, `/auth/register`, `/auth/login`, `/auth/logout`, `/auth/session` | Verification, account, and session lifecycle | -| Profile & Teachers | `/me`, `/me/password`, `/teachers` | Profiles, professional teacher information, and discovery | -| Courses & Admin | `/courses`, `/admin/course-reviews`, `/admin/teacher-verifications`, `/admin/metrics` | Course CRUD, course and teacher review, and operational metrics | -| Appointments & Classrooms | `/appointments`, `/classrooms/:id/*` | Appointment responses, conflict checks, join information, and classroom completion | -| Assignments | `/courses/:id/assignments`, `/assignments`, `/submissions`, `/me/submissions` | Assignment lifecycle, student submissions, and teacher grading | -| Files | `/files`, `/files/:id/content` | Categorized uploads, content validation, metadata, and deletion | -| Dialogues | `/dialogues`, `/dialogues/:id/messages` | Persistent dialogue generation and continuation | -| Notifications | `/notifications` | Pagination, unread state, and mark-all-read | -| Realtime | `/classrooms/:id/ticket`, `/classrooms/:id/messages`, `/ws/classroom` | One-time tickets, history, presence, chat, and RTC signaling | +The browser may write only short-lived keys under `tmp/uploads/`; signatures bind Content-Type and Content-Length. Completion streams the object, validates size, magic bytes, and SHA-256, then conditionally copies the source ETag into a new `files/` key. A final key never receives a PUT URL, so reusing the original URL cannot overwrite a verified file. -## Security Design +Deletion is backed by a retryable database outbox. Production R2 should also expire `tmp/` after one day. Private video and material downloads first pass API authorization and then redirect to a five-minute signed URL. -- Passwords use parameterized scrypt hashes; login performs a password check even when the account is absent to reduce enumeration signals. -- Browsers receive only an HttpOnly, SameSite session cookie. The database stores a digest of the opaque token; logout, password changes, and expiration revoke sessions. -- Cookie-authenticated writes validate `Origin`; production Compose enables secure cookies and disables bearer authentication by default. -- Domain writes enforce roles and resource relationships, and the server rechecks each state transition. Non-public file content, metadata, and deletion are limited to the owner or an administrator. -- Unverified teachers are not publicly discoverable or bookable. Administrator approval/revocation notifies the teacher and is audited; a later profile edit resets verification for review. -- Zod validates inputs, Helmet applies security headers, and Fastify rate limits requests and sensitive endpoints. -- Uploads stream through private temporary files with incremental hashing, signature checks, same-volume atomic rename, role and size rules, per-account/platform quotas, and global concurrency limits. This is not malware scanning. -- Verification codes are stored as digests, have bounded attempts, and are consumed once. Failed mail delivery does not create a usable registration path. -- Important moderation, appointment, assignment, and administrator-bootstrap actions are written to the audit log. +## Deployment -Classroom ticket issuance, the WebSocket handshake, history reads, every realtime event, and the periodic connection sweep all revalidate the session, account, classroom state, and participant relationship; stale connections are closed. Non-public course videos and materials are currently readable only by their owner or an administrator. Because formal enrollment is outside this project's scope, student entitlement to private course material is not modeled; deployments that distribute such material should add an enrollment or resource-grant model first. +- Frontend: Vercel project `international-chinese-platform` +- Backend: one Railway Docker service +- Database: Railway PostgreSQL +- Files: private Cloudflare R2 bucket +- Mail: Gmail SMTP application password -These controls are not a third-party security certification. Operators remain responsible for TLS, secrets, dependency updates, logging, retention, and backup access. Report vulnerabilities privately through [`SECURITY.md`](./SECURITY.md); do not disclose details in a public issue. +See the [operations manual](./docs/operations.md) for exact secrets, R2 CORS/lifecycle, administrator bootstrap, backups, restore drills, and rollback. Architecture decisions are in [`docs/adr`](./docs/adr). -## Production Boundaries - -| Boundary | Current design | Expansion path | -| ------------------------ | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| Database | SQLite WAL with one application replica | Move to a service database and reassess transactions and migrations before multi-instance operation | -| Live classroom | In-process WebSocket rooms with peer-to-peer WebRTC media | Multiple nodes require shared presence/pub-sub, sticky routing, and separate media infrastructure | -| Network traversal | TURN is optional configuration | Strict NAT or enterprise-network production classrooms require a monitored TURN service | -| Files | Local volume; default 250 MiB/account, 5 GiB/platform, and four concurrent upload slots | Larger deployments may move to object storage with scanning, lifecycle rules, and CDN delivery | -| Email | SMTP adapter | Production registration requires a deliverable mail service and failure monitoring | -| AI | One HTTP adapter with deterministic local fallback | Provider policy, budget controls, safety, and observability can be added | -| Teaching commerce | Administrator-driven platform teacher verification; price and capacity remain profile data | Payments, formal enrollment, and issuer-backed certificate verification/OCR need separate domains | -| Classroom media | Live audio/video and signaling | Recording, transcoding, playback, and content moderation are not implemented | -| Continuous authorization | Tickets, handshakes, every event, and periodic sweeps revalidate access; private materials are owner/admin only | Add shared revocation signaling for multiple instances and enrollment-based student material access | - - - -## Documentation - -- [`README.md`](./README.md) — Primary Chinese documentation. -- [`README.en.md`](./README.en.md) — Equivalent English documentation. -- [`docs/operations.md`](./docs/operations.md) — Docker production deployment, reverse proxy, migrations, backup, recovery, and monitoring. -- [`SECURITY.md`](./SECURITY.md) — Vulnerability reporting and operator security expectations. -- [`docs/adr/0001-self-contained-modular-monolith.md`](./docs/adr/0001-self-contained-modular-monolith.md) — Self-contained modular monolith decision. -- [`docs/adr/0002-server-side-opaque-sessions.md`](./docs/adr/0002-server-side-opaque-sessions.md) — Opaque server-side session decision. -- [`docs/plans/2026-07-19-full-stack-completion-design.md`](./docs/plans/2026-07-19-full-stack-completion-design.md) — Completion design. -- [`docs/plans/2026-07-19-full-stack-completion.md`](./docs/plans/2026-07-19-full-stack-completion.md) — Implementation and acceptance plan. - -## Canonical Repository - -All future code, issues, documentation, and releases belong in this repository: - -> [computersciencefreshmen/International_Chinese_Platform](https://github.com/computersciencefreshmen/International_Chinese_Platform) - -Early history was split between [`vue3-project-initialization`](https://github.com/computersciencefreshmen/vue3-project-initialization) and a repository named `project`. The latter is no longer reachable on GitHub, so this README does not retain a dead link. Relevant history has been merged here; any accessible older repository is only a historical reference and should not receive new feature work. - -## Contributing - -Create a focused branch from the latest `main`, and update tests and documentation with functional changes. Before submitting: - -```bash -pnpm check -``` +## Verification -Merge a pull request back into `main` only after CI passes; do not leave completed features stranded on personal branches. +CI uses PostgreSQL and isolated MinIO, then runs source checks, API integration tests, the production build, and cross-role Playwright workflows. File tests cover forged extensions, quota races, concurrent completion, presigned URL reuse, ETag-conditional promotion, and unauthorized downloads. -## License +## Public Beta limits -This repository currently declares **no open-source license**. Until the owner adds an explicit license, do not assume permission to copy, distribute, modify, or use the code commercially. +- No TURN server; audio/video may fail behind strict NAT. +- No multi-instance room broadcasting, HA, payments, formal enrollment, class recording, or paid external AI. +- Price and capacity are informational course fields, not payment or seat allocation. -
-

Built for complete, inspectable international Chinese teaching workflows.

- Back to top -
+Security issues should be reported privately through [`SECURITY.md`](./SECURITY.md), never through a public issue containing credentials or vulnerability details. diff --git a/README.md b/README.md index c4cb5a8..bca21ff 100644 --- a/README.md +++ b/README.md @@ -1,465 +1,115 @@ - +# International Chinese Platform -
- International Chinese Platform 标志 +面向国际中文教育的公开 Beta 全栈平台。学生、教师和管理员在同一套持久化数据上完成注册、教师审核、课程、预约、实时课堂、作业、通知和对话流程。 -

International Chinese Platform

+## 当前能力 -

面向国际中文教育的自包含全栈教学协作平台

-

把学生学习、教师授课与平台审核放进同一套可运行、可测试、可部署的教学闭环。

+- 邮箱验证码注册、登录、退出、数据库 Session 与 HttpOnly Cookie +- 学生、教师、管理员三角色权限与资源所有权校验 +- 教师资料申请、管理员审核与公开教师检索 +- 课程草稿、提交、驳回、修改、发布和评价 +- 师生预约、接受/拒绝、课堂创建与完成 +- 作业题目、提交、评分和学生结果查看 +- 通知、持久化对话、审计日志 +- 一次性 WebSocket 课堂票据、聊天、在线状态和 WebRTC 信令 +- PostgreSQL 编号迁移、事务和并发状态保护 +- R2/MinIO 私有对象存储、直传意图、配额预留、魔数/SHA-256 校验、条件晋升和签名下载 +- 一次性生产管理员初始化与首次强制改密 -

- 项目概览 - · - 完整性定义 - · - 快速开始 - · - 文档 - · - English -

- -

- - CI - - API tests: 53 passing - Vue 3.5 - Fastify 5 - SQLite - Node.js 24 - pnpm 11.9.0 - Docker Compose -

-
- ---- - - - -## 项目概览 - -International Chinese Platform 是一个围绕真实教学协作流程构建的模块化单体。Vue 3 前端、Fastify API、SQLite 持久层、文件服务和实时课堂协议均位于本仓库;后端入口在 [`server/`](./server/),所有领域路由由 [`server/app.js`](./server/app.js) 统一注册。项目不依赖早期仓库中可能已经失效或版本不明的外部后端。 - -它覆盖学生、教师和管理员三个工作区,并让课程审核、教师预约与作业评分真正跨角色流转。关键状态保存在数据库中,通知和审计记录随业务事务写入,而不是只在页面中模拟成功。 - -> [!NOTE] -> 本仓库将“完整”定义为:在单实例部署边界内,可以独立安装、迁移、启动、演示、测试和备份核心教学流程。它不是一套宣称具备支付、正式选课、课堂录制和无限水平扩展能力的商业 LMS,也没有虚构在线 Demo 或截图。 - -### Portfolio Highlights - -- **不是前端壳工程** — 认证、授权、领域 API、迁移、种子数据、上传、邮件适配、WebSocket、测试与生产容器都在同一仓库。 -- **围绕状态闭环建模** — 课程可驳回后修改再审;预约在冲突检查后生成课堂;作业从草稿、提交到评分均受服务端状态机约束。 -- **跨角色数据一致性** — 教师、学生与管理员看到同一份 SQLite 业务数据;通知、审核记录和关键操作日志与状态变更一起持久化。 -- **安全默认值可解释** — 使用服务端不透明会话、HttpOnly Cookie、scrypt 密码哈希、来源检查、角色与所有权校验、速率限制和文件魔数验证。 -- **外部依赖有降级边界** — SMTP、AI 和 TURN 都通过适配器接入;未配置外部 AI 时使用本地确定性对话,核心教学流程仍然可运行。 -- **能进入部署与运维阶段** — 提供有序迁移、一次性管理员引导、多阶段 Docker 镜像、健康探针、持久卷、备份和恢复说明。 - - - -## 完整性定义 - -| 分类 | 状态 | 本仓库的承诺 | -| ------------- | ------------------ | ------------------------------------------------------------------------------------------------------------ | -| 核心教学域 | ✅ Complete | 三角色认证与权限、教师认证、课程审核、预约与课堂、作业提交评分、通知、档案、文件和对话均有前后端实现与持久化 | -| 工程与运维 | ✅ Complete | 可复现安装、自动迁移、演示数据、API 测试、生产构建、CI、Docker Compose、健康检查、管理员引导、备份与恢复文档 | -| SMTP 邮件 | ◇ Conditional | 本地开发可直接取得开发验证码;开放生产注册时必须配置 `SMTP_URL`、`MAIL_FROM` 和稳定的验证码密钥 | -| AI 对话 | ◇ Optional adapter | 可调用符合当前 JSON 契约的外部 AI;超时、失败或未配置时回退到本地确定性教学对话 | -| TURN 中继 | ◇ Conditional | 同网段或可直连环境可使用 WebRTC;严格 NAT、企业网络和可靠生产课堂必须配置 TURN | -| 商业 LMS 能力 | — Out of scope | 支付结算、正式选课与容量扣减、证书颁发方联网核验/OCR、课堂录制、版权工作流、多节点 WebSocket 不在当前范围 | - -价格和容量目前作为课程信息展示,不代表已经实现收费或正式席位占用。在正式选课模型加入前,已发布课程的作业对所有登录学生可见并可提交。SQLite 方案面向单进程、单副本;若要水平扩展,需先迁移持久层与实时房间协调机制。 - -## 三角色工作区 - -### 学生 - -- 注册、登录、恢复会话、维护资料和修改密码。 -- 浏览已发布课程与平台已认证教师的专业档案,创建或取消预约并接收状态通知。 -- 在可加入时间窗内进入课堂,使用聊天、举手和 WebRTC 音视频信令,并可确认完成课堂。 -- 查看已发布作业、保存服务端草稿、提交答案并查看教师评分与反馈。 -- 创建可持久化的中文对话练习;外部 AI 不可用时仍可使用本地教学反馈。 -- 通过通知深链回到对应预约、课堂或作业上下文。 - -### 教师 - -- 维护学校、职称、教龄、专长、语言、教学风格等专业资料。 -- 注册后进入管理员认证队列;已认证教师修改个人或专业资料后会自动回到待审核状态。 -- 创建和编辑课程,提交审核,并根据驳回意见修改后再次提交。 -- 接受或拒绝学生预约;服务端同时检查教师与学生双方的时间冲突。 -- 进入、完成课堂,查看历史消息并参与实时互动。 -- 创建、编辑、发布和关闭作业,查看提交列表并完成评分反馈。 - -### 管理员 - -- 查看待审核课程,批准发布或填写理由驳回。 -- 核验教师提交的机构、职称和证书资料,批准或撤销平台内教师认证并填写审核依据。 -- 查看用户、课程、预约、作业、提交和审核等聚合指标。 -- 查看关键领域操作形成的近期审计活动。 -- 维护自己的会话、密码和通知;首个生产管理员通过一次性命令安全创建。 - -## 四条核心状态闭环 - -```mermaid -flowchart TB - subgraph Verification["教师认证闭环"] - T1["pending · 教师注册或资料变更"] -->|管理员人工核验通过| T2["verified · 可公开展示与预约"] - T2 -->|管理员撤销或教师再次修改资料| T1 - end - - subgraph Course["课程审核闭环"] - C1["draft · 教师创建"] -->|提交审核| C2["pending"] - C2 -->|管理员通过| C3["published · 学生可见"] - C2 -->|管理员驳回并给出理由| C4["rejected"] - C4 -->|教师修改并重提| C2 - end - - subgraph Appointment["预约与课堂闭环"] - A1["pending · 学生发起"] -->|教师接受 + 双方冲突检查| A2["accepted + scheduled classroom"] - A1 -->|教师拒绝| A3["rejected"] - A1 -->|参与者取消| A4["cancelled"] - A2 -->|进入时间窗 + 一次性票据| A5["WebSocket / WebRTC classroom"] - A2 -->|参与者取消| A4 - A5 -->|参与者完成课堂| A6["completed + closed classroom"] - end - - subgraph Homework["作业提交评分闭环"] - H1["draft · 教师编写"] -->|截止时间校验后发布| H2["published"] - H2 -->|学生保存| H3["draft submission"] - H3 -->|截止前提交| H4["submitted"] - H4 -->|教师评分| H5["graded · 分数与反馈"] - H2 -->|教师关闭| H6["closed · 禁止新写入"] - end -``` - -这些转换在服务端校验,并结合事务、唯一约束、通知去重和审计记录防止页面绕过或部分写入。 - -## 应用架构 +## 架构 ```mermaid flowchart LR - Browser["浏览器 · 学生 / 教师 / 管理员"] -->|"同源页面与 /api/v1"| App["Fastify 5 · server/app.js"] - Browser <-->|"/ws/classroom · ticketed WebSocket"| App - - App --> Static["生产静态资源 · dist/"] - App --> Routes["认证、课程、预约、作业、通知、文件、对话"] - App --> Realtime["课堂聊天、在线状态、举手、RTC 信令"] - - Routes --> DB[("SQLite · WAL + ordered migrations")] - Realtime --> DB - Routes --> Files["本地持久上传目录"] - - Routes -. "optional" .-> SMTP["SMTP 邮件"] - Routes -. "optional + local fallback" .-> AI["外部 AI"] - Realtime -. "strict NAT" .-> TURN["TURN 中继"] + Browser["Vue 3 browser client"] -->|"HTTPS page + /api/v1/*"| Vercel["Vercel · SPA + mail relay"] + Vercel -->|"/api/v1/* rewrite"| API["Railway · Fastify"] + Browser -->|"WSS"| API + Browser -->|"10-minute presigned PUT"| R2["Cloudflare R2 · private"] + API --> PG[(Railway PostgreSQL)] + API --> R2 + API -->|"HMAC HTTPS /api/mail-relay"| Vercel + Vercel -->|"SMTP 465"| Gmail["Gmail SMTP"] ``` -开发环境由 Vite 在 `http://localhost:5173` 提供页面,并把 `/api` 和 `/ws` 代理到 `http://127.0.0.1:7777`。生产环境由同一个 Fastify 进程在 `7777` 端口同源托管 `dist/`、`/api/v1`(包括受权限控制的文件内容)和 WebSocket;公网 TLS 应由反向代理终止。 - -## 技术栈 - -| 层级 | 技术 | -| ---------- | ---------------------------------------------------------------------- | -| Web 客户端 | Vue 3.5、Vite 6、Vue Router 4、Pinia 2 | -| UI 与样式 | Element Plus 2、Tailwind CSS 3、Sass | -| API | Fastify 5、Zod 4、Node.js ESM | -| 数据 | SQLite、better-sqlite3、WAL、有序迁移台账 | -| 认证与安全 | scrypt、不透明服务端会话、HttpOnly Cookie、Helmet、Rate Limit | -| 实时课堂 | WebSocket、WebRTC 信令、一次性短期课堂票据 | -| 外部适配器 | Nodemailer / SMTP、HTTP AI provider、TURN | -| 质量与交付 | Node test runner、ESLint 9、Prettier 3、GitHub Actions、Docker Compose | - - +Beta 阶段后端保持单实例,因为实时房间成员状态保存在进程内。Vercel 只把 `/api/v1/*` 代理至 Railway,以维持第一方 Cookie;`/api/mail-relay` 留在 Vercel 执行 Gmail SMTP,课堂 WebSocket 则直接连接 Railway。 -## 快速开始 +## 本地启动 -### 前置要求 - -- 推荐 Node.js 24;`package.json` 的最低版本为 Node.js 22。 -- pnpm `11.9.0`。 -- 如需浏览器 E2E,另需 Python 3.12 与 Playwright Chromium。 - -### 1. 克隆与安装 +要求 Node.js 24、pnpm 11、Docker Desktop / Docker Engine。 ```bash -git clone https://github.com/computersciencefreshmen/International_Chinese_Platform.git -cd International_Chinese_Platform -corepack enable -corepack prepare pnpm@11.9.0 --activate pnpm install --frozen-lockfile -``` - -### 2. 创建本地配置 - -macOS / Linux: - -```bash -cp .env.example .env.local -``` - -Windows PowerShell: - -```powershell -Copy-Item .env.example .env.local -``` - -默认配置即可运行本地完整流程;外部 SMTP、AI 和 TURN 均不是本地演示的前置条件。 - -### 3. 初始化演示数据库 - -```bash -pnpm db:reset -``` - -> [!CAUTION] -> `db:reset` 会删除 `DATABASE_PATH` 指向的 SQLite 文件及 WAL/SHM 文件,再重建演示数据。只应对本地开发数据库使用;生产发布请运行迁移并先完成备份。 - -### 4. 启动 Web 与 API - -```bash -pnpm dev -``` - -- Web: -- API: -- 健康检查: - -### 演示账号 - -| 角色 | 邮箱 | 密码 | 默认入口 | -| ------ | --------------------- | ---------- | ------------------------------ | -| 学生 | `student@example.com` | `Demo123!` | `/student/home` | -| 教师 | `teacher@example.com` | `Demo123!` | `/teacher/home` | -| 管理员 | `admin@example.com` | `Demo123!` | `/administrator/courseDocking` | - -这些账号由本地 seed 创建,不能用于生产。生产镜像固定 `SEED_ON_START=false`。 - -## 环境变量 - -完整的本地模板见 [`.env.example`](./.env.example),生产配置与密钥要求见 [`docs/operations.md`](./docs/operations.md)。 - -| 变量 | 用途 | 默认或边界 | -| ------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------- | -| `VITE_API_BASE_URL` | 浏览器 API 前缀 | `/api/v1`;推荐保持同源 | -| `HOST` / `PORT` | API 监听地址与端口 | `127.0.0.1` / `7777` | -| `DATABASE_PATH` | SQLite 文件 | `.data/platform.db` | -| `UPLOAD_DIR` | 上传文件目录 | `.data/uploads` | -| `UPLOAD_OWNER_QUOTA_BYTES` / `UPLOAD_TOTAL_QUOTA_BYTES` | 用户与平台上传总配额 | 默认 250 MiB / 5 GiB;单实例内原子预留 | -| `UPLOAD_MAX_CONCURRENT` | 全局并发上传槽位 | 默认 `4`,最大 `32` | -| `APP_ORIGIN` | 允许携带 Cookie 写请求的精确来源 | 开发为 `http://localhost:5173`;生产必须是公网 HTTPS origin | -| `SESSION_TTL_HOURS` | 会话有效期 | `12` | -| `VERIFICATION_CODE_SECRET` | 注册验证码 HMAC 密钥 | 生产必须设置高熵、稳定值 | -| `SMTP_URL` / `MAIL_FROM` | 注册验证码邮件 | 生产开放注册时必需 | -| `AI_API_URL` / `AI_API_KEY` | 可选外部对话生成器 | 未配置或失败时使用本地确定性实现 | -| `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` | 可选 WebRTC 中继 | 严格 NAT 和可靠生产课堂需要 | -| `SEED_ON_START` | 启动时写入演示数据 | 开发默认开启;生产 Compose 固定关闭 | -| `SECURE_COOKIES` / `ALLOW_BEARER_AUTH` / `TRUST_PROXY` | Cookie、Bearer 兼容与代理边界 | 生产 Compose 启用 Secure Cookie、关闭 Bearer;代理信任必须按拓扑设置 | -| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_DISPLAY_NAME` | 一次性首个管理员引导 | 只在运行引导命令时临时注入,不应保存在生产环境文件 | - -所有 `VITE_` 变量会进入浏览器构建产物,禁止在其中存放密钥。生产注册必须同时具备 SMTP 和验证码密钥;开发模式在未配置 SMTP 时会返回仅供本机测试的验证码。 - -## 数据库迁移与管理员引导 - -数据库打开时会读取 `schema_migrations` 台账,并在事务中依次应用 [`server/db/database.js`](./server/db/database.js) 中的迁移。也可以显式执行: - -```bash +docker compose up -d postgres minio minio-init pnpm db:migrate +pnpm db:seed +pnpm dev ``` -生产环境不创建演示管理员。首次部署后,临时注入 `ADMIN_EMAIL`、`ADMIN_PASSWORD` 和 `ADMIN_DISPLAY_NAME`,再执行: +- 前端:`http://localhost:5173` +- API:`http://localhost:7777/api/v1` +- MinIO 控制台:`http://localhost:9001` -```bash -pnpm admin:bootstrap -``` +配置模板见 [`.env.example`](./.env.example)。本地和 CI 也使用 PostgreSQL,避免 SQLite/生产差异。 -管理员密码必须至少 12 位,并包含大写字母、小写字母、数字和特殊字符。命令只允许创建首个管理员;检测到已有管理员时会拒绝覆盖。Docker 中的安全注入方式、备份、迁移与恢复流程见[运维手册](./docs/operations.md)。 - -## 测试与质量门禁 +## 常用命令 ```bash -pnpm test:api -pnpm check +pnpm dev # 前端与 API +pnpm build # Vite 生产构建 +pnpm db:migrate # 应用未执行的编号迁移 +pnpm db:seed # 仅本地/测试演示数据 +pnpm admin:bootstrap # 创建首个生产管理员 +pnpm test:api # PostgreSQL API 集成测试 +pnpm check # lint + format + API tests + build +pnpm backup:create # 加密 pg_dump 并上传备份 Bucket +pnpm backup:restore # 受 CONFIRM_RESTORE 保护的恢复 ``` -| 层级 | 当前覆盖 | -| ------------ | ------------------------------------------------------------------------------------------------------------------------ | -| API 集成测试 | **53 / 53 通过**;覆盖认证、教师审核、课程、预约与课堂、作业、迁移、邮件、流式上传与配额、通知、资料、对话和实时房间隔离 | -| 浏览器 E2E | **4 条跨角色流程通过**;真实 UI 登录三个角色,验证教师认证撤销/恢复、课程审核、预约/课堂完成、作业提交/评分 | -| 静态质量 | `pnpm check` 依次运行 ESLint、Prettier、API 测试和生产构建 | -| 持续集成 | 独立 Validate 与 Cross-role browser E2E 任务;Node 24、pnpm 11.9.0、Python 3.12、冻结锁文件和隔离数据库 | - -浏览器测试使用锁定版本的 Python Playwright。先安装测试依赖并构建生产前端: - -```bash -python -m pip install --requirement e2e/requirements.txt -python -m playwright install chromium -pnpm build -``` +生产配置会拒绝 `SEED_ON_START=true`,不会导入本地演示账号或课程。 -在第一个终端启动隔离的生产模式服务。macOS / Linux: +## 文件安全模型 -```bash -mkdir -p .data -rm -f .data/e2e.db .data/e2e.db-shm .data/e2e.db-wal -NODE_ENV=production \ -SEED_ON_START=true \ -SECURE_COOKIES=false \ -APP_ORIGIN=http://localhost:7777 \ -VERIFICATION_CODE_SECRET=e2e-only-verification-secret-32-characters \ -DATABASE_PATH=.data/e2e.db \ -pnpm start -``` +浏览器只能写入 `tmp/uploads/` 的短期 Key,并且签名绑定 Content-Type 与 Content-Length。完成接口流式检查大小、魔数和 SHA-256,并以源 ETag 为条件复制到新的 `files/` Key。正式 Key 从不签发 PUT URL,因此原上传 URL 即使被重复使用也不能覆盖已验证文件。 -Windows PowerShell: +对象删除使用数据库 Outbox 重试;生产 R2 还应为 `tmp/` 配置 1 天生命周期兜底。私有视频和资料下载先经过 API 权限判断,再 302 到 5 分钟签名 URL。 -```powershell -New-Item -ItemType Directory -Force .data | Out-Null -Remove-Item -Force -ErrorAction SilentlyContinue .data/e2e.db, .data/e2e.db-shm, .data/e2e.db-wal -$env:NODE_ENV = 'production' -$env:SEED_ON_START = 'true' -$env:SECURE_COOKIES = 'false' -$env:APP_ORIGIN = 'http://localhost:7777' -$env:VERIFICATION_CODE_SECRET = 'e2e-only-verification-secret-32-characters' -$env:DATABASE_PATH = '.data/e2e.db' -pnpm start -``` +## 健康检查 -在第二个终端执行: +- `GET /api/v1/health`:进程存活 +- `GET /api/v1/ready`:数据库不可用返回 503;R2 不可用返回 `degraded` -```bash -python e2e/test_workflows.py -``` +## 云部署 -脚本默认访问 `http://localhost:7777`;可通过 `E2E_BASE_URL` 改写地址,通过 `E2E_BROWSER_EXECUTABLE` 使用本机 Chrome。失败时截图、trace 和浏览器日志写入 `test-results/e2e/`。CI 会自行构建、安装 Chromium、启动独立 seed 数据库并上传失败产物。 +- 前端:Vercel 项目 `international-chinese-platform` +- 后端:Railway Docker 单实例 +- 数据库:Railway PostgreSQL +- 文件:Cloudflare R2 私有 Bucket +- 邮件:Railway 通过 HMAC HTTPS 调用 Vercel `/api/mail-relay`;Gmail 应用密码只保存在 Vercel +- 备份:独立 Railway Cron 每日 `18:00 UTC` 运行;公开 Beta 暂时复用私有 staging Bucket -API 测试使用临时数据库,不依赖演示数据库。仓库当前不宣称存在公开托管的 Demo;可复现验证入口是本地启动、自动测试和 Docker 部署。 +完整 Secret、R2 CORS/生命周期、管理员初始化、备份、恢复和回滚步骤见[运维手册](./docs/operations.md)。架构决策见 [`docs/adr`](./docs/adr)。 -## Docker 同源部署 +## 验证 -生产镜像由 Node 24 多阶段构建生成,并以非 root 用户运行。准备好受限制的 `.env.production` 后: +CI 使用 PostgreSQL 服务和独立 MinIO,执行源码检查、API 集成测试、生产构建与学生/教师/管理员 Playwright 工作流。文件测试覆盖伪造扩展名、配额竞争、并发完成、签名 URL 重用、ETag 条件复制和越权下载。 -```bash -docker compose --env-file .env.production build --pull -docker compose --env-file .env.production up -d -docker compose --env-file .env.production ps -``` +## 公开 Beta 边界 -Compose 默认只把应用发布到宿主机 `127.0.0.1:7777`,并将数据库和上传分别放入持久卷。反向代理必须让页面、`/api/v1`、上传文件和 `/ws` 共享同一公网 origin;不要把 API 单独暴露到另一个域名。 +- 暂无 TURN;严格 NAT 环境可能无法建立音视频。 +- 暂无多实例房间广播、高可用、支付、正式入学、课堂录像和外部付费 AI。 +- 价格与容量当前是课程信息,不代表支付或正式席位占用。 -上线前请完整阅读[生产部署与运维手册](./docs/operations.md)。其中包含 TLS 代理示例、管理员引导、迁移、冷备份、恢复演练、监控和事故处理,而不把“构建成功”等同于“生产就绪”。 - -## 项目结构 +## 主要目录 ```text -International_Chinese_Platform/ -├── src/ # Vue 3 客户端与三个角色工作区 -│ ├── api/ # 同源 API 调用 -│ ├── components/ # 账户、服务与基础组件 -│ ├── router/ # 会话恢复与角色路由守卫 -│ ├── stores/ # Pinia 状态 -│ └── views/ # 学生、教师、管理员与课堂页面 -├── server/ # Fastify 后端(不是外部依赖) -│ ├── app.js # 应用组合根与全部路由注册 -│ ├── routes/ # 认证及领域 API / WebSocket -│ ├── db/ # Schema、迁移、seed、reset、管理员引导 -│ ├── services/ # SMTP 与 AI 适配器 -│ └── test/ # 53 项 API 集成测试 -├── e2e/ # Python Playwright 跨角色浏览器测试 -├── docs/ -│ ├── adr/ # 架构决策记录 -│ ├── plans/ # 完整化设计与实施计划 -│ └── operations.md # 生产部署、备份与恢复 -├── .github/workflows/ci.yml # 质量门禁与浏览器 E2E -├── Dockerfile # Node 24 多阶段生产镜像 -├── docker-compose.yml # 单实例、双持久卷部署 -├── SECURITY.md # 漏洞报告与安全期望 -└── .env.example # 本地配置模板 -``` - -## API 分组 - -所有 REST 接口使用 `/api/v1` 前缀,并返回统一的 `{ code, msg, data }` 结构。实现以 [`server/app.js`](./server/app.js) 的注册列表为准。 - -| 分组 | 主要路径 | 能力 | -| ------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| System | `/health`, `/ready` | 存活与数据库就绪检查 | -| Auth | `/auth/verification-code`, `/auth/register`, `/auth/login`, `/auth/logout`, `/auth/session` | 验证码、账号与会话生命周期 | -| Profile & Teachers | `/me`, `/me/password`, `/teachers` | 个人资料、教师专业档案与发现 | -| Courses & Admin | `/courses`, `/admin/course-reviews`, `/admin/teacher-verifications`, `/admin/metrics` | 课程 CRUD、课程与教师审核、运营指标 | -| Appointments & Classrooms | `/appointments`, `/classrooms/:id/*` | 预约响应、冲突校验、加入信息、完成课堂 | -| Assignments | `/courses/:id/assignments`, `/assignments`, `/submissions`, `/me/submissions` | 作业生命周期、学生提交与教师评分 | -| Files | `/files`, `/files/:id/content` | 分类上传、内容校验、元数据与删除 | -| Dialogues | `/dialogues`, `/dialogues/:id/messages` | 持久化对话生成与继续练习 | -| Notifications | `/notifications` | 分页、未读状态与全部已读 | -| Realtime | `/classrooms/:id/ticket`, `/classrooms/:id/messages`, `/ws/classroom` | 一次性票据、历史消息、在线状态、聊天与 RTC 信令 | - -## 安全设计 - -- 密码使用参数化 scrypt 哈希;登录路径执行无用户差异的密码校验以降低枚举信号。 -- 浏览器只持有 HttpOnly、SameSite 会话 Cookie;数据库保存不透明令牌的摘要,登出、改密和过期均会撤销会话。 -- Cookie 写请求校验 `Origin`,生产 Compose 默认启用 Secure Cookie 并关闭 Bearer 认证。 -- 领域写操作按角色和资源关系授权,业务状态转换在服务端再次检查;非公开文件的内容、元数据和删除均限 owner/admin。 -- 未认证教师不会进入公开发现且不能被预约;管理员的批准/撤销会通知教师并写审计,教师修改资料会自动回到待审核状态。 -- Zod 校验输入;Helmet 设置安全响应头;Fastify 对请求和敏感接口执行速率限制。 -- 上传采用私有临时文件流式落盘、增量哈希、签名字节校验和同卷原子改名,并执行角色、大小、用户/平台配额与全局并发限制;这不是恶意内容扫描。 -- 验证码以摘要保存、限制尝试次数并一次性消费;邮件发送失败不会创建可用注册流程。 -- 关键审核、预约、作业和管理员引导操作写入审计日志。 - -课堂票据签发、握手、历史读取、每个实时事件以及周期性连接清理都会重新校验会话、账号、课堂状态和参与者关系;权限失效的连接会被关闭。非公开课程视频和材料当前只允许 owner/admin 读取;由于正式选课不在本项目范围内,尚未建立学生的课程资料授权关系。需要分发私有课程资料的部署,应先补充选课或资源授权模型。 - -这些措施不等于第三方安全认证。部署者仍需管理 TLS、密钥、依赖更新、日志、数据保留和备份访问。漏洞请按 [`SECURITY.md`](./SECURITY.md) 私密报告,不要在公开 Issue 中披露细节。 - -## 生产边界 - -| 边界 | 当前设计 | 扩展方向 | -| -------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | -| 数据库 | SQLite WAL,单应用副本 | 多实例前迁移到服务型数据库并重新评估事务与迁移策略 | -| 实时课堂 | 单进程 WebSocket 房间,WebRTC 点对点媒体 | 多节点需要共享 presence/pub-sub、粘性路由和独立媒体基础设施 | -| 网络穿透 | TURN 为可选配置 | 严格 NAT 或企业网络的生产课堂必须提供受监控的 TURN | -| 文件 | 本地持久卷;默认用户 250 MiB、平台 5 GiB、4 个并发上传槽位 | 大规模部署可迁移对象存储并补充扫描、生命周期与 CDN | -| 邮件 | SMTP 适配器 | 生产注册必须配置可交付的邮件服务并监控失败率 | -| AI | 单一 HTTP 适配器 + 本地确定性降级 | 可增加供应商策略、成本控制、内容安全和可观测性 | -| 教学商务 | 平台内管理员教师认证;价格与容量仍是资料字段 | 支付、正式选课及证书颁发方联网核验/OCR 需独立领域设计 | -| 课堂媒体 | 实时音视频与信令 | 录制、转码、回放和内容审核不在当前实现中 | -| 持续授权 | 票据、握手、每个事件及周期清理均复验权限;私有材料限 owner/admin | 多实例时增加共享撤权信号,并补充基于选课关系的学生资料权限 | - - - -## 文档索引 - -- [`README.md`](./README.md) — 中文主文档。 -- [`README.en.md`](./README.en.md) — 等价英文文档。 -- [`docs/operations.md`](./docs/operations.md) — Docker 生产部署、反向代理、迁移、备份、恢复与监控。 -- [`SECURITY.md`](./SECURITY.md) — 漏洞报告流程与部署安全期望。 -- [`docs/adr/0001-self-contained-modular-monolith.md`](./docs/adr/0001-self-contained-modular-monolith.md) — 自包含模块化单体决策。 -- [`docs/adr/0002-server-side-opaque-sessions.md`](./docs/adr/0002-server-side-opaque-sessions.md) — 服务端不透明会话决策。 -- [`docs/plans/2026-07-19-full-stack-completion-design.md`](./docs/plans/2026-07-19-full-stack-completion-design.md) — 完整化设计说明。 -- [`docs/plans/2026-07-19-full-stack-completion.md`](./docs/plans/2026-07-19-full-stack-completion.md) — 实施计划与验收范围。 - -## 唯一主仓库 - -本项目后续代码、Issue、文档与发布只以以下仓库为准: - -> [computersciencefreshmen/International_Chinese_Platform](https://github.com/computersciencefreshmen/International_Chinese_Platform) - -早期历史曾分散在 [`vue3-project-initialization`](https://github.com/computersciencefreshmen/vue3-project-initialization) 和一个名为 `project` 的仓库;后者当前已无法从 GitHub 访问,因此不再保留失效链接。有效历史已经合并到当前仓库;可访问的旧仓库只作为历史参考,不应继续接收功能提交。 - -## 参与开发 - -从最新 `main` 创建聚焦分支,修改功能时同步更新测试和文档。提交前运行: - -```bash -pnpm check -``` - -Pull Request 应在 CI 通过后合并回 `main`,不要让功能长期停留在个人分支。 - -## 许可证 - -本仓库目前**没有声明开源许可证**。在仓库所有者添加明确许可证之前,请不要假定代码已被授权复制、分发、修改或用于商业用途。 - -
-

Built for complete, inspectable international Chinese teaching workflows.

- 返回顶部 -
+src/ Vue 3 前端 +server/routes/ Fastify 领域 API +server/db/migrations/ PostgreSQL 编号迁移 +server/services/ 邮件、对话与对象存储适配器 +server/ops/ 备份与恢复 +server/test/ API/数据库/安全集成测试 +e2e/ 跨角色 Playwright 工作流 +docs/adr/ 架构决策记录 +docs/operations.md 云部署与运维手册 +``` + +安全问题请按 [`SECURITY.md`](./SECURITY.md) 私密报告,不要在公开 Issue 中披露凭据或漏洞细节。 diff --git a/api/mail-relay.js b/api/mail-relay.js new file mode 100644 index 0000000..7487026 --- /dev/null +++ b/api/mail-relay.js @@ -0,0 +1,220 @@ +/* global process */ + +import { Buffer } from 'node:buffer' +import { createHmac, timingSafeEqual } from 'node:crypto' +import { TextDecoder } from 'node:util' + +import { createMailProvider } from '../server/services/mail-provider.js' + +const BODY_LIMIT = 4096 +const CLOCK_SKEW_SECONDS = 5 * 60 +const decoder = new TextDecoder('utf-8', { fatal: true }) + +class RequestError extends Error { + constructor(status, message) { + super(message) + this.status = status + } +} + +function json(status, body, extraHeaders = {}) { + return new globalThis.Response(JSON.stringify(body), { + status, + headers: { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + ...extraHeaders + } + }) +} + +function singleHeader(request, name) { + const value = request.headers.get(name) + return typeof value === 'string' ? value.trim() : '' +} + +async function readBoundedBody(request) { + const declared = singleHeader(request, 'content-length') + if (declared && (!/^\d+$/.test(declared) || Number(declared) > BODY_LIMIT)) { + throw new RequestError(413, 'Request body is too large') + } + + if (!request.body) return Buffer.alloc(0) + const reader = request.body.getReader() + const chunks = [] + let length = 0 + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + length += value.byteLength + if (length > BODY_LIMIT) { + await reader.cancel() + throw new RequestError(413, 'Request body is too large') + } + chunks.push(Buffer.from(value)) + } + } finally { + reader.releaseLock() + } + + return Buffer.concat(chunks, length) +} + +function secureEqualHex(actual, expected) { + if (!/^[a-f0-9]{64}$/i.test(actual)) return false + const actualBytes = Buffer.from(actual, 'hex') + const expectedBytes = Buffer.from(expected, 'hex') + return ( + actualBytes.length === expectedBytes.length && + timingSafeEqual(actualBytes, expectedBytes) + ) +} + +function validateSignature(request, rawBody, secret, now) { + const timestampText = singleHeader(request, 'x-mail-relay-timestamp') + const signatureHeader = singleHeader(request, 'x-mail-relay-signature') + if (!/^\d{10,11}$/.test(timestampText)) return false + + const timestamp = Number(timestampText) + const current = Math.floor(now() / 1000) + if (Math.abs(current - timestamp) > CLOCK_SKEW_SECONDS) return false + + const signature = signatureHeader.startsWith('sha256=') + ? signatureHeader.slice(7) + : '' + const expected = createHmac('sha256', secret) + .update(`${timestampText}.`, 'utf8') + .update(rawBody) + .digest('hex') + return secureEqualHex(signature, expected) +} + +function parsePayload(rawBody) { + let payload + try { + payload = JSON.parse(decoder.decode(rawBody)) + } catch { + throw new RequestError(400, 'Request body must be valid UTF-8 JSON') + } + + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new RequestError(400, 'Request body is invalid') + } + const keys = Object.keys(payload).sort() + if (keys.join(',') !== 'code,email,expiresAt') { + throw new RequestError(400, 'Request body is invalid') + } + if ( + typeof payload.email !== 'string' || + payload.email.length > 254 || + payload.email !== payload.email.trim().toLowerCase() || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.email) || + typeof payload.code !== 'string' || + !/^\d{6}$/.test(payload.code) || + typeof payload.expiresAt !== 'string' || + payload.expiresAt.length > 40 || + !Number.isFinite(Date.parse(payload.expiresAt)) + ) { + throw new RequestError(400, 'Request body is invalid') + } + return payload +} + +function relayConfig(env) { + const secret = String(env.MAIL_RELAY_SECRET ?? '').trim() + if (secret.length < 32) { + throw new Error('MAIL_RELAY_SECRET must contain at least 32 characters') + } + const smtp = Object.fromEntries( + ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS', 'MAIL_FROM'].map((name) => [ + name, + String(env[name] ?? '').trim() + ]) + ) + const missing = Object.entries(smtp) + .filter(([, value]) => !value) + .map(([name]) => name) + if (missing.length > 0) { + throw new Error( + `SMTP relay configuration is missing: ${missing.join(', ')}` + ) + } + + const port = Number(String(env.SMTP_PORT ?? '465').trim()) + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error('SMTP_PORT is invalid') + } + const secureText = String(env.SMTP_SECURE ?? 'true') + .trim() + .toLowerCase() + if (!['true', 'false'].includes(secureText)) { + throw new Error('SMTP_SECURE is invalid') + } + + return { + secret, + smtpHost: smtp.SMTP_HOST, + smtpPort: port, + smtpSecure: secureText === 'true', + smtpUser: smtp.SMTP_USER, + smtpPass: smtp.SMTP_PASS, + mailFrom: smtp.MAIL_FROM + } +} + +export function createMailRelayHandler({ + env = process.env, + now = Date.now, + transportFactory +} = {}) { + let provider + + return async function mailRelay(request) { + if (request.method !== 'POST') { + return json(405, { error: 'Method not allowed' }, { allow: 'POST' }) + } + if ( + singleHeader(request, 'content-type').split(';', 1)[0].toLowerCase() !== + 'application/json' + ) { + return json(415, { error: 'Content-Type must be application/json' }) + } + + let config + try { + config = relayConfig(env) + } catch (error) { + console.error('Mail relay configuration invalid', { + message: error?.message + }) + return json(503, { error: 'Mail relay is not configured' }) + } + + try { + const rawBody = await readBoundedBody(request) + if (!validateSignature(request, rawBody, config.secret, now)) { + return json(401, { error: 'Invalid or expired signature' }) + } + const payload = parsePayload(rawBody) + provider ??= createMailProvider({ ...config, transportFactory }) + await provider.sendVerificationCode(payload) + return json(202, { accepted: true }) + } catch (error) { + if (error instanceof RequestError) { + return json(error.status, { error: error.message }) + } + console.error('Mail relay delivery failed', { + name: error?.name, + code: error?.code, + command: error?.command + }) + return json(502, { error: 'Mail delivery failed' }) + } + } +} + +const handler = createMailRelayHandler() + +export default { fetch: handler } diff --git a/docker-compose.yml b/docker-compose.yml index be45840..b5d0e62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,53 @@ name: international-chinese-platform services: + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_DB: platform + POSTGRES_USER: platform + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-platform} + ports: + - '${POSTGRES_BIND_ADDRESS:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432' + volumes: + - platform-postgres:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U platform -d platform'] + interval: 5s + timeout: 3s + retries: 20 + + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address :9001 + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-platform} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-platform-development-secret} + ports: + - '${MINIO_BIND_ADDRESS:-127.0.0.1}:${MINIO_PORT:-9000}:9000' + - '${MINIO_BIND_ADDRESS:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001' + volumes: + - platform-objects:/data + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_started + entrypoint: >- + /bin/sh -c "until mc alias set local http://minio:9000 + $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do + sleep 1; + done; + mc mb --ignore-existing local/$${S3_BUCKET}" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-platform} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-platform-development-secret} + S3_BUCKET: ${S3_BUCKET:-platform-files} + restart: 'no' + app: image: ${IMAGE_NAME:-international-chinese-platform:local} build: @@ -10,67 +57,70 @@ services: VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api/v1} restart: unless-stopped init: true + depends_on: + postgres: + condition: service_healthy + minio-init: + condition: service_completed_successfully ports: - - "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-7777}:7777" + - '${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-7777}:7777' environment: NODE_ENV: production HOST: 0.0.0.0 PORT: 7777 - DATABASE_PATH: /app/data/platform.db - UPLOAD_DIR: /app/uploads - UPLOAD_OWNER_QUOTA_BYTES: ${UPLOAD_OWNER_QUOTA_BYTES:-262144000} - UPLOAD_TOTAL_QUOTA_BYTES: ${UPLOAD_TOTAL_QUOTA_BYTES:-5368709120} - UPLOAD_MAX_CONCURRENT: ${UPLOAD_MAX_CONCURRENT:-4} + DATABASE_URL: postgresql://platform:${POSTGRES_PASSWORD:-platform}@postgres:5432/platform + DATABASE_SSL: 'false' + DATABASE_POOL_MAX: ${DATABASE_POOL_MAX:-10} DIST_DIR: /app/dist - APP_ORIGIN: "${APP_ORIGIN:?Set APP_ORIGIN to the public HTTPS origin}" + APP_ORIGINS: '${APP_ORIGINS:-http://localhost:7777,http://localhost:5173}' + PUBLIC_WEBSOCKET_ORIGIN: '${PUBLIC_WEBSOCKET_ORIGIN:-http://localhost:7777}' SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12} - VERIFICATION_CODE_SECRET: "${VERIFICATION_CODE_SECRET:?Set a stable random verification secret}" - SECURE_COOKIES: ${SECURE_COOKIES:-true} + VERIFICATION_CODE_SECRET: '${VERIFICATION_CODE_SECRET:-local-compose-verification-secret-32-characters}' + SECURE_COOKIES: ${SECURE_COOKIES:-false} ALLOW_BEARER_AUTH: ${ALLOW_BEARER_AUTH:-false} - TRUST_PROXY: ${TRUST_PROXY:-true} + TRUST_PROXY: ${TRUST_PROXY:-1} LOGGER: ${LOGGER:-true} - SEED_ON_START: "false" - BODY_LIMIT_BYTES: ${BODY_LIMIT_BYTES:-1048576} - RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-300} - RATE_LIMIT_WINDOW: ${RATE_LIMIT_WINDOW:-1 minute} + SEED_ON_START: ${SEED_ON_START:-false} + UPLOAD_OWNER_QUOTA_BYTES: ${UPLOAD_OWNER_QUOTA_BYTES:-262144000} + UPLOAD_TOTAL_QUOTA_BYTES: ${UPLOAD_TOTAL_QUOTA_BYTES:-5368709120} + UPLOAD_MAX_CONCURRENT: ${UPLOAD_MAX_CONCURRENT:-4} + S3_ENDPOINT: http://minio:9000 + S3_REGION: us-east-1 + S3_BUCKET: ${S3_BUCKET:-platform-files} + S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-platform} + S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:-platform-development-secret} + S3_FORCE_PATH_STYLE: 'true' SMTP_URL: ${SMTP_URL:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_SECURE: ${SMTP_SECURE:-true} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} MAIL_FROM: ${MAIL_FROM:-} AI_API_URL: ${AI_API_URL:-} AI_API_KEY: ${AI_API_KEY:-} TURN_URL: ${TURN_URL:-} TURN_USERNAME: ${TURN_USERNAME:-} TURN_CREDENTIAL: ${TURN_CREDENTIAL:-} - volumes: - - platform-database:/app/data - - platform-uploads:/app/uploads read_only: true tmpfs: - /tmp:rw,noexec,nosuid,nodev,size=64m - cap_drop: - - ALL - security_opt: - - no-new-privileges:true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] healthcheck: test: - - CMD - - node - - -e - - >- - fetch('http://127.0.0.1:7777/api/v1/ready').then((response) => { - if (!response.ok) process.exit(1) }).catch(() => process.exit(1)) + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:7777/api/v1/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + ] interval: 30s timeout: 5s start_period: 20s retries: 3 stop_grace_period: 20s - logging: - driver: json-file - options: - max-size: 10m - max-file: "3" volumes: - platform-database: - name: ${DATABASE_VOLUME_NAME:-international-chinese-platform-database} - platform-uploads: - name: ${UPLOAD_VOLUME_NAME:-international-chinese-platform-uploads} + platform-postgres: + platform-objects: diff --git a/docs/adr/0003-postgresql-production-database.md b/docs/adr/0003-postgresql-production-database.md new file mode 100644 index 0000000..1482122 --- /dev/null +++ b/docs/adr/0003-postgresql-production-database.md @@ -0,0 +1,12 @@ +# ADR 0003: PostgreSQL as the production database + +- Status: Accepted +- Date: 2026-07-20 + +## Decision + +Use Railway PostgreSQL through `pg` with an async connection pool, numbered SQL migrations, native UUID/timestamptz/citext/jsonb types, and explicit transactions. Local development and CI use PostgreSQL 15 as well. + +## Consequences + +The API remains a modular monolith, but every database operation is asynchronous. State-changing workflows use conditional updates, transactions, and advisory/row locks where concurrency matters. SQLite files and SQLite-only migrations are no longer production inputs. diff --git a/docs/adr/0004-r2-object-storage.md b/docs/adr/0004-r2-object-storage.md new file mode 100644 index 0000000..9b72f6c --- /dev/null +++ b/docs/adr/0004-r2-object-storage.md @@ -0,0 +1,12 @@ +# ADR 0004: Cloudflare R2 for durable file storage + +- Status: Accepted +- Date: 2026-07-20 + +## Decision + +Store uploads in a private Cloudflare R2 bucket through the S3 API. Browsers upload to temporary object keys through ten-minute presigned PUT URLs whose content type and content length are signed. The API reserves quota in PostgreSQL, claims each completion once, validates the temporary object by size, SHA-256 and magic bytes, then conditionally copies the validated ETag to a new immutable final key. It returns five-minute signed download redirects only after authorization. + +## Consequences + +Large files do not traverse Vercel or Railway request bodies. Reusing an unexpired upload URL can only replace the temporary object and cannot mutate an already-promoted file. Pending and interrupted validation intents expire and release reserved quota. Temporary and deleted objects are removed through a retryable PostgreSQL cleanup outbox, with an R2 lifecycle rule on the temporary prefix as a final safety net. Development uses MinIO with the same S3 interface; API tests use an in-memory adapter for deterministic failure cases. diff --git a/docs/adr/0005-vercel-railway-split-deployment.md b/docs/adr/0005-vercel-railway-split-deployment.md new file mode 100644 index 0000000..787c492 --- /dev/null +++ b/docs/adr/0005-vercel-railway-split-deployment.md @@ -0,0 +1,12 @@ +# ADR 0005: Split Vercel frontend and Railway backend + +- Status: Accepted +- Date: 2026-07-20 + +## Decision + +Serve the Vue SPA from Vercel and the single-instance Fastify API from Railway. Vercel externally rewrites `/api/:path*` to Railway, while classroom WebSockets connect directly to the Railway WSS origin returned in the one-time ticket response. + +## Consequences + +HTTP sessions remain first-party HttpOnly cookies. The backend stays at one instance during Beta because classroom presence is in memory. TURN, Redis fan-out and multi-instance high availability are deferred. diff --git a/docs/adr/0006-vercel-hmac-mail-relay.md b/docs/adr/0006-vercel-hmac-mail-relay.md new file mode 100644 index 0000000..a9eadec --- /dev/null +++ b/docs/adr/0006-vercel-hmac-mail-relay.md @@ -0,0 +1,24 @@ +# ADR 0006: Relay production email through Vercel over HMAC HTTPS + +- Status: Accepted +- Date: 2026-07-23 + +## Context + +The Railway Hobby plan does not provide outbound SMTP, so the Fastify service cannot connect directly to Gmail even with valid application credentials. Registration still requires real verification email, and the Gmail password must not be exposed to the browser or committed to the repository. + +## Decision + +Keep Gmail SMTP variables only in the Vercel server environment. Railway sends the minimal `email`, `code`, and `expiresAt` payload to the Vercel `/api/mail-relay` function over HTTPS. + +Each request includes a Unix timestamp and an HMAC-SHA-256 signature over `timestamp + "." + raw JSON`, using a random shared Secret stored in Railway and Vercel. The function uses constant-time signature comparison, accepts at most five minutes of clock skew, validates an exact payload schema, and then sends through Gmail SMTP. + +Vercel rewrites only `/api/v1/:path*` to Railway. The narrower boundary leaves `/api/mail-relay` executing locally on Vercel. + +## Consequences + +- Real registration email works without upgrading Railway solely for SMTP. +- Gmail credentials have a smaller blast radius because they never enter Railway or client-side variables. +- The relay is not a general public mail endpoint: unsigned, stale, malformed, or extra-field requests are rejected. +- Mail delivery gains an additional HTTPS hop and depends on both Railway and Vercel availability. +- Secret rotation must update both platforms together, and monitoring must distinguish relay authentication failures from Gmail delivery failures. diff --git a/docs/operations.md b/docs/operations.md index f478098..6425a37 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,218 +1,170 @@ -# 生产部署与运维手册 +# 公开 Beta 部署与运维手册 -本手册面向单机 Docker Compose 生产部署。平台是一个 Vue 3 + Fastify + SQLite 的模块化单体:同一进程提供编译后的前端、`/api/v1` API(包括受权限控制的文件内容)和 `/ws` 实时连接。 - -> SQLite 部署应始终保持一个应用副本。不要让多个容器通过网络文件系统共享同一数据库。如果需要水平扩展,应先将持久层迁移到适合多实例的数据库。 - -## 1. 生产拓扑与持久数据 - -Compose 默认只将 `127.0.0.1:7777` 暴露给宿主机,应由 Nginx、Caddy 或云负载均衡器终止 TLS: +本文档对应当前线上架构:Vercel 托管 Vue 前端和邮件中继函数,Railway 运行单实例 Fastify 与独立备份 Cron 服务,Railway PostgreSQL 保存业务数据,Cloudflare R2 私有 Bucket 保存文件与加密备份。 ```text -Internet -> HTTPS reverse proxy -> 127.0.0.1:7777 -> Fastify - |-- /app/data/platform.db - `-- /app/uploads -``` +Browser ── HTTPS ──> Vercel ── /api/v1/* rewrite ──> Railway Fastify ──> PostgreSQL + │ │ + ├── presigned PUT ───────────────────────────────────┴────────> private R2 + └── WSS /ws/classroom ───────────────────────────────────────> Railway -两个命名卷分别保存数据库与用户上传文件: +Railway Fastify ── HMAC HTTPS ──> Vercel /api/mail-relay ── SMTP 465 ──> Gmail +Railway Backup Cron ── pg_dump + AES-256-GCM ───────────────────────────> private R2 +``` -| Compose 卷(默认物理名) | 容器目录 | 内容 | -| ---------------------------------------------------------------- | -------------- | -------------------------------- | -| `platform-database`(`international-chinese-platform-database`) | `/app/data` | SQLite 主文件及 WAL/SHM 辅助文件 | -| `platform-uploads`(`international-chinese-platform-uploads`) | `/app/uploads` | 头像、作业和课程附件 | +Beta 阶段只运行一个后端实例。实时房间状态保存在进程内;增加实例前必须引入跨实例广播与房间协调。 -镜像为多阶段 Node 24 构建,运行阶段不包含编译工具,以 UID/GID `10001` 的非 root 用户运行。Compose 另外启用只读根文件系统、临时 `/tmp`、全 capability 丢弃和 `no-new-privileges`。 +## 1. 生产变量 -## 2. 准备生产配置 +### Railway API -需要 Docker Engine 和 Docker Compose v2。先在部署机上创建不纳入 Git 的 `.env.production`,限制为仅部署用户可读: +Railway 后端保存数据库、对象存储和邮件中继配置,但不保存 Gmail 应用密码: ```dotenv -APP_ORIGIN=https://chinese.example.com -APP_PORT=7777 -BIND_ADDRESS=127.0.0.1 - -# 可选:稳定的 Docker 卷名,也可用于恢复时安全切换到新卷 -DATABASE_VOLUME_NAME=international-chinese-platform-database -UPLOAD_VOLUME_NAME=international-chinese-platform-uploads - -# 默认用户 250 MiB、平台 5 GiB、最多 4 个并发上传;按容量规划调整 -UPLOAD_OWNER_QUOTA_BYTES=262144000 -UPLOAD_TOTAL_QUOTA_BYTES=5368709120 -UPLOAD_MAX_CONCURRENT=4 - -# 至少 32 个 ASCII 字符的稳定随机值;不要在每次发布时更换 -VERIFICATION_CODE_SECRET=replace-with-at-least-32-random-characters - -SESSION_TTL_HOURS=12 +NODE_ENV=production +HOST=0.0.0.0 +DATABASE_URL= +DATABASE_SSL=true +DATABASE_POOL_MAX=10 +APP_ORIGINS=https://international-chinese-platform.vercel.app +PUBLIC_WEBSOCKET_ORIGIN=wss:// SECURE_COOKIES=true -TRUST_PROXY=true ALLOW_BEARER_AUTH=false - -# 生产注册验证邮件必需 -SMTP_URL=smtps://username:percent-encoded-password@smtp.example.com:465 -MAIL_FROM=International Chinese Platform - -# 可选外部能力 -AI_API_URL= -AI_API_KEY= -TURN_URL= -TURN_USERNAME= -TURN_CREDENTIAL= +TRUST_PROXY=1 +SEED_ON_START=false +VERIFICATION_CODE_SECRET= +MAIL_RELAY_URL=https://international-chinese-platform.vercel.app/api/mail-relay +MAIL_RELAY_SECRET= + +S3_ENDPOINT=https://.r2.cloudflarestorage.com +S3_REGION=auto +S3_BUCKET= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_FORCE_PATH_STYLE=false ``` -生成验证码密钥的一种方式是 `openssl rand -hex 32`。请使用密钥管理器或受限制的环境文件;不要把真实值写入镜像、Compose 文件、CI 日志或 shell 历史。注意 Docker 环境变量对具有容器检查权限的运维人员可见。 - -`SEED_ON_START=false` 在生产 Compose 中是固定值:生产容器不会创建演示账号或演示数据。Compose 也不声明任何 `ADMIN_*` 变量。 - -## 3. 构建、启动与验证 +`MAIL_RELAY_URL` 必须是无用户名、密码、查询参数和片段的 HTTPS URL。公开域名下 `TRUST_PROXY` 必须是明确跳数;不要配置成 `true`。 -```bash -docker compose --env-file .env.production build --pull -docker compose --env-file .env.production up -d -docker compose --env-file .env.production ps -docker compose --env-file .env.production logs --tail=100 app -``` +### Vercel 邮件中继 -首次启动会创建 SQLite 文件并自动应用未执行的有序迁移。两个探针的意义不同: +Railway Hobby 不提供 SMTP 出站能力,因此生产邮件由 Railway 通过 HTTPS 调用 Vercel Function。以下变量只配置在 Vercel 的目标环境: -- `GET /api/v1/health`:进程存活。 -- `GET /api/v1/ready`:应用就绪且数据库可读。Docker 健康检查使用此接口。 - -```bash -curl --fail https://chinese.example.com/api/v1/health -curl --fail https://chinese.example.com/api/v1/ready +```dotenv +MAIL_RELAY_SECRET= +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER=yanghanyu2023@gmail.com +SMTP_PASS= +MAIL_FROM=International Chinese Platform ``` -如果使用宿主目录替代命名卷,请先确保目录属于 `10001:10001` 且不对其他用户开放写权限,否则数据库或上传会因权限错误失败。 - -## 4. 反向代理与同源要求 - -浏览器页面、API、上传文件与 WebSocket 必须对外使用完全相同的 scheme、host 和 port。`APP_ORIGIN` 必须精确等于这个公网 origin,不要带路径或末尾斜杠。一个 Nginx 示例如下: +中继对 `timestamp + "." + raw JSON` 计算 HMAC-SHA-256,并只接受 5 分钟时间窗内、字段严格为 `email`、`code`、`expiresAt` 的请求。Railway 和 Vercel 必须共享同一个随机中继 Secret;Gmail 应用密码只存在于 Vercel,且变量不得使用 `VITE_` 前缀。 -```nginx -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} +真实 Secret 只录入 Railway/Cloudflare/Vercel,不写入 Git、聊天、构建参数或日志。 -server { - listen 443 ssl http2; - server_name chinese.example.com; +## 2. R2 配置 - # ssl_certificate /etc/letsencrypt/live/chinese.example.com/fullchain.pem; - # ssl_certificate_key /etc/letsencrypt/live/chinese.example.com/privkey.pem; +Bucket 必须保持私有。Token 只授予目标 Bucket 的对象读写权限。浏览器 CORS 仅允许正式 Vercel 域名和明确的 staging 域名: - client_max_body_size 50m; - - location / { - proxy_pass http://127.0.0.1:7777; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_read_timeout 3600s; - } -} +```json +[ + { + "AllowedOrigins": ["https://international-chinese-platform.vercel.app"], + "AllowedMethods": ["PUT"], + "AllowedHeaders": ["Content-Type"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } +] ``` -不要单独将 API 发布到另一个 origin。同源部署才能与默认的 HttpOnly/SameSite/Secure 会话 Cookie、来源检查和 WebSocket 路由保持一致。只有反向代理是受信边界时才设置 `TRUST_PROXY=true`,并确保应用端口不可被公网绕过。 +为 `tmp/` 前缀设置 1 天生命周期删除规则,作为数据库清理 Outbox 之外的兜底。上传只进入 `tmp/uploads/`;服务端完成大小、魔数和 SHA-256 校验后,按 ETag 条件复制到新的 `files/` Key。正式 Key 从不签发 PUT URL。 -## 5. 首个管理员引导 +## 3. 发布流程 -首次部署就绪后,通过一次性容器执行引导。密码必须至少 12 位,并同时包含大写字母、小写字母、数字和特殊字符。先在当前 shell 中安全注入下列三个变量: +1. 合并前运行 `pnpm check` 和跨角色 Playwright E2E。 +2. 先在 Vercel 配置邮件中继和 SMTP 变量并部署 `/api/mail-relay`;无签名请求应返回 401。 +3. Railway 构建 `Dockerfile`;`railway.json` 的 pre-deploy 命令先运行 `node server/db/migrate.js`。 +4. `/api/v1/ready` 返回 200 后,让 Vercel 只转发版本化业务 API: -```text -ADMIN_EMAIL -ADMIN_PASSWORD -ADMIN_DISPLAY_NAME -``` - -然后执行: + ```json + { + "source": "/api/v1/:path*", + "destination": "https:///api/v1/:path*" + } + ``` -```bash -docker compose --env-file .env.production run --rm \ - -e ADMIN_EMAIL -e ADMIN_PASSWORD -e ADMIN_DISPLAY_NAME \ - app node server/db/bootstrap-admin.js -``` +5. 将 SPA fallback 放在 rewrite 列表最后。不要转发整个 `/api/*`,否则 `/api/mail-relay` 会被误送到 Railway。 +6. 在 Railway 配置生产中继 URL 和共享 Secret;触发真实邮箱验证码,确认 API 不返回开发验证码。 +7. 刷新任意 SPA 路由,确认返回页面;请求不存在的 `/api/v1/*`,确认返回 JSON 404 而不是 `index.html`。 +8. 完成教师申请、管理员审核、课程、预约、课堂、作业全链路冒烟。 -不要把密码直接写在命令行或 `.env.production` 中。成功后立即从 shell 清除三个变量。引导程序只允许创建第一个管理员;如果已存在管理员,它会拒绝覆盖。 +生产环境会拒绝 `SEED_ON_START=true`。演示数据只能在本地或测试环境显式执行 `pnpm db:seed`。 -## 6. 数据库迁移与发布 +## 4. 管理员初始化 -应用在打开数据库时检查 `schema_migrations` 台账,并在事务中按顺序应用新迁移。也可以在维护窗口显式执行: +空库迁移后,在 Railway 一次性任务中设置 `ADMIN_EMAIL`、`ADMIN_PASSWORD`、`ADMIN_DISPLAY_NAME`,执行: ```bash -docker compose --env-file .env.production stop app -docker compose --env-file .env.production run --rm app node server/db/migrate.js -docker compose --env-file .env.production up -d +node server/db/bootstrap-admin.js ``` -推荐发布流程: +命令只创建首个管理员,密码以 scrypt 保存,并标记为必须首次改密。成功后立刻删除 `ADMIN_PASSWORD` Secret。管理员登录后只能查看会话、退出或修改密码;其他 API 在改密前返回 `PASSWORD_RESET_REQUIRED`。 -1. 验证新镜像来源和依赖审计结果。 -2. 创建数据库与上传文件的一致备份。 -3. 在维护窗口停止应用并执行迁移。 -4. 启动新镜像,确认 `/ready`、登录、文件访问和 WebSocket 连接。 -5. 保留旧镜像与发布前备份,直到观察窗口结束。 +## 5. 探针与监控 -当前迁移是向前迁移,不提供自动 down migration。需要回滚数据结构时,请还原发布前备份,而不是手工删改生产表。 +- `/api/v1/health`:进程存活。 +- `/api/v1/ready`:PostgreSQL 不可用时返回 503;R2 不可用时返回 200 且状态为 `degraded`。 +- 监控 5xx、429、进程重启、数据库连接池耗尽、对象清理任务重试,以及 Railway 到中继和 Vercel 到 Gmail 的两段邮件失败。 +- 中继 401 通常表示 Secret、签名或时钟问题;502 表示 Gmail 投递失败;503 表示 Vercel 中继环境变量不完整。 +- Railway 与 Vercel 日志不得输出 Cookie、验证码、中继 Secret、SMTP 密码、数据库 URL 或 R2 Secret。 +- 监控独立备份服务的 Cron 执行状态,并定期确认最新加密对象已写入目标 Bucket。 -## 7. 备份 +## 6. 备份与恢复 -备份必须同时包含 `/app/data` 和 `/app/uploads`。只备份 SQLite 会丢失附件;只备份上传目录会丢失文件索引和权限关系。 +独立 Railway 服务使用同一运行时镜像,并由 [`railway.backup.json`](../railway.backup.json) 在每天 `18:00 UTC`(北京时间次日 `02:00`)执行 `node server/ops/backup.js`。它使用 PostgreSQL 18 客户端的 `pg_dump --format=custom`,再以 AES-256-GCM 加密并上传私有 Bucket。较新的 `pg_dump` 也兼容本项目本地 PostgreSQL 15 环境。备份服务需要: -最简单可靠的方式是在短维护窗口执行冷备份: - -1. 确认磁盘空间,创建带 UTC 时间戳的宿主备份目录。 -2. 执行 `docker compose --env-file .env.production stop app`,等待优雅停机完成。 -3. 用 `docker compose --env-file .env.production cp app:/app/data/. /database` 复制完整数据目录。 -4. 用 `docker compose --env-file .env.production cp app:/app/uploads/. /uploads` 复制完整上传目录。 -5. 执行 `docker compose --env-file .env.production start app`,并确认 `/ready`。 -6. 压缩备份,生成 SHA-256 校验和,使用独立密钥加密,然后复制到另一存储位置。 - -不要在应用写入期间用普通文件复制直接抓取 `platform.db`;WAL 模式下这样的副本可能不一致。如果业务无法接受短暂停机,请引入经验证的 SQLite 在线备份方案,并定期做恢复演练。 - -建议至少保留每日 7 份、每周 4 份和每月 6 份,实际策略应按数据保留法规和可接受的 RPO/RTO 确定。 - -## 8. 恢复演练 +```dotenv +DATABASE_URL= +DATABASE_SSL=true +BACKUP_ENCRYPTION_KEY= +S3_ENDPOINT= +S3_REGION=auto +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_BACKUP_BUCKET=international-chinese-platform-staging +``` -先在隔离环境演练,再恢复生产。恢复前必须再做一份当前数据的应急备份。 +当前公开 Beta 暂时复用 `international-chinese-platform-staging` 作为加密备份 Bucket,生产应用文件仍写入 production Bucket。它不是长期隔离方案:启用真实 staging 文件上传前,应创建专用私有备份 Bucket、迁移已加密对象并更新 `S3_BACKUP_BUCKET`。 -1. 校验备份的 SHA-256、解密并确认包含 `database/platform.db` 和 `uploads/`。 -2. 停止应用并做当前数据的应急备份,然后执行 `docker compose --env-file .env.production down`。不要附加 `--volumes`;原卷将作为回退点保留。 -3. 复制一份 `.env.production` 为受限制的 `.env.restore`,只将 `DATABASE_VOLUME_NAME` 和 `UPLOAD_VOLUME_NAME` 改为新的、带恢复时间戳的名称。 -4. 执行 `docker compose --env-file .env.restore create app`,让 Docker 创建干净卷和已停止的容器。 -5. 用 `docker compose --env-file .env.restore cp /database/. app:/app/data` 和 `docker compose --env-file .env.restore cp /uploads/. app:/app/uploads` 导入备份。 -6. 如果复制改变了所有者,执行一次性权限修复:`docker compose --env-file .env.restore run --rm --user 0 --cap-add CHOWN --cap-add DAC_OVERRIDE --entrypoint chown app -R 10001:10001 /app/data /app/uploads`。 -7. 执行 `docker compose --env-file .env.restore run --rm app node server/db/migrate.js`,再执行 `docker compose --env-file .env.restore up -d`。 -8. 检查 `/ready`,并用最小冒烟测试验证用户数、课程、预约、作业、附件下载与实时教室。确认恢复点可用后,再将受管理的生产配置切换到这两个新卷。 +保留 7 个日备份和 4 个周备份。每次 Cron 后检查任务成功和新对象时间;每月在临时 PostgreSQL 实例执行恢复演练。 -复制回已有卷会改变生产数据。每次操作前都要记录完整卷名、备份标识和操作人,并采用双人复核。 +恢复必须先在隔离环境验证。确认目标数据库允许被覆盖后设置 `BACKUP_FILE` 与 `CONFIRM_RESTORE=RESTORE`,运行 `node server/ops/restore.js`。目标 RPO 为 24 小时,RTO 为 4 小时。 -## 9. 外部适配器与密钥 +## 7. 回滚 -| 变量 | 用途 | 运维要求 | -| --------------------------- | -------------- | ------------------------------------------------------- | -| `VERIFICATION_CODE_SECRET` | 验证码 HMAC | 高熵、稳定保存;轮换会使待使用验证码失效 | -| `SMTP_URL` / `MAIL_FROM` | 注册邮件 | 生产注册必需;URL 内密码应正确百分号编码 | -| `AI_API_URL` / `AI_API_KEY` | 服务端 AI 对话 | 只允许 HTTPS 且可信的供应商;确认数据保留条款 | -| `TURN_URL` / 用户名 / 凭据 | WebRTC 中继 | TURN 凭据会发给授权教室客户端,应限权、轮换并监控流量 | -| `UPLOAD_*` 配额与并发 | 上传资源边界 | 按持久卷容量设置平台/用户上限;监控 503、507 与剩余空间 | +- 前端恢复上一版 Vercel Deployment。 +- 后端恢复上一版 Railway Image。 +- 首发迁移只做扩展型变更;回滚应用时保留新增表和字段,不立即执行破坏性 down migration。 +- 如数据损坏,先冻结写入、保存当前应急备份,再按最近一次已验证备份恢复。 -外部 AI 未配置时,核心教学工作流仍可运行;TURN 未配置时,直连 WebRTC 可能在严格 NAT 或企业网络中失败。SMTP 未配置时,生产环境不会回传开发验证码,新用户注册将不可用。 +## 8. 本地一致环境 -## 10. 监控与事故处理 +```bash +docker compose up -d postgres minio minio-init +pnpm db:migrate +pnpm db:seed +pnpm dev +``` -至少监控以下信号: +本地 PostgreSQL 在 `127.0.0.1:5432`,MinIO API 在 `127.0.0.1:9000`,控制台在 `127.0.0.1:9001`。不要把 Compose 默认凭据用于公网。 -- `/ready` 成功率和延迟; -- 容器重启次数、CPU、内存和文件句柄; -- 数据卷剩余空间、SQLite 文件与 WAL 增长; -- HTTP 5xx/429、邮件发送失败、AI 适配器失败和 WebSocket 异常断开; -- 备份完成时间、大小、校验和与恢复演练结果。 +## 9. 已知 Beta 限制 -Compose 已将容器 JSON 日志轮转限制为 3 个、每个 10 MB。数据卷不在该限制内,需要单独监控。故障时先保全日志和数据副本,再执行修复;涉及漏洞或凭据泄露时,按根目录 [SECURITY.md](../SECURITY.md) 进行私密报告和协调披露。 +- 单 Railway 后端实例,没有 Redis 房间广播。 +- 没有 TURN;严格 NAT 下音视频可能失败。 +- 没有支付、正式入学、课程录像或外部付费 AI。 +- 多实例和高可用不在本轮范围内。 +- Railway Hobby 不能直接发送 SMTP;注册邮件依赖 Vercel HTTPS 中继及 Gmail 可用性。 diff --git a/docs/plans/2026-07-20-cloud-production-deployment.md b/docs/plans/2026-07-20-cloud-production-deployment.md new file mode 100644 index 0000000..62ac894 --- /dev/null +++ b/docs/plans/2026-07-20-cloud-production-deployment.md @@ -0,0 +1,81 @@ +# Public Beta cloud deployment runbook + +## Services + +- Vercel project: `international-chinese-platform` +- Railway: one Dockerfile-backed Fastify API, one PostgreSQL service, and one independent backup Cron service +- Cloudflare R2: separate private `staging` and `production` buckets +- Gmail SMTP: `yanghanyu2023@gmail.com` from a Vercel Function; Railway calls it over HMAC-authenticated HTTPS + +## Railway production variables + +Set `NODE_ENV=production`, `HOST=0.0.0.0`, `DATABASE_URL` from the PostgreSQL reference, `DATABASE_SSL=true`, `DATABASE_POOL_MAX=10`, `APP_ORIGINS=https://international-chinese-platform.vercel.app`, `PUBLIC_WEBSOCKET_ORIGIN=wss://`, `SECURE_COOKIES=true`, `ALLOW_BEARER_AUTH=false`, `TRUST_PROXY=1`, `SEED_ON_START=false`, and a random `VERIFICATION_CODE_SECRET` of at least 32 characters. + +Set R2 variables `S3_ENDPOINT`, `S3_REGION=auto`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_FORCE_PATH_STYLE=false`. Set `MAIL_RELAY_URL=https://international-chinese-platform.vercel.app/api/mail-relay` and a random `MAIL_RELAY_SECRET` of at least 32 characters. Do not put Gmail SMTP credentials in Railway. + +## Vercel mail relay variables + +Set the same `MAIL_RELAY_SECRET` in Vercel, plus `SMTP_HOST=smtp.gmail.com`, `SMTP_PORT=465`, `SMTP_SECURE=true`, `SMTP_USER=yanghanyu2023@gmail.com`, `SMTP_PASS=`, and `MAIL_FROM=International Chinese Platform `. + +Railway Hobby blocks SMTP egress, so the API signs the exact JSON request body and a Unix timestamp with HMAC-SHA-256 before calling `/api/mail-relay` over HTTPS. The function rejects signatures outside a five-minute window and accepts only `email`, `code`, and `expiresAt`. Gmail credentials remain only in Vercel. + +Secrets must be entered directly in their owning platform. Never commit or paste Gmail, relay, database, R2, backup encryption, or administrator passwords into Git. + +## R2 browser CORS + +Apply this policy to each application bucket, replacing the staging origin with its exact assigned domain: + +```json +[ + { + "AllowedOrigins": [ + "https://international-chinese-platform.vercel.app", + "https://" + ], + "AllowedMethods": ["PUT"], + "AllowedHeaders": ["Content-Type"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } +] +``` + +Add a one-day lifecycle expiration rule for the `tmp/` prefix. The application normally removes temporary objects through its retryable cleanup queue; the lifecycle rule is the final orphan-object safety net. + +## Release order + +1. Create Railway staging PostgreSQL and API services, then attach the repository. +2. Create the staging R2 bucket and minimum-scope object read/write token. +3. Configure the Vercel relay variables and deploy `/api/mail-relay`; verify an unsigned request is rejected. +4. Deploy Railway; `railway.json` runs `node server/db/migrate.js` before startup. +5. Verify `/api/v1/health`, `/api/v1/ready`, upload and WebSocket ticket creation. +6. Put the exact Railway HTTPS origin in the first Vercel rewrite, before the SPA fallback: + + ```json + { + "source": "/api/v1/:path*", + "destination": "https:///api/v1/:path*" + } + ``` + +7. Keep `/api/mail-relay` on Vercel by never rewriting the broader `/api/*` prefix. Deploy and validate a Vercel preview, then repeat for production. +8. Configure Railway's production relay URL and shared Secret, then verify a real registration email without a development code in the response. +9. Bootstrap the only production administrator with `ADMIN_EMAIL=yanghanyu2023@gmail.com`, a generated temporary password, and `node server/db/bootstrap-admin.js`. The account is forced to change that password after login. + +## Backup and recovery + +The independent Railway service uses [`railway.backup.json`](../../railway.backup.json) to run `node server/ops/backup.js` every day at `18:00 UTC` (`02:00` the next day in Asia/Shanghai). Generate `BACKUP_ENCRYPTION_KEY` as 32 random bytes encoded in base64. The job retains seven daily and four Sunday weekly AES-256-GCM encrypted custom-format dumps. + +The current public Beta temporarily sets `S3_BACKUP_BUCKET=international-chinese-platform-staging`. Production application objects remain in the production Bucket. Before using staging for application uploads, create a dedicated private backup Bucket, migrate the encrypted backup objects, and update the Cron service. + +For the monthly restore drill, download a backup to an isolated worker, point `DATABASE_URL` at a disposable PostgreSQL database, and run: + +```bash +CONFIRM_RESTORE=RESTORE BACKUP_FILE=/tmp/database.dump.enc node server/ops/restore.js +``` + +Never run a restore drill against production. Beta targets are RPO 24 hours and RTO 4 hours. + +## Rollback + +The first release uses expand-only migrations. Roll back with the previous Vercel deployment and Railway image; keep added database columns/tables in place until a later reviewed cleanup migration. diff --git a/package.json b/package.json index f4e7844..da497d1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "db:seed": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/db/seed.js", "db:reset": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/db/reset.js", "admin:bootstrap": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/db/bootstrap-admin.js", + "backup:create": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/ops/backup.js", + "backup:restore": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/ops/restore.js", "test": "pnpm test:api", "test:api": "node --test \"server/test/*.test.js\"", "lint": "eslint . --fix", @@ -33,6 +35,8 @@ "check": "pnpm lint:check && pnpm format:check && pnpm test:api && pnpm build" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1090.0", + "@aws-sdk/s3-request-presigner": "^3.1090.0", "@element-plus/icons-vue": "^2.3.1", "@fastify/cookie": "^11.1.2", "@fastify/helmet": "^13.1.0", @@ -41,10 +45,10 @@ "@fastify/static": "^10.1.0", "@fastify/websocket": "^11.3.0", "axios": "^1.8.4", - "better-sqlite3": "^12.11.1", "element-plus": "^2.9.5", "fastify": "^5.10.0", "nodemailer": "^9.0.3", + "pg": "^8.22.0", "pinia": "^2.3.1", "pinia-plugin-persistedstate": "4.2.0", "vue": "^3.5.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cad8a71..ef4b0d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: .: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1090.0 + version: 3.1090.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1090.0 + version: 3.1090.0 '@element-plus/icons-vue': specifier: ^2.3.1 version: 2.3.2(vue@3.5.40) @@ -35,9 +41,6 @@ importers: axios: specifier: ^1.8.4 version: 1.18.1 - better-sqlite3: - specifier: ^12.11.1 - version: 12.11.1 element-plus: specifier: ^2.9.5 version: 2.14.3(vue@3.5.40) @@ -47,6 +50,9 @@ importers: nodemailer: specifier: ^9.0.3 version: 9.0.3 + pg: + specifier: ^8.22.0 + version: 8.22.0 pinia: specifier: ^2.3.1 version: 2.3.1(vue@3.5.40) @@ -120,6 +126,82 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@aws-sdk/checksums@3.1000.18': + resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1090.0': + resolution: {integrity: sha512-R6GX9cd1jljwzZ8xFmgAI/hHCuX1MobIKBdsymv7WL9SENvO9Vgz9KOR6avTnu0Ao+w1LmxnTe+jqmZXEn7Q/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.975.3': + resolution: {integrity: sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.59': + resolution: {integrity: sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.61': + resolution: {integrity: sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.4': + resolution: {integrity: sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.66': + resolution: {integrity: sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.70': + resolution: {integrity: sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.59': + resolution: {integrity: sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.3': + resolution: {integrity: sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.65': + resolution: {integrity: sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.64': + resolution: {integrity: sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.33': + resolution: {integrity: sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1090.0': + resolution: {integrity: sha512-DaYXp8GMptWJUDbvpoB51xZztOw6tcbpXjYAZMXTu17E0aR+5g8FWBU3t3t0KEhhrUiKyfLpVi6uNQxNSXfbhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.41': + resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1088.0': + resolution: {integrity: sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -866,6 +948,30 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/core@3.29.5': + resolution: {integrity: sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.10': + resolution: {integrity: sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.7': + resolution: {integrity: sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.7': + resolution: {integrity: sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.6': + resolution: {integrity: sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@sxzz/popperjs-es@2.11.8': resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} @@ -1034,6 +1140,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -1092,6 +1202,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -1445,6 +1558,11 @@ packages: resolution: {integrity: sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2076,6 +2194,40 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2158,6 +2310,22 @@ packages: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.1.0: + resolution: {integrity: sha512-txJzyrdoPw5F06xk3BEtzl46JCr6qU2DM8gfulxBFgqGKmfREbrcFyVqllttHuqWtafoBmowgJtGjm6kPGA/bg==} + engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -2234,6 +2402,10 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + recast@0.23.12: + resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==} + engines: {node: '>= 4'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2348,6 +2520,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -2439,6 +2615,9 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -2702,6 +2881,180 @@ snapshots: '@antfu/utils@0.7.10': {} + '@aws-sdk/checksums@3.1000.18': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1090.0': + dependencies: + '@aws-sdk/checksums': 3.1000.18 + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-node': 3.972.70 + '@aws-sdk/middleware-sdk-s3': 3.972.64 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.975.3': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.5 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.61': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.4': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-login': 3.972.66 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.66': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.70': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-ini': 3.973.4 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.3': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/token-providers': 3.1088.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.65': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.33': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1090.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.41': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1088.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3386,6 +3739,39 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.29.5': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.10': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.6': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@sxzz/popperjs-es@2.11.8': {} '@types/estree@1.0.9': {} @@ -3591,6 +3977,10 @@ snapshots: argparse@2.0.1: {} + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + async-validator@4.2.5: {} asynckit@0.4.0: {} @@ -3650,6 +4040,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.0 @@ -4044,6 +4436,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 3.3.0 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -4610,6 +5004,41 @@ snapshots: perfect-debounce@2.1.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.1.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -4697,6 +5126,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.1.0: + dependencies: + xtend: 4.0.2 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -4771,6 +5210,14 @@ snapshots: real-require@1.0.0: {} + recast@0.23.12: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -4891,6 +5338,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: {} + speakingurl@14.0.1: {} split2@4.2.0: {} @@ -4998,6 +5447,8 @@ snapshots: dependencies: real-require: 1.0.0 + tiny-invariant@1.3.3: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) diff --git a/railway.backup.json b/railway.backup.json new file mode 100644 index 0000000..6d56171 --- /dev/null +++ b/railway.backup.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" + }, + "deploy": { + "startCommand": "node server/ops/backup.js", + "cronSchedule": "0 18 * * *", + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 1 + } +} diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..3c67687 --- /dev/null +++ b/railway.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" + }, + "deploy": { + "preDeployCommand": ["node server/db/migrate.js"], + "healthcheckPath": "/api/v1/ready", + "healthcheckTimeout": 120, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/server/app.js b/server/app.js index 7992ce9..7ba2f81 100644 --- a/server/app.js +++ b/server/app.js @@ -24,6 +24,7 @@ import notificationRoutes from './routes/notifications.js' import profileRoutes from './routes/profile.js' import realtimeRoutes from './routes/realtime.js' import teacherRoutes from './routes/teachers.js' +import { createObjectStorage } from './services/object-storage.js' function resolveConfig(overrides = {}) { const nodeEnv = overrides.nodeEnv || defaultConfig.nodeEnv @@ -60,17 +61,20 @@ function isServiceRequest(request) { ) } -function probeDatabase(database) { +async function probeDatabase(database) { if (!database) return 'not_configured' - const result = database.prepare('SELECT 1 AS ok').get() + const result = await database.prepare('SELECT 1 AS ok').get() return result?.ok === 1 ? 'up' : 'down' } export async function buildApp({ database = null, + objectStorage, config: configOverrides = {} } = {}) { const config = resolveConfig(configOverrides) + const storage = + objectStorage === undefined ? createObjectStorage(config) : objectStorage const app = Fastify({ bodyLimit: config.bodyLimit, logger: config.logger, @@ -80,6 +84,7 @@ export async function buildApp({ app.decorate('config', config) app.decorate('db', database) app.decorate('database', database) + app.decorate('objectStorage', storage) await app.register(cookie) await app.register(helmet, { @@ -112,7 +117,7 @@ export async function buildApp({ await authPlugin(app, { db: database, allowBearer: config.allowBearer, - appOrigin: config.appOrigin, + appOrigins: config.appOrigins, secureCookies: config.secureCookies, sessionTtlSeconds: config.sessionTtlSeconds }) @@ -144,9 +149,10 @@ export async function buildApp({ app.get('/api/v1/ready', async (request, reply) => { let databaseStatus + let storageStatus = storage ? 'up' : 'not_configured' try { - databaseStatus = probeDatabase(database) + databaseStatus = await probeDatabase(database) } catch (error) { databaseStatus = 'down' request.log.warn({ err: error }, 'Database readiness probe failed') @@ -159,18 +165,30 @@ export async function buildApp({ msg: 'Service is not ready', data: { status: 'not_ready', - checks: { database: databaseStatus } + checks: { database: databaseStatus, storage: storageStatus } } }) } + if (storage) { + try { + storageStatus = await storage.probe() + } catch (error) { + storageStatus = 'down' + request.log.warn( + { err: error }, + 'Object storage readiness probe failed' + ) + } + } + return sendSuccess( reply, { - status: 'ready', - checks: { database: databaseStatus } + status: storageStatus === 'up' ? 'ready' : 'degraded', + checks: { database: databaseStatus, storage: storageStatus } }, - { msg: 'ready' } + { msg: storageStatus === 'up' ? 'ready' : 'degraded' } ) }) diff --git a/server/config.js b/server/config.js index b62fb35..3adbc78 100644 --- a/server/config.js +++ b/server/config.js @@ -34,12 +34,71 @@ function boolean(value, fallback, name) { throw new TypeError(`${name} must be a boolean value`) } +function proxyTrust(value, fallback = false) { + if (value === undefined || value === '') return fallback + const normalized = String(value).trim().toLowerCase() + if (normalized === '0') return false + if (/^[1-9]\d*$/.test(normalized)) { + const hops = Number(normalized) + if (hops <= 10) return hops + throw new TypeError('TRUST_PROXY hop count must be between 1 and 10') + } + return boolean(value, fallback, 'TRUST_PROXY') +} + function localPath(value, fallback) { const target = value || fallback if (target === ':memory:') return target return isAbsolute(target) ? target : resolve(PROJECT_ROOT, target) } +function httpOrigin(value, name, { production = false } = {}) { + let parsed + try { + parsed = new URL(value) + } catch { + throw new TypeError(`${name} must be a valid HTTP(S) origin`) + } + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.origin !== value.replace(/\/$/, '') + ) { + throw new TypeError(`${name} must contain only an HTTP(S) origin`) + } + const loopback = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname) + if (production && parsed.protocol !== 'https:' && !loopback) { + throw new TypeError(`${name} must use HTTPS in production`) + } + return parsed.origin +} + +function websocketOrigin(value, { production = false } = {}) { + let parsed + try { + parsed = new URL(value) + } catch { + throw new TypeError('PUBLIC_WEBSOCKET_ORIGIN must be a valid origin') + } + if (['http:', 'https:'].includes(parsed.protocol)) { + parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:' + } + if ( + !['ws:', 'wss:'].includes(parsed.protocol) || + parsed.pathname !== '/' || + parsed.search || + parsed.hash + ) { + throw new TypeError( + 'PUBLIC_WEBSOCKET_ORIGIN must contain only a WS(S) origin' + ) + } + const loopback = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname) + if (production && parsed.protocol !== 'wss:' && !loopback) { + throw new TypeError('PUBLIC_WEBSOCKET_ORIGIN must use WSS in production') + } + return parsed.origin +} + export function loadConfig(env = process.env) { const nodeEnv = env.NODE_ENV || 'development' if (!['development', 'test', 'production'].includes(nodeEnv)) { @@ -48,24 +107,35 @@ export function loadConfig(env = process.env) { ) } const isProduction = nodeEnv === 'production' - const appOrigin = env.APP_ORIGIN || 'http://localhost:5173' - let parsedOrigin - try { - parsedOrigin = new URL(appOrigin) - } catch { - throw new TypeError('APP_ORIGIN must be a valid HTTP(S) origin') - } + const appOrigins = ( + env.APP_ORIGINS || + env.APP_ORIGIN || + 'http://localhost:5173' + ) + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + .map((origin) => + httpOrigin(origin, 'APP_ORIGINS', { production: isProduction }) + ) if ( - !['http:', 'https:'].includes(parsedOrigin.protocol) || - parsedOrigin.origin !== appOrigin.replace(/\/$/, '') + appOrigins.length === 0 || + new Set(appOrigins).size !== appOrigins.length ) { - throw new TypeError('APP_ORIGIN must contain only an HTTP(S) origin') + throw new TypeError('APP_ORIGINS must contain unique HTTP(S) origins') } - const loopbackOrigin = ['localhost', '127.0.0.1', '::1'].includes( - parsedOrigin.hostname + const loopbackOrigin = appOrigins.every((origin) => { + const hostname = new URL(origin).hostname + return ['localhost', '127.0.0.1', '::1'].includes(hostname) + }) + + const publicWebsocketOrigin = websocketOrigin( + env.PUBLIC_WEBSOCKET_ORIGIN || appOrigins[0], + { production: isProduction } ) - if (isProduction && parsedOrigin.protocol !== 'https:' && !loopbackOrigin) { - throw new TypeError('Production APP_ORIGIN must use HTTPS') + + if (isProduction && !env.DATABASE_URL) { + throw new TypeError('DATABASE_URL is required in production') } const verificationCodeSecret = env.VERIFICATION_CODE_SECRET || '' @@ -83,6 +153,14 @@ export function loadConfig(env = process.env) { if (isProduction && !loopbackOrigin && !secureCookies) { throw new TypeError('SECURE_COOKIES cannot be disabled for a public origin') } + const allowBearer = boolean( + env.ALLOW_BEARER_AUTH, + !isProduction, + 'ALLOW_BEARER_AUTH' + ) + if (isProduction && allowBearer) { + throw new TypeError('ALLOW_BEARER_AUTH must be disabled in production') + } const aiApiUrl = env.AI_API_URL || '' if (isProduction && aiApiUrl) { @@ -97,6 +175,34 @@ export function loadConfig(env = process.env) { } } + const mailRelayUrl = String(env.MAIL_RELAY_URL ?? '').trim() + const mailRelaySecret = String(env.MAIL_RELAY_SECRET ?? '').trim() + if (Boolean(mailRelayUrl) !== Boolean(mailRelaySecret)) { + throw new TypeError( + 'MAIL_RELAY_URL and MAIL_RELAY_SECRET must be configured together' + ) + } + if (mailRelayUrl) { + let parsedRelayUrl + try { + parsedRelayUrl = new URL(mailRelayUrl) + } catch { + throw new TypeError('MAIL_RELAY_URL must be a valid HTTPS URL') + } + if ( + parsedRelayUrl.protocol !== 'https:' || + parsedRelayUrl.username || + parsedRelayUrl.password || + parsedRelayUrl.search || + parsedRelayUrl.hash + ) { + throw new TypeError('MAIL_RELAY_URL must be a credential-free HTTPS URL') + } + } + if (mailRelaySecret && mailRelaySecret.length < 32) { + throw new TypeError('MAIL_RELAY_SECRET must contain at least 32 characters') + } + const smtpUrl = env.SMTP_URL || '' if (smtpUrl) { let parsedSmtpUrl @@ -133,15 +239,39 @@ export function loadConfig(env = process.env) { 'UPLOAD_TOTAL_QUOTA_BYTES must be greater than or equal to UPLOAD_OWNER_QUOTA_BYTES' ) } + const seedOnStart = boolean( + env.SEED_ON_START, + nodeEnv !== 'production', + 'SEED_ON_START' + ) + if (isProduction && seedOnStart) { + throw new TypeError( + 'SEED_ON_START cannot be enabled in production; seed test data explicitly before startup' + ) + } + + const trustProxy = proxyTrust(env.TRUST_PROXY) + if (isProduction && !loopbackOrigin && !Number.isInteger(trustProxy)) { + throw new TypeError( + 'TRUST_PROXY must be an explicit hop count for a public production origin' + ) + } return Object.freeze({ nodeEnv, isProduction, host: env.HOST || '127.0.0.1', port: integer(env.PORT, 7777, 'PORT', { max: 65_535 }), - appOrigin: parsedOrigin.origin, - databasePath: localPath(env.DATABASE_PATH, '.data/platform.db'), - uploadDir: localPath(env.UPLOAD_DIR, '.data/uploads'), + appOrigin: appOrigins[0], + appOrigins: Object.freeze(appOrigins), + publicWebsocketOrigin, + databaseUrl: + env.DATABASE_URL || + 'postgresql://platform:platform@127.0.0.1:5432/platform', + databaseSsl: boolean(env.DATABASE_SSL, isProduction, 'DATABASE_SSL'), + databasePoolMax: integer(env.DATABASE_POOL_MAX, 10, 'DATABASE_POOL_MAX', { + max: 50 + }), uploadOwnerQuotaBytes, uploadTotalQuotaBytes, uploadMaxConcurrent: integer( @@ -158,17 +288,30 @@ export function loadConfig(env = process.env) { sessionTtlSeconds: sessionTtlHours * 60 * 60, verificationCodeSecret, secureCookies, - allowBearer: boolean(env.ALLOW_BEARER_AUTH, true, 'ALLOW_BEARER_AUTH'), - trustProxy: boolean(env.TRUST_PROXY, false, 'TRUST_PROXY'), + allowBearer, + trustProxy, logger: boolean(env.LOGGER, nodeEnv !== 'test', 'LOGGER'), - seedOnStart: boolean( - env.SEED_ON_START, - nodeEnv !== 'production', - 'SEED_ON_START' - ), + seedOnStart, + mailRelayUrl, + mailRelaySecret, smtpUrl, + smtpHost: env.SMTP_HOST || '', + smtpPort: integer(env.SMTP_PORT, 465, 'SMTP_PORT', { max: 65_535 }), + smtpSecure: boolean(env.SMTP_SECURE, true, 'SMTP_SECURE'), + smtpUser: env.SMTP_USER || '', + smtpPass: env.SMTP_PASS || '', mailFrom: env.MAIL_FROM || 'International Chinese Platform ', + s3Endpoint: env.S3_ENDPOINT || '', + s3Region: env.S3_REGION || 'auto', + s3Bucket: env.S3_BUCKET || '', + s3AccessKeyId: env.S3_ACCESS_KEY_ID || '', + s3SecretAccessKey: env.S3_SECRET_ACCESS_KEY || '', + s3ForcePathStyle: boolean( + env.S3_FORCE_PATH_STYLE, + !isProduction, + 'S3_FORCE_PATH_STYLE' + ), aiApiUrl, aiApiKey: env.AI_API_KEY || '', turnUrl: env.TURN_URL || '', diff --git a/server/db/bootstrap-admin.js b/server/db/bootstrap-admin.js index eae941f..1ebfbb2 100644 --- a/server/db/bootstrap-admin.js +++ b/server/db/bootstrap-admin.js @@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url' import { z } from 'zod' -import { createDatabase, DEFAULT_DATABASE_FILE } from './database.js' +import { createDatabase } from './database.js' import { hashPassword } from '../lib/password.js' const bootstrapSchema = z @@ -45,7 +45,7 @@ export async function bootstrapAdministrator(database, input) { throw bootstrapError('INVALID_ADMIN_INPUT', message) } - const existingAdministrator = database + const existingAdministrator = await database .prepare("SELECT id FROM users WHERE role = 'administrator' LIMIT 1") .get() if (existingAdministrator) { @@ -55,8 +55,8 @@ export async function bootstrapAdministrator(database, input) { ) } - const existingEmail = database - .prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') + const existingEmail = await database + .prepare('SELECT id FROM users WHERE email = ? LIMIT 1') .get(result.data.email) if (existingEmail) { throw bootstrapError('EMAIL_IN_USE', '该邮箱已被其他账号使用') @@ -66,9 +66,10 @@ export async function bootstrapAdministrator(database, input) { const passwordHash = await hashPassword(result.data.password) const timestamp = new Date().toISOString() - const create = database.transaction(() => { + const create = database.transaction(async () => { + await database.exec('LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE') if ( - database + await database .prepare("SELECT id FROM users WHERE role = 'administrator' LIMIT 1") .get() ) { @@ -78,11 +79,12 @@ export async function bootstrapAdministrator(database, input) { ) } - database + await database .prepare( `INSERT INTO users ( - id, email, password_hash, role, display_name, status, created_at, updated_at - ) VALUES (?, ?, ?, 'administrator', ?, 'active', ?, ?)` + id, email, password_hash, role, display_name, status, + must_reset_password, created_at, updated_at + ) VALUES (?, ?, ?, 'administrator', ?, 'active', true, ?, ?)` ) .run( id, @@ -92,7 +94,7 @@ export async function bootstrapAdministrator(database, input) { timestamp, timestamp ) - database + await database .prepare( `INSERT INTO audit_logs ( id, actor_id, action, entity_type, entity_id, details_json, created_at @@ -106,7 +108,7 @@ export async function bootstrapAdministrator(database, input) { timestamp ) }) - create() + await create() return { id, @@ -124,9 +126,7 @@ function isMainModule() { } if (isMainModule()) { - const database = createDatabase({ - filename: process.env.DATABASE_PATH || DEFAULT_DATABASE_FILE - }) + const database = await createDatabase() try { const administrator = await bootstrapAdministrator(database, { email: process.env.ADMIN_EMAIL, @@ -140,7 +140,7 @@ if (isMainModule()) { process.stderr.write(`Administrator bootstrap failed: ${error.message}\n`) process.exitCode = 1 } finally { - database.close() + await database.close() } } diff --git a/server/db/database.js b/server/db/database.js index 3fbe914..0eba202 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -1,141 +1,276 @@ -import Database from 'better-sqlite3' -import { mkdirSync, readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { AsyncLocalStorage } from 'node:async_hooks' +import { readdir, readFile } from 'node:fs/promises' import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { seedDatabase } from './seed-data.js' +import pg from 'pg' -const projectRoot = fileURLToPath(new URL('../..', import.meta.url)) -const schemaSql = readFileSync(new URL('./schema.sql', import.meta.url), 'utf8') +const { Pool, types } = pg +const transactionStorage = new AsyncLocalStorage() +const migrationsDirectory = new URL('./migrations/', import.meta.url) +const migrationLockNamespace = 'international-chinese-platform' +const migrationLockName = 'schema-migrations' -function tableExists(database, tableName) { - return Boolean( - database - .prepare( - `SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = ? LIMIT 1` - ) - .get(tableName) - ) -} +// Preserve the existing API wire format while PostgreSQL stores native types. +types.setTypeParser(20, (value) => Number(value)) +types.setTypeParser(1082, (value) => value) +types.setTypeParser(1114, (value) => `${value.replace(' ', 'T')}Z`) +types.setTypeParser(1184, (value) => new Date(value).toISOString()) +types.setTypeParser(114, (value) => value) +types.setTypeParser(3802, (value) => value) -function columnExists(database, tableName, columnName) { - if (!tableExists(database, tableName)) return false - return database - .prepare(`PRAGMA table_info(${tableName})`) - .all() - .some((column) => column.name === columnName) -} +export const DEFAULT_DATABASE_URL = + 'postgresql://platform:platform@127.0.0.1:5432/platform' +export const MIGRATION_IDS = Object.freeze([ + '001_initial_schema', + '002_secure_object_promotion' +]) + +function postgresPlaceholders(sql) { + let parameter = 0 + let singleQuoted = false + let doubleQuoted = false + let dollarTag = null + let output = '' -const migrations = Object.freeze([ - { - id: '001_initial_schema', - up(database) { - // A new installation gets the current canonical schema in one pass. - // Existing pre-ledger databases are adopted and upgraded below. - if (!tableExists(database, 'users')) database.exec(schemaSql) + for (let index = 0; index < sql.length; index += 1) { + const character = sql[index] + const next = sql[index + 1] + + if (dollarTag) { + if (sql.startsWith(dollarTag, index)) { + output += dollarTag + index += dollarTag.length - 1 + dollarTag = null + } else output += character + continue } - }, - { - id: '002_realtime_message_dedupe', - up(database) { - if ( - tableExists(database, 'chat_messages') && - !columnExists(database, 'chat_messages', 'client_message_id') - ) { - database.exec( - 'ALTER TABLE chat_messages ADD COLUMN client_message_id TEXT' - ) + + if (!singleQuoted && !doubleQuoted && character === '$') { + const match = sql.slice(index).match(/^\$[A-Za-z0-9_]*\$/) + if (match) { + dollarTag = match[0] + output += dollarTag + index += dollarTag.length - 1 + continue } } - }, - { - id: '003_platform_extensions', - up(database) { - // Creates domain tables introduced after the baseline and reconciles all - // indexes only after their required columns have been added. - database.exec(schemaSql) + + if (!doubleQuoted && character === "'") { + output += character + if (singleQuoted && next === "'") { + output += next + index += 1 + } else singleQuoted = !singleQuoted + continue + } + if (!singleQuoted && character === '"') { + doubleQuoted = !doubleQuoted + output += character + continue } - } -]) -export const MIGRATION_IDS = Object.freeze( - migrations.map((migration) => migration.id) -) + if (!singleQuoted && !doubleQuoted && character === '?') { + parameter += 1 + output += `$${parameter}` + } else output += character + } -export const DEFAULT_DATABASE_FILE = process.env.DATABASE_PATH - ? resolve(process.env.DATABASE_PATH) - : resolve(projectRoot, '.data', 'platform.db') + output = output.replace(/\s+COLLATE\s+NOCASE/gi, '') + const names = [] + output = output.replace(/@([A-Za-z][A-Za-z0-9_]*)/g, (_match, name) => { + names.push(name) + parameter += 1 + return `$${parameter}` + }) + if (/^\s*INSERT\s+OR\s+IGNORE\s+INTO\b/i.test(output)) { + output = output.replace(/INSERT\s+OR\s+IGNORE\s+INTO/i, 'INSERT INTO') + output = `${output.trim().replace(/;$/, '')} ON CONFLICT DO NOTHING` + } + return { names, sql: output } +} -function configureDatabase(database) { - database.pragma('foreign_keys = ON') - database.pragma('busy_timeout = 5000') - database.pragma('journal_mode = WAL') - database.pragma('synchronous = NORMAL') +function parameters(values, names) { + if (values.length === 1 && Array.isArray(values[0])) return values[0] + if ( + values.length === 1 && + names.length > 0 && + values[0] && + typeof values[0] === 'object' + ) { + return names.map((name) => values[0][name]) + } + return values } -export function migrateDatabase(database) { - if (!database || typeof database.exec !== 'function') { - throw new TypeError( - 'migrateDatabase requires an open better-sqlite3 database' +class PreparedStatement { + constructor(database, sql) { + this.database = database + const compiled = postgresPlaceholders(sql) + this.sql = compiled.sql + this.parameterNames = compiled.names + } + + async all(...values) { + const result = await this.database.query( + this.sql, + parameters(values, this.parameterNames) ) + return result.rows } - database.exec(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - id TEXT PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + async get(...values) { + const result = await this.database.query( + this.sql, + parameters(values, this.parameterNames) ) - `) - - const applied = new Set( - database - .prepare('SELECT id FROM schema_migrations ORDER BY applied_at, id') - .all() - .map((row) => row.id) - ) - const recordMigration = database.prepare( - 'INSERT INTO schema_migrations (id) VALUES (?)' - ) - - for (const migration of migrations) { - if (applied.has(migration.id)) continue - - database.transaction(() => { - migration.up(database) - recordMigration.run(migration.id) - })() + return result.rows[0] } - return database + async run(...values) { + const result = await this.database.query( + this.sql, + parameters(values, this.parameterNames) + ) + return { + changes: result.rowCount ?? 0, + lastInsertRowid: result.rows[0]?.id ?? null + } + } } -export function createDatabase({ - filename = DEFAULT_DATABASE_FILE, - seed = false -} = {}) { - if (typeof filename !== 'string' || filename.length === 0) { - throw new TypeError('Database filename must be a non-empty string') +export class PostgresDatabase { + constructor({ connectionString, ssl = false, max = 10, schema } = {}) { + this.pool = new Pool({ + connectionString, + options: schema ? `-c search_path=${schema},public` : undefined, + max, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 10_000, + ssl: ssl ? { rejectUnauthorized: false } : false + }) + this.open = true } - if (filename !== ':memory:') { - mkdirSync(dirname(resolve(filename)), { recursive: true }) + prepare(sql) { + if (!this.open) throw new Error('Database is closed') + return new PreparedStatement(this, sql) } - const database = new Database(filename) + async query(sql, values = []) { + if (!this.open) throw new Error('Database is closed') + const client = transactionStorage.getStore() + return (client ?? this.pool).query(sql, values) + } - try { - configureDatabase(database) - migrateDatabase(database) + async exec(sql) { + return this.query(sql) + } - if (seed) { - seedDatabase(database) + transaction(callback) { + if (typeof callback !== 'function') { + throw new TypeError('transaction requires a callback') } + return async (...args) => { + if (transactionStorage.getStore()) return callback(...args) + + const client = await this.pool.connect() + try { + await client.query('BEGIN') + const result = await transactionStorage.run(client, () => + callback(...args) + ) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } + } + } + + async close() { + if (!this.open) return + this.open = false + await this.pool.end() + } +} + +async function migrationFiles() { + return (await readdir(migrationsDirectory)) + .filter((file) => /^\d{3}_[a-z0-9_]+\.sql$/.test(file)) + .sort() +} + +export async function migrateDatabase(database) { + if (!database?.open) { + throw new TypeError('migrateDatabase requires an open PostgreSQL database') + } + + const migrate = database.transaction(async () => { + // Extensions are database-global while test/application schemas are not. + // Serialize the entire migration sequence so parallel pools cannot race on + // CREATE EXTENSION or observe a partially applied schema. + await database + .prepare( + 'SELECT pg_advisory_xact_lock(hashtext(?), hashtext(?)) AS locked' + ) + .get(migrationLockNamespace, migrationLockName) + + await database.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `) + const applied = new Set( + ( + await database + .prepare('SELECT id FROM schema_migrations ORDER BY applied_at, id') + .all() + ).map((row) => row.id) + ) + + for (const file of await migrationFiles()) { + const id = file.replace(/\.sql$/, '') + if (applied.has(id)) continue + const sql = await readFile(new URL(file, migrationsDirectory), 'utf8') + await database.exec(sql) + await database + .prepare('INSERT INTO schema_migrations (id) VALUES (?)') + .run(id) + applied.add(id) + } + }) + + await migrate() + + return database +} + +export async function createDatabase({ + connectionString = process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + ssl = String(process.env.DATABASE_SSL || '').toLowerCase() === 'true', + max = Number(process.env.DATABASE_POOL_MAX || 10), + schema, + migrate = true +} = {}) { + if (!connectionString) throw new TypeError('DATABASE_URL is required') + const database = new PostgresDatabase({ + connectionString, + ssl, + max, + schema + }) + try { + if (migrate) await migrateDatabase(database) return database } catch (error) { - database.close() + await database.close() throw error } } + +export default createDatabase diff --git a/server/db/migrate.js b/server/db/migrate.js index 578329e..9e82df0 100644 --- a/server/db/migrate.js +++ b/server/db/migrate.js @@ -1,10 +1,10 @@ -import { pathToFileURL } from 'node:url' import { resolve } from 'node:path' import process from 'node:process' +import { pathToFileURL } from 'node:url' import { createDatabase, - DEFAULT_DATABASE_FILE, + DEFAULT_DATABASE_URL, migrateDatabase } from './database.js' @@ -18,8 +18,8 @@ function isMainModule() { } if (isMainModule()) { - const filename = process.argv[2] || DEFAULT_DATABASE_FILE - const database = createDatabase({ filename }) - database.close() - process.stdout.write(`Database migration complete: ${filename}\n`) + const connectionString = process.env.DATABASE_URL || DEFAULT_DATABASE_URL + const database = await createDatabase({ connectionString }) + await database.close() + process.stdout.write('PostgreSQL migration complete\n') } diff --git a/server/db/migrations/001_initial_schema.sql b/server/db/migrations/001_initial_schema.sql new file mode 100644 index 0000000..82800f1 --- /dev/null +++ b/server/db/migrations/001_initial_schema.sql @@ -0,0 +1,286 @@ +CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; + +CREATE TABLE users ( + id uuid PRIMARY KEY, + email citext NOT NULL UNIQUE CHECK (length(email::text) BETWEEN 3 AND 254 AND position('@' IN email::text) > 1), + password_hash text NOT NULL CHECK (length(password_hash) >= 32), + role text NOT NULL CHECK (role IN ('student', 'teacher', 'administrator')), + display_name text NOT NULL CHECK (length(trim(display_name)) BETWEEN 1 AND 100), + avatar_url text, + country text, + region text, + age integer CHECK (age IS NULL OR age BETWEEN 6 AND 120), + chinese_level text, + bio text, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + must_reset_password boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE teacher_profiles ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + school text NOT NULL DEFAULT '' CHECK (length(school) <= 160), + title text NOT NULL DEFAULT '' CHECK (length(title) <= 120), + experience_years integer NOT NULL DEFAULT 0 CHECK (experience_years BETWEEN 0 AND 80), + rating double precision NOT NULL DEFAULT 5 CHECK (rating BETWEEN 0 AND 5), + hourly_rate_cents integer NOT NULL DEFAULT 0 CHECK (hourly_rate_cents >= 0), + specialties_json jsonb NOT NULL DEFAULT '[]'::jsonb, + certificates_json jsonb NOT NULL DEFAULT '[]'::jsonb, + teaching_style_json jsonb NOT NULL DEFAULT '[]'::jsonb, + languages_json jsonb NOT NULL DEFAULT '[]'::jsonb, + verified_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE sessions ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE CHECK (length(token_hash) BETWEEN 43 AND 64), + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, + user_agent text, + ip_address text, + CHECK (expires_at > created_at), + CHECK (revoked_at IS NULL OR revoked_at >= created_at) +); + +CREATE TABLE verification_codes ( + id uuid PRIMARY KEY, + email citext NOT NULL CHECK (length(email::text) BETWEEN 3 AND 254 AND position('@' IN email::text) > 1), + purpose text NOT NULL CHECK (purpose IN ('register', 'reset_password', 'change_email')), + code_hash text NOT NULL CHECK (length(code_hash) = 64), + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + attempts integer NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 10), + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (expires_at > created_at), + CHECK (consumed_at IS NULL OR consumed_at >= created_at) +); + +CREATE TABLE courses ( + id uuid PRIMARY KEY, + teacher_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + title text NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + summary text NOT NULL DEFAULT '' CHECK (length(summary) <= 500), + description text NOT NULL DEFAULT '', + level text NOT NULL DEFAULT 'beginner' CHECK (level IN ('beginner', 'elementary', 'intermediate', 'advanced', 'all')), + category text NOT NULL DEFAULT 'general' CHECK (length(category) BETWEEN 1 AND 80), + cover_url text, + duration_minutes integer NOT NULL DEFAULT 60 CHECK (duration_minutes BETWEEN 1 AND 1440), + price_cents integer NOT NULL DEFAULT 0 CHECK (price_cents >= 0), + capacity integer NOT NULL DEFAULT 30 CHECK (capacity BETWEEN 1 AND 10000), + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'pending', 'published', 'rejected', 'archived')), + rejection_reason text, + published_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (status = 'rejected' OR rejection_reason IS NULL), + CHECK (status = 'published' OR published_at IS NULL) +); + +CREATE TABLE course_reviews ( + id uuid PRIMARY KEY, + course_id uuid NOT NULL REFERENCES courses(id) ON UPDATE CASCADE ON DELETE CASCADE, + reviewer_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + decision text NOT NULL CHECK (decision IN ('approved', 'rejected')), + review_note text NOT NULL DEFAULT '' CHECK (length(review_note) <= 2000), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE appointments ( + id uuid PRIMARY KEY, + student_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + teacher_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + course_id uuid REFERENCES courses(id) ON UPDATE CASCADE ON DELETE SET NULL, + scheduled_start timestamptz NOT NULL, + scheduled_end timestamptz NOT NULL, + topic text NOT NULL CHECK (length(trim(topic)) BETWEEN 1 AND 160), + message text NOT NULL DEFAULT '' CHECK (length(message) <= 2000), + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'completed')), + response_note text NOT NULL DEFAULT '' CHECK (length(response_note) <= 2000), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (student_id <> teacher_id), + CHECK (scheduled_end > scheduled_start) +); + +CREATE TABLE classrooms ( + id uuid PRIMARY KEY, + appointment_id uuid NOT NULL UNIQUE REFERENCES appointments(id) ON UPDATE CASCADE ON DELETE CASCADE, + room_code text NOT NULL UNIQUE CHECK (length(room_code) BETWEEN 6 AND 64), + status text NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'open', 'closed')), + opened_at timestamptz, + closed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (closed_at IS NULL OR opened_at IS NOT NULL), + CHECK (closed_at IS NULL OR closed_at >= opened_at) +); + +CREATE TABLE assignments ( + id uuid PRIMARY KEY, + course_id uuid NOT NULL REFERENCES courses(id) ON UPDATE CASCADE ON DELETE CASCADE, + teacher_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + title text NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + instructions text NOT NULL DEFAULT '', + due_at timestamptz, + max_score double precision NOT NULL DEFAULT 100 CHECK (max_score > 0), + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'closed')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (course_id, title) +); + +CREATE TABLE assignment_questions ( + id uuid PRIMARY KEY, + assignment_id uuid NOT NULL REFERENCES assignments(id) ON UPDATE CASCADE ON DELETE CASCADE, + position integer NOT NULL CHECK (position > 0), + question_type text NOT NULL CHECK (question_type IN ('single_choice', 'multiple_choice', 'text')), + prompt text NOT NULL CHECK (length(trim(prompt)) > 0), + options_json jsonb, + correct_answer_json jsonb, + points double precision NOT NULL CHECK (points > 0), + explanation text NOT NULL DEFAULT '', + UNIQUE (assignment_id, position), + CHECK (question_type = 'text' OR options_json IS NOT NULL) +); + +CREATE TABLE submissions ( + id uuid PRIMARY KEY, + assignment_id uuid NOT NULL REFERENCES assignments(id) ON UPDATE CASCADE ON DELETE CASCADE, + student_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + answers_json jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'graded')), + submitted_at timestamptz, + score double precision CHECK (score IS NULL OR score >= 0), + feedback text NOT NULL DEFAULT '' CHECK (length(feedback) <= 5000), + graded_by uuid REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + graded_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (assignment_id, student_id), + CHECK (status = 'draft' OR submitted_at IS NOT NULL), + CHECK (status <> 'graded' OR (score IS NOT NULL AND graded_by IS NOT NULL AND graded_at IS NOT NULL)) +); + +CREATE TABLE chat_messages ( + id uuid PRIMARY KEY, + classroom_id uuid NOT NULL REFERENCES classrooms(id) ON UPDATE CASCADE ON DELETE CASCADE, + sender_id uuid REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL, + message_type text NOT NULL DEFAULT 'text' CHECK (message_type IN ('text', 'system', 'hand_raise')), + content text NOT NULL CHECK (length(content) BETWEEN 1 AND 4000), + metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb, + client_message_id text, + created_at timestamptz NOT NULL DEFAULT now(), + edited_at timestamptz, + deleted_at timestamptz, + CHECK (edited_at IS NULL OR edited_at >= created_at), + CHECK (deleted_at IS NULL OR deleted_at >= created_at) +); + +CREATE TABLE notifications ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + type text NOT NULL CHECK (length(type) BETWEEN 1 AND 80), + title text NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + body text NOT NULL DEFAULT '' CHECK (length(body) <= 4000), + resource_type text, + resource_id uuid, + link text, + dedupe_key text, + read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (read_at IS NULL OR read_at >= created_at) +); + +CREATE TABLE audit_logs ( + id uuid PRIMARY KEY, + actor_id uuid REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL, + action text NOT NULL CHECK (length(action) BETWEEN 1 AND 120), + entity_type text NOT NULL CHECK (length(entity_type) BETWEEN 1 AND 80), + entity_id uuid, + details_json jsonb NOT NULL DEFAULT '{}'::jsonb, + request_id text, + ip_address text, + user_agent text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE files ( + id uuid PRIMARY KEY, + owner_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + storage_key text NOT NULL UNIQUE CHECK (length(storage_key) BETWEEN 1 AND 512), + original_name text NOT NULL CHECK (length(original_name) BETWEEN 1 AND 255), + mime_type text NOT NULL CHECK (length(mime_type) BETWEEN 1 AND 120), + size_bytes bigint NOT NULL CHECK (size_bytes BETWEEN 1 AND 52428800), + sha256 text NOT NULL CHECK (length(sha256) = 64), + category text NOT NULL CHECK (category IN ('avatar', 'course_cover', 'course_video', 'course_material')), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE upload_intents ( + id uuid PRIMARY KEY, + owner_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + storage_key text NOT NULL UNIQUE CHECK (length(storage_key) BETWEEN 1 AND 512), + original_name text NOT NULL CHECK (length(original_name) BETWEEN 1 AND 255), + declared_mime_type text NOT NULL, + declared_size_bytes bigint NOT NULL CHECK (declared_size_bytes BETWEEN 1 AND 52428800), + category text NOT NULL CHECK (category IN ('avatar', 'course_cover', 'course_video', 'course_material')), + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'cancelled', 'expired')), + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + CHECK (expires_at > created_at) +); + +CREATE TABLE dialogue_sessions ( + id uuid PRIMARY KEY, + student_id uuid NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + title text NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + keywords_json jsonb NOT NULL DEFAULT '[]'::jsonb, + provider text NOT NULL DEFAULT 'local', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE dialogue_turns ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL REFERENCES dialogue_sessions(id) ON UPDATE CASCADE ON DELETE CASCADE, + position integer NOT NULL CHECK (position > 0), + speaker text NOT NULL CHECK (speaker IN ('student', 'tutor', 'system')), + content text NOT NULL CHECK (length(trim(content)) BETWEEN 1 AND 4000), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (session_id, position) +); + +CREATE INDEX users_role_status_idx ON users (role, status); +CREATE INDEX teacher_profiles_discovery_idx ON teacher_profiles (rating DESC, experience_years DESC); +CREATE INDEX sessions_user_active_idx ON sessions (user_id, expires_at) WHERE revoked_at IS NULL; +CREATE INDEX sessions_expiry_idx ON sessions (expires_at); +CREATE INDEX verification_codes_lookup_idx ON verification_codes (email, purpose, created_at DESC) WHERE consumed_at IS NULL; +CREATE INDEX courses_teacher_status_idx ON courses (teacher_id, status, updated_at DESC); +CREATE INDEX courses_catalog_idx ON courses (status, level, category, published_at DESC); +CREATE INDEX course_reviews_course_idx ON course_reviews (course_id, created_at DESC); +CREATE INDEX appointments_student_idx ON appointments (student_id, status, scheduled_start); +CREATE INDEX appointments_teacher_idx ON appointments (teacher_id, status, scheduled_start); +CREATE UNIQUE INDEX appointments_active_request_uq ON appointments (student_id, teacher_id, scheduled_start) WHERE status IN ('pending', 'accepted'); +CREATE INDEX classrooms_status_idx ON classrooms (status, created_at DESC); +CREATE INDEX assignments_course_status_idx ON assignments (course_id, status, due_at); +CREATE INDEX assignments_teacher_status_idx ON assignments (teacher_id, status, updated_at DESC); +CREATE INDEX submissions_assignment_status_idx ON submissions (assignment_id, status, submitted_at DESC); +CREATE INDEX submissions_student_status_idx ON submissions (student_id, status, updated_at DESC); +CREATE INDEX chat_messages_history_idx ON chat_messages (classroom_id, created_at, id); +CREATE UNIQUE INDEX chat_messages_client_dedupe_uq ON chat_messages (sender_id, client_message_id) WHERE sender_id IS NOT NULL AND client_message_id IS NOT NULL; +CREATE INDEX notifications_user_created_idx ON notifications (user_id, created_at DESC); +CREATE INDEX notifications_user_unread_idx ON notifications (user_id, created_at DESC) WHERE read_at IS NULL; +CREATE UNIQUE INDEX notifications_dedupe_uq ON notifications (user_id, dedupe_key) WHERE dedupe_key IS NOT NULL; +CREATE INDEX audit_logs_actor_created_idx ON audit_logs (actor_id, created_at DESC); +CREATE INDEX audit_logs_entity_idx ON audit_logs (entity_type, entity_id, created_at DESC); +CREATE INDEX files_owner_created_idx ON files (owner_id, created_at DESC); +CREATE INDEX upload_intents_owner_status_idx ON upload_intents (owner_id, status, expires_at); +CREATE INDEX dialogue_sessions_student_updated_idx ON dialogue_sessions (student_id, updated_at DESC); +CREATE INDEX dialogue_turns_session_position_idx ON dialogue_turns (session_id, position); diff --git a/server/db/migrations/002_secure_object_promotion.sql b/server/db/migrations/002_secure_object_promotion.sql new file mode 100644 index 0000000..4bd52dd --- /dev/null +++ b/server/db/migrations/002_secure_object_promotion.sql @@ -0,0 +1,23 @@ +ALTER TABLE upload_intents + DROP CONSTRAINT upload_intents_status_check; + +ALTER TABLE upload_intents + ADD CONSTRAINT upload_intents_status_check + CHECK (status IN ('pending', 'validating', 'completed', 'cancelled', 'expired')); + +ALTER TABLE upload_intents + ADD COLUMN validation_started_at timestamptz, + ADD COLUMN file_id uuid REFERENCES files(id) ON UPDATE CASCADE ON DELETE SET NULL; + +CREATE TABLE object_cleanup_jobs ( + storage_key text PRIMARY KEY CHECK (length(storage_key) BETWEEN 1 AND 512), + reason text NOT NULL CHECK (length(reason) BETWEEN 1 AND 120), + attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX object_cleanup_jobs_due_idx + ON object_cleanup_jobs (next_attempt_at, attempts); diff --git a/server/db/reset.js b/server/db/reset.js index 0f3f1e8..bf5a1bb 100644 --- a/server/db/reset.js +++ b/server/db/reset.js @@ -1,36 +1,21 @@ -import { existsSync, lstatSync, unlinkSync } from 'node:fs' import { resolve } from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' -import { createDatabase, DEFAULT_DATABASE_FILE } from './database.js' +import { createDatabase, DEFAULT_DATABASE_URL } from './database.js' +import { seedDatabase } from './seed-data.js' -function removeDatabaseFile(filename) { - for (const candidate of [filename, `${filename}-shm`, `${filename}-wal`]) { - if (!existsSync(candidate)) { - continue - } +export async function resetDatabase({ + connectionString = process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + seed = true +} = {}) { + const database = await createDatabase({ connectionString, migrate: false }) + await database.exec('DROP SCHEMA public CASCADE; CREATE SCHEMA public;') + await database.close() - if (!lstatSync(candidate).isFile()) { - throw new Error(`Refusing to remove non-file database path: ${candidate}`) - } - - unlinkSync(candidate) - } -} - -export function resetDatabase({ filename = DEFAULT_DATABASE_FILE } = {}) { - if (typeof filename !== 'string' || filename.length === 0) { - throw new TypeError('Database filename must be a non-empty string') - } - - if (filename === ':memory:') { - return createDatabase({ filename, seed: true }) - } - - const resolvedFilename = resolve(filename) - removeDatabaseFile(resolvedFilename) - return createDatabase({ filename: resolvedFilename, seed: true }) + const recreated = await createDatabase({ connectionString }) + if (seed) await seedDatabase(recreated) + return recreated } function isMainModule() { @@ -41,8 +26,10 @@ function isMainModule() { } if (isMainModule()) { - const filename = process.argv[2] || DEFAULT_DATABASE_FILE - const database = resetDatabase({ filename }) - database.close() - process.stdout.write(`Database reset complete: ${filename}\n`) + if (process.env.NODE_ENV === 'production') { + throw new Error('db:reset is disabled in production') + } + const database = await resetDatabase() + await database.close() + process.stdout.write('PostgreSQL database reset complete\n') } diff --git a/server/db/schema.sql b/server/db/schema.sql deleted file mode 100644 index e4c6937..0000000 --- a/server/db/schema.sql +++ /dev/null @@ -1,331 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL COLLATE NOCASE UNIQUE - CHECK (length(email) BETWEEN 3 AND 254 AND instr(email, '@') > 1), - password_hash TEXT NOT NULL CHECK (length(password_hash) >= 32), - role TEXT NOT NULL CHECK (role IN ('student', 'teacher', 'administrator')), - display_name TEXT NOT NULL CHECK (length(trim(display_name)) BETWEEN 1 AND 100), - avatar_url TEXT, - country TEXT, - region TEXT, - age INTEGER CHECK (age IS NULL OR age BETWEEN 6 AND 120), - chinese_level TEXT, - bio TEXT, - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE TABLE IF NOT EXISTS teacher_profiles ( - user_id TEXT PRIMARY KEY REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, - school TEXT NOT NULL DEFAULT '' CHECK (length(school) <= 160), - title TEXT NOT NULL DEFAULT '' CHECK (length(title) <= 120), - experience_years INTEGER NOT NULL DEFAULT 0 CHECK (experience_years BETWEEN 0 AND 80), - rating REAL NOT NULL DEFAULT 5 CHECK (rating BETWEEN 0 AND 5), - hourly_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (hourly_rate_cents >= 0), - specialties_json TEXT NOT NULL DEFAULT '[]', - certificates_json TEXT NOT NULL DEFAULT '[]', - teaching_style_json TEXT NOT NULL DEFAULT '[]', - languages_json TEXT NOT NULL DEFAULT '[]', - verified_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE CHECK (length(token_hash) BETWEEN 43 AND 64), - expires_at TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - revoked_at TEXT, - user_agent TEXT, - ip_address TEXT, - CHECK (expires_at > created_at), - CHECK (revoked_at IS NULL OR revoked_at >= created_at) -); - -CREATE TABLE IF NOT EXISTS verification_codes ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL COLLATE NOCASE - CHECK (length(email) BETWEEN 3 AND 254 AND instr(email, '@') > 1), - purpose TEXT NOT NULL CHECK (purpose IN ('register', 'reset_password', 'change_email')), - code_hash TEXT NOT NULL CHECK (length(code_hash) = 64), - expires_at TEXT NOT NULL, - consumed_at TEXT, - attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 10), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - CHECK (expires_at > created_at), - CHECK (consumed_at IS NULL OR consumed_at >= created_at) -); - -CREATE TABLE IF NOT EXISTS courses ( - id TEXT PRIMARY KEY, - teacher_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), - summary TEXT NOT NULL DEFAULT '' CHECK (length(summary) <= 500), - description TEXT NOT NULL DEFAULT '', - level TEXT NOT NULL DEFAULT 'beginner' - CHECK (level IN ('beginner', 'elementary', 'intermediate', 'advanced', 'all')), - category TEXT NOT NULL DEFAULT 'general' CHECK (length(category) BETWEEN 1 AND 80), - cover_url TEXT, - duration_minutes INTEGER NOT NULL DEFAULT 60 CHECK (duration_minutes BETWEEN 1 AND 1440), - price_cents INTEGER NOT NULL DEFAULT 0 CHECK (price_cents >= 0), - capacity INTEGER NOT NULL DEFAULT 30 CHECK (capacity BETWEEN 1 AND 10000), - status TEXT NOT NULL DEFAULT 'draft' - CHECK (status IN ('draft', 'pending', 'published', 'rejected', 'archived')), - rejection_reason TEXT, - published_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - CHECK (status = 'rejected' OR rejection_reason IS NULL), - CHECK (status = 'published' OR published_at IS NULL) -); - -CREATE TABLE IF NOT EXISTS course_reviews ( - id TEXT PRIMARY KEY, - course_id TEXT NOT NULL REFERENCES courses(id) ON UPDATE CASCADE ON DELETE CASCADE, - reviewer_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')), - review_note TEXT NOT NULL DEFAULT '' CHECK (length(review_note) <= 2000), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE TABLE IF NOT EXISTS appointments ( - id TEXT PRIMARY KEY, - student_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - teacher_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - course_id TEXT REFERENCES courses(id) ON UPDATE CASCADE ON DELETE SET NULL, - scheduled_start TEXT NOT NULL, - scheduled_end TEXT NOT NULL, - topic TEXT NOT NULL CHECK (length(trim(topic)) BETWEEN 1 AND 160), - message TEXT NOT NULL DEFAULT '' CHECK (length(message) <= 2000), - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'completed')), - response_note TEXT NOT NULL DEFAULT '' CHECK (length(response_note) <= 2000), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - CHECK (student_id <> teacher_id), - CHECK (scheduled_end > scheduled_start) -); - -CREATE TABLE IF NOT EXISTS classrooms ( - id TEXT PRIMARY KEY, - appointment_id TEXT NOT NULL UNIQUE - REFERENCES appointments(id) ON UPDATE CASCADE ON DELETE CASCADE, - room_code TEXT NOT NULL UNIQUE CHECK (length(room_code) BETWEEN 6 AND 64), - status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'open', 'closed')), - opened_at TEXT, - closed_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - CHECK (closed_at IS NULL OR opened_at IS NOT NULL), - CHECK (closed_at IS NULL OR closed_at >= opened_at) -); - -CREATE TABLE IF NOT EXISTS assignments ( - id TEXT PRIMARY KEY, - course_id TEXT NOT NULL REFERENCES courses(id) ON UPDATE CASCADE ON DELETE CASCADE, - teacher_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), - instructions TEXT NOT NULL DEFAULT '', - due_at TEXT, - max_score REAL NOT NULL DEFAULT 100 CHECK (max_score > 0), - status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'closed')), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - UNIQUE (course_id, title) -); - -CREATE TABLE IF NOT EXISTS assignment_questions ( - id TEXT PRIMARY KEY, - assignment_id TEXT NOT NULL REFERENCES assignments(id) ON UPDATE CASCADE ON DELETE CASCADE, - position INTEGER NOT NULL CHECK (position > 0), - question_type TEXT NOT NULL CHECK (question_type IN ('single_choice', 'multiple_choice', 'text')), - prompt TEXT NOT NULL CHECK (length(trim(prompt)) > 0), - options_json TEXT, - correct_answer_json TEXT, - points REAL NOT NULL CHECK (points > 0), - explanation TEXT NOT NULL DEFAULT '', - UNIQUE (assignment_id, position), - CHECK (question_type = 'text' OR options_json IS NOT NULL) -); - -CREATE TABLE IF NOT EXISTS submissions ( - id TEXT PRIMARY KEY, - assignment_id TEXT NOT NULL REFERENCES assignments(id) ON UPDATE CASCADE ON DELETE CASCADE, - student_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - answers_json TEXT NOT NULL DEFAULT '{}', - status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'graded')), - submitted_at TEXT, - score REAL CHECK (score IS NULL OR score >= 0), - feedback TEXT NOT NULL DEFAULT '' CHECK (length(feedback) <= 5000), - graded_by TEXT REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - graded_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - UNIQUE (assignment_id, student_id), - CHECK (status = 'draft' OR submitted_at IS NOT NULL), - CHECK ( - status <> 'graded' - OR (score IS NOT NULL AND graded_by IS NOT NULL AND graded_at IS NOT NULL) - ) -); - -CREATE TABLE IF NOT EXISTS chat_messages ( - id TEXT PRIMARY KEY, - classroom_id TEXT NOT NULL REFERENCES classrooms(id) ON UPDATE CASCADE ON DELETE CASCADE, - sender_id TEXT REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL, - message_type TEXT NOT NULL DEFAULT 'text' - CHECK (message_type IN ('text', 'system', 'hand_raise')), - content TEXT NOT NULL CHECK (length(content) BETWEEN 1 AND 4000), - metadata_json TEXT NOT NULL DEFAULT '{}', - client_message_id TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - edited_at TEXT, - deleted_at TEXT, - CHECK (edited_at IS NULL OR edited_at >= created_at), - CHECK (deleted_at IS NULL OR deleted_at >= created_at) -); - -CREATE TABLE IF NOT EXISTS notifications ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, - type TEXT NOT NULL CHECK (length(type) BETWEEN 1 AND 80), - title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), - body TEXT NOT NULL DEFAULT '' CHECK (length(body) <= 4000), - resource_type TEXT, - resource_id TEXT, - link TEXT, - dedupe_key TEXT, - read_at TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - CHECK (read_at IS NULL OR read_at >= created_at) -); - -CREATE TABLE IF NOT EXISTS audit_logs ( - id TEXT PRIMARY KEY, - actor_id TEXT REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL, - action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 120), - entity_type TEXT NOT NULL CHECK (length(entity_type) BETWEEN 1 AND 80), - entity_id TEXT, - details_json TEXT NOT NULL DEFAULT '{}', - request_id TEXT, - ip_address TEXT, - user_agent TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE TABLE IF NOT EXISTS files ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, - storage_key TEXT NOT NULL UNIQUE CHECK (length(storage_key) BETWEEN 1 AND 255), - original_name TEXT NOT NULL CHECK (length(original_name) BETWEEN 1 AND 255), - mime_type TEXT NOT NULL CHECK (length(mime_type) BETWEEN 1 AND 120), - size_bytes INTEGER NOT NULL CHECK (size_bytes BETWEEN 1 AND 52428800), - sha256 TEXT NOT NULL CHECK (length(sha256) = 64), - category TEXT NOT NULL CHECK (category IN ('avatar', 'course_cover', 'course_video', 'course_material')), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE TABLE IF NOT EXISTS dialogue_sessions ( - id TEXT PRIMARY KEY, - student_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, - title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), - keywords_json TEXT NOT NULL DEFAULT '[]', - provider TEXT NOT NULL DEFAULT 'local', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - -CREATE TABLE IF NOT EXISTS dialogue_turns ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL REFERENCES dialogue_sessions(id) ON UPDATE CASCADE ON DELETE CASCADE, - position INTEGER NOT NULL CHECK (position > 0), - speaker TEXT NOT NULL CHECK (speaker IN ('student', 'tutor', 'system')), - content TEXT NOT NULL CHECK (length(trim(content)) BETWEEN 1 AND 4000), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - UNIQUE (session_id, position) -); - -CREATE INDEX IF NOT EXISTS users_role_status_idx - ON users (role, status); - -CREATE INDEX IF NOT EXISTS teacher_profiles_discovery_idx - ON teacher_profiles (rating DESC, experience_years DESC); - -CREATE INDEX IF NOT EXISTS sessions_user_active_idx - ON sessions (user_id, expires_at) - WHERE revoked_at IS NULL; - -CREATE INDEX IF NOT EXISTS sessions_expiry_idx - ON sessions (expires_at); - -CREATE INDEX IF NOT EXISTS verification_codes_lookup_idx - ON verification_codes (email, purpose, created_at DESC) - WHERE consumed_at IS NULL; - -CREATE INDEX IF NOT EXISTS courses_teacher_status_idx - ON courses (teacher_id, status, updated_at DESC); - -CREATE INDEX IF NOT EXISTS courses_catalog_idx - ON courses (status, level, category, published_at DESC); - -CREATE INDEX IF NOT EXISTS course_reviews_course_idx - ON course_reviews (course_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS appointments_student_idx - ON appointments (student_id, status, scheduled_start); - -CREATE INDEX IF NOT EXISTS appointments_teacher_idx - ON appointments (teacher_id, status, scheduled_start); - -CREATE UNIQUE INDEX IF NOT EXISTS appointments_active_request_uq - ON appointments (student_id, teacher_id, scheduled_start) - WHERE status IN ('pending', 'accepted'); - -CREATE INDEX IF NOT EXISTS classrooms_status_idx - ON classrooms (status, created_at DESC); - -CREATE INDEX IF NOT EXISTS assignments_course_status_idx - ON assignments (course_id, status, due_at); - -CREATE INDEX IF NOT EXISTS assignments_teacher_status_idx - ON assignments (teacher_id, status, updated_at DESC); - -CREATE INDEX IF NOT EXISTS submissions_assignment_status_idx - ON submissions (assignment_id, status, submitted_at DESC); - -CREATE INDEX IF NOT EXISTS submissions_student_status_idx - ON submissions (student_id, status, updated_at DESC); - -CREATE INDEX IF NOT EXISTS chat_messages_history_idx - ON chat_messages (classroom_id, created_at, id); - -CREATE UNIQUE INDEX IF NOT EXISTS chat_messages_client_dedupe_uq - ON chat_messages (sender_id, client_message_id) - WHERE sender_id IS NOT NULL AND client_message_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS notifications_user_created_idx - ON notifications (user_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS notifications_user_unread_idx - ON notifications (user_id, created_at DESC) - WHERE read_at IS NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS notifications_dedupe_uq - ON notifications (user_id, dedupe_key) - WHERE dedupe_key IS NOT NULL; - -CREATE INDEX IF NOT EXISTS audit_logs_actor_created_idx - ON audit_logs (actor_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS audit_logs_entity_idx - ON audit_logs (entity_type, entity_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS files_owner_category_idx - ON files (owner_id, category, created_at DESC); - -CREATE INDEX IF NOT EXISTS dialogue_sessions_student_idx - ON dialogue_sessions (student_id, updated_at DESC); diff --git a/server/db/seed-data.js b/server/db/seed-data.js index 9ec269f..10d86b0 100644 --- a/server/db/seed-data.js +++ b/server/db/seed-data.js @@ -121,7 +121,7 @@ export function verifyPasswordHash(password, encodedHash) { } } -function insertUsers(database) { +async function insertUsers(database) { const insert = database.prepare(` INSERT OR IGNORE INTO users ( id, email, password_hash, role, display_name, country, region, age, @@ -157,7 +157,7 @@ function insertUsers(database) { } for (const account of DEMO_ACCOUNTS) { - insert.run({ + await insert.run({ ...account, ...profiles[account.role], passwordHash: hashPassword(DEMO_PASSWORD), @@ -166,22 +166,20 @@ function insertUsers(database) { }) } - return Object.fromEntries( - DEMO_ACCOUNTS.map((account) => { - const row = database - .prepare('SELECT id FROM users WHERE email = ?') - .get(account.email) - - if (!row) { - throw new Error(`Unable to resolve seeded account: ${account.email}`) - } - - return [account.role, row.id] - }) - ) + const userIds = {} + for (const account of DEMO_ACCOUNTS) { + const row = await database + .prepare('SELECT id FROM users WHERE email = ?') + .get(account.email) + if (!row) { + throw new Error(`Unable to resolve seeded account: ${account.email}`) + } + userIds[account.role] = row.id + } + return userIds } -function insertCourses(database, userIds) { +async function insertCourses(database, userIds) { const insert = database.prepare(` INSERT OR IGNORE INTO courses ( id, teacher_id, title, summary, description, level, category, cover_url, @@ -194,7 +192,7 @@ function insertCourses(database, userIds) { ) `) - insert.run({ + await insert.run({ id: DEMO_IDS.publishedCourse, teacherId: userIds.teacher, title: '生活汉语入门', @@ -212,7 +210,7 @@ function insertCourses(database, userIds) { updatedAt: '2026-04-08T09:30:00.000Z' }) - insert.run({ + await insert.run({ id: DEMO_IDS.pendingCourse, teacherId: userIds.teacher, title: '中国文化与口语表达', @@ -229,7 +227,7 @@ function insertCourses(database, userIds) { updatedAt: '2026-07-15T05:00:00.000Z' }) - database + await database .prepare( ` INSERT OR IGNORE INTO course_reviews ( @@ -246,8 +244,8 @@ function insertCourses(database, userIds) { ) } -function insertTeacherProfile(database, userIds) { - database +async function insertTeacherProfile(database, userIds) { + await database .prepare( ` INSERT OR IGNORE INTO teacher_profiles ( @@ -274,7 +272,7 @@ function insertTeacherProfile(database, userIds) { ) } -function insertAppointments(database, userIds) { +async function insertAppointments(database, userIds) { const insert = database.prepare(` INSERT OR IGNORE INTO appointments ( id, student_id, teacher_id, course_id, scheduled_start, scheduled_end, @@ -285,7 +283,7 @@ function insertAppointments(database, userIds) { ) `) - insert.run({ + await insert.run({ id: DEMO_IDS.acceptedAppointment, studentId: userIds.student, teacherId: userIds.teacher, @@ -300,7 +298,7 @@ function insertAppointments(database, userIds) { updatedAt: '2026-07-16T09:00:00.000Z' }) - insert.run({ + await insert.run({ id: DEMO_IDS.pendingAppointment, studentId: userIds.student, teacherId: userIds.teacher, @@ -315,7 +313,7 @@ function insertAppointments(database, userIds) { updatedAt: '2026-07-18T08:00:00.000Z' }) - database + await database .prepare( ` INSERT OR IGNORE INTO classrooms ( @@ -332,8 +330,8 @@ function insertAppointments(database, userIds) { ) } -function insertAssignments(database, userIds) { - database +async function insertAssignments(database, userIds) { + await database .prepare( ` INSERT OR IGNORE INTO assignments ( @@ -363,7 +361,7 @@ function insertAssignments(database, userIds) { ) `) - insertQuestion.run({ + await insertQuestion.run({ id: DEMO_IDS.questionChoice, assignmentId: DEMO_IDS.assignment, position: 1, @@ -380,7 +378,7 @@ function insertAssignments(database, userIds) { explanation: '“请给我……”可以礼貌地提出点单需求。' }) - insertQuestion.run({ + await insertQuestion.run({ id: DEMO_IDS.questionText, assignmentId: DEMO_IDS.assignment, position: 2, @@ -392,7 +390,7 @@ function insertAssignments(database, userIds) { explanation: '注意使用时间、菜名和感受等信息。' }) - database + await database .prepare( ` INSERT OR IGNORE INTO submissions ( @@ -420,8 +418,8 @@ function insertAssignments(database, userIds) { ) } -function insertSupportingData(database, userIds) { - database +async function insertSupportingData(database, userIds) { + await database .prepare( ` INSERT OR IGNORE INTO chat_messages ( @@ -449,7 +447,7 @@ function insertSupportingData(database, userIds) { ) `) - insertNotification.run({ + await insertNotification.run({ id: DEMO_IDS.studentNotification, userId: userIds.student, type: 'appointment.accepted', @@ -462,7 +460,7 @@ function insertSupportingData(database, userIds) { createdAt: '2026-07-16T09:00:00.000Z' }) - insertNotification.run({ + await insertNotification.run({ id: DEMO_IDS.teacherNotification, userId: userIds.teacher, type: 'appointment.requested', @@ -475,7 +473,7 @@ function insertSupportingData(database, userIds) { createdAt: '2026-07-18T08:00:00.000Z' }) - database + await database .prepare( ` INSERT OR IGNORE INTO audit_logs ( @@ -496,34 +494,36 @@ function insertSupportingData(database, userIds) { ) } -export function seedDatabase(database) { +export async function seedDatabase(database) { if (!database || typeof database.prepare !== 'function') { - throw new TypeError('seedDatabase requires an open better-sqlite3 database') + throw new TypeError('seedDatabase requires an open PostgreSQL database') } - const seed = database.transaction(() => { - const userIds = insertUsers(database) - insertTeacherProfile(database, userIds) - insertCourses(database, userIds) - insertAppointments(database, userIds) - insertAssignments(database, userIds) - insertSupportingData(database, userIds) + const seed = database.transaction(async () => { + const userIds = await insertUsers(database) + await insertTeacherProfile(database, userIds) + await insertCourses(database, userIds) + await insertAppointments(database, userIds) + await insertAssignments(database, userIds) + await insertSupportingData(database, userIds) }) - seed() + await seed() return { - users: database.prepare('SELECT COUNT(*) AS count FROM users').get().count, - courses: database.prepare('SELECT COUNT(*) AS count FROM courses').get() + users: (await database.prepare('SELECT COUNT(*) AS count FROM users').get()) .count, - appointments: database - .prepare('SELECT COUNT(*) AS count FROM appointments') - .get().count, - assignments: database - .prepare('SELECT COUNT(*) AS count FROM assignments') - .get().count, - submissions: database - .prepare('SELECT COUNT(*) AS count FROM submissions') - .get().count + courses: ( + await database.prepare('SELECT COUNT(*) AS count FROM courses').get() + ).count, + appointments: ( + await database.prepare('SELECT COUNT(*) AS count FROM appointments').get() + ).count, + assignments: ( + await database.prepare('SELECT COUNT(*) AS count FROM assignments').get() + ).count, + submissions: ( + await database.prepare('SELECT COUNT(*) AS count FROM submissions').get() + ).count } } diff --git a/server/db/seed.js b/server/db/seed.js index 00d7296..bb3ac2a 100644 --- a/server/db/seed.js +++ b/server/db/seed.js @@ -2,7 +2,7 @@ import { resolve } from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' -import { createDatabase, DEFAULT_DATABASE_FILE } from './database.js' +import { createDatabase } from './database.js' import { DEMO_ACCOUNTS, DEMO_IDS, @@ -29,11 +29,13 @@ function isMainModule() { } if (isMainModule()) { - const filename = process.argv[2] || DEFAULT_DATABASE_FILE - const database = createDatabase({ filename }) - const result = seedDatabase(database) - database.close() + if (process.env.NODE_ENV === 'production') { + throw new Error('db:seed is disabled in production') + } + const database = await createDatabase() + const result = await seedDatabase(database) + await database.close() process.stdout.write( - `Demo data seeded: ${filename} (${result.users} users, ${result.courses} courses)\n` + `Demo data seeded (${result.users} users, ${result.courses} courses)\n` ) } diff --git a/server/index.js b/server/index.js index 0f0f7f6..176379a 100644 --- a/server/index.js +++ b/server/index.js @@ -18,15 +18,20 @@ async function shutdown(signal) { try { if (app) await app.close() } finally { - if (database?.open) database.close() + if (database?.open) await database.close() } } try { - database = createDatabase({ - filename: config.databasePath, - seed: config.seedOnStart + database = await createDatabase({ + connectionString: config.databaseUrl, + ssl: config.databaseSsl, + max: config.databasePoolMax }) + if (config.seedOnStart) { + const { seedDatabase } = await import('./db/seed-data.js') + await seedDatabase(database) + } app = await buildApp({ database, config }) await app.listen({ host: config.host, port: config.port }) @@ -46,6 +51,6 @@ try { console.error('API server failed to start') } - if (database?.open) database.close() + if (database?.open) await database.close() process.exitCode = 1 } diff --git a/server/lib/session.js b/server/lib/session.js index 25d8fd7..4514331 100644 --- a/server/lib/session.js +++ b/server/lib/session.js @@ -36,12 +36,14 @@ export function publicUser(user) { chineseLevel: user.chinese_level ?? user.chineseLevel ?? null, bio: user.bio ?? '', status: user.status, + mustResetPassword: + user.must_reset_password ?? user.mustResetPassword ?? false, createdAt: user.created_at ?? user.createdAt, updatedAt: user.updated_at ?? user.updatedAt } } -export function createSession( +export async function createSession( db, userId, { @@ -71,8 +73,9 @@ export function createSession( const createdAt = nowIso() const expiresAt = new Date(Date.now() + boundedTtl * 1000).toISOString() - db.prepare( - `INSERT INTO sessions ( + await db + .prepare( + `INSERT INTO sessions ( id, user_id, token_hash, @@ -82,16 +85,17 @@ export function createSession( user_agent, ip_address ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - userId, - tokenHash, - expiresAt, - createdAt, - createdAt, - userAgent?.slice(0, 512) ?? null, - ipAddress?.slice(0, 64) ?? null - ) + ) + .run( + id, + userId, + tokenHash, + expiresAt, + createdAt, + createdAt, + userAgent?.slice(0, 512) ?? null, + ipAddress?.slice(0, 64) ?? null + ) return { id, @@ -102,14 +106,14 @@ export function createSession( } } -export function findSessionByToken(db, token, { touch = true } = {}) { +export async function findSessionByToken(db, token, { touch = true } = {}) { const tokenHash = hashSessionToken(token) if (!tokenHash) { return null } const currentTime = nowIso() - const row = db + const row = await db .prepare( `SELECT s.id AS session_id, @@ -127,6 +131,7 @@ export function findSessionByToken(db, token, { touch = true } = {}) { u.chinese_level, u.bio, u.status, + u.must_reset_password, u.created_at, u.updated_at FROM sessions AS s @@ -144,10 +149,9 @@ export function findSessionByToken(db, token, { touch = true } = {}) { } if (touch) { - db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?').run( - currentTime, - row.session_id - ) + await db + .prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?') + .run(currentTime, row.session_id) } return { @@ -158,12 +162,12 @@ export function findSessionByToken(db, token, { touch = true } = {}) { } } -export function revokeSession(db, sessionId) { +export async function revokeSession(db, sessionId) { if (!sessionId) { return false } - const result = db + const result = await db .prepare( `UPDATE sessions SET revoked_at = ? @@ -174,13 +178,13 @@ export function revokeSession(db, sessionId) { return result.changes > 0 } -export function revokeSessionByToken(db, token) { +export async function revokeSessionByToken(db, token) { const tokenHash = hashSessionToken(token) if (!tokenHash) { return false } - const result = db + const result = await db .prepare( `UPDATE sessions SET revoked_at = ? @@ -191,7 +195,7 @@ export function revokeSessionByToken(db, token) { return result.changes > 0 } -export function revokeUserSessions( +export async function revokeUserSessions( db, userId, { exceptSessionId = null } = {} @@ -199,22 +203,26 @@ export function revokeUserSessions( const revokedAt = nowIso() if (exceptSessionId) { - return db - .prepare( - `UPDATE sessions + return ( + await db + .prepare( + `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND id <> ? AND revoked_at IS NULL` - ) - .run(revokedAt, userId, exceptSessionId).changes + ) + .run(revokedAt, userId, exceptSessionId) + ).changes } - return db - .prepare( - `UPDATE sessions + return ( + await db + .prepare( + `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL` - ) - .run(revokedAt, userId).changes + ) + .run(revokedAt, userId) + ).changes } export function extractSessionToken(request, { allowBearer = true } = {}) { diff --git a/server/ops/backup.js b/server/ops/backup.js new file mode 100644 index 0000000..964fce4 --- /dev/null +++ b/server/ops/backup.js @@ -0,0 +1,137 @@ +import { Buffer } from 'node:buffer' +import { createCipheriv, randomBytes } from 'node:crypto' +import { createReadStream, createWriteStream } from 'node:fs' +import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { spawn } from 'node:child_process' +import { pipeline } from 'node:stream/promises' + +import { + DeleteObjectsCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client +} from '@aws-sdk/client-s3' + +const MAGIC = Buffer.from('ICPBKP1') +const DAILY_KEEP = 7 +const WEEKLY_KEEP = 4 + +function required(name) { + const value = process.env[name] + if (!value) throw new Error(`${name} is required`) + return value +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'inherit', 'inherit'] + }) + child.once('error', reject) + child.once('exit', (code) => + code === 0 + ? resolve() + : reject(new Error(`${command} exited with ${code}`)) + ) + }) +} + +function s3Client() { + return new S3Client({ + endpoint: required('S3_ENDPOINT'), + region: process.env.S3_REGION || 'auto', + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', + requestChecksumCalculation: 'WHEN_REQUIRED', + credentials: { + accessKeyId: required('S3_ACCESS_KEY_ID'), + secretAccessKey: required('S3_SECRET_ACCESS_KEY') + } + }) +} + +async function prune(client, bucket, prefix, keep) { + const listed = await client.send( + new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix }) + ) + const remove = (listed.Contents ?? []) + .sort( + (left, right) => + new Date(right.LastModified) - new Date(left.LastModified) + ) + .slice(keep) + .map(({ Key }) => ({ Key })) + if (remove.length > 0) { + await client.send( + new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: remove } }) + ) + } +} + +const databaseUrl = required('DATABASE_URL') +const bucket = process.env.S3_BACKUP_BUCKET || required('S3_BUCKET') +const encryptionKey = Buffer.from(required('BACKUP_ENCRYPTION_KEY'), 'base64') +if (encryptionKey.length !== 32) { + throw new Error('BACKUP_ENCRYPTION_KEY must be a base64 encoded 32-byte key') +} + +const directory = await mkdtemp(join(tmpdir(), 'icp-backup-')) +const dumpFile = join(directory, 'database.dump') +const encryptedFile = join(directory, 'database.dump.enc') +const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replaceAll('.', '-') + +try { + await run('pg_dump', [ + '--dbname', + databaseUrl, + '--format=custom', + '--no-owner', + '--file', + dumpFile + ]) + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv) + await writeFile(encryptedFile, Buffer.concat([MAGIC, iv])) + await pipeline( + createReadStream(dumpFile), + cipher, + createWriteStream(encryptedFile, { flags: 'a' }) + ) + await appendFile(encryptedFile, cipher.getAuthTag()) + + const client = s3Client() + const dailyKey = `backups/daily/${timestamp}.dump.enc` + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: dailyKey, + Body: createReadStream(encryptedFile), + ContentType: 'application/octet-stream', + Metadata: { encryption: 'aes-256-gcm', format: 'pg-custom' } + }) + ) + + if (new Date().getUTCDay() === 0) { + const weeklyKey = `backups/weekly/${timestamp}.dump.enc` + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: weeklyKey, + Body: createReadStream(encryptedFile), + ContentType: 'application/octet-stream', + Metadata: { encryption: 'aes-256-gcm', format: 'pg-custom' } + }) + ) + } + + await prune(client, bucket, 'backups/daily/', DAILY_KEEP) + await prune(client, bucket, 'backups/weekly/', WEEKLY_KEEP) + process.stdout.write(`Encrypted PostgreSQL backup uploaded: ${dailyKey}\n`) +} finally { + await rm(directory, { recursive: true, force: true }) +} diff --git a/server/ops/restore.js b/server/ops/restore.js new file mode 100644 index 0000000..6c4a16b --- /dev/null +++ b/server/ops/restore.js @@ -0,0 +1,82 @@ +import { Buffer } from 'node:buffer' +import { createDecipheriv } from 'node:crypto' +import { createReadStream, createWriteStream } from 'node:fs' +import { mkdtemp, open, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import process from 'node:process' +import { spawn } from 'node:child_process' +import { pipeline } from 'node:stream/promises' + +const MAGIC = Buffer.from('ICPBKP1') + +function required(name) { + const value = process.env[name] + if (!value) throw new Error(`${name} is required`) + return value +} + +function run(command, args) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'inherit', 'inherit'] + }) + child.once('error', reject) + child.once('exit', (code) => + code === 0 + ? resolvePromise() + : reject(new Error(`${command} exited with ${code}`)) + ) + }) +} + +if (process.env.CONFIRM_RESTORE !== 'RESTORE') { + throw new Error( + 'Set CONFIRM_RESTORE=RESTORE to acknowledge the destructive restore' + ) +} +const source = resolve(required('BACKUP_FILE')) +const databaseUrl = required('DATABASE_URL') +const encryptionKey = Buffer.from(required('BACKUP_ENCRYPTION_KEY'), 'base64') +if (encryptionKey.length !== 32) + throw new Error('Invalid BACKUP_ENCRYPTION_KEY') + +const { size } = await stat(source) +const handle = await open(source, 'r') +const header = Buffer.alloc(MAGIC.length + 12) +const tag = Buffer.alloc(16) +await handle.read(header, 0, header.length, 0) +await handle.read(tag, 0, tag.length, size - tag.length) +await handle.close() +if (!header.subarray(0, MAGIC.length).equals(MAGIC)) + throw new Error('Invalid backup header') + +const directory = await mkdtemp(join(tmpdir(), 'icp-restore-')) +const dumpFile = join(directory, 'database.dump') +try { + const decipher = createDecipheriv( + 'aes-256-gcm', + encryptionKey, + header.subarray(MAGIC.length) + ) + decipher.setAuthTag(tag) + await pipeline( + createReadStream(source, { + start: header.length, + end: size - tag.length - 1 + }), + decipher, + createWriteStream(dumpFile) + ) + await run('pg_restore', [ + '--dbname', + databaseUrl, + '--clean', + '--if-exists', + '--no-owner', + dumpFile + ]) + process.stdout.write('PostgreSQL restore completed\n') +} finally { + await rm(directory, { recursive: true, force: true }) +} diff --git a/server/plugins/auth.js b/server/plugins/auth.js index 426f25f..61be98f 100644 --- a/server/plugins/auth.js +++ b/server/plugins/auth.js @@ -15,6 +15,16 @@ function sendError(reply, statusCode, message, data = null) { }) } +const passwordResetAllowedRequests = new Set([ + 'GET /api/v1/auth/session', + 'PATCH /api/v1/me/password' +]) + +function canUseTemporaryPassword(request) { + const pathname = request.routeOptions?.url ?? request.url.split('?', 1)[0] + return passwordResetAllowedRequests.has(`${request.method} ${pathname}`) +} + export async function authPlugin(app, options = {}) { const db = options.db ?? app.db if (!db) { @@ -24,7 +34,11 @@ export async function authPlugin(app, options = {}) { const allowBearer = options.allowBearer !== false const sessionTtlSeconds = Number(options.sessionTtlSeconds) || DEFAULT_SESSION_TTL_SECONDS - const appOrigin = options.appOrigin ?? app.config?.appOrigin ?? null + const appOrigins = new Set( + options.appOrigins ?? + app.config?.appOrigins ?? + [app.config?.appOrigin].filter(Boolean) + ) const secureCookies = options.secureCookies ?? process.env.NODE_ENV === 'production' @@ -39,7 +53,7 @@ export async function authPlugin(app, options = {}) { const hasSessionCookie = Boolean(request.cookies?.[SESSION_COOKIE_NAME]) const origin = request.headers.origin - if (hasSessionCookie && origin && appOrigin && origin !== appOrigin) { + if (hasSessionCookie && origin && !appOrigins.has(origin)) { return sendError(reply, 403, '请求来源不受信任') } }) @@ -50,7 +64,7 @@ export async function authPlugin(app, options = {}) { return sendError(reply, 401, '请先登录') } - const session = findSessionByToken(db, credential.token) + const session = await findSessionByToken(db, credential.token) if (!session) { if (credential.source === 'cookie') { reply.clearCookie(SESSION_COOKIE_NAME, { @@ -68,6 +82,12 @@ export async function authPlugin(app, options = {}) { ...session, credentialSource: credential.source } + + if (session.user.mustResetPassword && !canUseTemporaryPassword(request)) { + return sendError(reply, 403, '请先修改临时密码', { + reason: 'PASSWORD_RESET_REQUIRED' + }) + } } const requireRole = (...allowedRoles) => { diff --git a/server/routes/admin.js b/server/routes/admin.js index 061d528..71f6a9e 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -240,15 +240,17 @@ export async function adminRoutes(app) { } const whereClause = `WHERE ${where.join(' AND ')}` - const total = db - .prepare( - `SELECT COUNT(*) AS count + const total = ( + await db + .prepare( + `SELECT COUNT(*) AS count FROM courses AS c INNER JOIN users AS u ON u.id = c.teacher_id ${whereClause}` - ) - .get(...parameters).count - const rows = db + ) + .get(...parameters) + ).count + const rows = await db .prepare( `SELECT ${adminCourseColumns}, ( @@ -313,8 +315,8 @@ export async function adminRoutes(app) { const notificationId = randomUUID() const auditId = randomUUID() - const reviewCourse = db.transaction(() => { - const course = db + const reviewCourse = db.transaction(async () => { + const course = await db .prepare( `SELECT c.*, u.status AS teacher_account_status, tp.verified_at AS teacher_verified_at @@ -342,7 +344,7 @@ export async function adminRoutes(app) { throw new Error('COURSE_TEACHER_NOT_VERIFIED') } - const updateResult = db + const updateResult = await db .prepare( `UPDATE courses SET status = ?, rejection_reason = ?, published_at = ?, updated_at = ? @@ -360,63 +362,69 @@ export async function adminRoutes(app) { throw new Error('COURSE_STATE_CHANGED') } - db.prepare( - `INSERT INTO course_reviews ( + await db + .prepare( + `INSERT INTO course_reviews ( id, course_id, reviewer_id, decision, review_note, created_at ) VALUES (?, ?, ?, ?, ?, ?)` - ).run( - reviewId, - courseId, - request.auth.user.id, - decision, - note, - reviewedAt - ) + ) + .run( + reviewId, + courseId, + request.auth.user.id, + decision, + note, + reviewedAt + ) - db.prepare( - `INSERT INTO notifications ( + await db + .prepare( + `INSERT INTO notifications ( id, user_id, type, title, body, resource_type, resource_id, link, dedupe_key, created_at ) VALUES (?, ?, ?, ?, ?, 'course', ?, ?, ?, ?)` - ).run( - notificationId, - course.teacher_id, - `course.${decision}`, - action === 'approve' ? '课程审核已通过' : '课程审核未通过', - action === 'approve' - ? `课程「${course.title}」已发布,学生现在可以查看。` - : `课程「${course.title}」需要修改:${note}`, - courseId, - `/teacher/onlineCourses?courseId=${courseId}`, - `course:${courseId}:review:${reviewId}`, - reviewedAt - ) + ) + .run( + notificationId, + course.teacher_id, + `course.${decision}`, + action === 'approve' ? '课程审核已通过' : '课程审核未通过', + action === 'approve' + ? `课程「${course.title}」已发布,学生现在可以查看。` + : `课程「${course.title}」需要修改:${note}`, + courseId, + `/teacher/onlineCourses?courseId=${courseId}`, + `course:${courseId}:review:${reviewId}`, + reviewedAt + ) - db.prepare( - `INSERT INTO audit_logs ( + await db + .prepare( + `INSERT INTO audit_logs ( id, actor_id, action, entity_type, entity_id, details_json, request_id, ip_address, user_agent, created_at ) VALUES (?, ?, ?, 'course', ?, ?, ?, ?, ?, ?)` - ).run( - auditId, - request.auth.user.id, - `course.${decision}`, - courseId, - JSON.stringify({ - previousStatus: 'pending', - nextStatus, - reviewId, - note - }), - request.id ?? null, - request.ip ?? null, - request.headers['user-agent'] ?? null, - reviewedAt - ) + ) + .run( + auditId, + request.auth.user.id, + `course.${decision}`, + courseId, + JSON.stringify({ + previousStatus: 'pending', + nextStatus, + reviewId, + note + }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + reviewedAt + ) }) try { - reviewCourse() + await reviewCourse() } catch (error) { if (error?.message === 'COURSE_NOT_FOUND') { return responseError(reply, 404, '课程不存在') @@ -440,10 +448,10 @@ export async function adminRoutes(app) { throw error } - const course = db + const course = await db .prepare(`${adminCourseSelect} WHERE c.id = ? LIMIT 1`) .get(courseId) - const review = db + const review = await db .prepare('SELECT * FROM course_reviews WHERE id = ? LIMIT 1') .get(reviewId) @@ -486,15 +494,17 @@ export async function adminRoutes(app) { } const whereClause = `WHERE ${where.join(' AND ')}` - const total = db - .prepare( - `SELECT COUNT(*) AS count + const total = ( + await db + .prepare( + `SELECT COUNT(*) AS count FROM users AS u LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id ${whereClause}` - ) - .get(...parameters).count - const rows = db + ) + .get(...parameters) + ).count + const rows = await db .prepare( `${adminTeacherSelect} ${whereClause} @@ -533,8 +543,8 @@ export async function adminRoutes(app) { const notificationId = randomUUID() const nextStatus = action === 'approve' ? 'verified' : 'pending' - const decideVerification = db.transaction(() => { - const teacher = db + const decideVerification = db.transaction(async () => { + const teacher = await db .prepare( `SELECT u.id, u.status, u.display_name, tp.verified_at FROM users AS u @@ -559,65 +569,73 @@ export async function adminRoutes(app) { } if (action === 'approve') { - db.prepare( - `INSERT INTO teacher_profiles ( + await db + .prepare( + `INSERT INTO teacher_profiles ( user_id, verified_at, created_at, updated_at ) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET verified_at = excluded.verified_at, updated_at = excluded.updated_at` - ).run(teacherId, decidedAt, decidedAt, decidedAt) + ) + .run(teacherId, decidedAt, decidedAt, decidedAt) } else { - db.prepare( - `UPDATE teacher_profiles + await db + .prepare( + `UPDATE teacher_profiles SET verified_at = NULL, updated_at = ? WHERE user_id = ? AND verified_at IS NOT NULL` - ).run(decidedAt, teacherId) + ) + .run(decidedAt, teacherId) } - db.prepare( - `INSERT INTO notifications ( + await db + .prepare( + `INSERT INTO notifications ( id, user_id, type, title, body, resource_type, resource_id, link, dedupe_key, created_at ) VALUES (?, ?, ?, ?, ?, 'teacher', ?, ?, ?, ?)` - ).run( - notificationId, - teacherId, - `teacher.verification.${action === 'approve' ? 'approved' : 'revoked'}`, - action === 'approve' ? '教师身份认证已通过' : '教师身份认证已撤销', - action === 'approve' - ? `您的教师身份已通过平台认证。${note ? `审核备注:${note}` : ''}` - : `您的教师身份认证已撤销。原因:${note}`, - teacherId, - '/teacher/user', - `teacher:${teacherId}:verification:${auditId}`, - decidedAt - ) + ) + .run( + notificationId, + teacherId, + `teacher.verification.${action === 'approve' ? 'approved' : 'revoked'}`, + action === 'approve' ? '教师身份认证已通过' : '教师身份认证已撤销', + action === 'approve' + ? `您的教师身份已通过平台认证。${note ? `审核备注:${note}` : ''}` + : `您的教师身份认证已撤销。原因:${note}`, + teacherId, + '/teacher/user', + `teacher:${teacherId}:verification:${auditId}`, + decidedAt + ) - db.prepare( - `INSERT INTO audit_logs ( + await db + .prepare( + `INSERT INTO audit_logs ( id, actor_id, action, entity_type, entity_id, details_json, request_id, ip_address, user_agent, created_at ) VALUES (?, ?, ?, 'teacher', ?, ?, ?, ?, ?, ?)` - ).run( - auditId, - request.auth.user.id, - `teacher.verification.${action === 'approve' ? 'approved' : 'revoked'}`, - teacherId, - JSON.stringify({ - previousStatus: currentStatus, - nextStatus, - note - }), - request.id ?? null, - request.ip ?? null, - request.headers['user-agent'] ?? null, - decidedAt - ) + ) + .run( + auditId, + request.auth.user.id, + `teacher.verification.${action === 'approve' ? 'approved' : 'revoked'}`, + teacherId, + JSON.stringify({ + previousStatus: currentStatus, + nextStatus, + note + }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + decidedAt + ) }) try { - decideVerification() + await decideVerification() } catch (error) { if (error?.message === 'TEACHER_NOT_FOUND') { return responseError(reply, 404, '教师不存在') @@ -635,7 +653,7 @@ export async function adminRoutes(app) { throw error } - const teacher = db + const teacher = await db .prepare(`${adminTeacherSelect} WHERE u.id = ? LIMIT 1`) .get(teacherId) @@ -651,31 +669,31 @@ export async function adminRoutes(app) { '/api/v1/admin/metrics', { preHandler: app.requireRole('administrator') }, async (_request, reply) => { - const users = db + const users = await db .prepare( `SELECT COUNT(*) AS total, - COALESCE(SUM(role = 'student'), 0) AS students, - COALESCE(SUM(role = 'teacher'), 0) AS teachers, - COALESCE(SUM(role = 'administrator'), 0) AS administrators, - COALESCE(SUM(status = 'active'), 0) AS active, - COALESCE(SUM(status = 'disabled'), 0) AS disabled + COUNT(*) FILTER (WHERE role = 'student') AS students, + COUNT(*) FILTER (WHERE role = 'teacher') AS teachers, + COUNT(*) FILTER (WHERE role = 'administrator') AS administrators, + COUNT(*) FILTER (WHERE status = 'active') AS active, + COUNT(*) FILTER (WHERE status = 'disabled') AS disabled FROM users` ) .get() - const courses = db + const courses = await db .prepare( `SELECT COUNT(*) AS total, - COALESCE(SUM(status = 'draft'), 0) AS draft, - COALESCE(SUM(status = 'pending'), 0) AS pending, - COALESCE(SUM(status = 'published'), 0) AS published, - COALESCE(SUM(status = 'rejected'), 0) AS rejected, - COALESCE(SUM(status = 'archived'), 0) AS archived + COUNT(*) FILTER (WHERE status = 'draft') AS draft, + COUNT(*) FILTER (WHERE status = 'pending') AS pending, + COUNT(*) FILTER (WHERE status = 'published') AS published, + COUNT(*) FILTER (WHERE status = 'rejected') AS rejected, + COUNT(*) FILTER (WHERE status = 'archived') AS archived FROM courses` ) .get() - const learning = db + const learning = await db .prepare( `SELECT (SELECT COUNT(*) FROM appointments) AS appointments, @@ -687,18 +705,19 @@ export async function adminRoutes(app) { WHERE status = 'submitted') AS pending_grading` ) .get() - const reviews = db + const reviews = await db .prepare( `SELECT COUNT(*) AS total, - COALESCE(SUM(decision = 'approved'), 0) AS approved, - COALESCE(SUM(decision = 'rejected'), 0) AS rejected + COUNT(*) FILTER (WHERE decision = 'approved') AS approved, + COUNT(*) FILTER (WHERE decision = 'rejected') AS rejected FROM course_reviews` ) .get() - const recentActivity = db - .prepare( - `SELECT + const recentActivity = ( + await db + .prepare( + `SELECT a.id, a.actor_id, a.action, @@ -712,19 +731,19 @@ export async function adminRoutes(app) { LEFT JOIN users AS u ON u.id = a.actor_id ORDER BY a.created_at DESC, a.id DESC LIMIT 20` - ) - .all() - .map((row) => ({ - id: row.id, - actorId: row.actor_id ?? null, - actorName: row.actor_name ?? '系统', - actorRole: row.actor_role ?? null, - action: row.action, - entityType: row.entity_type, - entityId: row.entity_id ?? null, - details: parseJsonObject(row.details_json), - createdAt: row.created_at - })) + ) + .all() + ).map((row) => ({ + id: row.id, + actorId: row.actor_id ?? null, + actorName: row.actor_name ?? '系统', + actorRole: row.actor_role ?? null, + action: row.action, + entityType: row.entity_type, + entityId: row.entity_id ?? null, + details: parseJsonObject(row.details_json), + createdAt: row.created_at + })) return responseData(reply, { users, diff --git a/server/routes/appointments.js b/server/routes/appointments.js index 44e9a52..b1420a5 100644 --- a/server/routes/appointments.js +++ b/server/routes/appointments.js @@ -155,40 +155,57 @@ const appointmentSelect = ` LEFT JOIN classrooms AS classroom ON classroom.appointment_id = a.id ` -function findAppointment(db, appointmentId) { - return db +async function findAppointment(db, appointmentId) { + return await db .prepare(`${appointmentSelect} WHERE a.id = ? LIMIT 1`) .get(appointmentId) } -function insertNotification( +async function insertNotification( db, { userId, type, title, body, appointmentId, link = null, dedupeKey } ) { - db.prepare( - `INSERT INTO notifications ( + await db + .prepare( + `INSERT INTO notifications ( id, user_id, type, title, body, resource_type, resource_id, link, dedupe_key ) VALUES (?, ?, ?, ?, ?, 'appointment', ?, ?, ?)` - ).run(randomUUID(), userId, type, title, body, appointmentId, link, dedupeKey) + ) + .run( + randomUUID(), + userId, + type, + title, + body, + appointmentId, + link, + dedupeKey + ) } -function insertAudit(db, request, { action, appointmentId, details = {} }) { - db.prepare( - `INSERT INTO audit_logs ( +async function insertAudit( + db, + request, + { action, appointmentId, details = {} } +) { + await db + .prepare( + `INSERT INTO audit_logs ( id, actor_id, action, entity_type, entity_id, details_json, request_id, ip_address, user_agent ) VALUES (?, ?, ?, 'appointment', ?, ?, ?, ?, ?)` - ).run( - randomUUID(), - request.auth.user.id, - action, - appointmentId, - JSON.stringify(details), - String(request.id ?? '').slice(0, 128) || null, - String(request.ip ?? '').slice(0, 64) || null, - String(request.headers['user-agent'] ?? '').slice(0, 512) || null - ) + ) + .run( + randomUUID(), + request.auth.user.id, + action, + appointmentId, + JSON.stringify(details), + String(request.id ?? '').slice(0, 128) || null, + String(request.ip ?? '').slice(0, 64) || null, + String(request.headers['user-agent'] ?? '').slice(0, 512) || null + ) } function roomCode() { @@ -259,7 +276,7 @@ export async function appointmentRoutes(app) { return responseError(reply, 400, '预约时间不能超过未来一年') } - const teacher = db + const teacher = await db .prepare( `SELECT u.id, tp.verified_at FROM users AS u @@ -279,7 +296,7 @@ export async function appointmentRoutes(app) { const courseId = result.data.courseId ?? null if (courseId) { - const course = db + const course = await db .prepare( `SELECT id FROM courses WHERE id = ? AND teacher_id = ? AND status = 'published' @@ -298,27 +315,29 @@ export async function appointmentRoutes(app) { ).toISOString() const timestamp = new Date().toISOString() - const createAppointment = db.transaction(() => { - db.prepare( - `INSERT INTO appointments ( + const createAppointment = db.transaction(async () => { + await db + .prepare( + `INSERT INTO appointments ( id, student_id, teacher_id, course_id, scheduled_start, scheduled_end, topic, message, status, response_note, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', '', ?, ?)` - ).run( - appointmentId, - request.auth.user.id, - teacher.id, - courseId, - scheduledStart, - scheduledEnd, - result.data.topic, - result.data.message, - timestamp, - timestamp - ) + ) + .run( + appointmentId, + request.auth.user.id, + teacher.id, + courseId, + scheduledStart, + scheduledEnd, + result.data.topic, + result.data.message, + timestamp, + timestamp + ) - insertNotification(db, { + await insertNotification(db, { userId: teacher.id, type: 'appointment.requested', title: '收到新的课堂预约', @@ -327,7 +346,7 @@ export async function appointmentRoutes(app) { link: '/teacher/teachingDocking', dedupeKey: `appointment:${appointmentId}:requested:${teacher.id}` }) - insertAudit(db, request, { + await insertAudit(db, request, { action: 'appointment.requested', appointmentId, details: { @@ -340,7 +359,7 @@ export async function appointmentRoutes(app) { }) try { - createAppointment() + await createAppointment() } catch (error) { if (conflictError(error)) { return responseError(reply, 409, '相同时间的预约已经存在') @@ -350,7 +369,7 @@ export async function appointmentRoutes(app) { return responseData( reply, - appointmentData(findAppointment(db, appointmentId)), + appointmentData(await findAppointment(db, appointmentId)), '预约申请已提交', 201 ) @@ -420,11 +439,13 @@ export async function appointmentRoutes(app) { } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' - const total = db - .prepare(`SELECT COUNT(*) AS count FROM appointments AS a ${where}`) - .get(...parameters).count + const total = ( + await db + .prepare(`SELECT COUNT(*) AS count FROM appointments AS a ${where}`) + .get(...parameters) + ).count const offset = (filters.page - 1) * filters.pageSize - const appointments = db + const appointments = await db .prepare( `${appointmentSelect} ${where} @@ -454,8 +475,8 @@ export async function appointmentRoutes(app) { return validationError(reply, result) } - const respond = db.transaction(() => { - const appointment = db + const respond = db.transaction(async () => { + const appointment = await db .prepare('SELECT * FROM appointments WHERE id = ? LIMIT 1') .get(request.params.id) @@ -470,7 +491,7 @@ export async function appointmentRoutes(app) { } if (result.data.action === 'accept') { - const eligibleTeacher = db + const eligibleTeacher = await db .prepare( `SELECT 1 FROM users AS u @@ -485,7 +506,7 @@ export async function appointmentRoutes(app) { return { error: 'TEACHER_NOT_VERIFIED' } } - const conflict = db + const conflict = await db .prepare( `SELECT id, teacher_id, student_id FROM appointments WHERE (teacher_id = ? OR student_id = ?) @@ -517,7 +538,7 @@ export async function appointmentRoutes(app) { const timestamp = new Date().toISOString() const status = result.data.action === 'accept' ? 'accepted' : 'rejected' - const updated = db + const updated = await db .prepare( `UPDATE appointments SET status = ?, response_note = ?, updated_at = ? @@ -531,13 +552,15 @@ export async function appointmentRoutes(app) { let classroomId = null if (status === 'accepted') { classroomId = randomUUID() - db.prepare( - `INSERT INTO classrooms ( + await db + .prepare( + `INSERT INTO classrooms ( id, appointment_id, room_code, status, created_at, updated_at ) VALUES (?, ?, ?, 'scheduled', ?, ?)` - ).run(classroomId, appointment.id, roomCode(), timestamp, timestamp) + ) + .run(classroomId, appointment.id, roomCode(), timestamp, timestamp) - insertNotification(db, { + await insertNotification(db, { userId: appointment.student_id, type: 'appointment.accepted', title: '预约已被教师接受', @@ -546,7 +569,7 @@ export async function appointmentRoutes(app) { link: `/student/liveClass?classroomId=${classroomId}`, dedupeKey: `appointment:${appointment.id}:accepted:${appointment.student_id}` }) - insertNotification(db, { + await insertNotification(db, { userId: appointment.teacher_id, type: 'appointment.accepted', title: '课堂预约已确认', @@ -556,7 +579,7 @@ export async function appointmentRoutes(app) { dedupeKey: `appointment:${appointment.id}:accepted:${appointment.teacher_id}` }) } else { - insertNotification(db, { + await insertNotification(db, { userId: appointment.student_id, type: 'appointment.rejected', title: '预约未被接受', @@ -566,7 +589,7 @@ export async function appointmentRoutes(app) { }) } - insertAudit(db, request, { + await insertAudit(db, request, { action: `appointment.${status}`, appointmentId: appointment.id, details: { @@ -580,7 +603,7 @@ export async function appointmentRoutes(app) { return { appointmentId: appointment.id } }) - const outcome = respond() + const outcome = await respond() if (outcome.error === 'NOT_FOUND') { return responseError(reply, 404, '预约不存在') } @@ -606,7 +629,7 @@ export async function appointmentRoutes(app) { }) } - const appointment = findAppointment(db, outcome.appointmentId) + const appointment = await findAppointment(db, outcome.appointmentId) return responseData( reply, appointmentData(appointment), @@ -624,8 +647,8 @@ export async function appointmentRoutes(app) { return validationError(reply, result) } - const cancel = db.transaction(() => { - const appointment = db + const cancel = db.transaction(async () => { + const appointment = await db .prepare('SELECT * FROM appointments WHERE id = ? LIMIT 1') .get(request.params.id) if (!appointment) { @@ -646,7 +669,7 @@ export async function appointmentRoutes(app) { } const timestamp = new Date().toISOString() - const updated = db + const updated = await db .prepare( `UPDATE appointments SET status = 'cancelled', updated_at = ? @@ -657,18 +680,20 @@ export async function appointmentRoutes(app) { return { error: 'INVALID_STATE' } } - db.prepare( - `UPDATE classrooms + await db + .prepare( + `UPDATE classrooms SET status = 'closed', opened_at = COALESCE(opened_at, ?), closed_at = ?, updated_at = ? WHERE appointment_id = ? AND status <> 'closed'` - ).run(timestamp, timestamp, timestamp, appointment.id) + ) + .run(timestamp, timestamp, timestamp, appointment.id) const otherParticipantId = role === 'student' ? appointment.teacher_id : appointment.student_id - insertNotification(db, { + await insertNotification(db, { userId: otherParticipantId, type: 'appointment.cancelled', title: '课堂预约已取消', @@ -676,7 +701,7 @@ export async function appointmentRoutes(app) { appointmentId: appointment.id, dedupeKey: `appointment:${appointment.id}:cancelled:${otherParticipantId}` }) - insertAudit(db, request, { + await insertAudit(db, request, { action: 'appointment.cancelled', appointmentId: appointment.id, details: { @@ -689,7 +714,7 @@ export async function appointmentRoutes(app) { return { appointmentId: appointment.id } }) - const outcome = cancel() + const outcome = await cancel() if (outcome.error === 'NOT_FOUND') { return responseError(reply, 404, '预约不存在') } @@ -704,7 +729,7 @@ export async function appointmentRoutes(app) { return responseData( reply, - appointmentData(findAppointment(db, outcome.appointmentId)), + appointmentData(await findAppointment(db, outcome.appointmentId)), '预约已取消' ) } @@ -714,8 +739,8 @@ export async function appointmentRoutes(app) { '/api/v1/classrooms/:id/complete', { preHandler: app.authenticate }, async (request, reply) => { - const complete = db.transaction(() => { - const classroom = db + const complete = db.transaction(async () => { + const classroom = await db .prepare( `SELECT classroom.id AS classroom_id, @@ -748,7 +773,7 @@ export async function appointmentRoutes(app) { } const timestamp = new Date().toISOString() - const appointmentUpdated = db + const appointmentUpdated = await db .prepare( `UPDATE appointments SET status = 'completed', updated_at = ? @@ -759,7 +784,7 @@ export async function appointmentRoutes(app) { throw lifecycleFailure('APPOINTMENT_UPDATE_FAILED') } - const classroomUpdated = db + const classroomUpdated = await db .prepare( `UPDATE classrooms SET status = 'closed', @@ -774,7 +799,7 @@ export async function appointmentRoutes(app) { const otherParticipantId = role === 'student' ? classroom.teacher_id : classroom.student_id - insertNotification(db, { + await insertNotification(db, { userId: otherParticipantId, type: 'appointment.completed', title: '课堂已完成', @@ -784,7 +809,7 @@ export async function appointmentRoutes(app) { role === 'student' ? '/teacher/teachingDocking' : '/student/home', dedupeKey: `appointment:${classroom.id}:completed:${otherParticipantId}` }) - insertAudit(db, request, { + await insertAudit(db, request, { action: 'appointment.completed', appointmentId: classroom.id, details: { @@ -800,7 +825,7 @@ export async function appointmentRoutes(app) { let outcome try { - outcome = complete() + outcome = await complete() } catch (error) { if (error.lifecycleCode) { request.log.error( @@ -827,7 +852,7 @@ export async function appointmentRoutes(app) { return responseData( reply, - appointmentData(findAppointment(db, outcome.appointmentId)), + appointmentData(await findAppointment(db, outcome.appointmentId)), '课堂已完成' ) } @@ -837,7 +862,7 @@ export async function appointmentRoutes(app) { '/api/v1/classrooms/:id/join-info', { preHandler: app.authenticate }, async (request, reply) => { - const classroom = db + const classroom = await db .prepare( `SELECT classroom.id, diff --git a/server/routes/assignments.js b/server/routes/assignments.js index 702ef03..64cbd04 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -263,14 +263,14 @@ const assignmentSelect = ` INNER JOIN courses AS c ON c.id = a.course_id ` -function findAssignment(db, assignmentId) { - return db +async function findAssignment(db, assignmentId) { + return await db .prepare(`${assignmentSelect} WHERE a.id = ? LIMIT 1`) .get(assignmentId) } -function findQuestions(db, assignmentId) { - return db +async function findQuestions(db, assignmentId) { + return await db .prepare( `SELECT * FROM assignment_questions WHERE assignment_id = ? @@ -373,8 +373,8 @@ function submissionData(row, { includeStudent = false } = {}) { return submission } -function assignmentDetail(db, row, { includeCorrectAnswer, studentId }) { - const questions = findQuestions(db, row.id) +async function assignmentDetail(db, row, { includeCorrectAnswer, studentId }) { + const questions = await findQuestions(db, row.id) const detail = { ...assignmentSummary(row), questionCount: questions.length, @@ -389,7 +389,7 @@ function assignmentDetail(db, row, { includeCorrectAnswer, studentId }) { } if (studentId) { - const submission = db + const submission = await db .prepare( `SELECT * FROM submissions WHERE assignment_id = ? AND student_id = ? @@ -402,7 +402,7 @@ function assignmentDetail(db, row, { includeCorrectAnswer, studentId }) { return detail } -function insertQuestions(db, assignmentId, questions) { +async function insertQuestions(db, assignmentId, questions) { const insert = db.prepare( `INSERT INTO assignment_questions ( id, assignment_id, position, question_type, prompt, options_json, @@ -410,8 +410,8 @@ function insertQuestions(db, assignmentId, questions) { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ) - questions.forEach((question, index) => { - insert.run( + for (const [index, question] of questions.entries()) { + await insert.run( randomUUID(), assignmentId, index + 1, @@ -422,26 +422,32 @@ function insertQuestions(db, assignmentId, questions) { question.points, question.explanation ) - }) + } } -function insertAudit(db, request, { action, entityType, entityId, details }) { - db.prepare( - `INSERT INTO audit_logs ( +async function insertAudit( + db, + request, + { action, entityType, entityId, details } +) { + await db + .prepare( + `INSERT INTO audit_logs ( id, actor_id, action, entity_type, entity_id, details_json, request_id, ip_address, user_agent ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - randomUUID(), - request.auth.user.id, - action, - entityType, - entityId, - JSON.stringify(details ?? {}), - String(request.id ?? '').slice(0, 128) || null, - String(request.ip ?? '').slice(0, 64) || null, - String(request.headers['user-agent'] ?? '').slice(0, 512) || null - ) + ) + .run( + randomUUID(), + request.auth.user.id, + action, + entityType, + entityId, + JSON.stringify(details ?? {}), + String(request.id ?? '').slice(0, 128) || null, + String(request.ip ?? '').slice(0, 64) || null, + String(request.headers['user-agent'] ?? '').slice(0, 512) || null + ) } function answerErrors(questions, answers, requireComplete) { @@ -519,7 +525,7 @@ export async function assignmentRoutes(app) { const paramsResult = courseIdParamsSchema.safeParse(request.params) if (!paramsResult.success) return validationError(reply, paramsResult) - const course = db + const course = await db .prepare('SELECT * FROM courses WHERE id = ? LIMIT 1') .get(paramsResult.data.courseId) if (!course) return responseError(reply, 404, '课程不存在') @@ -537,7 +543,7 @@ export async function assignmentRoutes(app) { let rows if (role === 'student') { - rows = db + rows = await db .prepare( `SELECT a.*, @@ -553,12 +559,12 @@ export async function assignmentRoutes(app) { LEFT JOIN submissions AS s ON s.assignment_id = a.id AND s.student_id = ? WHERE a.course_id = ? AND a.status = 'published' - GROUP BY a.id + GROUP BY a.id, s.id ORDER BY a.due_at IS NULL, a.due_at ASC, a.created_at DESC` ) .all(userId, course.id) } else { - rows = db + rows = await db .prepare( `SELECT a.*, COUNT(q.id) AS question_count FROM assignments AS a @@ -589,8 +595,8 @@ export async function assignmentRoutes(app) { const input = bodyResult.data const assignmentId = randomUUID() const timestamp = new Date().toISOString() - const create = db.transaction(() => { - const course = db + const create = db.transaction(async () => { + const course = await db .prepare('SELECT * FROM courses WHERE id = ? LIMIT 1') .get(paramsResult.data.courseId) if (!course) return { error: 'NOT_FOUND' } @@ -601,24 +607,26 @@ export async function assignmentRoutes(app) { return { error: 'COURSE_ARCHIVED' } } - db.prepare( - `INSERT INTO assignments ( + await db + .prepare( + `INSERT INTO assignments ( id, course_id, teacher_id, title, instructions, due_at, max_score, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)` - ).run( - assignmentId, - course.id, - request.auth.user.id, - input.title, - input.instructions, - input.dueAt, - input.maxScore, - timestamp, - timestamp - ) - insertQuestions(db, assignmentId, input.questions) - insertAudit(db, request, { + ) + .run( + assignmentId, + course.id, + request.auth.user.id, + input.title, + input.instructions, + input.dueAt, + input.maxScore, + timestamp, + timestamp + ) + await insertQuestions(db, assignmentId, input.questions) + await insertAudit(db, request, { action: 'assignment.created', entityType: 'assignment', entityId: assignmentId, @@ -629,7 +637,7 @@ export async function assignmentRoutes(app) { let outcome try { - outcome = create() + outcome = await create() } catch (error) { if (conflictError(error)) { return responseError(reply, 409, '该课程中已存在同名作业') @@ -649,7 +657,7 @@ export async function assignmentRoutes(app) { return responseData( reply, - assignmentDetail(db, findAssignment(db, assignmentId), { + await assignmentDetail(db, await findAssignment(db, assignmentId), { includeCorrectAnswer: true }), '作业草稿已创建', @@ -671,8 +679,8 @@ export async function assignmentRoutes(app) { if (!bodyResult.success) return validationError(reply, bodyResult) const input = bodyResult.data - const update = db.transaction(() => { - const assignment = findAssignment(db, paramsResult.data.id) + const update = db.transaction(async () => { + const assignment = await findAssignment(db, paramsResult.data.id) if (!assignment) return { error: 'NOT_FOUND' } if (assignment.teacher_id !== request.auth.user.id) { return { error: 'FORBIDDEN' } @@ -685,12 +693,14 @@ export async function assignmentRoutes(app) { const questionTotal = input.questions ? input.questions.reduce((sum, question) => sum + question.points, 0) : Number( - db - .prepare( - `SELECT COALESCE(SUM(points), 0) AS total + ( + await db + .prepare( + `SELECT COALESCE(SUM(points), 0) AS total FROM assignment_questions WHERE assignment_id = ?` - ) - .get(assignment.id).total + ) + .get(assignment.id) + ).total ) if (!scoresEqual(questionTotal, nextMaxScore)) { return { @@ -717,18 +727,20 @@ export async function assignmentRoutes(app) { const timestamp = new Date().toISOString() assignments.push('updated_at = ?') values.push(timestamp, assignment.id) - db.prepare( - `UPDATE assignments SET ${assignments.join(', ')} WHERE id = ?` - ).run(...values) + await db + .prepare( + `UPDATE assignments SET ${assignments.join(', ')} WHERE id = ?` + ) + .run(...values) if (input.questions) { - db.prepare( - 'DELETE FROM assignment_questions WHERE assignment_id = ?' - ).run(assignment.id) - insertQuestions(db, assignment.id, input.questions) + await db + .prepare('DELETE FROM assignment_questions WHERE assignment_id = ?') + .run(assignment.id) + await insertQuestions(db, assignment.id, input.questions) } - insertAudit(db, request, { + await insertAudit(db, request, { action: 'assignment.updated', entityType: 'assignment', entityId: assignment.id, @@ -739,7 +751,7 @@ export async function assignmentRoutes(app) { let outcome try { - outcome = update() + outcome = await update() } catch (error) { if (conflictError(error)) { return responseError(reply, 409, '该课程中已存在同名作业') @@ -768,9 +780,13 @@ export async function assignmentRoutes(app) { return responseData( reply, - assignmentDetail(db, findAssignment(db, outcome.assignmentId), { - includeCorrectAnswer: true - }), + await assignmentDetail( + db, + await findAssignment(db, outcome.assignmentId), + { + includeCorrectAnswer: true + } + ), '作业草稿已更新' ) } @@ -783,8 +799,8 @@ export async function assignmentRoutes(app) { const paramsResult = assignmentIdParamsSchema.safeParse(request.params) if (!paramsResult.success) return validationError(reply, paramsResult) - const publish = db.transaction(() => { - const assignment = findAssignment(db, paramsResult.data.id) + const publish = db.transaction(async () => { + const assignment = await findAssignment(db, paramsResult.data.id) if (!assignment) return { error: 'NOT_FOUND' } if (assignment.teacher_id !== request.auth.user.id) { return { error: 'FORBIDDEN' } @@ -796,7 +812,7 @@ export async function assignmentRoutes(app) { return { error: 'DEADLINE_PASSED', dueAt: assignment.due_at } } - const questions = db + const questions = await db .prepare( `SELECT COUNT(*) AS count, COALESCE(SUM(points), 0) AS total FROM assignment_questions WHERE assignment_id = ?` @@ -815,7 +831,7 @@ export async function assignmentRoutes(app) { } const timestamp = new Date().toISOString() - const updated = db + const updated = await db .prepare( `UPDATE assignments SET status = 'published', updated_at = ? @@ -824,7 +840,7 @@ export async function assignmentRoutes(app) { .run(timestamp, assignment.id, request.auth.user.id) if (updated.changes !== 1) return { error: 'STATE_CHANGED' } - insertAudit(db, request, { + await insertAudit(db, request, { action: 'assignment.published', entityType: 'assignment', entityId: assignment.id, @@ -833,7 +849,7 @@ export async function assignmentRoutes(app) { return { assignmentId: assignment.id } }) - const outcome = publish() + const outcome = await publish() if (outcome.error === 'NOT_FOUND') { return responseError(reply, 404, '作业不存在') } @@ -864,9 +880,13 @@ export async function assignmentRoutes(app) { return responseData( reply, - assignmentDetail(db, findAssignment(db, outcome.assignmentId), { - includeCorrectAnswer: true - }), + await assignmentDetail( + db, + await findAssignment(db, outcome.assignmentId), + { + includeCorrectAnswer: true + } + ), '作业已发布' ) } @@ -879,8 +899,8 @@ export async function assignmentRoutes(app) { const paramsResult = assignmentIdParamsSchema.safeParse(request.params) if (!paramsResult.success) return validationError(reply, paramsResult) - const close = db.transaction(() => { - const assignment = findAssignment(db, paramsResult.data.id) + const close = db.transaction(async () => { + const assignment = await findAssignment(db, paramsResult.data.id) if (!assignment) return { error: 'NOT_FOUND' } if (assignment.teacher_id !== request.auth.user.id) { return { error: 'FORBIDDEN' } @@ -890,7 +910,7 @@ export async function assignmentRoutes(app) { } const timestamp = new Date().toISOString() - const updated = db + const updated = await db .prepare( `UPDATE assignments SET status = 'closed', updated_at = ? @@ -899,7 +919,7 @@ export async function assignmentRoutes(app) { .run(timestamp, assignment.id, request.auth.user.id) if (updated.changes !== 1) return { error: 'STATE_CHANGED' } - insertAudit(db, request, { + await insertAudit(db, request, { action: 'assignment.closed', entityType: 'assignment', entityId: assignment.id, @@ -908,7 +928,7 @@ export async function assignmentRoutes(app) { return { assignmentId: assignment.id } }) - const outcome = close() + const outcome = await close() if (outcome.error === 'NOT_FOUND') { return responseError(reply, 404, '作业不存在') } @@ -927,9 +947,13 @@ export async function assignmentRoutes(app) { return responseData( reply, - assignmentDetail(db, findAssignment(db, outcome.assignmentId), { - includeCorrectAnswer: true - }), + await assignmentDetail( + db, + await findAssignment(db, outcome.assignmentId), + { + includeCorrectAnswer: true + } + ), '作业已关闭' ) } @@ -942,7 +966,7 @@ export async function assignmentRoutes(app) { const paramsResult = assignmentIdParamsSchema.safeParse(request.params) if (!paramsResult.success) return validationError(reply, paramsResult) - const assignment = findAssignment(db, paramsResult.data.id) + const assignment = await findAssignment(db, paramsResult.data.id) if (!assignment) return responseError(reply, 404, '作业不存在') const { role, id: userId } = request.auth.user @@ -955,7 +979,7 @@ export async function assignmentRoutes(app) { } return responseData( reply, - assignmentDetail(db, assignment, { + await assignmentDetail(db, assignment, { includeCorrectAnswer: false, studentId: userId }) @@ -971,7 +995,7 @@ export async function assignmentRoutes(app) { return responseData( reply, - assignmentDetail(db, assignment, { includeCorrectAnswer: true }) + await assignmentDetail(db, assignment, { includeCorrectAnswer: true }) ) } ) @@ -989,8 +1013,8 @@ export async function assignmentRoutes(app) { if (!bodyResult.success) return validationError(reply, bodyResult) const input = bodyResult.data - const save = db.transaction(() => { - const assignment = findAssignment(db, paramsResult.data.id) + const save = db.transaction(async () => { + const assignment = await findAssignment(db, paramsResult.data.id) if (!assignment) return { error: 'NOT_FOUND' } if (assignment.status === 'closed') { return { error: 'ASSIGNMENT_CLOSED' } @@ -1009,7 +1033,7 @@ export async function assignmentRoutes(app) { } } - const existing = db + const existing = await db .prepare( `SELECT * FROM submissions WHERE assignment_id = ? AND student_id = ? LIMIT 1` @@ -1031,7 +1055,7 @@ export async function assignmentRoutes(app) { } } - const questions = findQuestions(db, assignment.id) + const questions = await findQuestions(db, assignment.id) const issues = answerErrors( questions, input.answers, @@ -1045,7 +1069,7 @@ export async function assignmentRoutes(app) { const submittedAt = nextStatus === 'submitted' ? timestamp : null if (existing) { - const updated = db + const updated = await db .prepare( `UPDATE submissions SET answers_json = ?, status = ?, submitted_at = ?, updated_at = ? @@ -1061,40 +1085,44 @@ export async function assignmentRoutes(app) { ) if (updated.changes !== 1) return { error: 'STATE_CHANGED' } } else { - db.prepare( - `INSERT INTO submissions ( + await db + .prepare( + `INSERT INTO submissions ( id, assignment_id, student_id, answers_json, status, submitted_at, feedback, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, '', ?, ?)` - ).run( - submissionId, - assignment.id, - request.auth.user.id, - JSON.stringify(input.answers), - nextStatus, - submittedAt, - timestamp, - timestamp - ) + ) + .run( + submissionId, + assignment.id, + request.auth.user.id, + JSON.stringify(input.answers), + nextStatus, + submittedAt, + timestamp, + timestamp + ) } if (nextStatus === 'submitted') { - db.prepare( - `INSERT INTO notifications ( + await db + .prepare( + `INSERT INTO notifications ( id, user_id, type, title, body, resource_type, resource_id, link, dedupe_key, created_at ) VALUES (?, ?, 'assignment.submitted', ?, ?, 'submission', ?, ?, ?, ?)` - ).run( - randomUUID(), - assignment.teacher_id, - '有新的作业提交', - `学生提交了作业「${assignment.title}」。`, - submissionId, - `/teacher/courseDetails?assignmentId=${assignment.id}`, - `submission:${submissionId}:submitted:${assignment.teacher_id}`, - timestamp - ) - insertAudit(db, request, { + ) + .run( + randomUUID(), + assignment.teacher_id, + '有新的作业提交', + `学生提交了作业「${assignment.title}」。`, + submissionId, + `/teacher/courseDetails?assignmentId=${assignment.id}`, + `submission:${submissionId}:submitted:${assignment.teacher_id}`, + timestamp + ) + await insertAudit(db, request, { action: 'submission.submitted', entityType: 'submission', entityId: submissionId, @@ -1105,7 +1133,7 @@ export async function assignmentRoutes(app) { return { submissionId, created: !existing } }) - const outcome = save() + const outcome = await save() if (outcome.error === 'NOT_FOUND') { return responseError(reply, 404, '作业不存在') } @@ -1147,7 +1175,7 @@ export async function assignmentRoutes(app) { return responseError(reply, 409, '提交状态已变化,请刷新后重试') } - const submission = db + const submission = await db .prepare('SELECT * FROM submissions WHERE id = ? LIMIT 1') .get(outcome.submissionId) return responseData( @@ -1176,10 +1204,12 @@ export async function assignmentRoutes(app) { parameters.push(status) } const where = `WHERE ${conditions.join(' AND ')}` - const total = db - .prepare(`SELECT COUNT(*) AS count FROM submissions AS s ${where}`) - .get(...parameters).count - const rows = db + const total = ( + await db + .prepare(`SELECT COUNT(*) AS count FROM submissions AS s ${where}`) + .get(...parameters) + ).count + const rows = await db .prepare( `SELECT s.*, @@ -1215,7 +1245,7 @@ export async function assignmentRoutes(app) { ) if (!queryResult.success) return validationError(reply, queryResult) - const assignment = findAssignment(db, paramsResult.data.id) + const assignment = await findAssignment(db, paramsResult.data.id) if (!assignment) return responseError(reply, 404, '作业不存在') if (assignment.teacher_id !== request.auth.user.id) { return responseError(reply, 403, '只能查看自己作业的提交记录') @@ -1232,10 +1262,12 @@ export async function assignmentRoutes(app) { parameters.push(status) } const where = `WHERE ${conditions.join(' AND ')}` - const total = db - .prepare(`SELECT COUNT(*) AS count FROM submissions AS s ${where}`) - .get(...parameters).count - const rows = db + const total = ( + await db + .prepare(`SELECT COUNT(*) AS count FROM submissions AS s ${where}`) + .get(...parameters) + ).count + const rows = await db .prepare( `SELECT s.*, @@ -1274,8 +1306,8 @@ export async function assignmentRoutes(app) { if (!bodyResult.success) return validationError(reply, bodyResult) const input = bodyResult.data - const grade = db.transaction(() => { - const submission = db + const grade = db.transaction(async () => { + const submission = await db .prepare( `SELECT s.*, @@ -1303,7 +1335,7 @@ export async function assignmentRoutes(app) { } const timestamp = new Date().toISOString() - const updated = db + const updated = await db .prepare( `UPDATE submissions SET status = 'graded', score = ?, feedback = ?, graded_by = ?, @@ -1320,22 +1352,24 @@ export async function assignmentRoutes(app) { ) if (updated.changes !== 1) return { error: 'STATE_CHANGED' } - db.prepare( - `INSERT INTO notifications ( + await db + .prepare( + `INSERT INTO notifications ( id, user_id, type, title, body, resource_type, resource_id, link, dedupe_key, created_at ) VALUES (?, ?, 'submission.graded', ?, ?, 'submission', ?, ?, ?, ?)` - ).run( - randomUUID(), - submission.student_id, - '作业已批阅', - `作业「${submission.assignment_title}」已批阅,得分 ${input.score}/${Number(submission.max_score)}。`, - submission.id, - `/student/homeWork?submissionId=${submission.id}`, - `submission:${submission.id}:graded:${submission.student_id}`, - timestamp - ) - insertAudit(db, request, { + ) + .run( + randomUUID(), + submission.student_id, + '作业已批阅', + `作业「${submission.assignment_title}」已批阅,得分 ${input.score}/${Number(submission.max_score)}。`, + submission.id, + `/student/homeWork?submissionId=${submission.id}`, + `submission:${submission.id}:graded:${submission.student_id}`, + timestamp + ) + await insertAudit(db, request, { action: 'submission.graded', entityType: 'submission', entityId: submission.id, @@ -1349,7 +1383,7 @@ export async function assignmentRoutes(app) { return { submissionId: submission.id } }) - const outcome = grade() + const outcome = await grade() if (outcome.error === 'NOT_FOUND') { return responseError(reply, 404, '提交记录不存在') } @@ -1371,7 +1405,7 @@ export async function assignmentRoutes(app) { return responseError(reply, 409, '提交状态已变化,请刷新后重试') } - const submission = db + const submission = await db .prepare( `SELECT s.*, diff --git a/server/routes/auth.js b/server/routes/auth.js index fc46f56..efe7619 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -142,9 +142,21 @@ export async function authRoutes(app) { const verificationSecret = app.config?.verificationCodeSecret || 'local-development-only-secret' const mailProvider = createMailProvider({ + mailRelayUrl: app.config?.mailRelayUrl, + mailRelaySecret: app.config?.mailRelaySecret, smtpUrl: app.config?.smtpUrl, + smtpHost: app.config?.smtpHost, + smtpPort: app.config?.smtpPort, + smtpSecure: app.config?.smtpSecure, + smtpUser: app.config?.smtpUser, + smtpPass: app.config?.smtpPass, mailFrom: app.config?.mailFrom }) + const mailConfigured = Boolean( + (app.config?.mailRelayUrl && app.config?.mailRelaySecret) || + app.config?.smtpUrl || + (app.config?.smtpHost && app.config?.smtpUser && app.config?.smtpPass) + ) app.post( '/api/v1/auth/verification-code', @@ -166,13 +178,13 @@ export async function authRoutes(app) { if ( isProduction && - (!app.config?.verificationCodeSecret || !app.config?.smtpUrl) + (!app.config?.verificationCodeSecret || !mailConfigured) ) { return responseError(reply, 503, '注册邮件服务尚未配置') } const { email } = result.data - const existingUser = db + const existingUser = await db .prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') .get(email) if (existingUser) { @@ -184,36 +196,42 @@ export async function authRoutes(app) { const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString() const id = randomUUID() - const replaceCode = db.transaction(() => { - db.prepare( - `UPDATE verification_codes + const replaceCode = db.transaction(async () => { + await db + .prepare( + `UPDATE verification_codes SET consumed_at = ? WHERE email = ? COLLATE NOCASE AND purpose = 'register' AND consumed_at IS NULL` - ).run(now, email) - db.prepare( - `INSERT INTO verification_codes ( + ) + .run(now, email) + await db + .prepare( + `INSERT INTO verification_codes ( id, email, purpose, code_hash, expires_at, created_at ) VALUES (?, ?, 'register', ?, ?, ?)` - ).run( - id, - email, - verificationDigest(verificationSecret, email, code), - expiresAt, - now - ) + ) + .run( + id, + email, + verificationDigest(verificationSecret, email, code), + expiresAt, + now + ) }) - replaceCode() + await replaceCode() if (isProduction) { try { await mailProvider.sendVerificationCode({ email, code, expiresAt }) } catch (error) { - db.prepare( - `UPDATE verification_codes + await db + .prepare( + `UPDATE verification_codes SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL` - ).run(new Date().toISOString(), id) + ) + .run(new Date().toISOString(), id) request.log.error( { err: error }, 'Registration verification email delivery failed' @@ -254,7 +272,7 @@ export async function authRoutes(app) { } const input = result.data - const existingUser = db + const existingUser = await db .prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') .get(input.email) @@ -262,7 +280,7 @@ export async function authRoutes(app) { return responseError(reply, 409, '该邮箱已注册') } - const verification = db + const verification = await db .prepare( `SELECT id, code_hash, expires_at, attempts FROM verification_codes @@ -286,11 +304,13 @@ export async function authRoutes(app) { if (!verificationValid) { if (verification) { - db.prepare( - `UPDATE verification_codes - SET attempts = MIN(attempts + 1, 10) + await db + .prepare( + `UPDATE verification_codes + SET attempts = LEAST(attempts + 1, 10) WHERE id = ?` - ).run(verification.id) + ) + .run(verification.id) } return responseError(reply, 400, '验证码无效或已过期') } @@ -301,9 +321,10 @@ export async function authRoutes(app) { let session try { - const createUserAndSession = db.transaction(() => { - db.prepare( - `INSERT INTO users ( + const createUserAndSession = db.transaction(async () => { + await db + .prepare( + `INSERT INTO users ( id, email, password_hash, @@ -319,44 +340,47 @@ export async function authRoutes(app) { created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)` - ).run( - userId, - input.email, - passwordHash, - input.role, - input.displayName ?? input.email.split('@')[0], - input.avatarUrl ?? null, - input.country ?? null, - input.region ?? null, - input.age ?? null, - input.chineseLevel ?? null, - input.bio ?? '', - createdAt, - createdAt - ) + ) + .run( + userId, + input.email, + passwordHash, + input.role, + input.displayName ?? input.email.split('@')[0], + input.avatarUrl ?? null, + input.country ?? null, + input.region ?? null, + input.age ?? null, + input.chineseLevel ?? null, + input.bio ?? '', + createdAt, + createdAt + ) if (input.role === 'teacher') { - db.prepare( - `INSERT INTO teacher_profiles ( + await db + .prepare( + `INSERT INTO teacher_profiles ( user_id, school, title, experience_years, specialties_json, certificates_json, teaching_style_json, languages_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - userId, - input.school ?? '', - input.title ?? '国际中文教师', - input.experienceYears ?? 0, - JSON.stringify(input.specialties ?? []), - JSON.stringify(input.certificates ?? []), - JSON.stringify(input.teachingStyle ?? []), - JSON.stringify(input.languages ?? ['中文']), - createdAt, - createdAt - ) + ) + .run( + userId, + input.school ?? '', + input.title ?? '国际中文教师', + input.experienceYears ?? 0, + JSON.stringify(input.specialties ?? []), + JSON.stringify(input.certificates ?? []), + JSON.stringify(input.teachingStyle ?? []), + JSON.stringify(input.languages ?? ['中文']), + createdAt, + createdAt + ) } - const consumeResult = db + const consumeResult = await db .prepare( `UPDATE verification_codes SET consumed_at = ? @@ -367,18 +391,19 @@ export async function authRoutes(app) { throw new Error('VERIFICATION_CODE_ALREADY_USED') } - return createSession(db, userId, { + return await createSession(db, userId, { ttlSeconds: app.sessionTtlSeconds, ...sessionMetadata(request) }) }) - session = createUserAndSession() + session = await createUserAndSession() } catch (error) { if (error?.message === 'VERIFICATION_CODE_ALREADY_USED') { return responseError(reply, 409, '验证码已被使用,请重新获取') } if ( + error?.code === '23505' || error?.code === 'SQLITE_CONSTRAINT_UNIQUE' || error?.code === 'SQLITE_CONSTRAINT' ) { @@ -388,7 +413,9 @@ export async function authRoutes(app) { throw error } - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) + const user = await db + .prepare('SELECT * FROM users WHERE id = ?') + .get(userId) reply.setCookie( SESSION_COOKIE_NAME, session.token, @@ -416,7 +443,7 @@ export async function authRoutes(app) { } const input = result.data - const user = db + const user = await db .prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') .get(input.email) const hashToCheck = @@ -434,12 +461,14 @@ export async function authRoutes(app) { if (needsPasswordRehash(user.password_hash)) { const replacementHash = await hashPassword(input.password) - db.prepare( - 'UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?' - ).run(replacementHash, new Date().toISOString(), user.id) + await db + .prepare( + 'UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?' + ) + .run(replacementHash, new Date().toISOString(), user.id) } - const session = createSession(db, user.id, { + const session = await createSession(db, user.id, { ttlSeconds: app.sessionTtlSeconds, ...sessionMetadata(request) }) @@ -457,7 +486,7 @@ export async function authRoutes(app) { app.post('/api/v1/auth/logout', async (request, reply) => { const credential = extractSessionToken(request) if (credential) { - revokeSessionByToken(db, credential.token) + await revokeSessionByToken(db, credential.token) } reply.clearCookie(SESSION_COOKIE_NAME, { diff --git a/server/routes/courses.js b/server/routes/courses.js index 9ad6f40..166fdb1 100644 --- a/server/routes/courses.js +++ b/server/routes/courses.js @@ -179,8 +179,10 @@ function optionalAuthentication(app) { } } -function findCourse(db, courseId) { - return db.prepare(`${courseSelect} WHERE c.id = ? LIMIT 1`).get(courseId) +async function findCourse(db, courseId) { + return await db + .prepare(`${courseSelect} WHERE c.id = ? LIMIT 1`) + .get(courseId) } function canReadUnpublishedCourse(request, course) { @@ -251,18 +253,21 @@ export async function courseRoutes(app) { } const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '' - const total = db - .prepare( - `SELECT COUNT(*) AS count + const total = ( + await db + .prepare( + `SELECT COUNT(*) AS count FROM courses AS c INNER JOIN users AS u ON u.id = c.teacher_id LEFT JOIN teacher_profiles AS tp ON tp.user_id = c.teacher_id ${whereClause}` - ) - .get(...parameters).count - const items = db - .prepare( - `${courseSelect} + ) + .get(...parameters) + ).count + const items = ( + await db + .prepare( + `${courseSelect} ${whereClause} ORDER BY CASE c.status @@ -275,9 +280,9 @@ export async function courseRoutes(app) { COALESCE(c.published_at, c.updated_at) DESC, c.id DESC LIMIT ? OFFSET ?` - ) - .all(...parameters, pageSize, (page - 1) * pageSize) - .map(courseFromRow) + ) + .all(...parameters, pageSize, (page - 1) * pageSize) + ).map(courseFromRow) return responseData(reply, { items, @@ -295,7 +300,7 @@ export async function courseRoutes(app) { return validationError(reply, paramsResult) } - const course = findCourse(db, paramsResult.data.id) + const course = await findCourse(db, paramsResult.data.id) if (!course) { return responseError(reply, 404, '课程不存在') } @@ -313,7 +318,7 @@ export async function courseRoutes(app) { const data = courseFromRow(course) if (canReadUnpublishedCourse(request, course)) { - const latestReview = db + const latestReview = await db .prepare( `SELECT * FROM course_reviews WHERE course_id = ? @@ -340,31 +345,33 @@ export async function courseRoutes(app) { const input = result.data const id = randomUUID() const now = new Date().toISOString() - db.prepare( - `INSERT INTO courses ( + await db + .prepare( + `INSERT INTO courses ( id, teacher_id, title, summary, description, level, category, cover_url, duration_minutes, price_cents, capacity, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)` - ).run( - id, - request.auth.user.id, - input.title, - input.summary, - input.description, - input.level, - input.category, - input.coverUrl ?? null, - input.durationMinutes, - input.priceCents, - input.capacity, - now, - now - ) + ) + .run( + id, + request.auth.user.id, + input.title, + input.summary, + input.description, + input.level, + input.category, + input.coverUrl ?? null, + input.durationMinutes, + input.priceCents, + input.capacity, + now, + now + ) return responseData( reply, - courseFromRow(findCourse(db, id)), + courseFromRow(await findCourse(db, id)), '课程草稿已创建', 201 ) @@ -385,7 +392,7 @@ export async function courseRoutes(app) { return validationError(reply, result) } - const course = findCourse(db, paramsResult.data.id) + const course = await findCourse(db, paramsResult.data.id) if (!course) { return responseError(reply, 404, '课程不存在') } @@ -422,13 +429,13 @@ export async function courseRoutes(app) { assignments.push('updated_at = ?') values.push(new Date().toISOString(), course.id) - db.prepare( - `UPDATE courses SET ${assignments.join(', ')} WHERE id = ?` - ).run(...values) + await db + .prepare(`UPDATE courses SET ${assignments.join(', ')} WHERE id = ?`) + .run(...values) return responseData( reply, - courseFromRow(findCourse(db, course.id)), + courseFromRow(await findCourse(db, course.id)), '课程已更新' ) } @@ -443,7 +450,7 @@ export async function courseRoutes(app) { return validationError(reply, paramsResult) } - const course = findCourse(db, paramsResult.data.id) + const course = await findCourse(db, paramsResult.data.id) if (!course) { return responseError(reply, 404, '课程不存在') } @@ -457,7 +464,7 @@ export async function courseRoutes(app) { }) } - const verifiedTeacher = db + const verifiedTeacher = await db .prepare( `SELECT 1 FROM teacher_profiles @@ -478,8 +485,8 @@ export async function courseRoutes(app) { const submittedAt = new Date().toISOString() const auditId = randomUUID() - const notifyAdministrators = db.transaction(() => { - const updated = db + const notifyAdministrators = db.transaction(async () => { + const updated = await db .prepare( `UPDATE courses SET status = 'pending', rejection_reason = NULL, updated_at = ? @@ -491,26 +498,28 @@ export async function courseRoutes(app) { throw new Error('COURSE_STATE_CHANGED') } - db.prepare( - `INSERT INTO audit_logs ( + await db + .prepare( + `INSERT INTO audit_logs ( id, actor_id, action, entity_type, entity_id, details_json, request_id, ip_address, user_agent, created_at ) VALUES (?, ?, 'course.submitted', 'course', ?, ?, ?, ?, ?, ?)` - ).run( - auditId, - request.auth.user.id, - course.id, - JSON.stringify({ - previousStatus: course.status, - nextStatus: 'pending' - }), - request.id ?? null, - request.ip ?? null, - request.headers['user-agent'] ?? null, - submittedAt - ) + ) + .run( + auditId, + request.auth.user.id, + course.id, + JSON.stringify({ + previousStatus: course.status, + nextStatus: 'pending' + }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + submittedAt + ) - const administrators = db + const administrators = await db .prepare( "SELECT id FROM users WHERE role = 'administrator' AND status = 'active'" ) @@ -524,7 +533,7 @@ export async function courseRoutes(app) { for (const administrator of administrators) { const notificationId = randomUUID() - insertNotification.run( + await insertNotification.run( notificationId, administrator.id, '有新课程待审核', @@ -538,7 +547,7 @@ export async function courseRoutes(app) { }) try { - notifyAdministrators() + await notifyAdministrators() } catch (error) { if (error?.message === 'COURSE_STATE_CHANGED') { return responseError(reply, 409, '课程状态已变化,请刷新后重试') @@ -548,7 +557,7 @@ export async function courseRoutes(app) { return responseData( reply, - courseFromRow(findCourse(db, course.id)), + courseFromRow(await findCourse(db, course.id)), '课程已提交审核' ) } diff --git a/server/routes/dialogues.js b/server/routes/dialogues.js index 66f4b66..58d788c 100644 --- a/server/routes/dialogues.js +++ b/server/routes/dialogues.js @@ -81,8 +81,8 @@ function sessionData(row) { } } -function findOwnedSession(db, sessionId, studentId) { - return db +async function findOwnedSession(db, sessionId, studentId) { + return await db .prepare( `SELECT * FROM dialogue_sessions WHERE id = ? AND student_id = ? LIMIT 1` @@ -90,8 +90,8 @@ function findOwnedSession(db, sessionId, studentId) { .get(sessionId, studentId) } -function getTurns(db, sessionId) { - return db +async function getTurns(db, sessionId) { + return await db .prepare( `SELECT * FROM dialogue_turns WHERE session_id = ? ORDER BY position ASC` @@ -118,13 +118,15 @@ export async function dialogueRoutes(app) { const { page, pageSize } = result.data const studentId = request.auth.user.id const total = Number( - db - .prepare( - 'SELECT COUNT(*) AS count FROM dialogue_sessions WHERE student_id = ?' - ) - .get(studentId).count + ( + await db + .prepare( + 'SELECT COUNT(*) AS count FROM dialogue_sessions WHERE student_id = ?' + ) + .get(studentId) + ).count ) - const rows = db + const rows = await db .prepare( `SELECT s.*, @@ -165,28 +167,30 @@ export async function dialogueRoutes(app) { const generated = await provider.generate(result.data) const sessionId = randomUUID() const timestamp = new Date().toISOString() - const create = db.transaction(() => { - db.prepare( - `INSERT INTO dialogue_sessions ( + const create = db.transaction(async () => { + await db + .prepare( + `INSERT INTO dialogue_sessions ( id, student_id, title, keywords_json, provider, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?)` - ).run( - sessionId, - request.auth.user.id, - generated.title, - JSON.stringify(result.data.keywords), - generated.provider, - timestamp, - timestamp - ) + ) + .run( + sessionId, + request.auth.user.id, + generated.title, + JSON.stringify(result.data.keywords), + generated.provider, + timestamp, + timestamp + ) const insertTurn = db.prepare( `INSERT INTO dialogue_turns ( id, session_id, position, speaker, content, created_at ) VALUES (?, ?, ?, ?, ?, ?)` ) - generated.turns.forEach((turn, index) => { - insertTurn.run( + for (const [index, turn] of generated.turns.entries()) { + await insertTurn.run( randomUUID(), sessionId, index + 1, @@ -194,16 +198,20 @@ export async function dialogueRoutes(app) { turn.content, timestamp ) - }) + } }) - create() + await create() - const session = findOwnedSession(db, sessionId, request.auth.user.id) + const session = await findOwnedSession( + db, + sessionId, + request.auth.user.id + ) return responseData( reply, { ...sessionData(session), - turns: getTurns(db, sessionId).map(turnData) + turns: (await getTurns(db, sessionId)).map(turnData) }, '对话练习已生成', 201 @@ -218,9 +226,13 @@ export async function dialogueRoutes(app) { const result = idSchema.safeParse(request.params) if (!result.success) return validationError(reply, result) - const session = findOwnedSession(db, result.data.id, request.auth.user.id) + const session = await findOwnedSession( + db, + result.data.id, + request.auth.user.id + ) if (!session) return responseError(reply, 404, '对话练习不存在') - const turns = getTurns(db, session.id) + const turns = await getTurns(db, session.id) return responseData(reply, { ...sessionData({ ...session, turn_count: turns.length }), turns: turns.map(turnData) @@ -240,14 +252,14 @@ export async function dialogueRoutes(app) { const bodyResult = messageSchema.safeParse(unwrapBody(request.body)) if (!bodyResult.success) return validationError(reply, bodyResult) - const session = findOwnedSession( + const session = await findOwnedSession( db, paramsResult.data.id, request.auth.user.id ) if (!session) return responseError(reply, 404, '对话练习不存在') - const turns = getTurns(db, session.id) + const turns = await getTurns(db, session.id) if (turns.length >= 100) { return responseError(reply, 409, '本轮对话已达到上限,请创建新的练习') } @@ -259,16 +271,22 @@ export async function dialogueRoutes(app) { message: bodyResult.data.message }) const timestamp = new Date().toISOString() - const append = db.transaction(() => { - const current = findOwnedSession(db, session.id, request.auth.user.id) + const append = db.transaction(async () => { + const current = await findOwnedSession( + db, + session.id, + request.auth.user.id + ) if (!current) return null const position = Number( - db - .prepare( - `SELECT COALESCE(MAX(position), 0) AS position + ( + await db + .prepare( + `SELECT COALESCE(MAX(position), 0) AS position FROM dialogue_turns WHERE session_id = ?` - ) - .get(session.id).position + ) + .get(session.id) + ).position ) const studentTurn = { id: randomUUID(), @@ -288,7 +306,7 @@ export async function dialogueRoutes(app) { ) VALUES (?, ?, ?, ?, ?, ?)` ) for (const turn of [studentTurn, tutorTurn]) { - insert.run( + await insert.run( turn.id, session.id, turn.position, @@ -297,14 +315,16 @@ export async function dialogueRoutes(app) { timestamp ) } - db.prepare( - `UPDATE dialogue_sessions + await db + .prepare( + `UPDATE dialogue_sessions SET provider = ?, updated_at = ? WHERE id = ?` - ).run(generated.provider, timestamp, session.id) + ) + .run(generated.provider, timestamp, session.id) return { studentTurn, tutorTurn } }) - const appended = append() + const appended = await append() if (!appended) return responseError(reply, 409, '对话状态已变化,请刷新重试') return responseData(reply, { diff --git a/server/routes/files.js b/server/routes/files.js index 28f5589..e787d4b 100644 --- a/server/routes/files.js +++ b/server/routes/files.js @@ -1,25 +1,9 @@ import { createHash, randomUUID } from 'node:crypto' import { Buffer } from 'node:buffer' -import { basename, extname, join, relative, resolve } from 'node:path' -import { createReadStream } from 'node:fs' -import { mkdir, open, rename, stat, unlink } from 'node:fs/promises' +import { basename, extname } from 'node:path' import { z } from 'zod' -const idSchema = z - .object({ id: z.string().uuid('文件 ID 格式不正确') }) - .strict() -const uploadQuerySchema = z - .object({ - category: z.enum([ - 'avatar', - 'course_cover', - 'course_video', - 'course_material' - ]) - }) - .strict() - const categories = Object.freeze({ avatar: { maxBytes: 5 * 1024 * 1024, @@ -47,12 +31,6 @@ const categories = Object.freeze({ } }) -const DEFAULT_OWNER_STORAGE_BYTES = 250 * 1024 * 1024 -const DEFAULT_PLATFORM_STORAGE_BYTES = 5 * 1024 * 1024 * 1024 -const DEFAULT_MAX_CONCURRENT_UPLOADS = 4 -const MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -const HEADER_INSPECTION_BYTES = 512 - const extensions = Object.freeze({ 'image/jpeg': '.jpg', 'image/png': '.png', @@ -62,6 +40,20 @@ const extensions = Object.freeze({ 'application/pdf': '.pdf' }) +const idSchema = z.object({ id: z.string().uuid('ID 格式不正确') }).strict() +const createIntentSchema = z + .object({ + category: z.enum(Object.keys(categories)), + originalName: z.string().trim().min(1).max(255), + mimeType: z.string().trim().min(1).max(120), + sizeBytes: z.coerce + .number() + .int() + .positive() + .max(50 * 1024 * 1024) + }) + .strict() + function responseData(reply, data, message = '操作成功', statusCode = 200) { return reply.code(statusCode).send({ code: 0, msg: message, data }) } @@ -79,521 +71,602 @@ function validationError(reply, result) { }) } +function safeOriginalName(value) { + const name = basename(String(value || 'upload').normalize('NFC')) + .split('') + .filter((character) => { + const codePoint = character.codePointAt(0) + return codePoint > 31 && codePoint !== 127 + }) + .join('') + .trim() + if (!name) return 'upload' + if (name.length <= 255) return name + const extension = extname(name).slice(0, 20) + return `${name.slice(0, Math.max(1, 255 - extension.length))}${extension}` +} + function startsWith(buffer, bytes, offset = 0) { if (buffer.length < offset + bytes.length) return false return bytes.every((byte, index) => buffer[offset + index] === byte) } function detectMimeType(buffer) { - if ( - startsWith(buffer, [0xff, 0xd8, 0xff]) && - buffer.length >= 4 && - buffer[3] >= 0xc0 && - buffer[3] !== 0xff - ) { - return 'image/jpeg' - } + if (startsWith(buffer, [0xff, 0xd8, 0xff])) return 'image/jpeg' if ( startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) && startsWith(buffer, [0x49, 0x48, 0x44, 0x52], 12) - ) { + ) return 'image/png' - } if ( startsWith(buffer, [0x52, 0x49, 0x46, 0x46]) && - startsWith(buffer, [0x57, 0x45, 0x42, 0x50], 8) && - ['VP8 ', 'VP8L', 'VP8X'].includes(buffer.subarray(12, 16).toString('ascii')) - ) { + startsWith(buffer, [0x57, 0x45, 0x42, 0x50], 8) + ) return 'image/webp' - } - if (/^%PDF-[12]\.\d/.test(buffer.subarray(0, 8).toString('ascii'))) { + if (/^%PDF-[12]\.\d/.test(buffer.subarray(0, 8).toString('ascii'))) return 'application/pdf' - } - if ( - buffer.length >= 16 && - startsWith(buffer, [0x66, 0x74, 0x79, 0x70], 4) && - buffer.readUInt32BE(0) >= 16 && - /^[\x20-\x7e]{4}$/.test(buffer.subarray(8, 12).toString('ascii')) - ) { + if (buffer.length >= 16 && startsWith(buffer, [0x66, 0x74, 0x79, 0x70], 4)) return 'video/mp4' - } if ( startsWith(buffer, [0x1a, 0x45, 0xdf, 0xa3]) && buffer.indexOf(Buffer.from('webm'), 4) !== -1 - ) { + ) return 'video/webm' - } return null } -class UploadRejectedError extends Error { - constructor(statusCode, message) { - super(message) - this.name = 'UploadRejectedError' - this.statusCode = statusCode - } +function contentDisposition(originalName) { + const encoded = encodeURIComponent(originalName).replaceAll("'", '%27') + return `inline; filename*=UTF-8''${encoded}` } -function positiveSafeInteger(value, fallback, name) { - const candidate = value ?? fallback - if (!Number.isSafeInteger(candidate) || candidate < 1) { - throw new TypeError(`${name} must be a positive safe integer`) +function fileData(row) { + return { + id: row.id, + ownerId: row.owner_id, + originalName: row.original_name, + mimeType: row.mime_type, + sizeBytes: Number(row.size_bytes), + sha256: row.sha256, + category: row.category, + url: `/api/v1/files/${row.id}/content`, + createdAt: row.created_at } - return candidate } -function resolveUploadLimits(config) { - const ownerQuotaBytes = positiveSafeInteger( - config.uploadOwnerQuotaBytes, - DEFAULT_OWNER_STORAGE_BYTES, - 'uploadOwnerQuotaBytes' - ) - const totalQuotaBytes = positiveSafeInteger( - config.uploadTotalQuotaBytes, - DEFAULT_PLATFORM_STORAGE_BYTES, - 'uploadTotalQuotaBytes' - ) - const maxConcurrent = positiveSafeInteger( - config.uploadMaxConcurrent, - DEFAULT_MAX_CONCURRENT_UPLOADS, - 'uploadMaxConcurrent' +function resolveLimits(config) { + const ownerQuota = Number(config.uploadOwnerQuotaBytes) + const platformQuota = Number(config.uploadTotalQuotaBytes) + const maxConcurrent = Number(config.uploadMaxConcurrent) + if (!Number.isSafeInteger(ownerQuota) || ownerQuota < 1) + throw new TypeError('uploadOwnerQuotaBytes must be a positive safe integer') + if (!Number.isSafeInteger(platformQuota) || platformQuota < ownerQuota) + throw new TypeError( + 'uploadTotalQuotaBytes must be at least the owner quota' + ) + if ( + !Number.isInteger(maxConcurrent) || + maxConcurrent < 1 || + maxConcurrent > 32 ) - if (maxConcurrent > 32) { - throw new TypeError('uploadMaxConcurrent must not exceed 32') - } - if (totalQuotaBytes < ownerQuotaBytes) { throw new TypeError( - 'uploadTotalQuotaBytes must be greater than or equal to uploadOwnerQuotaBytes' + 'uploadMaxConcurrent must be an integer between 1 and 32' ) - } - return { ownerQuotaBytes, totalQuotaBytes, maxConcurrent } + return { ownerQuota, platformQuota, maxConcurrent } } -function createUploadCoordinator(db, limits) { - let ownerUsage - let platformUsage - const reservedByOwner = new Map() - let activeUploads = 0 - let totalReservedBytes = 0 - - function committedOwnerBytes(ownerId) { - ownerUsage ??= db.prepare( - `SELECT COALESCE(SUM(size_bytes), 0) AS total - FROM files - WHERE owner_id = ?` - ) - return Number(ownerUsage.get(ownerId).total) - } - - function committedPlatformBytes() { - platformUsage ??= db.prepare( - 'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM files' - ) - return Number(platformUsage.get().total) - } - - return { - tryStart(ownerId) { - if (activeUploads >= limits.maxConcurrent) return null - - activeUploads += 1 - let released = false - let tokenReservedBytes = 0 - - function assertOpen() { - if (released) - throw new Error('Upload admission has already been released') - } - - return { - assertCapacityAvailable() { - assertOpen() - const ownerReservedBytes = reservedByOwner.get(ownerId) ?? 0 - if ( - committedOwnerBytes(ownerId) + ownerReservedBytes >= - limits.ownerQuotaBytes - ) { - throw new UploadRejectedError(413, '账户文件存储空间已用尽') - } - if ( - committedPlatformBytes() + totalReservedBytes >= - limits.totalQuotaBytes - ) { - throw new UploadRejectedError(507, '平台文件存储空间已用尽') - } - }, - reserve(bytes) { - assertOpen() - if (!Number.isSafeInteger(bytes) || bytes < 0) { - throw new TypeError('Reserved upload bytes must be a safe integer') - } - - const ownerReservedBytes = reservedByOwner.get(ownerId) ?? 0 - if ( - committedOwnerBytes(ownerId) + ownerReservedBytes + bytes > - limits.ownerQuotaBytes - ) { - throw new UploadRejectedError(413, '账户文件存储空间不足') - } - if ( - committedPlatformBytes() + totalReservedBytes + bytes > - limits.totalQuotaBytes - ) { - throw new UploadRejectedError(507, '平台文件存储空间不足') - } - - reservedByOwner.set(ownerId, ownerReservedBytes + bytes) - totalReservedBytes += bytes - tokenReservedBytes += bytes - }, - assertWithinQuota() { - assertOpen() - const ownerReservedBytes = reservedByOwner.get(ownerId) ?? 0 - if ( - committedOwnerBytes(ownerId) + ownerReservedBytes > - limits.ownerQuotaBytes - ) { - throw new UploadRejectedError(413, '账户文件存储空间不足') - } - if ( - committedPlatformBytes() + totalReservedBytes > - limits.totalQuotaBytes - ) { - throw new UploadRejectedError(507, '平台文件存储空间不足') - } - }, - release() { - if (released) return - released = true - activeUploads -= 1 - totalReservedBytes -= tokenReservedBytes - - const remainingOwnerBytes = - (reservedByOwner.get(ownerId) ?? 0) - tokenReservedBytes - if (remainingOwnerBytes === 0) reservedByOwner.delete(ownerId) - else reservedByOwner.set(ownerId, remainingOwnerBytes) - } - } - } - } +function isMissingObject(error) { + return ['NoSuchKey', 'NotFound', '404'].includes( + String(error?.name ?? error?.$metadata?.httpStatusCode) + ) } -async function writeAll(fileHandle, chunk, position) { - let offset = 0 - while (offset < chunk.length) { - const { bytesWritten } = await fileHandle.write( - chunk, - offset, - chunk.length - offset, - position + offset - ) - if (bytesWritten === 0) - throw new Error('Unable to make progress writing upload') - offset += bytesWritten - } +function isPreconditionFailed(error) { + return ['PreconditionFailed', '412'].includes( + String(error?.name ?? error?.$metadata?.httpStatusCode) + ) } -async function unlinkIfPresent(targetFile) { - try { - await unlink(targetFile) - } catch (error) { - if (error?.code !== 'ENOENT') throw error +async function inspectObject(storage, key, maxBytes) { + const object = await storage.read(key) + if (!object.ETag) { + throw Object.assign(new Error('OBJECT_ETAG_MISSING'), { + statusCode: 502 + }) } -} - -async function streamPartToFile(part, targetFile, maxBytes, admission) { const hash = createHash('sha256') - const header = Buffer.alloc(HEADER_INSPECTION_BYTES) + const header = Buffer.alloc(512) let headerLength = 0 let sizeBytes = 0 - let fileHandle - let created = false - try { - fileHandle = await open(targetFile, 'wx', 0o600) - created = true + for await (const value of object.Body) { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) + sizeBytes += chunk.length + if (sizeBytes > maxBytes) + throw Object.assign(new Error('FILE_TOO_LARGE'), { statusCode: 413 }) + hash.update(chunk) + if (headerLength < header.length) { + const count = Math.min(chunk.length, header.length - headerLength) + chunk.copy(header, headerLength, 0, count) + headerLength += count + } + } + if (sizeBytes === 0) + throw Object.assign(new Error('EMPTY_FILE'), { statusCode: 400 }) + return { + detectedMimeType: detectMimeType(header.subarray(0, headerLength)), + etag: object.ETag, + sha256: hash.digest('hex'), + sizeBytes + } +} - for await (const value of part.file) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (sizeBytes + chunk.length > maxBytes) { - throw new UploadRejectedError(413, '文件超过该类型允许的大小') - } +export async function fileRoutes(app) { + const db = app.db + const storage = app.objectStorage + if (!db) throw new Error('fileRoutes requires app.db') - admission.reserve(chunk.length) - hash.update(chunk) + const { ownerQuota, platformQuota, maxConcurrent } = resolveLimits(app.config) + + let cleanupPromise = null + + async function enqueueCleanup(storageKey, reason, notBefore = new Date()) { + const timestamp = new Date().toISOString() + const nextAttemptAt = new Date(notBefore).toISOString() + await db + .prepare( + `INSERT INTO object_cleanup_jobs ( + storage_key, reason, attempts, next_attempt_at, created_at, updated_at + ) VALUES (?, ?, 0, ?, ?, ?) + ON CONFLICT (storage_key) DO UPDATE SET + reason = EXCLUDED.reason, + next_attempt_at = GREATEST( + object_cleanup_jobs.next_attempt_at, + EXCLUDED.next_attempt_at + ), + updated_at = EXCLUDED.updated_at` + ) + .run(storageKey, reason, nextAttemptAt, timestamp, timestamp) + } + + async function enqueueIntentCleanup(intent, reason) { + const expiresAt = Date.parse(intent.expires_at) + const notBefore = new Date( + Math.max(Date.now(), Number.isFinite(expiresAt) ? expiresAt + 60_000 : 0) + ) + await enqueueCleanup(intent.storage_key, reason, notBefore) + } - if (headerLength < HEADER_INSPECTION_BYTES) { - const bytesToCopy = Math.min( - chunk.length, - HEADER_INSPECTION_BYTES - headerLength + async function runCleanupJobs() { + if (!storage) return + if (cleanupPromise) return cleanupPromise + cleanupPromise = (async () => { + const rows = await db + .prepare( + `SELECT * FROM object_cleanup_jobs + WHERE next_attempt_at <= ? + ORDER BY next_attempt_at, attempts + LIMIT 25` ) - chunk.copy(header, headerLength, 0, bytesToCopy) - headerLength += bytesToCopy + .all(new Date().toISOString()) + for (const row of rows) { + try { + await storage.delete(row.storage_key) + await db + .prepare('DELETE FROM object_cleanup_jobs WHERE storage_key = ?') + .run(row.storage_key) + } catch (error) { + const attempts = Number(row.attempts) + 1 + const delaySeconds = Math.min(3600, 2 ** Math.min(attempts, 10)) + await db + .prepare( + `UPDATE object_cleanup_jobs + SET attempts = ?, next_attempt_at = ?, last_error = ?, updated_at = ? + WHERE storage_key = ?` + ) + .run( + attempts, + new Date(Date.now() + delaySeconds * 1000).toISOString(), + String(error?.message ?? error).slice(0, 1000), + new Date().toISOString(), + row.storage_key + ) + } } - - await writeAll(fileHandle, chunk, sizeBytes) - sizeBytes += chunk.length - } - - if (part.file.truncated) { - throw new UploadRejectedError(413, '文件超过该类型允许的大小') - } - if (sizeBytes === 0) throw new UploadRejectedError(400, '文件不能为空') - - await fileHandle.sync() - await fileHandle.close() - fileHandle = null - return { - header: header.subarray(0, headerLength), - sha256: hash.digest('hex'), - sizeBytes + })() + try { + return await cleanupPromise + } finally { + cleanupPromise = null } - } catch (error) { - if (fileHandle) await fileHandle.close().catch(() => {}) - if (created) await unlinkIfPresent(targetFile) - throw error } -} -function safeOriginalName(value) { - const candidate = basename(String(value || 'upload').normalize('NFC')) - .split('') - .filter((character) => { - const codePoint = character.codePointAt(0) - return codePoint > 31 && codePoint !== 127 + async function cancelIntent(intent, reason) { + const cancel = db.transaction(async () => { + const result = await db + .prepare( + `UPDATE upload_intents + SET status = 'cancelled', validation_started_at = NULL + WHERE id = ? AND status IN ('pending', 'validating')` + ) + .run(intent.id) + if (result.changes === 1) await enqueueIntentCleanup(intent, reason) + return result.changes === 1 }) - .join('') - .trim() - if (!candidate) return 'upload' - if (candidate.length <= 255) return candidate - - const extension = extname(candidate).slice(0, 20) - return `${candidate.slice(0, Math.max(1, 255 - extension.length))}${extension}` -} + return cancel() + } -function fileData(row) { - return { - id: row.id, - ownerId: row.owner_id, - originalName: row.original_name, - mimeType: row.mime_type, - sizeBytes: Number(row.size_bytes), - sha256: row.sha256, - category: row.category, - url: `/api/v1/files/${row.id}/content`, - createdAt: row.created_at + async function resetIntent(intent) { + const reset = await db + .prepare( + `UPDATE upload_intents + SET status = 'pending', validation_started_at = NULL + WHERE id = ? AND status = 'validating' AND expires_at > ?` + ) + .run(intent.id, new Date().toISOString()) + if (reset.changes !== 1) await expireIntents() } -} -function resolveStoredFile(uploadDir, storageKey) { - const root = resolve(uploadDir) - const target = resolve(root, storageKey) - const pathWithinRoot = relative(root, target) - if ( - !pathWithinRoot || - pathWithinRoot.startsWith('..') || - pathWithinRoot.includes(':') - ) { - return null + async function expireIntents() { + if (!storage) return + const now = new Date() + const staleValidation = new Date(now.getTime() - 5 * 60 * 1000) + const expire = db.transaction(async () => { + const rows = await db + .prepare( + `UPDATE upload_intents + SET status = 'expired', validation_started_at = NULL + WHERE (status = 'pending' AND expires_at <= ?) + OR (status = 'validating' AND validation_started_at <= ?) + RETURNING *` + ) + .all(now.toISOString(), staleValidation.toISOString()) + for (const row of rows) { + await enqueueIntentCleanup(row, 'expired_upload_intent') + } + }) + await expire() + await runCleanupJobs() } - return target -} -function contentDisposition(originalName) { - const encoded = encodeURIComponent(originalName).replaceAll("'", '%27') - return `inline; filename*=UTF-8''${encoded}` -} + const cleanupTimer = setInterval(() => { + expireIntents().catch((error) => + app.log.warn({ err: error }, 'Upload cleanup cycle failed') + ) + }, 60 * 1000) + cleanupTimer.unref() + app.addHook('onClose', async () => clearInterval(cleanupTimer)) -export async function fileRoutes(app) { - const db = app.db - if (!db) throw new Error('fileRoutes requires app.db') + app.post( + '/api/v1/files/upload-intents', + { + preHandler: app.authenticate, + config: { rateLimit: { max: 20, timeWindow: '1 minute' } } + }, + async (request, reply) => { + if (!storage) return responseError(reply, 503, '文件存储服务尚未配置') + const result = createIntentSchema.safeParse(request.body ?? {}) + if (!result.success) return validationError(reply, result) + const input = result.data + const policy = categories[input.category] + if (policy.roles && !policy.roles.has(request.auth.user.role)) + return responseError(reply, 403, '当前角色不能上传该类型文件') + if (!policy.mimeTypes.has(input.mimeType)) + return responseError(reply, 415, '文件类型不受支持') + if (input.sizeBytes > policy.maxBytes) + return responseError(reply, 413, '文件超过该类型允许的大小') - const uploadDir = app.config?.uploadDir - if (!uploadDir) throw new Error('fileRoutes requires app.config.uploadDir') - const uploadLimits = resolveUploadLimits(app.config) - const uploadCoordinator = createUploadCoordinator(db, uploadLimits) - const temporaryUploadDir = join(uploadDir, '.tmp') + await expireIntents() + const id = randomUUID() + const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString() + const storageKey = `tmp/uploads/${request.auth.user.id}/${id}${extensions[input.mimeType]}` + const reserve = db.transaction(async () => { + await db.exec( + "SELECT pg_advisory_xact_lock(hashtext('file-upload-quota'))" + ) + const usage = await db + .prepare( + `SELECT + COALESCE((SELECT SUM(size_bytes) FROM files WHERE owner_id = ?), 0) + + COALESCE((SELECT SUM(declared_size_bytes) FROM upload_intents + WHERE owner_id = ? AND status IN ('pending', 'validating')), 0) AS owner_total, + COALESCE((SELECT SUM(size_bytes) FROM files), 0) + + COALESCE((SELECT SUM(declared_size_bytes) FROM upload_intents + WHERE status IN ('pending', 'validating')), 0) AS platform_total, + (SELECT COUNT(*) FROM upload_intents + WHERE owner_id = ? AND status IN ('pending', 'validating')) AS active_count` + ) + .get(request.auth.user.id, request.auth.user.id, request.auth.user.id) + if (Number(usage.active_count) >= maxConcurrent) + throw Object.assign(new Error('TOO_MANY_UPLOADS'), { + statusCode: 429 + }) + if (Number(usage.owner_total) + input.sizeBytes > ownerQuota) + throw Object.assign(new Error('OWNER_QUOTA'), { statusCode: 413 }) + if (Number(usage.platform_total) + input.sizeBytes > platformQuota) + throw Object.assign(new Error('PLATFORM_QUOTA'), { statusCode: 507 }) + await db + .prepare( + `INSERT INTO upload_intents ( + id, owner_id, storage_key, original_name, declared_mime_type, + declared_size_bytes, category, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + id, + request.auth.user.id, + storageKey, + safeOriginalName(input.originalName), + input.mimeType, + input.sizeBytes, + input.category, + expiresAt + ) + }) + try { + await reserve() + } catch (error) { + const messages = { + TOO_MANY_UPLOADS: '并发上传数量已达上限', + OWNER_QUOTA: '账户文件存储空间不足', + PLATFORM_QUOTA: '平台文件存储空间不足' + } + if (error.statusCode) + return responseError( + reply, + error.statusCode, + messages[error.message] ?? '无法创建上传任务' + ) + throw error + } + + try { + const uploadUrl = await storage.createUploadUrl({ + key: storageKey, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes + }) + return responseData( + reply, + { + id, + uploadUrl, + method: 'PUT', + headers: { 'Content-Type': input.mimeType }, + expiresAt + }, + '上传任务已创建', + 201 + ) + } catch (error) { + await db + .prepare( + "UPDATE upload_intents SET status = 'cancelled' WHERE id = ?" + ) + .run(id) + throw error + } + } + ) app.post( - '/api/v1/files', + '/api/v1/files/upload-intents/:id/complete', { preHandler: app.authenticate, - config: { rateLimit: { max: 12, timeWindow: '1 minute' } } + config: { rateLimit: { max: 8, timeWindow: '1 minute' } } }, async (request, reply) => { - const queryResult = uploadQuerySchema.safeParse(request.query ?? {}) - if (!queryResult.success) return validationError(reply, queryResult) + if (!storage) return responseError(reply, 503, '文件存储服务尚未配置') + const params = idSchema.safeParse(request.params) + if (!params.success) return validationError(reply, params) + const now = new Date().toISOString() + const intent = await db + .prepare( + `UPDATE upload_intents + SET status = 'validating', validation_started_at = ? + WHERE id = ? AND owner_id = ? AND status = 'pending' AND expires_at > ? + RETURNING *` + ) + .get(now, params.data.id, request.auth.user.id, now) + + if (!intent) { + const existing = await db + .prepare('SELECT * FROM upload_intents WHERE id = ? LIMIT 1') + .get(params.data.id) + if (!existing || existing.owner_id !== request.auth.user.id) + return responseError(reply, 404, '上传任务不存在') + if (existing.status === 'completed' && existing.file_id) { + const file = await db + .prepare('SELECT * FROM files WHERE id = ? LIMIT 1') + .get(existing.file_id) + if (file) return responseData(reply, fileData(file), '文件已上传') + } + if ( + existing.status === 'expired' || + (existing.status === 'pending' && existing.expires_at <= now) + ) { + await expireIntents() + return responseError(reply, 410, '上传任务已过期') + } + return responseError(reply, 409, '上传任务正在验证或已结束') + } - const category = queryResult.data.category - const policy = categories[category] - if (policy.roles && !policy.roles.has(request.auth.user.role)) { - return responseError(reply, 403, '当前角色不能上传该类型文件') + let inspected + try { + inspected = await inspectObject( + storage, + intent.storage_key, + categories[intent.category].maxBytes + ) + } catch (error) { + if (isMissingObject(error)) { + await resetIntent(intent) + return responseError(reply, 409, '尚未收到上传文件') + } + if (error.statusCode) { + await cancelIntent(intent, 'invalid_upload_object') + return responseError(reply, error.statusCode, '文件验证失败') + } + await resetIntent(intent) + throw error } - const declaredLength = Number(request.headers['content-length']) if ( - Number.isFinite(declaredLength) && - declaredLength > policy.maxBytes + MULTIPART_OVERHEAD_BYTES + inspected.sizeBytes !== Number(intent.declared_size_bytes) || + inspected.detectedMimeType !== intent.declared_mime_type ) { - return responseError(reply, 413, '文件超过该类型允许的大小') + await cancelIntent(intent, 'upload_declaration_mismatch') + return responseError(reply, 415, '文件内容与声明信息不一致') } - let admission = uploadCoordinator.tryStart(request.auth.user.id) - if (!admission) { - reply.header('Retry-After', '1') - return responseError(reply, 503, '上传任务繁忙,请稍后重试') - } + const fileId = randomUUID() + const finalKey = `files/${intent.owner_id}/${fileId}${extensions[intent.declared_mime_type]}` + await enqueueCleanup( + finalKey, + 'uncommitted_file_promotion', + new Date(Date.now() + 10 * 60 * 1000) + ) - let cleanupFile = null try { - admission.assertCapacityAvailable() - - const part = await request.file({ - limits: { files: 1, fileSize: policy.maxBytes } + await storage.copy({ + sourceKey: intent.storage_key, + destinationKey: finalKey, + sourceEtag: inspected.etag, + mimeType: inspected.detectedMimeType, + sha256: inspected.sha256 }) - if (!part) throw new UploadRejectedError(400, '请选择需要上传的文件') - - await mkdir(temporaryUploadDir, { recursive: true }) - const temporaryFile = join(temporaryUploadDir, `${randomUUID()}.upload`) - const streamed = await streamPartToFile( - part, - temporaryFile, - policy.maxBytes, - admission - ) - cleanupFile = temporaryFile - - const detectedMimeType = detectMimeType(streamed.header) - if (!detectedMimeType || !policy.mimeTypes.has(detectedMimeType)) { - throw new UploadRejectedError(415, '文件内容与所选类型不匹配') + } catch (error) { + if (isPreconditionFailed(error)) { + await cancelIntent(intent, 'upload_changed_during_validation') + return responseError(reply, 409, '上传文件在验证过程中发生变化') } + await resetIntent(intent) + throw error + } - admission.assertWithinQuota() - - const id = randomUUID() - const storageName = `${randomUUID()}${extensions[detectedMimeType]}` - const storageKey = `${category}/${storageName}` - const targetDir = join(uploadDir, category) - const targetFile = join(targetDir, storageName) - const originalName = safeOriginalName(part.filename) - const createdAt = new Date().toISOString() - - await mkdir(targetDir, { recursive: true }) - await rename(temporaryFile, targetFile) - cleanupFile = targetFile - - db.prepare( - `INSERT INTO files ( - id, owner_id, storage_key, original_name, mime_type, size_bytes, - sha256, category, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - request.auth.user.id, - storageKey, - originalName, - detectedMimeType, - streamed.sizeBytes, - streamed.sha256, - category, - createdAt - ) - cleanupFile = null - admission.release() - admission = null - const row = db.prepare('SELECT * FROM files WHERE id = ?').get(id) - return responseData(reply, fileData(row), '文件已上传', 201) + const completedAt = new Date().toISOString() + const complete = db.transaction(async () => { + await db + .prepare( + `INSERT INTO files ( + id, owner_id, storage_key, original_name, mime_type, + size_bytes, sha256, category, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + fileId, + intent.owner_id, + finalKey, + intent.original_name, + inspected.detectedMimeType, + inspected.sizeBytes, + inspected.sha256, + intent.category, + completedAt + ) + const updated = await db + .prepare( + `UPDATE upload_intents + SET status = 'completed', completed_at = ?, file_id = ? + WHERE id = ? AND status = 'validating'` + ) + .run(completedAt, fileId, intent.id) + if (updated.changes !== 1) throw new Error('INTENT_STATE_CHANGED') + await db + .prepare('DELETE FROM object_cleanup_jobs WHERE storage_key = ?') + .run(finalKey) + await enqueueIntentCleanup(intent, 'promoted_upload_source') + }) + + try { + await complete() } catch (error) { - if ( - error instanceof UploadRejectedError || - error?.code === 'FST_REQ_FILE_TOO_LARGE' - ) { - const statusCode = error.statusCode ?? 413 - const message = - error instanceof UploadRejectedError - ? error.message - : '文件超过该类型允许的大小' - if (cleanupFile) { - await unlinkIfPresent(cleanupFile) - cleanupFile = null - } - admission?.release() - admission = null - return responseError(reply, statusCode, message) - } + await resetIntent(intent) + if (error.message === 'INTENT_STATE_CHANGED') + return responseError(reply, 409, '上传任务状态已变化') throw error - } finally { - if (cleanupFile) await unlinkIfPresent(cleanupFile) - admission?.release() } + + runCleanupJobs().catch((error) => + request.log.warn({ err: error }, 'Post-promotion cleanup failed') + ) + const row = await db + .prepare('SELECT * FROM files WHERE id = ?') + .get(fileId) + return responseData(reply, fileData(row), '文件已上传', 201) } ) - app.get('/api/v1/files/:id/content', async (request, reply) => { - const result = idSchema.safeParse(request.params) - if (!result.success) return validationError(reply, result) + app.delete( + '/api/v1/files/upload-intents/:id', + { preHandler: app.authenticate }, + async (request, reply) => { + const params = idSchema.safeParse(request.params) + if (!params.success) return validationError(reply, params) + const intent = await db + .prepare('SELECT * FROM upload_intents WHERE id = ?') + .get(params.data.id) + if (!intent || intent.owner_id !== request.auth.user.id) + return responseError(reply, 404, '上传任务不存在') + await cancelIntent(intent, 'cancelled_upload_intent') + runCleanupJobs().catch((error) => + request.log.warn({ err: error }, 'Cancelled upload cleanup failed') + ) + return responseData(reply, { id: intent.id }, '上传任务已取消') + } + ) - const row = db + app.get('/api/v1/files/:id/content', async (request, reply) => { + if (!storage) return responseError(reply, 503, '文件存储服务尚未配置') + const params = idSchema.safeParse(request.params) + if (!params.success) return validationError(reply, params) + const row = await db .prepare('SELECT * FROM files WHERE id = ? LIMIT 1') - .get(result.data.id) + .get(params.data.id) if (!row) return responseError(reply, 404, '文件不存在') - const policy = categories[row.category] - if (!policy?.public) { + if (!policy.public) { await app.authenticate(request, reply) if (reply.sent) return if ( row.owner_id !== request.auth.user.id && request.auth.user.role !== 'administrator' - ) { + ) return responseError(reply, 403, '没有权限读取该文件') - } } - - const targetFile = resolveStoredFile(uploadDir, row.storage_key) - if (!targetFile) return responseError(reply, 404, '文件不存在') - - let fileStats try { - fileStats = await stat(targetFile) + const url = await storage.createDownloadUrl({ + key: row.storage_key, + contentDisposition: contentDisposition(row.original_name), + contentType: row.mime_type + }) + return reply + .header( + 'Cache-Control', + policy.public ? 'public, max-age=300' : 'private, no-store' + ) + .redirect(url, 302) } catch (error) { - if (error?.code === 'ENOENT') - return responseError(reply, 404, '文件不存在') + if (isMissingObject(error)) return responseError(reply, 404, '文件不存在') throw error } - - reply - .type(row.mime_type) - .header('Content-Length', String(fileStats.size)) - .header('Content-Disposition', contentDisposition(row.original_name)) - .header('X-Content-Type-Options', 'nosniff') - .header( - 'Cache-Control', - policy.public - ? 'public, max-age=31536000, immutable' - : 'private, no-store' - ) - return reply.send(createReadStream(targetFile)) }) app.get( '/api/v1/files/:id', { preHandler: app.authenticate }, async (request, reply) => { - const result = idSchema.safeParse(request.params) - if (!result.success) return validationError(reply, result) - - const row = db - .prepare('SELECT * FROM files WHERE id = ? LIMIT 1') - .get(result.data.id) + const params = idSchema.safeParse(request.params) + if (!params.success) return validationError(reply, params) + const row = await db + .prepare('SELECT * FROM files WHERE id = ?') + .get(params.data.id) if (!row) return responseError(reply, 404, '文件不存在') if ( row.owner_id !== request.auth.user.id && request.auth.user.role !== 'administrator' - ) { + ) return responseError(reply, 403, '没有权限查看文件信息') - } - return responseData(reply, fileData(row)) } ) @@ -602,26 +675,34 @@ export async function fileRoutes(app) { '/api/v1/files/:id', { preHandler: app.authenticate }, async (request, reply) => { - const result = idSchema.safeParse(request.params) - if (!result.success) return validationError(reply, result) - - const row = db - .prepare('SELECT * FROM files WHERE id = ? LIMIT 1') - .get(result.data.id) + const params = idSchema.safeParse(request.params) + if (!params.success) return validationError(reply, params) + const row = await db + .prepare('SELECT * FROM files WHERE id = ?') + .get(params.data.id) if (!row) return responseError(reply, 404, '文件不存在') if ( row.owner_id !== request.auth.user.id && request.auth.user.role !== 'administrator' - ) { + ) return responseError(reply, 403, '没有权限删除文件') + const remove = db.transaction(async () => { + await db.prepare('DELETE FROM files WHERE id = ?').run(row.id) + await enqueueCleanup(row.storage_key, 'deleted_file') + }) + await remove() + try { + await runCleanupJobs() + } catch (error) { + request.log.warn( + { err: error, fileId: row.id }, + 'Object cleanup failed' + ) } - - const targetFile = resolveStoredFile(uploadDir, row.storage_key) - if (targetFile) await unlinkIfPresent(targetFile) - db.prepare('DELETE FROM files WHERE id = ?').run(row.id) return responseData(reply, { id: row.id }, '文件已删除') } ) } +export { categories, detectMimeType } export default fileRoutes diff --git a/server/routes/notifications.js b/server/routes/notifications.js index 5ad24b2..085f787 100644 --- a/server/routes/notifications.js +++ b/server/routes/notifications.js @@ -69,18 +69,22 @@ export async function notificationRoutes(app) { const where = `WHERE ${conditions.join(' AND ')}` const total = Number( - db - .prepare(`SELECT COUNT(*) AS count FROM notifications ${where}`) - .get(...parameters).count + ( + await db + .prepare(`SELECT COUNT(*) AS count FROM notifications ${where}`) + .get(...parameters) + ).count ) const unread = Number( - db - .prepare( - 'SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND read_at IS NULL' - ) - .get(request.auth.user.id).count + ( + await db + .prepare( + 'SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND read_at IS NULL' + ) + .get(request.auth.user.id) + ).count ) - const rows = db + const rows = await db .prepare( `SELECT * FROM notifications ${where} @@ -109,7 +113,7 @@ export async function notificationRoutes(app) { const result = idSchema.safeParse(request.params) if (!result.success) return validationError(reply, result) - const notification = db + const notification = await db .prepare( 'SELECT * FROM notifications WHERE id = ? AND user_id = ? LIMIT 1' ) @@ -118,11 +122,13 @@ export async function notificationRoutes(app) { if (!notification.read_at) { const readAt = new Date().toISOString() - db.prepare( - `UPDATE notifications + await db + .prepare( + `UPDATE notifications SET read_at = ? WHERE id = ? AND user_id = ? AND read_at IS NULL` - ).run(readAt, notification.id, request.auth.user.id) + ) + .run(readAt, notification.id, request.auth.user.id) notification.read_at = readAt } @@ -135,7 +141,7 @@ export async function notificationRoutes(app) { { preHandler: app.authenticate }, async (request, reply) => { const readAt = new Date().toISOString() - const updated = db + const updated = await db .prepare( `UPDATE notifications SET read_at = ? diff --git a/server/routes/profile.js b/server/routes/profile.js index 832e53d..c12bcce 100644 --- a/server/routes/profile.js +++ b/server/routes/profile.js @@ -127,10 +127,10 @@ function teacherProfileData(row) { } } -function profileData(db, user) { +async function profileData(db, user) { const profile = publicUser(user) if (profile.role !== 'teacher') return profile - const teacherProfile = db + const teacherProfile = await db .prepare('SELECT * FROM teacher_profiles WHERE user_id = ? LIMIT 1') .get(profile.id) return { ...profile, teacherProfile: teacherProfileData(teacherProfile) } @@ -186,10 +186,10 @@ export async function profileRoutes(app) { '/api/v1/me', { preHandler: app.authenticate }, async (request, reply) => { - const user = db + const user = await db .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') .get(request.auth.user.id) - return responseData(reply, profileData(db, user)) + return responseData(reply, await profileData(db, user)) } ) @@ -228,10 +228,10 @@ export async function profileRoutes(app) { values.push(updatedAt, request.auth.user.id) let verificationReset = false - const updateProfile = db.transaction(() => { + const updateProfile = db.transaction(async () => { const verifiedProfile = request.auth.user.role === 'teacher' - ? db + ? await db .prepare( `SELECT verified_at FROM teacher_profiles @@ -241,16 +241,18 @@ export async function profileRoutes(app) { .get(request.auth.user.id) : null - db.prepare( - `UPDATE users SET ${assignments.join(', ')} WHERE id = ?` - ).run(...values) + await db + .prepare(`UPDATE users SET ${assignments.join(', ')} WHERE id = ?`) + .run(...values) if (result.data.teacherProfile) { - db.prepare( - `INSERT INTO teacher_profiles (user_id, created_at, updated_at) + await db + .prepare( + `INSERT INTO teacher_profiles (user_id, created_at, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO NOTHING` - ).run(request.auth.user.id, updatedAt, updatedAt) + ) + .run(request.auth.user.id, updatedAt, updatedAt) const teacherAssignments = [] const teacherValues = [] @@ -268,14 +270,16 @@ export async function profileRoutes(app) { } teacherAssignments.push('updated_at = ?') teacherValues.push(updatedAt, request.auth.user.id) - db.prepare( - `UPDATE teacher_profiles + await db + .prepare( + `UPDATE teacher_profiles SET ${teacherAssignments.join(', ')} WHERE user_id = ?` - ).run(...teacherValues) + ) + .run(...teacherValues) } if (verifiedProfile) { - const reset = db + const reset = await db .prepare( `UPDATE teacher_profiles SET verified_at = NULL, updated_at = ? @@ -285,33 +289,35 @@ export async function profileRoutes(app) { verificationReset = reset.changes === 1 if (verificationReset) { - db.prepare( - `INSERT INTO audit_logs ( + await db + .prepare( + `INSERT INTO audit_logs ( id, actor_id, action, entity_type, entity_id, details_json, request_id, ip_address, user_agent, created_at ) VALUES (?, ?, 'teacher.verification.reset', 'teacher', ?, ?, ?, ?, ?, ?)` - ).run( - randomUUID(), - request.auth.user.id, - request.auth.user.id, - JSON.stringify({ reason: 'profile_updated' }), - request.id ?? null, - request.ip ?? null, - request.headers['user-agent'] ?? null, - updatedAt - ) + ) + .run( + randomUUID(), + request.auth.user.id, + request.auth.user.id, + JSON.stringify({ reason: 'profile_updated' }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + updatedAt + ) } } }) - updateProfile() + await updateProfile() - const user = db + const user = await db .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') .get(request.auth.user.id) return responseData( reply, - profileData(db, user), + await profileData(db, user), verificationReset ? '个人资料已更新,教师认证需要重新审核' : '个人资料已更新' @@ -341,7 +347,7 @@ export async function profileRoutes(app) { return validationError(reply, result) } - const user = db + const user = await db .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') .get(request.auth.user.id) @@ -360,11 +366,11 @@ export async function profileRoutes(app) { const updatedAt = new Date().toISOString() let replacementSession - const rotatePasswordAndSessions = db.transaction(() => { - const updateResult = db + const rotatePasswordAndSessions = db.transaction(async () => { + const updateResult = await db .prepare( `UPDATE users - SET password_hash = ?, updated_at = ? + SET password_hash = ?, must_reset_password = false, updated_at = ? WHERE id = ? AND password_hash = ?` ) .run(passwordHash, updatedAt, user.id, user.password_hash) @@ -373,15 +379,15 @@ export async function profileRoutes(app) { throw new Error('PASSWORD_CHANGED_CONCURRENTLY') } - revokeUserSessions(db, user.id) - return createSession(db, user.id, { + await revokeUserSessions(db, user.id) + return await createSession(db, user.id, { ttlSeconds: app.sessionTtlSeconds, ...sessionMetadata(request) }) }) try { - replacementSession = rotatePasswordAndSessions() + replacementSession = await rotatePasswordAndSessions() } catch (error) { if (error?.message === 'PASSWORD_CHANGED_CONCURRENTLY') { return responseError(reply, 409, '密码已发生变化,请重新登录后再试') @@ -396,11 +402,15 @@ export async function profileRoutes(app) { app.sessionCookieOptions(replacementSession.ttlSeconds) ) - const updatedUser = db + const updatedUser = await db .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') .get(user.id) - return responseData(reply, profileData(db, updatedUser), '密码已更新') + return responseData( + reply, + await profileData(db, updatedUser), + '密码已更新' + ) } ) } diff --git a/server/routes/realtime.js b/server/routes/realtime.js index a8f1287..06cb99b 100644 --- a/server/routes/realtime.js +++ b/server/routes/realtime.js @@ -175,8 +175,8 @@ const messageSelect = ` LEFT JOIN users AS sender ON sender.id = message.sender_id ` -function findClassroom(db, classroomId) { - return db +async function findClassroom(db, classroomId) { + return await db .prepare( `SELECT classroom.id, @@ -369,24 +369,25 @@ export async function realtimeRoutes(app) { return rooms.get(classroomId)?.get(userId)?.size ?? 0 } - function claimCanAccessClassroom(claim) { + async function claimCanAccessClassroom(claim) { const now = new Date().toISOString() - const session = db + const session = await db .prepare( `SELECT session.id FROM sessions AS session - INNER JOIN users AS user ON user.id = session.user_id + INNER JOIN users AS account ON account.id = session.user_id WHERE session.id = ? AND session.user_id = ? AND session.revoked_at IS NULL AND session.expires_at > ? - AND user.status = 'active' + AND account.status = 'active' + AND account.must_reset_password = false LIMIT 1` ) .get(claim.sessionId, claim.user.id, now) if (!session) return false - const classroom = findClassroom(db, claim.classroomId) + const classroom = await findClassroom(db, claim.classroomId) if ( !classroom || classroom.appointment_status !== 'accepted' || @@ -402,12 +403,12 @@ export async function realtimeRoutes(app) { ) } - function closeStaleConnections() { + async function closeStaleConnections() { for (const users of rooms.values()) { for (const sockets of users.values()) { for (const socket of sockets) { const claim = connectionClaims.get(socket) - if (!claim || !claimCanAccessClassroom(claim)) { + if (!claim || !(await claimCanAccessClassroom(claim))) { protocolError( socket, 'CLASSROOM_ACCESS_REVOKED', @@ -420,15 +421,15 @@ export async function realtimeRoutes(app) { } } - function loadMessage(messageId) { - const row = db + async function loadMessage(messageId) { + const row = await db .prepare(`${messageSelect} WHERE message.id = ? LIMIT 1`) .get(messageId) return row ? messageData(row) : null } - function existingClientMessage(senderId, clientMessageId) { - const row = db + async function existingClientMessage(senderId, clientMessageId) { + const row = await db .prepare( `${messageSelect} WHERE message.sender_id = ? AND message.client_message_id = ? @@ -438,8 +439,11 @@ export async function realtimeRoutes(app) { return row ? messageData(row) : null } - function persistChatMessage(claim, event) { - const existing = existingClientMessage(claim.user.id, event.clientMessageId) + async function persistChatMessage(claim, event) { + const existing = await existingClientMessage( + claim.user.id, + event.clientMessageId + ) if (existing) { if (existing.classroomId !== claim.classroomId) { return { error: 'CLIENT_MESSAGE_ID_CONFLICT' } @@ -451,26 +455,28 @@ export async function realtimeRoutes(app) { const createdAt = new Date().toISOString() try { - db.prepare( - `INSERT INTO chat_messages ( + await db + .prepare( + `INSERT INTO chat_messages ( id, classroom_id, sender_id, message_type, content, metadata_json, client_message_id, created_at ) VALUES (?, ?, ?, 'text', ?, '{}', ?, ?)` - ).run( - id, - claim.classroomId, - claim.user.id, - event.content, - event.clientMessageId, - createdAt - ) + ) + .run( + id, + claim.classroomId, + claim.user.id, + event.content, + event.clientMessageId, + createdAt + ) } catch (error) { const isUniqueConflict = error?.code === 'SQLITE_CONSTRAINT_UNIQUE' || /UNIQUE constraint failed/i.test(error?.message ?? '') if (!isUniqueConflict) throw error - const duplicate = existingClientMessage( + const duplicate = await existingClientMessage( claim.user.id, event.clientMessageId ) @@ -480,7 +486,7 @@ export async function realtimeRoutes(app) { return { message: duplicate, duplicate: true } } - return { message: loadMessage(id), duplicate: false } + return { message: await loadMessage(id), duplicate: false } } function forwardRtc(socket, claim, event) { @@ -507,7 +513,7 @@ export async function realtimeRoutes(app) { } } - function handleEvent(socket, claim, rawData, isBinary) { + async function handleEvent(socket, claim, rawData, isBinary) { if (isBinary) { socket.close(1003, 'Binary events are not supported') return @@ -540,7 +546,7 @@ export async function realtimeRoutes(app) { } try { - const outcome = persistChatMessage(claim, result.data) + const outcome = await persistChatMessage(claim, result.data) if (outcome.error) { protocolError( socket, @@ -669,7 +675,7 @@ export async function realtimeRoutes(app) { const params = classroomParamsSchema.safeParse(request.params) if (!params.success) return validationError(reply, params) - const classroom = findClassroom(db, params.data.id) + const classroom = await findClassroom(db, params.data.id) if (!classroom) return responseError(reply, 404, '课堂不存在') const participant = participantFor(classroom, request.auth.user.id) @@ -708,6 +714,13 @@ export async function realtimeRoutes(app) { participantIds: [classroom.student_id, classroom.teacher_id], expiresAt }) + const websocketPath = `/ws/classroom?ticket=${encodeURIComponent(ticket)}` + const websocketOrigin = + app.config?.publicWebsocketOrigin ?? 'ws://localhost' + const websocketUrl = new URL( + websocketPath, + `${websocketOrigin.replace(/\/$/, '')}/` + ).toString() return responseData( reply, @@ -722,7 +735,8 @@ export async function realtimeRoutes(app) { !accessWindow.allowed && isDevelopmentDemoClassroom(app, classroom) }, - websocketPath: `/ws/classroom?ticket=${encodeURIComponent(ticket)}` + websocketUrl, + websocketPath }, '连接票据已创建', 201 @@ -740,7 +754,7 @@ export async function realtimeRoutes(app) { const query = historyQuerySchema.safeParse(request.query ?? {}) if (!query.success) return validationError(reply, query) - const classroom = findClassroom(db, params.data.id) + const classroom = await findClassroom(db, params.data.id) if (!classroom) return responseError(reply, 404, '课堂不存在') if (!participantFor(classroom, request.auth.user.id)) { return responseError(reply, 403, '只有课堂参与者可以查看课堂消息') @@ -756,7 +770,7 @@ export async function realtimeRoutes(app) { parameters.push(new Date(query.data.before).toISOString()) } - const rows = db + const rows = await db .prepare( `${messageSelect} WHERE ${conditions.join(' AND ')} @@ -789,7 +803,7 @@ export async function realtimeRoutes(app) { const claim = consumeTicket(query.data.ticket) if (!claim) return responseError(reply, 401, '课堂连接票据无效或已过期') - const classroom = findClassroom(db, claim.classroomId) + const classroom = await findClassroom(db, claim.classroomId) const accessWindow = classroom ? classroomJoinWindow(classroom) : { allowed: false } @@ -800,7 +814,7 @@ export async function realtimeRoutes(app) { (!accessWindow.allowed && !isDevelopmentDemoClassroom(app, classroom)) || !isActiveParticipant(classroom, claim.user.id) || - !claimCanAccessClassroom(claim) + !(await claimCanAccessClassroom(claim)) ) { return responseError(reply, 403, '当前无法加入课堂') } @@ -810,7 +824,7 @@ export async function realtimeRoutes(app) { request[ticketClaim] = claim } }, - (socket, request) => { + async (socket, request) => { const claim = request[ticketClaim] let cleanedUp = false let eventWindowStartedAt = Date.now() @@ -825,7 +839,7 @@ export async function realtimeRoutes(app) { return } - socket.on('message', (data, isBinary) => { + socket.on('message', async (data, isBinary) => { const now = Date.now() if (now - eventWindowStartedAt >= EVENT_RATE_WINDOW_MS) { eventWindowStartedAt = now @@ -837,7 +851,7 @@ export async function realtimeRoutes(app) { socket.close(1008, 'Event rate limit exceeded') return } - if (!claimCanAccessClassroom(claim)) { + if (!(await claimCanAccessClassroom(claim))) { protocolError( socket, 'CLASSROOM_ACCESS_REVOKED', @@ -846,7 +860,7 @@ export async function realtimeRoutes(app) { socket.close(1008, 'Classroom access revoked') return } - handleEvent(socket, claim, data, isBinary) + await handleEvent(socket, claim, data, isBinary) }) const cleanup = () => { @@ -868,17 +882,19 @@ export async function realtimeRoutes(app) { socket.once('error', cleanup) const firstForUser = addSocket(claim, socket) - db.prepare( - `UPDATE classrooms + await db + .prepare( + `UPDATE classrooms SET status = 'open', opened_at = COALESCE(opened_at, ?), updated_at = ? WHERE id = ? AND status = 'scheduled'` - ).run( - new Date().toISOString(), - new Date().toISOString(), - claim.classroomId - ) + ) + .run( + new Date().toISOString(), + new Date().toISOString(), + claim.classroomId + ) safeSend(socket, { type: 'presence.snapshot', diff --git a/server/routes/teachers.js b/server/routes/teachers.js index c152b1c..3a02c7d 100644 --- a/server/routes/teachers.js +++ b/server/routes/teachers.js @@ -105,12 +105,13 @@ const teacherSelect = ` tp.teaching_style_json, tp.languages_json, tp.verified_at, - COUNT(c.id) AS published_course_count + ( + SELECT COUNT(*) + FROM courses AS c + WHERE c.teacher_id = u.id AND c.status = 'published' + ) AS published_course_count FROM users AS u LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id - LEFT JOIN courses AS c - ON c.teacher_id = u.id - AND c.status = 'published' ` export async function teacherRoutes(app) { @@ -145,7 +146,9 @@ export async function teacherRoutes(app) { } if (specialty) { - conditions.push("COALESCE(tp.specialties_json, '[]') LIKE ?") + conditions.push( + "COALESCE(tp.specialties_json, '[]'::jsonb)::text ILIKE ?" + ) parameters.push(`%${specialty}%`) } @@ -155,20 +158,21 @@ export async function teacherRoutes(app) { } const where = `WHERE ${conditions.join(' AND ')}` - const total = db - .prepare( - `SELECT COUNT(*) AS count + const total = ( + await db + .prepare( + `SELECT COUNT(*) AS count FROM users AS u LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id ${where}` - ) - .get(...parameters).count + ) + .get(...parameters) + ).count const offset = (page - 1) * pageSize - const rows = db + const rows = await db .prepare( `${teacherSelect} ${where} - GROUP BY u.id ORDER BY COALESCE(tp.rating, 5) DESC, COALESCE(tp.experience_years, 0) DESC, @@ -190,14 +194,13 @@ export async function teacherRoutes(app) { }) app.get('/api/v1/teachers/:id', async (request, reply) => { - const teacher = db + const teacher = await db .prepare( `${teacherSelect} WHERE u.id = ? AND u.role = 'teacher' AND u.status = 'active' AND tp.verified_at IS NOT NULL - GROUP BY u.id LIMIT 1` ) .get(request.params.id) @@ -206,7 +209,7 @@ export async function teacherRoutes(app) { return responseError(reply, 404, '教师不存在') } - const courses = db + const courses = await db .prepare( `SELECT id, title, summary, description, level, category, cover_url, diff --git a/server/services/mail-provider.js b/server/services/mail-provider.js index bccae6b..7e0c120 100644 --- a/server/services/mail-provider.js +++ b/server/services/mail-provider.js @@ -1,14 +1,97 @@ +import { createHmac } from 'node:crypto' + import nodemailer from 'nodemailer' -export function createMailProvider({ +function createVerificationMessage({ email, code, expiresAt, mailFrom }) { + const expiry = new Intl.DateTimeFormat('zh-CN', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: 'Asia/Shanghai' + }).format(new Date(expiresAt)) + + return { + from: mailFrom, + to: email, + subject: '国际中文学习平台注册验证码', + text: `你的注册验证码是 ${code},有效期至 ${expiry}。请勿向任何人透露此验证码。`, + html: ` +
+

INTERNATIONAL CHINESE PLATFORM

+

完成你的平台注册

+

请在注册页面输入下面的六位验证码:

+

${code}

+

验证码有效期至 ${expiry}。如果这不是你的操作,请忽略本邮件。

+
+ ` + } +} + +function createRelayProvider({ + mailRelayUrl, + mailRelaySecret, + fetchImpl, + now, + relayTimeoutMs +}) { + return Object.freeze({ + async sendVerificationCode({ email, code, expiresAt }) { + const rawBody = JSON.stringify({ email, code, expiresAt }) + const timestamp = String(Math.floor(now() / 1000)) + const signature = createHmac('sha256', mailRelaySecret) + .update(`${timestamp}.`, 'utf8') + .update(rawBody, 'utf8') + .digest('hex') + + let response + try { + response = await fetchImpl(mailRelayUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-mail-relay-timestamp': timestamp, + 'x-mail-relay-signature': `sha256=${signature}` + }, + body: rawBody, + signal: globalThis.AbortSignal.timeout(relayTimeoutMs) + }) + } catch (error) { + throw new Error('Verification mail relay request failed', { + cause: error + }) + } + + if (!response.ok) { + throw new Error( + `Verification mail relay rejected the request (${response.status})` + ) + } + + return { relayed: true, status: response.status } + } + }) +} + +function createSmtpProvider({ smtpUrl, + smtpHost, + smtpPort, + smtpSecure, + smtpUser, + smtpPass, mailFrom, - transportFactory = nodemailer.createTransport -} = {}) { - if (!smtpUrl) return null + transportFactory +}) { + if (!smtpUrl && !(smtpHost && smtpUser && smtpPass)) return null const transporter = transportFactory({ - url: smtpUrl, + ...(smtpUrl + ? { url: smtpUrl } + : { + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { user: smtpUser, pass: smtpPass } + }), connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 15_000, @@ -18,30 +101,54 @@ export function createMailProvider({ }) return Object.freeze({ - async sendVerificationCode({ email, code, expiresAt }) { - const expiry = new Intl.DateTimeFormat('zh-CN', { - dateStyle: 'medium', - timeStyle: 'short', - timeZone: 'Asia/Shanghai' - }).format(new Date(expiresAt)) - - return transporter.sendMail({ - from: mailFrom, - to: email, - subject: '国际中文学习平台注册验证码', - text: `你的注册验证码是 ${code},有效期至 ${expiry}。请勿向任何人透露此验证码。`, - html: ` -
-

INTERNATIONAL CHINESE PLATFORM

-

完成你的平台注册

-

请在注册页面输入下面的六位验证码:

-

${code}

-

验证码有效期至 ${expiry}。如果这不是你的操作,请忽略本邮件。

-
- ` - }) + async sendVerificationCode(payload) { + return transporter.sendMail( + createVerificationMessage({ ...payload, mailFrom }) + ) } }) } +export function createMailProvider({ + mailRelayUrl, + mailRelaySecret, + smtpUrl, + smtpHost, + smtpPort, + smtpSecure, + smtpUser, + smtpPass, + mailFrom, + transportFactory = nodemailer.createTransport, + fetchImpl = globalThis.fetch, + now = Date.now, + relayTimeoutMs = 10_000 +} = {}) { + if (mailRelayUrl && mailRelaySecret) { + if (typeof fetchImpl !== 'function') { + throw new TypeError('Mail relay requires a Fetch-compatible client') + } + return createRelayProvider({ + mailRelayUrl, + mailRelaySecret, + fetchImpl, + now, + relayTimeoutMs + }) + } + + return createSmtpProvider({ + smtpUrl, + smtpHost, + smtpPort, + smtpSecure, + smtpUser, + smtpPass, + mailFrom, + transportFactory + }) +} + +export { createVerificationMessage } + export default createMailProvider diff --git a/server/services/object-storage.js b/server/services/object-storage.js new file mode 100644 index 0000000..6c7105a --- /dev/null +++ b/server/services/object-storage.js @@ -0,0 +1,99 @@ +import { + CopyObjectCommand, + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' + +export function createObjectStorage(config = {}) { + const configured = Boolean( + config.s3Endpoint && + config.s3Bucket && + config.s3AccessKeyId && + config.s3SecretAccessKey + ) + if (!configured) return null + + const client = new S3Client({ + endpoint: config.s3Endpoint, + region: config.s3Region, + forcePathStyle: config.s3ForcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + credentials: { + accessKeyId: config.s3AccessKeyId, + secretAccessKey: config.s3SecretAccessKey + } + }) + const Bucket = config.s3Bucket + + return Object.freeze({ + async probe() { + await client.send(new HeadBucketCommand({ Bucket })) + return 'up' + }, + async createUploadUrl({ key, mimeType, sizeBytes, expiresIn = 600 }) { + return getSignedUrl( + client, + new PutObjectCommand({ + Bucket, + Key: key, + ContentType: mimeType, + ContentLength: sizeBytes + }), + { + expiresIn, + signableHeaders: new Set(['content-length', 'content-type']) + } + ) + }, + async copy({ sourceKey, destinationKey, sourceEtag, mimeType, sha256 }) { + const copySource = `${Bucket}/${sourceKey + .split('/') + .map(encodeURIComponent) + .join('/')}` + return client.send( + new CopyObjectCommand({ + Bucket, + Key: destinationKey, + CopySource: copySource, + CopySourceIfMatch: sourceEtag, + MetadataDirective: 'REPLACE', + ContentType: mimeType, + Metadata: { sha256 } + }) + ) + }, + async head(key) { + return client.send(new HeadObjectCommand({ Bucket, Key: key })) + }, + async read(key) { + return client.send(new GetObjectCommand({ Bucket, Key: key })) + }, + async createDownloadUrl({ + key, + contentDisposition, + contentType, + expiresIn = 300 + }) { + return getSignedUrl( + client, + new GetObjectCommand({ + Bucket, + Key: key, + ResponseContentDisposition: contentDisposition, + ResponseContentType: contentType + }), + { expiresIn } + ) + }, + async delete(key) { + await client.send(new DeleteObjectCommand({ Bucket, Key: key })) + } + }) +} + +export default createObjectStorage diff --git a/server/test/appointment-workflow.test.js b/server/test/appointment-workflow.test.js index 2abc6a9..9869914 100644 --- a/server/test/appointment-workflow.test.js +++ b/server/test/appointment-workflow.test.js @@ -5,7 +5,7 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' import appointmentRoutes from '../routes/appointments.js' @@ -19,8 +19,8 @@ function bearer(token) { return { authorization: `Bearer ${token}` } } -function addUser(database, { id = randomUUID(), role, displayName }) { - database +async function addUser(database, { id = randomUUID(), role, displayName }) { + await database .prepare( `INSERT INTO users ( id, email, password_hash, role, display_name, status @@ -28,12 +28,12 @@ function addUser(database, { id = randomUUID(), role, displayName }) { ) .run(id, `${role}-${id}@example.com`, 'x'.repeat(64), role, displayName) - const session = createSession(database, id, { ttlSeconds: 3600 }) + const session = await createSession(database, id, { ttlSeconds: 3600 }) return { id, role, displayName, token: session.token } } -function addTeacherProfile(database, teacherId, overrides = {}) { - database +async function addTeacherProfile(database, teacherId, overrides = {}) { + await database .prepare( `INSERT INTO teacher_profiles ( user_id, school, title, experience_years, rating, @@ -56,9 +56,9 @@ function addTeacherProfile(database, teacherId, overrides = {}) { ) } -function addCourse(database, teacherId, overrides = {}) { +async function addCourse(database, teacherId, overrides = {}) { const id = overrides.id ?? randomUUID() - database + await database .prepare( `INSERT INTO courses ( id, teacher_id, title, summary, level, category, @@ -85,7 +85,7 @@ function futureIso(minutesFromNow) { } async function createTestApp(t) { - const database = createDatabase({ filename: ':memory:' }) + const database = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', database) @@ -101,7 +101,7 @@ async function createTestApp(t) { t.after(async () => { await app.close() - database.close() + await database.close() }) return { app, database } @@ -131,13 +131,16 @@ async function requestAppointment( test('teacher discovery exposes active teachers and only their published courses', async (t) => { const { app, database } = await createTestApp(t) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '陈老师' }) - addTeacherProfile(database, teacher.id, { specialties: ['商务汉语'] }) - const publishedCourseId = addCourse(database, teacher.id) - addCourse(database, teacher.id, { status: 'draft', title: '未发布课程' }) + await addTeacherProfile(database, teacher.id, { specialties: ['商务汉语'] }) + const publishedCourseId = await addCourse(database, teacher.id) + await addCourse(database, teacher.id, { + status: 'draft', + title: '未发布课程' + }) const listing = await app.inject({ method: 'GET', @@ -164,16 +167,16 @@ test('teacher discovery exposes active teachers and only their published courses test('a student request can be accepted atomically with a classroom, notifications and audit', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '学习者甲' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '教师甲' }) - addTeacherProfile(database, teacher.id) - const courseId = addCourse(database, teacher.id) + await addTeacherProfile(database, teacher.id) + const courseId = await addCourse(database, teacher.id) const created = await requestAppointment(app, student, teacher, courseId, { scheduledStart: futureIso(10) @@ -182,25 +185,29 @@ test('a student request can be accepted atomically with a classroom, notificatio const appointmentId = readBody(created).data.id assert.equal(readBody(created).data.status, 'pending') assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM notifications + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM notifications WHERE resource_id = ? AND type = 'appointment.requested'` - ) - .get(appointmentId).count, + ) + .get(appointmentId) + ).count, 1 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'appointment.requested' AND entity_id = ?` - ) - .get(appointmentId).count, + ) + .get(appointmentId) + ).count, 1 ) - database + await database .prepare('UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?') .run(teacher.id) const acceptWhileUnverified = await app.inject({ @@ -211,20 +218,24 @@ test('a student request can be accepted atomically with a classroom, notificatio }) assert.equal(acceptWhileUnverified.statusCode, 409) assert.equal( - database - .prepare('SELECT status FROM appointments WHERE id = ?') - .get(appointmentId).status, + ( + await database + .prepare('SELECT status FROM appointments WHERE id = ?') + .get(appointmentId) + ).status, 'pending' ) assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' - ) - .get(appointmentId).count, + ( + await database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(appointmentId) + ).count, 0 ) - database + await database .prepare('UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?') .run(new Date().toISOString(), teacher.id) @@ -241,21 +252,25 @@ test('a student request can be accepted atomically with a classroom, notificatio assert.equal(acceptedData.responseNote, '课堂见。') assert.match(acceptedData.classroom.roomCode, /^[A-Za-z0-9_-]{6,64}$/) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM notifications + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM notifications WHERE resource_type = 'appointment' AND resource_id = ?` - ) - .get(appointmentId).count, + ) + .get(appointmentId) + ).count, 3 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'appointment.accepted' AND entity_id = ?` - ) - .get(appointmentId).count, + ) + .get(appointmentId) + ).count, 1 ) @@ -278,16 +293,16 @@ test('a student request can be accepted atomically with a classroom, notificatio test('teachers can reject pending requests and either participant can cancel an active request', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '学习者乙' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '教师乙' }) - addTeacherProfile(database, teacher.id) - const courseId = addCourse(database, teacher.id) + await addTeacherProfile(database, teacher.id) + const courseId = await addCourse(database, teacher.id) const rejectedRequest = await requestAppointment( app, @@ -306,11 +321,13 @@ test('teachers can reject pending requests and either participant can cancel an assert.equal(rejected.statusCode, 200) assert.equal(readBody(rejected).data.status, 'rejected') assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' - ) - .get(rejectedId).count, + ( + await database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(rejectedId) + ).count, 0 ) @@ -358,16 +375,16 @@ test('teachers can reject pending requests and either participant can cancel an test('accepting an overlapping lesson is rejected without partial side effects', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '学习者丙' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '教师丙' }) - addTeacherProfile(database, teacher.id) - const courseId = addCourse(database, teacher.id) + await addTeacherProfile(database, teacher.id) + const courseId = await addCourse(database, teacher.id) const sharedStart = futureIso(720) const first = await requestAppointment(app, student, teacher, courseId, { @@ -402,39 +419,43 @@ test('accepting an overlapping lesson is rejected without partial side effects', assert.equal(conflicted.statusCode, 409) assert.equal(readBody(conflicted).data.conflictingAppointmentId, firstId) assert.equal( - database - .prepare('SELECT status FROM appointments WHERE id = ?') - .get(secondId).status, + ( + await database + .prepare('SELECT status FROM appointments WHERE id = ?') + .get(secondId) + ).status, 'pending' ) assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' - ) - .get(secondId).count, + ( + await database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(secondId) + ).count, 0 ) }) test('a student cannot be accepted into overlapping lessons with different teachers', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '档期冲突学生' }) - const firstTeacher = addUser(database, { + const firstTeacher = await addUser(database, { role: 'teacher', displayName: '第一位教师' }) - const secondTeacher = addUser(database, { + const secondTeacher = await addUser(database, { role: 'teacher', displayName: '第二位教师' }) - addTeacherProfile(database, firstTeacher.id) - addTeacherProfile(database, secondTeacher.id) - const firstCourseId = addCourse(database, firstTeacher.id) - const secondCourseId = addCourse(database, secondTeacher.id) + await addTeacherProfile(database, firstTeacher.id) + await addTeacherProfile(database, secondTeacher.id) + const firstCourseId = await addCourse(database, firstTeacher.id) + const secondCourseId = await addCourse(database, secondTeacher.id) const sharedStart = futureIso(780) const first = await requestAppointment( @@ -482,46 +503,52 @@ test('a student cannot be accepted into overlapping lessons with different teach assert.equal(readBody(conflicted).data.conflictingAppointmentId, firstId) assert.equal(readBody(conflicted).data.conflictingParticipant, 'student') assert.equal( - database - .prepare('SELECT status FROM appointments WHERE id = ?') - .get(secondId).status, + ( + await database + .prepare('SELECT status FROM appointments WHERE id = ?') + .get(secondId) + ).status, 'pending' ) assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' - ) - .get(secondId).count, + ( + await database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(secondId) + ).count, 0 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE entity_id = ? AND action = 'appointment.accepted'` - ) - .get(secondId).count, + ) + .get(secondId) + ).count, 0 ) }) test('a participant completes a classroom atomically while outsiders are rejected', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '结课学生' }) - const outsider = addUser(database, { + const outsider = await addUser(database, { role: 'student', displayName: '无关结课用户' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '结课教师' }) - addTeacherProfile(database, teacher.id) - const courseId = addCourse(database, teacher.id) + await addTeacherProfile(database, teacher.id) + const courseId = await addCourse(database, teacher.id) const created = await requestAppointment(app, student, teacher, courseId) const appointmentId = readBody(created).data.id const accepted = await app.inject({ @@ -547,7 +574,7 @@ test('a participant completes a classroom atomically while outsiders are rejecte assert.equal(beforeOpening.statusCode, 409) const openedAt = new Date().toISOString() - database + await database .prepare( `UPDATE classrooms SET status = 'open', opened_at = ?, updated_at = ? @@ -564,7 +591,7 @@ test('a participant completes a classroom atomically while outsiders are rejecte assert.equal(readBody(completed).data.status, 'completed') assert.equal(readBody(completed).data.classroom.status, 'closed') - const persisted = database + const persisted = await database .prepare( `SELECT a.status AS appointment_status, @@ -580,21 +607,25 @@ test('a participant completes a classroom atomically while outsiders are rejecte assert.equal(persisted.classroom_status, 'closed') assert.ok(Date.parse(persisted.opened_at) <= Date.parse(persisted.closed_at)) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM notifications + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM notifications WHERE resource_id = ? AND user_id = ? AND type = 'appointment.completed'` - ) - .get(appointmentId, teacher.id).count, + ) + .get(appointmentId, teacher.id) + ).count, 1 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE entity_id = ? AND action = 'appointment.completed'` - ) - .get(appointmentId).count, + ) + .get(appointmentId) + ).count, 1 ) @@ -609,25 +640,25 @@ test('a participant completes a classroom atomically while outsiders are rejecte test('appointment ownership prevents response, listing and classroom access leaks', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '学习者丁' }) - const outsiderStudent = addUser(database, { + const outsiderStudent = await addUser(database, { role: 'student', displayName: '无关学生' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '教师丁' }) - const outsiderTeacher = addUser(database, { + const outsiderTeacher = await addUser(database, { role: 'teacher', displayName: '无关教师' }) - addTeacherProfile(database, teacher.id) - addTeacherProfile(database, outsiderTeacher.id) - const courseId = addCourse(database, teacher.id) + await addTeacherProfile(database, teacher.id) + await addTeacherProfile(database, outsiderTeacher.id) + const courseId = await addCourse(database, teacher.id) const created = await requestAppointment(app, student, teacher, courseId) const appointmentId = readBody(created).data.id diff --git a/server/test/assignment-workflow.test.js b/server/test/assignment-workflow.test.js index 74a97bf..dd5317b 100644 --- a/server/test/assignment-workflow.test.js +++ b/server/test/assignment-workflow.test.js @@ -5,7 +5,7 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' import assignmentRoutes from '../routes/assignments.js' @@ -18,23 +18,23 @@ function body(response) { return JSON.parse(response.body) } -function addUser(database, role, displayName) { +async function addUser(database, role, displayName) { const id = randomUUID() - database + await database .prepare( `INSERT INTO users ( id, email, password_hash, role, display_name, status ) VALUES (?, ?, ?, ?, ?, 'active')` ) .run(id, `${role}-${id}@assignment.test`, 'x'.repeat(64), role, displayName) - const session = createSession(database, id, { ttlSeconds: 3600 }) + const session = await createSession(database, id, { ttlSeconds: 3600 }) return { id, role, headers: bearer(session.token) } } -function addCourse(database, teacherId, status = 'published') { +async function addCourse(database, teacherId, status = 'published') { const id = randomUUID() - database + await database .prepare( `INSERT INTO courses ( id, teacher_id, title, summary, description, level, category, @@ -52,7 +52,7 @@ function addCourse(database, teacherId, status = 'published') { } async function createAssignmentTestApp(t) { - const database = createDatabase({ filename: ':memory:' }) + const database = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', database) @@ -68,7 +68,7 @@ async function createAssignmentTestApp(t) { t.after(async () => { await app.close() - database.close() + await database.close() }) return { app, database } @@ -102,10 +102,10 @@ function assignmentPayload(title = '第一单元作业') { test('student submission, teacher grading and student result form a complete workflow', async (t) => { const { app, database } = await createAssignmentTestApp(t) - const student = addUser(database, 'student', '测试学生') - const teacher = addUser(database, 'teacher', '王老师') - const otherTeacher = addUser(database, 'teacher', '李老师') - const courseId = addCourse(database, teacher.id) + const student = await addUser(database, 'student', '测试学生') + const teacher = await addUser(database, 'teacher', '王老师') + const otherTeacher = await addUser(database, 'teacher', '李老师') + const courseId = await addCourse(database, teacher.id) const created = await app.inject({ method: 'POST', @@ -140,6 +140,17 @@ test('student submission, teacher grading and student result form a complete wor assert.equal(published.statusCode, 200) assert.equal(body(published).data.status, 'published') + const studentAssignmentList = await app.inject({ + method: 'GET', + url: `/api/v1/courses/${courseId}/assignments`, + headers: student.headers + }) + assert.equal(studentAssignmentList.statusCode, 200) + assert.equal(body(studentAssignmentList).data.items.length, 1) + assert.equal(body(studentAssignmentList).data.items[0].id, assignmentId) + assert.equal(body(studentAssignmentList).data.items[0].questionCount, 2) + assert.equal(body(studentAssignmentList).data.items[0].submission, null) + const studentDetail = await app.inject({ method: 'GET', url: `/api/v1/assignments/${assignmentId}`, @@ -277,31 +288,35 @@ test('student submission, teacher grading and student result form a complete wor ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM notifications + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND type = 'submission.graded' AND resource_id = ?` - ) - .get(student.id, submissionId).count, + ) + .get(student.id, submissionId) + ).count, 1 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'submission.graded' AND entity_id = ?` - ) - .get(submissionId).count, + ) + .get(submissionId) + ).count, 1 ) }) test('assignment visibility, ownership and state transitions stay explicit', async (t) => { const { app, database } = await createAssignmentTestApp(t) - const student = addUser(database, 'student', '学生乙') - const teacher = addUser(database, 'teacher', '教师乙') - const otherTeacher = addUser(database, 'teacher', '教师丙') - const draftCourseId = addCourse(database, teacher.id, 'draft') + const student = await addUser(database, 'student', '学生乙') + const teacher = await addUser(database, 'teacher', '教师乙') + const otherTeacher = await addUser(database, 'teacher', '教师丙') + const draftCourseId = await addCourse(database, teacher.id, 'draft') const invalidPoints = await app.inject({ method: 'POST', @@ -386,10 +401,10 @@ test('assignment visibility, ownership and state transitions stay explicit', asy test('expired deadlines reject publishing and submission without partial writes', async (t) => { const { app, database } = await createAssignmentTestApp(t) - const student = addUser(database, 'student', '截止时间学生') - const secondStudent = addUser(database, 'student', '逾期新学生') - const teacher = addUser(database, 'teacher', '截止时间教师') - const courseId = addCourse(database, teacher.id) + const student = await addUser(database, 'student', '截止时间学生') + const secondStudent = await addUser(database, 'student', '逾期新学生') + const teacher = await addUser(database, 'teacher', '截止时间教师') + const courseId = await addCourse(database, teacher.id) const expiredDraft = await app.inject({ method: 'POST', @@ -411,18 +426,22 @@ test('expired deadlines reject publishing and submission without partial writes' assert.equal(expiredPublish.statusCode, 409) assert.match(body(expiredPublish).msg, /截止时间已过/) assert.equal( - database - .prepare('SELECT status FROM assignments WHERE id = ?') - .get(expiredDraftId).status, + ( + await database + .prepare('SELECT status FROM assignments WHERE id = ?') + .get(expiredDraftId) + ).status, 'draft' ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'assignment.published' AND entity_id = ?` - ) - .get(expiredDraftId).count, + ) + .get(expiredDraftId) + ).count, 0 ) @@ -457,7 +476,7 @@ test('expired deadlines reject publishing and submission without partial writes' assert.equal(initialDraft.statusCode, 201) const submissionId = body(initialDraft).data.id - database + await database .prepare('UPDATE assignments SET due_at = ? WHERE id = ?') .run(new Date(Date.now() - 60_000).toISOString(), assignmentId) @@ -502,7 +521,7 @@ test('expired deadlines reject publishing and submission without partial writes' assert.match(body(lateSubmit).msg, /超过截止时间/) assert.match(body(lateSubmit).data.draftPolicy, /仍可保存/) - const storedSubmission = database + const storedSubmission = await database .prepare('SELECT * FROM submissions WHERE id = ?') .get(submissionId) assert.equal(storedSubmission.status, 'draft') @@ -510,40 +529,46 @@ test('expired deadlines reject publishing and submission without partial writes' [choiceQuestion.id]: '再见' }) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM submissions + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM submissions WHERE assignment_id = ? AND student_id = ?` - ) - .get(assignmentId, secondStudent.id).count, + ) + .get(assignmentId, secondStudent.id) + ).count, 0 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'submission.submitted' AND entity_id = ?` - ) - .get(submissionId).count, + ) + .get(submissionId) + ).count, 0 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM notifications + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM notifications WHERE type = 'assignment.submitted' AND resource_id = ?` - ) - .get(submissionId).count, + ) + .get(submissionId) + ).count, 0 ) }) test('closing and archiving block writes while keeping assignment details readable', async (t) => { const { app, database } = await createAssignmentTestApp(t) - const student = addUser(database, 'student', '状态流转学生') - const teacher = addUser(database, 'teacher', '状态流转教师') - const otherTeacher = addUser(database, 'teacher', '越权教师') - const courseId = addCourse(database, teacher.id) + const student = await addUser(database, 'student', '状态流转学生') + const teacher = await addUser(database, 'teacher', '状态流转教师') + const otherTeacher = await addUser(database, 'teacher', '越权教师') + const courseId = await addCourse(database, teacher.id) const closedDraft = await app.inject({ method: 'POST', @@ -566,18 +591,22 @@ test('closing and archiving block writes while keeping assignment details readab }) assert.equal(forbiddenClose.statusCode, 403) assert.equal( - database - .prepare('SELECT status FROM assignments WHERE id = ?') - .get(closedAssignmentId).status, + ( + await database + .prepare('SELECT status FROM assignments WHERE id = ?') + .get(closedAssignmentId) + ).status, 'published' ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'assignment.closed' AND entity_id = ?` - ) - .get(closedAssignmentId).count, + ) + .get(closedAssignmentId) + ).count, 0 ) @@ -589,12 +618,14 @@ test('closing and archiving block writes while keeping assignment details readab assert.equal(closed.statusCode, 200) assert.equal(body(closed).data.status, 'closed') assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'assignment.closed' AND entity_id = ?` - ) - .get(closedAssignmentId).count, + ) + .get(closedAssignmentId) + ).count, 1 ) @@ -616,11 +647,13 @@ test('closing and archiving block writes while keeping assignment details readab assert.equal(closedSubmit.statusCode, 409) assert.match(body(closedSubmit).msg, /已关闭/) assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM submissions WHERE assignment_id = ?' - ) - .get(closedAssignmentId).count, + ( + await database + .prepare( + 'SELECT COUNT(*) AS count FROM submissions WHERE assignment_id = ?' + ) + .get(closedAssignmentId) + ).count, 0 ) @@ -631,12 +664,14 @@ test('closing and archiving block writes while keeping assignment details readab }) assert.equal(secondClose.statusCode, 409) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'assignment.closed' AND entity_id = ?` - ) - .get(closedAssignmentId).count, + ) + .get(closedAssignmentId) + ).count, 1 ) @@ -653,7 +688,7 @@ test('closing and archiving block writes while keeping assignment details readab headers: teacher.headers }) assert.equal(archivedPublished.statusCode, 200) - database + await database .prepare( "UPDATE courses SET status = 'archived', published_at = NULL WHERE id = ?" ) @@ -676,11 +711,13 @@ test('closing and archiving block writes while keeping assignment details readab assert.equal(archivedSubmit.statusCode, 409) assert.match(body(archivedSubmit).msg, /课程已归档/) assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM submissions WHERE assignment_id = ?' - ) - .get(archivedAssignmentId).count, + ( + await database + .prepare( + 'SELECT COUNT(*) AS count FROM submissions WHERE assignment_id = ?' + ) + .get(archivedAssignmentId) + ).count, 0 ) }) diff --git a/server/test/auth.test.js b/server/test/auth.test.js index 9f11481..902a0ec 100644 --- a/server/test/auth.test.js +++ b/server/test/auth.test.js @@ -4,7 +4,7 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' import authRoutes from '../routes/auth.js' @@ -26,7 +26,7 @@ function cookieToken(cookieValue) { } async function createAuthTestApp(t) { - const db = createDatabase({ filename: ':memory:' }) + const db = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', db) @@ -49,7 +49,7 @@ async function createAuthTestApp(t) { await app.ready() t.after(async () => { await app.close() - db.close() + await db.close() }) return { app, db } @@ -98,7 +98,7 @@ test('register creates a cookie session without exposing its token', async (t) = const sessionCookie = cookieHeader(response) const rawToken = cookieToken(sessionCookie) - const storedSession = db + const storedSession = await db .prepare('SELECT token_hash FROM sessions LIMIT 1') .get() @@ -132,7 +132,7 @@ test('teacher registration creates an active account pending administrator verif }) assert.equal(response.statusCode, 201) - const teacher = db + const teacher = await db .prepare( `SELECT u.status, tp.verified_at FROM users AS u @@ -243,13 +243,19 @@ test('profile updates are scoped to the authenticated account', async (t) => { assert.equal(response.statusCode, 200) assert.equal(readBody(response).data.displayName, '更新后的昵称') assert.equal( - db.prepare('SELECT display_name FROM users WHERE id = ?').get(firstId) - .display_name, + ( + await db + .prepare('SELECT display_name FROM users WHERE id = ?') + .get(firstId) + ).display_name, '更新后的昵称' ) assert.equal( - db.prepare('SELECT display_name FROM users WHERE id = ?').get(secondId) - .display_name, + ( + await db + .prepare('SELECT display_name FROM users WHERE id = ?') + .get(secondId) + ).display_name, '第二位学生' ) }) @@ -363,7 +369,7 @@ test('logout revokes the session and bearer authentication remains API-compatibl }) assert.equal(revoked.statusCode, 401) - const apiSession = createSession(db, user.id, { ttlSeconds: 3600 }) + const apiSession = await createSession(db, user.id, { ttlSeconds: 3600 }) const bearerResponse = await app.inject({ method: 'GET', url: '/api/v1/auth/session', @@ -392,3 +398,59 @@ test('cookie-authenticated writes reject an untrusted Origin', async (t) => { assert.equal(response.statusCode, 403) assert.equal(readBody(response).msg, '请求来源不受信任') }) + +test('temporary credentials require a password reset after session restore', async (t) => { + const { app, db } = await createAuthTestApp(t) + const registration = await register(app) + const user = readBody(registration).data + const sessionCookie = cookieHeader(registration) + + await db + .prepare('UPDATE users SET must_reset_password = true WHERE id = ?') + .run(user.id) + + const restored = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session', + headers: { cookie: sessionCookie } + }) + assert.equal(restored.statusCode, 200) + assert.equal(readBody(restored).data.mustResetPassword, true) + + const blocked = await app.inject({ + method: 'GET', + url: '/api/v1/me', + headers: { cookie: sessionCookie } + }) + assert.equal(blocked.statusCode, 403) + assert.equal(readBody(blocked).data.reason, 'PASSWORD_RESET_REQUIRED') + + const changed = await app.inject({ + method: 'PATCH', + url: '/api/v1/me/password', + headers: { cookie: sessionCookie }, + payload: { + currentPassword: 'Secure123!', + newPassword: 'NewSecure456!', + confirmPassword: 'NewSecure456!' + } + }) + assert.equal(changed.statusCode, 200) + assert.equal(readBody(changed).data.mustResetPassword, false) + + const replacementCookie = cookieHeader(changed) + const replacementSession = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session', + headers: { cookie: replacementCookie } + }) + assert.equal(replacementSession.statusCode, 200) + assert.equal(readBody(replacementSession).data.mustResetPassword, false) + + const allowed = await app.inject({ + method: 'GET', + url: '/api/v1/me', + headers: { cookie: replacementCookie } + }) + assert.equal(allowed.statusCode, 200) +}) diff --git a/server/test/bootstrap-admin.test.js b/server/test/bootstrap-admin.test.js index 69bc9ec..fbae6e4 100644 --- a/server/test/bootstrap-admin.test.js +++ b/server/test/bootstrap-admin.test.js @@ -2,12 +2,12 @@ import assert from 'node:assert/strict' import test from 'node:test' import { bootstrapAdministrator } from '../db/bootstrap-admin.js' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { verifyPassword } from '../lib/password.js' test('one-time administrator bootstrap creates a hashed privileged account and audit', async (t) => { - const database = createDatabase({ filename: ':memory:' }) - t.after(() => database.close()) + const database = await createTestDatabase() + t.after(async () => database.close()) const administrator = await bootstrapAdministrator(database, { email: 'OWNER@EXAMPLE.TEST', @@ -17,7 +17,7 @@ test('one-time administrator bootstrap creates a hashed privileged account and a assert.equal(administrator.email, 'owner@example.test') assert.equal(administrator.role, 'administrator') - const row = database + const row = await database .prepare('SELECT * FROM users WHERE id = ?') .get(administrator.id) assert.match(row.password_hash, /^scrypt\$/) @@ -25,20 +25,23 @@ test('one-time administrator bootstrap creates a hashed privileged account and a await verifyPassword('Strong-Admin-2026!', row.password_hash), true ) + assert.equal(row.must_reset_password, true) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM audit_logs + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'administrator.bootstrapped' AND actor_id = ?` - ) - .get(administrator.id).count, + ) + .get(administrator.id) + ).count, 1 ) }) test('bootstrap refuses weak input and never overwrites an existing administrator', async (t) => { - const database = createDatabase({ filename: ':memory:' }) - t.after(() => database.close()) + const database = await createTestDatabase() + t.after(async () => database.close()) await assert.rejects( bootstrapAdministrator(database, { @@ -54,9 +57,11 @@ test('bootstrap refuses weak input and never overwrites an existing administrato password: 'Strong-Admin-2026!', displayName: '第一位管理员' }) - const originalHash = database - .prepare('SELECT password_hash FROM users WHERE id = ?') - .get(first.id).password_hash + const originalHash = ( + await database + .prepare('SELECT password_hash FROM users WHERE id = ?') + .get(first.id) + ).password_hash await assert.rejects( bootstrapAdministrator(database, { @@ -67,17 +72,21 @@ test('bootstrap refuses weak input and never overwrites an existing administrato (error) => error.code === 'ADMIN_ALREADY_EXISTS' ) assert.equal( - database - .prepare( - "SELECT COUNT(*) AS count FROM users WHERE role = 'administrator'" - ) - .get().count, + ( + await database + .prepare( + "SELECT COUNT(*) AS count FROM users WHERE role = 'administrator'" + ) + .get() + ).count, 1 ) assert.equal( - database - .prepare('SELECT password_hash FROM users WHERE id = ?') - .get(first.id).password_hash, + ( + await database + .prepare('SELECT password_hash FROM users WHERE id = ?') + .get(first.id) + ).password_hash, originalHash ) }) diff --git a/server/test/config.test.js b/server/test/config.test.js new file mode 100644 index 0000000..9588779 --- /dev/null +++ b/server/test/config.test.js @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { loadConfig } from '../config.js' + +function productionEnv(overrides = {}) { + return { + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://example.invalid/platform', + DATABASE_SSL: 'false', + APP_ORIGINS: 'https://platform.example', + PUBLIC_WEBSOCKET_ORIGIN: 'https://api.example', + VERIFICATION_CODE_SECRET: 'test-secret-that-is-at-least-32-characters', + SECURE_COOKIES: 'true', + ALLOW_BEARER_AUTH: 'false', + TRUST_PROXY: '1', + SEED_ON_START: 'false', + ...overrides + } +} + +test('production configuration rejects automatic demo seeding', () => { + assert.throws( + () => loadConfig(productionEnv({ SEED_ON_START: 'true' })), + /SEED_ON_START cannot be enabled in production/ + ) +}) + +test('production configuration normalizes HTTPS into an absolute WSS origin', () => { + const config = loadConfig(productionEnv()) + assert.equal(config.publicWebsocketOrigin, 'wss://api.example') + assert.equal(config.seedOnStart, false) +}) + +test('public production rejects all-proxy trust', () => { + assert.throws( + () => loadConfig(productionEnv({ TRUST_PROXY: 'true' })), + /TRUST_PROXY must be an explicit hop count/ + ) +}) + +test('mail relay configuration requires HTTPS and a strong paired secret', () => { + assert.throws( + () => + loadConfig( + productionEnv({ + MAIL_RELAY_URL: 'http://platform.example/api/mail-relay', + MAIL_RELAY_SECRET: 'x'.repeat(32) + }) + ), + /credential-free HTTPS URL/ + ) + assert.throws( + () => + loadConfig( + productionEnv({ + MAIL_RELAY_URL: 'https://platform.example/api/mail-relay', + MAIL_RELAY_SECRET: 'too-short' + }) + ), + /at least 32 characters/ + ) + assert.throws( + () => + loadConfig( + productionEnv({ + MAIL_RELAY_URL: 'https://platform.example/api/mail-relay' + }) + ), + /must be configured together/ + ) +}) + +test('valid production mail relay configuration is exposed to the provider', () => { + const config = loadConfig( + productionEnv({ + MAIL_RELAY_URL: 'https://platform.example/api/mail-relay\r', + MAIL_RELAY_SECRET: `${'x'.repeat(32)}\r` + }) + ) + assert.equal(config.mailRelayUrl, 'https://platform.example/api/mail-relay') + assert.equal(config.mailRelaySecret, 'x'.repeat(32)) +}) diff --git a/server/test/course-workflow.test.js b/server/test/course-workflow.test.js index 2bedf53..fd9b1e0 100644 --- a/server/test/course-workflow.test.js +++ b/server/test/course-workflow.test.js @@ -5,7 +5,7 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { hashPassword } from '../lib/password.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' @@ -17,7 +17,7 @@ function authorization(token) { } async function createCourseTestApp(t) { - const db = createDatabase({ filename: ':memory:' }) + const db = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', db) @@ -47,7 +47,7 @@ async function createCourseTestApp(t) { ['administrator', 'administrator', '审核员'] ]) { const id = randomUUID() - insertUser.run( + await insertUser.run( id, `${key.toLowerCase()}@course.test`, passwordHash, @@ -56,7 +56,7 @@ async function createCourseTestApp(t) { now, now ) - const session = createSession(db, id, { ttlSeconds: 3600 }) + const session = await createSession(db, id, { ttlSeconds: 3600 }) accounts[key] = { id, role, @@ -68,13 +68,13 @@ async function createCourseTestApp(t) { `INSERT INTO teacher_profiles (user_id, verified_at, created_at, updated_at) VALUES (?, ?, ?, ?)` ) - insertTeacherProfile.run(accounts.teacher.id, now, now, now) - insertTeacherProfile.run(accounts.otherTeacher.id, now, now, now) + await insertTeacherProfile.run(accounts.teacher.id, now, now, now) + await insertTeacherProfile.run(accounts.otherTeacher.id, now, now, now) await app.ready() t.after(async () => { await app.close() - db.close() + await db.close() }) return { app, db, accounts, now } @@ -174,18 +174,18 @@ test('course workflow enforces ownership and closes the reject-revise-approve lo }) assert.equal(otherTeacherSubmit.statusCode, 403) - db.prepare( - 'UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?' - ).run(accounts.teacher.id) + await db + .prepare('UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?') + .run(accounts.teacher.id) const unverifiedSubmission = await app.inject({ method: 'POST', url: `/api/v1/courses/${courseId}/submit`, headers: accounts.teacher.headers }) assert.equal(unverifiedSubmission.statusCode, 409) - db.prepare( - 'UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?' - ).run(now, accounts.teacher.id) + await db + .prepare('UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?') + .run(now, accounts.teacher.id) const firstSubmission = await app.inject({ method: 'POST', @@ -245,19 +245,23 @@ test('course workflow enforces ownership and closes the reject-revise-approve lo /作业评价标准/ ) assert.equal( - db - .prepare( - "SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND type = 'course.rejected'" - ) - .get(accounts.teacher.id).count, + ( + await db + .prepare( + "SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND type = 'course.rejected'" + ) + .get(accounts.teacher.id) + ).count, 1 ) assert.equal( - db - .prepare( - "SELECT COUNT(*) AS count FROM audit_logs WHERE entity_id = ? AND action = 'course.rejected'" - ) - .get(courseId).count, + ( + await db + .prepare( + "SELECT COUNT(*) AS count FROM audit_logs WHERE entity_id = ? AND action = 'course.rejected'" + ) + .get(courseId) + ).count, 1 ) @@ -282,9 +286,9 @@ test('course workflow enforces ownership and closes the reject-revise-approve lo assert.equal(secondSubmission.json().data.status, 'pending') assert.equal(secondSubmission.json().data.rejectionReason, null) - db.prepare( - 'UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?' - ).run(accounts.teacher.id) + await db + .prepare('UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?') + .run(accounts.teacher.id) const approvalWhileUnverified = await app.inject({ method: 'POST', url: `/api/v1/admin/course-reviews/${courseId}`, @@ -292,9 +296,9 @@ test('course workflow enforces ownership and closes the reject-revise-approve lo payload: { action: 'approve', note: '不应通过' } }) assert.equal(approvalWhileUnverified.statusCode, 409) - db.prepare( - 'UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?' - ).run(now, accounts.teacher.id) + await db + .prepare('UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?') + .run(now, accounts.teacher.id) const approvedResponse = await app.inject({ method: 'POST', @@ -337,9 +341,9 @@ test('course workflow enforces ownership and closes the reject-revise-approve lo assert.equal(publicDetail.json().data.id, courseId) assert.equal(publicDetail.json().data.status, 'published') - db.prepare( - 'UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?' - ).run(accounts.teacher.id) + await db + .prepare('UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?') + .run(accounts.teacher.id) const hiddenCatalog = await app.inject({ method: 'GET', url: '/api/v1/courses', @@ -357,9 +361,9 @@ test('course workflow enforces ownership and closes the reject-revise-approve lo headers: accounts.teacher.headers }) assert.equal(ownerStillReadsCourse.statusCode, 200) - db.prepare( - 'UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?' - ).run(now, accounts.teacher.id) + await db + .prepare('UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?') + .run(now, accounts.teacher.id) const studentMetrics = await app.inject({ method: 'GET', diff --git a/server/test/database.test.js b/server/test/database.test.js index 5231b8c..2d03c09 100644 --- a/server/test/database.test.js +++ b/server/test/database.test.js @@ -1,22 +1,15 @@ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { randomUUID } from 'node:crypto' import test from 'node:test' -import Database from 'better-sqlite3' - -import { - createDatabase, - MIGRATION_IDS, - migrateDatabase -} from '../db/database.js' +import { MIGRATION_IDS, migrateDatabase } from '../db/database.js' import { DEMO_ACCOUNTS, DEMO_PASSWORD, seedDatabase, verifyPasswordHash -} from '../db/seed.js' +} from '../db/seed-data.js' +import { createTestDatabase } from './support/database.js' const requiredTables = [ 'users', @@ -34,308 +27,129 @@ const requiredTables = [ 'notifications', 'audit_logs', 'files', + 'upload_intents', + 'object_cleanup_jobs', 'dialogue_sessions', 'dialogue_turns' ] -function createTemporaryDatabase(t, options = {}) { - const directory = mkdtempSync( - join(tmpdir(), 'international-chinese-platform-') - ) - const filename = join(directory, 'test.db') - const database = createDatabase({ filename, ...options }) - - t.after(() => { - if (database.open) { - database.close() - } - rmSync(directory, { recursive: true, force: true }) - }) - - return database -} - -test('database migration creates the complete schema with safe pragmas', (t) => { - const database = createTemporaryDatabase(t) +test('database migration creates the complete PostgreSQL schema', async (t) => { + const database = await createTestDatabase() + t.after(async () => database.close()) - assert.equal(database.pragma('foreign_keys', { simple: true }), 1) - assert.equal(database.pragma('journal_mode', { simple: true }), 'wal') - - const tables = new Set( - database - .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") - .all() - .map((row) => row.name) - ) - - for (const table of requiredTables) { - assert.ok(tables.has(table), `expected migration to create ${table}`) - } + const tables = await database + .prepare( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = ? AND table_type = 'BASE TABLE'` + ) + .all(database.testSchema) + const names = new Set(tables.map((row) => row.table_name)) + for (const table of requiredTables) assert.ok(names.has(table), table) + const migrations = await database + .prepare('SELECT id FROM schema_migrations ORDER BY id') + .all() assert.deepEqual( - database - .prepare('SELECT id FROM schema_migrations ORDER BY id') - .all() - .map((row) => row.id), - [...MIGRATION_IDS] + migrations.map((row) => row.id), + MIGRATION_IDS ) - migrateDatabase(database) - assert.equal( - database.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get() - .count, - MIGRATION_IDS.length, - 'migrations should be repeatable' - ) -}) - -test('legacy database upgrades in order without losing classroom messages', (t) => { - const directory = mkdtempSync(join(tmpdir(), 'international-chinese-legacy-')) - const filename = join(directory, 'legacy.db') - const legacy = new Database(filename) - legacy.pragma('foreign_keys = ON') - legacy.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL, - display_name TEXT NOT NULL, - avatar_url TEXT, - country TEXT, - region TEXT, - age INTEGER, - chinese_level TEXT, - bio TEXT, - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE TABLE appointments ( - id TEXT PRIMARY KEY, - student_id TEXT NOT NULL REFERENCES users(id), - teacher_id TEXT NOT NULL REFERENCES users(id), - course_id TEXT, - scheduled_start TEXT NOT NULL, - scheduled_end TEXT NOT NULL, - topic TEXT NOT NULL, - message TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - response_note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE TABLE classrooms ( - id TEXT PRIMARY KEY, - appointment_id TEXT NOT NULL UNIQUE REFERENCES appointments(id), - room_code TEXT NOT NULL UNIQUE, - status TEXT NOT NULL DEFAULT 'scheduled', - opened_at TEXT, - closed_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE TABLE chat_messages ( - id TEXT PRIMARY KEY, - classroom_id TEXT NOT NULL REFERENCES classrooms(id), - sender_id TEXT REFERENCES users(id), - message_type TEXT NOT NULL DEFAULT 'text', - content TEXT NOT NULL, - metadata_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - edited_at TEXT, - deleted_at TEXT - ); - `) - - const now = '2025-01-01T00:00:00.000Z' - const studentId = '10000000-0000-4000-8000-000000000001' - const teacherId = '10000000-0000-4000-8000-000000000002' - legacy - .prepare( - `INSERT INTO users ( - id, email, password_hash, role, display_name, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)` - ) - .run( - studentId, - 'legacy@student.test', - 'x'.repeat(64), - 'student', - '旧学生', - now, - now - ) - legacy + const columns = await database .prepare( - `INSERT INTO users ( - id, email, password_hash, role, display_name, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)` - ) - .run( - teacherId, - 'legacy@teacher.test', - 'x'.repeat(64), - 'teacher', - '旧教师', - now, - now + `SELECT column_name, data_type, udt_name + FROM information_schema.columns + WHERE table_schema = ? AND table_name = 'users'` ) - legacy - .prepare( - `INSERT INTO appointments ( - id, student_id, teacher_id, scheduled_start, scheduled_end, topic, - status, created_at, updated_at - ) VALUES ('20000000-0000-4000-8000-000000000001', ?, ?, ?, ?, '旧课堂', - 'accepted', ?, ?)` - ) - .run(studentId, teacherId, now, '2025-01-01T01:00:00.000Z', now, now) - legacy.exec(` - INSERT INTO classrooms ( - id, appointment_id, room_code, status, created_at, updated_at - ) VALUES ( - '30000000-0000-4000-8000-000000000001', - '20000000-0000-4000-8000-000000000001', - 'legacy-room', 'open', '${now}', '${now}' - ); - INSERT INTO chat_messages ( - id, classroom_id, sender_id, content, created_at - ) VALUES ( - '40000000-0000-4000-8000-000000000001', - '30000000-0000-4000-8000-000000000001', - '${studentId}', '迁移前的课堂消息', '${now}' - ); - `) - legacy.close() + .all(database.testSchema) + const byName = Object.fromEntries( + columns.map((row) => [row.column_name, row]) + ) + assert.equal(byName.id.udt_name, 'uuid') + assert.equal(byName.email.udt_name, 'citext') + assert.equal(byName.created_at.udt_name, 'timestamptz') + assert.equal(byName.must_reset_password.udt_name, 'bool') +}) - const upgraded = createDatabase({ filename }) - t.after(() => { - if (upgraded.open) upgraded.close() - rmSync(directory, { recursive: true, force: true }) - }) +test('numbered migrations are idempotent', async (t) => { + const database = await createTestDatabase() + t.after(async () => database.close()) + await migrateDatabase(database) + await migrateDatabase(database) + const row = await database + .prepare('SELECT COUNT(*) AS count FROM schema_migrations') + .get() + assert.equal(row.count, MIGRATION_IDS.length) +}) - assert.equal( - upgraded - .prepare('SELECT content FROM chat_messages WHERE id = ?') - .get('40000000-0000-4000-8000-000000000001').content, - '迁移前的课堂消息' - ) - assert.ok( - upgraded - .prepare('PRAGMA table_info(chat_messages)') - .all() - .some((column) => column.name === 'client_message_id') +test('concurrent migration runners serialize across pools and schemas', async (t) => { + const databases = await Promise.all([ + createTestDatabase({ migrate: false }), + createTestDatabase({ migrate: false }) + ]) + t.after(async () => + Promise.all(databases.map((database) => database.close())) ) - for (const table of ['teacher_profiles', 'files', 'dialogue_sessions']) { - assert.ok( - upgraded - .prepare( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?" - ) - .get(table), - `expected legacy migration to create ${table}` - ) + + await Promise.all([ + migrateDatabase(databases[0]), + migrateDatabase(databases[0]), + migrateDatabase(databases[1]) + ]) + + for (const database of databases) { + const row = await database + .prepare('SELECT COUNT(*) AS count FROM schema_migrations') + .get() + assert.equal(row.count, MIGRATION_IDS.length) } - assert.deepEqual( - upgraded - .prepare('SELECT id FROM schema_migrations ORDER BY id') - .all() - .map((row) => row.id), - [...MIGRATION_IDS] - ) }) -test('demo seed is idempotent and stores all account passwords as scrypt hashes', (t) => { - const database = createTemporaryDatabase(t) +test('demo seed is idempotent and stores all account passwords as scrypt hashes', async (t) => { + const database = await createTestDatabase() + t.after(async () => database.close()) + const first = await seedDatabase(database) + const second = await seedDatabase(database) + assert.deepEqual(second, first) + assert.equal(first.users, DEMO_ACCOUNTS.length) - const firstResult = seedDatabase(database) - const firstCounts = Object.fromEntries( - requiredTables.map((table) => [ - table, - database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count - ]) - ) - const firstHashes = database + const rows = await database .prepare('SELECT email, password_hash FROM users ORDER BY email') .all() - - const secondResult = seedDatabase(database) - const secondCounts = Object.fromEntries( - requiredTables.map((table) => [ - table, - database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count - ]) - ) - const secondHashes = database - .prepare('SELECT email, password_hash FROM users ORDER BY email') - .all() - - assert.deepEqual(secondResult, firstResult) - assert.deepEqual(secondCounts, firstCounts) - assert.deepEqual( - secondHashes, - firstHashes, - 'rerunning seed must not rotate existing hashes' - ) - - for (const account of DEMO_ACCOUNTS) { - const row = database - .prepare('SELECT role, password_hash FROM users WHERE email = ?') - .get(account.email) - - assert.equal(row.role, account.role) - assert.notEqual(row.password_hash, DEMO_PASSWORD) + for (const row of rows) { assert.match(row.password_hash, /^scrypt\$/) assert.equal(verifyPasswordHash(DEMO_PASSWORD, row.password_hash), true) - assert.equal( - verifyPasswordHash('not-the-demo-password', row.password_hash), - false - ) } - - assert.equal(firstCounts.courses, 2) - assert.equal(firstCounts.appointments, 2) - assert.equal(firstCounts.assignments, 1) - assert.equal(firstCounts.assignment_questions, 2) - assert.equal(firstCounts.submissions, 1) }) -test('foreign keys and domain checks reject invalid records', (t) => { - const database = createTemporaryDatabase(t) +test('foreign keys and domain checks reject invalid records', async (t) => { + const database = await createTestDatabase() + t.after(async () => database.close()) - assert.throws( - () => - database - .prepare( - ` - INSERT INTO courses (id, teacher_id, title) - VALUES (?, ?, ?) - ` - ) - .run( - 'f0000000-0000-4000-8000-000000000001', - 'f0000000-0000-4000-8000-000000000002', - 'Orphan course' - ), - /FOREIGN KEY constraint failed/ + await assert.rejects( + database + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name + ) VALUES (?, ?, ?, ?, ?)` + ) + .run( + randomUUID(), + 'invalid@example.com', + 'x'.repeat(64), + 'owner', + 'Invalid' + ), + (error) => error.code === '23514' ) - assert.throws( - () => - database - .prepare( - ` - INSERT INTO users (id, email, password_hash, role, display_name) - VALUES (?, ?, ?, ?, ?) - ` - ) - .run( - 'f0000000-0000-4000-8000-000000000003', - 'invalid-role@example.com', - 'x'.repeat(64), - 'owner', - 'Invalid role' - ), - /CHECK constraint failed/ + await assert.rejects( + database + .prepare( + `INSERT INTO courses ( + id, teacher_id, title, level, category + ) VALUES (?, ?, ?, 'beginner', 'general')` + ) + .run(randomUUID(), randomUUID(), 'Orphan course'), + (error) => error.code === '23503' ) }) diff --git a/server/test/dialogues.test.js b/server/test/dialogues.test.js index c0d96af..c1f10a8 100644 --- a/server/test/dialogues.test.js +++ b/server/test/dialogues.test.js @@ -5,7 +5,7 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { hashPassword } from '../lib/password.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' @@ -16,7 +16,7 @@ function authorization(token) { } async function createTestApp(t) { - const db = createDatabase({ filename: ':memory:' }) + const db = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', db) app.decorate('config', { aiApiUrl: '', aiApiKey: '' }) @@ -39,21 +39,23 @@ async function createTestApp(t) { ['teacher', 'teacher'] ]) { const id = randomUUID() - db.prepare( - `INSERT INTO users ( + await db + .prepare( + `INSERT INTO users ( id, email, password_hash, role, display_name, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` - ).run(id, `${name}@dialogue.test`, passwordHash, role, name, now, now) + ) + .run(id, `${name}@dialogue.test`, passwordHash, role, name, now, now) users[name] = { id, - token: createSession(db, id, { ttlSeconds: 3600 }).token + token: (await createSession(db, id, { ttlSeconds: 3600 })).token } } await app.ready() t.after(async () => { await app.close() - db.close() + await db.close() }) return { app, db, users } } @@ -107,11 +109,13 @@ test('student can generate, continue and revisit a persistent local dialogue', a assert.equal(list.json().data.items.length, 1) assert.equal(list.json().data.items[0].turnCount, 4) assert.equal( - db - .prepare( - 'SELECT COUNT(*) AS count FROM dialogue_turns WHERE session_id = ?' - ) - .get(dialogueId).count, + ( + await db + .prepare( + 'SELECT COUNT(*) AS count FROM dialogue_turns WHERE session_id = ?' + ) + .get(dialogueId) + ).count, 4 ) }) diff --git a/server/test/files.test.js b/server/test/files.test.js index 9eb6469..633e3ba 100644 --- a/server/test/files.test.js +++ b/server/test/files.test.js @@ -1,375 +1,320 @@ import assert from 'node:assert/strict' import { Buffer } from 'node:buffer' -import { randomUUID } from 'node:crypto' -import { mkdtemp, readdir, rm } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' +import { createHash, randomUUID } from 'node:crypto' import test from 'node:test' import cookie from '@fastify/cookie' -import multipart from '@fastify/multipart' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' -import { hashPassword } from '../lib/password.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' import fileRoutes from '../routes/files.js' -import { loadConfig } from '../config.js' +import { createTestDatabase } from './support/database.js' -function authorization(token) { - return { authorization: `Bearer ${token}` } +const PNG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, + 0x52, 1, 2, 3, 4 +]) +const PDF = Buffer.from('%PDF-1.7\nexample') + +function objectEtag(body) { + return `"${createHash('sha256').update(body).digest('hex')}"` } -function multipartBody({ content, filename, mimeType }) { - const boundary = `test-${randomUUID()}` - const prefix = Buffer.from( - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + - `Content-Type: ${mimeType}\r\n\r\n` - ) - const suffix = Buffer.from(`\r\n--${boundary}--\r\n`) +function createFakeStorage() { + const objects = new Map() + const promotions = [] + let readCalls = 0 return { - body: Buffer.concat([prefix, content, suffix]), - contentType: `multipart/form-data; boundary=${boundary}` + objects, + promotions, + get readCalls() { + return readCalls + }, + async probe() { + return 'up' + }, + async createUploadUrl({ key }) { + return `https://objects.test/${encodeURIComponent(key)}` + }, + putFromUrl(url, body) { + const key = decodeURIComponent(new URL(url).pathname.slice(1)) + objects.set(key, Buffer.from(body)) + return key + }, + async read(key) { + readCalls += 1 + const body = objects.get(key) + if (!body) + throw Object.assign(new Error('missing'), { name: 'NoSuchKey' }) + return { + ETag: objectEtag(body), + Body: (async function* () { + yield body + })() + } + }, + async copy({ sourceKey, destinationKey, sourceEtag }) { + const body = objects.get(sourceKey) + if (!body) + throw Object.assign(new Error('missing'), { name: 'NoSuchKey' }) + if (objectEtag(body) !== sourceEtag) { + throw Object.assign(new Error('changed'), { + name: 'PreconditionFailed', + $metadata: { httpStatusCode: 412 } + }) + } + objects.set(destinationKey, Buffer.from(body)) + promotions.push({ sourceKey, destinationKey }) + }, + async createDownloadUrl({ key }) { + if (!objects.has(key)) + throw Object.assign(new Error('missing'), { name: 'NoSuchKey' }) + return `https://objects.test/download/${encodeURIComponent(key)}` + }, + async delete(key) { + objects.delete(key) + } } } -function pngContent(sizeBytes = 48) { - const header = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, - 0x49, 0x48, 0x44, 0x52 - ]) - assert.ok(sizeBytes >= header.length) - return Buffer.concat([header, Buffer.alloc(sizeBytes - header.length, 0x61)]) +async function addUser(database, role) { + const id = randomUUID() + await database + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name + ) VALUES (?, ?, ?, ?, ?)` + ) + .run(id, `${id}@example.com`, 'x'.repeat(64), role, role) + const session = await createSession(database, id, { ttlSeconds: 3600 }) + return { id, headers: { cookie: `icp_session=${session.token}` } } } -async function createTestApp(t, uploadConfig = {}) { - const uploadDir = await mkdtemp(join(tmpdir(), 'chinese-platform-files-')) - const db = createDatabase({ filename: ':memory:' }) +async function createTestApp(t, limits = {}) { + const db = await createTestDatabase() + const storage = createFakeStorage() const app = Fastify({ logger: false }) app.decorate('db', db) + app.decorate('objectStorage', storage) app.decorate('config', { - uploadDir, uploadOwnerQuotaBytes: 250 * 1024 * 1024, uploadTotalQuotaBytes: 5 * 1024 * 1024 * 1024, uploadMaxConcurrent: 4, - ...uploadConfig + ...limits }) - await app.register(cookie) - await app.register(multipart, { - limits: { files: 1, fileSize: 50 * 1024 * 1024, fields: 10 } - }) - await authPlugin(app, { - db, - allowBearer: true, - secureCookies: false, - sessionTtlSeconds: 3600 - }) + await authPlugin(app, { db, secureCookies: false, sessionTtlSeconds: 3600 }) await app.register(fileRoutes) - - const now = new Date().toISOString() - const passwordHash = await hashPassword('FileTest123!') - const users = {} - for (const [name, role] of [ - ['owner', 'teacher'], - ['other', 'teacher'], - ['student', 'student'] - ]) { - const id = randomUUID() - db.prepare( - `INSERT INTO users ( - id, email, password_hash, role, display_name, status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` - ).run(id, `${name}@file.test`, passwordHash, role, name, now, now) - users[name] = { - id, - token: createSession(db, id, { ttlSeconds: 3600 }).token - } - } - await app.ready() t.after(async () => { await app.close() - db.close() - await rm(uploadDir, { recursive: true, force: true }) + await db.close() }) - - return { app, db, uploadDir, users } + return { app, db, storage } } -test('validated image upload is public while metadata and deletion remain owned', async (t) => { - const { app, db, users } = await createTestApp(t) - const png = pngContent() - const form = multipartBody({ - content: png, - filename: '课程封面.png', - mimeType: 'image/png' - }) - - const upload = await app.inject({ +async function createIntent(app, user, input = {}) { + const response = await app.inject({ method: 'POST', - url: '/api/v1/files?category=course_cover', - headers: { - ...authorization(users.owner.token), - 'content-type': form.contentType - }, - payload: form.body + url: '/api/v1/files/upload-intents', + headers: user.headers, + payload: { + category: 'avatar', + originalName: 'avatar.png', + mimeType: 'image/png', + sizeBytes: PNG.length, + ...input + } }) - assert.equal(upload.statusCode, 201) + return { response, body: response.json() } +} - assert.equal(upload.json().data.mimeType, 'image/png') - assert.equal(upload.json().data.ownerId, users.owner.id) - assert.equal(upload.json().data.sha256.length, 64) - const fileId = upload.json().data.id +test('validated image upload is public while metadata and deletion remain owned', async (t) => { + const { app, storage } = await createTestApp(t) + const owner = await addUser(app.db, 'student') + const outsider = await addUser(app.db, 'student') + const intent = await createIntent(app, owner) + assert.equal(intent.response.statusCode, 201) + storage.putFromUrl(intent.body.data.uploadUrl, PNG) - const content = await app.inject({ - method: 'GET', - url: `/api/v1/files/${fileId}/content` + const completed = await app.inject({ + method: 'POST', + url: `/api/v1/files/upload-intents/${intent.body.data.id}/complete`, + headers: owner.headers }) - assert.equal(content.statusCode, 200) - assert.equal(content.headers['content-type'], 'image/png') - assert.deepEqual(content.rawPayload, png) + assert.equal(completed.statusCode, 201) + const file = completed.json().data - const otherMetadata = await app.inject({ + const content = await app.inject({ method: 'GET', url: file.url }) + assert.equal(content.statusCode, 302) + assert.match(content.headers.location, /^https:\/\/objects\.test\/download\//) + const forbidden = await app.inject({ method: 'GET', - url: `/api/v1/files/${fileId}`, - headers: authorization(users.other.token) + url: `/api/v1/files/${file.id}`, + headers: outsider.headers }) - assert.equal(otherMetadata.statusCode, 403) - - const otherDelete = await app.inject({ - method: 'DELETE', - url: `/api/v1/files/${fileId}`, - headers: authorization(users.other.token) - }) - assert.equal(otherDelete.statusCode, 403) - assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 1) - - const ownerDelete = await app.inject({ + assert.equal(forbidden.statusCode, 403) + const removed = await app.inject({ method: 'DELETE', - url: `/api/v1/files/${fileId}`, - headers: authorization(users.owner.token) + url: `/api/v1/files/${file.id}`, + headers: owner.headers }) - assert.equal(ownerDelete.statusCode, 200) - assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 0) + assert.equal(removed.statusCode, 200) + assert.equal(storage.objects.has(storage.promotions[0].destinationKey), false) }) test('magic-byte validation rejects disguised files and protects private material', async (t) => { - const { app, db, uploadDir, users } = await createTestApp(t) - const disguised = multipartBody({ - content: Buffer.from('%PDF-1.7 disguised as an image'), - filename: 'avatar.png', - mimeType: 'image/png' - }) - const invalid = await app.inject({ + const { app, storage } = await createTestApp(t) + const teacher = await addUser(app.db, 'teacher') + const student = await addUser(app.db, 'student') + const disguised = await createIntent(app, teacher) + storage.putFromUrl(disguised.body.data.uploadUrl, Buffer.alloc(PNG.length, 1)) + const rejected = await app.inject({ method: 'POST', - url: '/api/v1/files?category=avatar', - headers: { - ...authorization(users.owner.token), - 'content-type': disguised.contentType - }, - payload: disguised.body - }) - assert.equal(invalid.statusCode, 415) - assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 0) - assert.deepEqual(await readdir(join(uploadDir, '.tmp')), []) - - const signatureOnly = multipartBody({ - content: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - filename: 'truncated.png', - mimeType: 'image/png' - }) - const truncatedHeader = await app.inject({ - method: 'POST', - url: '/api/v1/files?category=avatar', - headers: { - ...authorization(users.owner.token), - 'content-type': signatureOnly.contentType - }, - payload: signatureOnly.body + url: `/api/v1/files/upload-intents/${disguised.body.data.id}/complete`, + headers: teacher.headers }) - assert.equal(truncatedHeader.statusCode, 415) - assert.deepEqual(await readdir(join(uploadDir, '.tmp')), []) + assert.equal(rejected.statusCode, 415) - const pdf = Buffer.from('%PDF-1.7 portfolio material') - const material = multipartBody({ - content: pdf, - filename: 'lesson.pdf', - mimeType: 'application/pdf' + const material = await createIntent(app, teacher, { + category: 'course_material', + originalName: 'lesson.pdf', + mimeType: 'application/pdf', + sizeBytes: PDF.length }) - const forbiddenStudentUpload = await app.inject({ + storage.putFromUrl(material.body.data.uploadUrl, PDF) + const completed = await app.inject({ method: 'POST', - url: '/api/v1/files?category=course_material', - headers: { - ...authorization(users.student.token), - 'content-type': material.contentType - }, - payload: material.body - }) - assert.equal(forbiddenStudentUpload.statusCode, 403) - - const upload = await app.inject({ - method: 'POST', - url: '/api/v1/files?category=course_material', - headers: { - ...authorization(users.owner.token), - 'content-type': material.contentType - }, - payload: material.body + url: `/api/v1/files/upload-intents/${material.body.data.id}/complete`, + headers: teacher.headers }) - assert.equal(upload.statusCode, 201) - - const anonymousContent = await app.inject({ + assert.equal(completed.statusCode, 201) + const denied = await app.inject({ method: 'GET', - url: upload.json().data.url + url: completed.json().data.url, + headers: student.headers }) - assert.equal(anonymousContent.statusCode, 401) + assert.equal(denied.statusCode, 403) +}) - const authenticatedContent = await app.inject({ - method: 'GET', - url: upload.json().data.url, - headers: authorization(users.other.token) +test('transactional reservations prevent account and platform quota bypass', async (t) => { + const { app } = await createTestApp(t, { + uploadOwnerQuotaBytes: PNG.length + 5, + uploadTotalQuotaBytes: PNG.length + 5 }) - assert.equal(authenticatedContent.statusCode, 403) + const owner = await addUser(app.db, 'student') + const results = await Promise.all([ + createIntent(app, owner), + createIntent(app, owner) + ]) + assert.deepEqual( + results.map(({ response }) => response.statusCode).sort(), + [201, 413] + ) +}) - const ownerContent = await app.inject({ - method: 'GET', - url: upload.json().data.url, - headers: authorization(users.owner.token) +test('the per-account upload admission limit is fail-fast and released by cancellation', async (t) => { + const { app } = await createTestApp(t, { uploadMaxConcurrent: 1 }) + const owner = await addUser(app.db, 'student') + const first = await createIntent(app, owner) + assert.equal(first.response.statusCode, 201) + const blocked = await createIntent(app, owner) + assert.equal(blocked.response.statusCode, 429) + const cancelled = await app.inject({ + method: 'DELETE', + url: `/api/v1/files/upload-intents/${first.body.data.id}`, + headers: owner.headers }) - assert.equal(ownerContent.statusCode, 200) - assert.deepEqual(ownerContent.rawPayload, pdf) + assert.equal(cancelled.statusCode, 200) + assert.equal((await createIntent(app, owner)).response.statusCode, 201) }) -test('concurrent reservations prevent account and platform quota bypass', async (t) => { - const ownerLimited = await createTestApp(t, { - uploadOwnerQuotaBytes: 70, - uploadTotalQuotaBytes: 200, - uploadMaxConcurrent: 2 +test('upload resource configuration rejects malformed or unsafe limits', async () => { + const app = Fastify({ logger: false }) + app.decorate('db', {}) + app.decorate('objectStorage', createFakeStorage()) + + app.decorate('config', { + uploadOwnerQuotaBytes: 0, + uploadTotalQuotaBytes: 1, + uploadMaxConcurrent: 99 }) - const png = pngContent(48) - const ownerUploads = await Promise.all( - ['first.png', 'second.png'].map(async (filename) => { - const form = multipartBody({ - content: png, - filename, - mimeType: 'image/png' - }) - return ownerLimited.app.inject({ - method: 'POST', - url: '/api/v1/files?category=avatar', - headers: { - ...authorization(ownerLimited.users.owner.token), - 'content-type': form.contentType - }, - payload: form.body - }) - }) - ) + await assert.rejects(async () => { + await app.register(fileRoutes) + await app.ready() + }, /uploadOwnerQuotaBytes/) + await app.close() +}) - assert.deepEqual( - ownerUploads.map((response) => response.statusCode).sort(), - [201, 413] - ) - assert.equal( - ownerLimited.db.prepare('SELECT SUM(size_bytes) AS total FROM files').get() - .total, - 48 - ) - assert.deepEqual(await readdir(join(ownerLimited.uploadDir, '.tmp')), []) +test('a reusable presigned URL cannot overwrite the promoted file', async (t) => { + const { app, db, storage } = await createTestApp(t) + const owner = await addUser(db, 'student') + const intent = await createIntent(app, owner) + const temporaryKey = storage.putFromUrl(intent.body.data.uploadUrl, PNG) - const platformLimited = await createTestApp(t, { - uploadOwnerQuotaBytes: 60, - uploadTotalQuotaBytes: 80, - uploadMaxConcurrent: 2 + const completed = await app.inject({ + method: 'POST', + url: `/api/v1/files/upload-intents/${intent.body.data.id}/complete`, + headers: owner.headers }) - const platformUploads = await Promise.all( - [platformLimited.users.owner, platformLimited.users.other].map( - async (user, index) => { - const form = multipartBody({ - content: png, - filename: `platform-${index}.png`, - mimeType: 'image/png' - }) - return platformLimited.app.inject({ - method: 'POST', - url: '/api/v1/files?category=avatar', - headers: { - ...authorization(user.token), - 'content-type': form.contentType - }, - payload: form.body - }) - } - ) - ) + assert.equal(completed.statusCode, 201) + const file = completed.json().data + const row = await db.prepare('SELECT * FROM files WHERE id = ?').get(file.id) + assert.match(row.storage_key, /^files\//) + assert.notEqual(row.storage_key, temporaryKey) + assert.deepEqual(storage.objects.get(row.storage_key), PNG) - assert.deepEqual( - platformUploads.map((response) => response.statusCode).sort(), - [201, 507] - ) - assert.equal( - platformLimited.db - .prepare('SELECT SUM(size_bytes) AS total FROM files') - .get().total, - 48 - ) - assert.deepEqual(await readdir(join(platformLimited.uploadDir, '.tmp')), []) + storage.putFromUrl(intent.body.data.uploadUrl, Buffer.alloc(PNG.length, 1)) + assert.deepEqual(storage.objects.get(row.storage_key), PNG) }) -test('the upload admission limit applies fail-fast process backpressure', async (t) => { - const { app, db, uploadDir, users } = await createTestApp(t, { - uploadOwnerQuotaBytes: 3 * 1024 * 1024, - uploadTotalQuotaBytes: 6 * 1024 * 1024, - uploadMaxConcurrent: 1 - }) - const png = pngContent(1024 * 1024) - const responses = await Promise.all( - ['one.png', 'two.png'].map(async (filename) => { - const form = multipartBody({ - content: png, - filename, - mimeType: 'image/png' - }) - return app.inject({ - method: 'POST', - url: '/api/v1/files?category=avatar', - headers: { - ...authorization(users.owner.token), - 'content-type': form.contentType - }, - payload: form.body - }) - }) - ) +test('source changes during validation fail the conditional promotion', async (t) => { + const { app, db, storage } = await createTestApp(t) + const owner = await addUser(db, 'student') + const intent = await createIntent(app, owner) + storage.putFromUrl(intent.body.data.uploadUrl, PNG) + const copy = storage.copy.bind(storage) + storage.copy = async (input) => { + storage.objects.set(input.sourceKey, Buffer.alloc(PNG.length, 1)) + return copy(input) + } - assert.deepEqual( - responses.map((response) => response.statusCode).sort(), - [201, 503] + const completed = await app.inject({ + method: 'POST', + url: `/api/v1/files/upload-intents/${intent.body.data.id}/complete`, + headers: owner.headers + }) + assert.equal(completed.statusCode, 409) + assert.equal( + ( + await db + .prepare('SELECT status FROM upload_intents WHERE id = ?') + .get(intent.body.data.id) + ).status, + 'cancelled' ) assert.equal( - responses.find((response) => response.statusCode === 503).headers[ - 'retry-after' - ], - '1' + (await db.prepare('SELECT COUNT(*) AS count FROM files').get()).count, + 0 ) - assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 1) - assert.deepEqual(await readdir(join(uploadDir, '.tmp')), []) }) -test('upload resource configuration rejects malformed or unsafe limits', () => { - assert.throws( - () => loadConfig({ UPLOAD_MAX_CONCURRENT: '4workers' }), - /UPLOAD_MAX_CONCURRENT/ - ) - assert.throws( - () => - loadConfig({ - UPLOAD_OWNER_QUOTA_BYTES: '1024', - UPLOAD_TOTAL_QUOTA_BYTES: '512' - }), - /UPLOAD_TOTAL_QUOTA_BYTES/ +test('concurrent completion claims inspect an intent only once', async (t) => { + const { app, db, storage } = await createTestApp(t) + const owner = await addUser(db, 'student') + const intent = await createIntent(app, owner) + storage.putFromUrl(intent.body.data.uploadUrl, PNG) + const url = `/api/v1/files/upload-intents/${intent.body.data.id}/complete` + + const responses = await Promise.all([ + app.inject({ method: 'POST', url, headers: owner.headers }), + app.inject({ method: 'POST', url, headers: owner.headers }) + ]) + const statusCodes = responses.map((response) => response.statusCode) + assert.equal(statusCodes.filter((statusCode) => statusCode === 201).length, 1) + assert.ok( + statusCodes.every((statusCode) => [200, 201, 409].includes(statusCode)) ) + assert.equal(storage.readCalls, 1) }) diff --git a/server/test/health.test.js b/server/test/health.test.js index 8fc6d3d..ff5ee1b 100644 --- a/server/test/health.test.js +++ b/server/test/health.test.js @@ -25,7 +25,8 @@ async function createApp(options = {}) { rateLimitMax: 1000, ...options.config }, - database: options.database + database: options.database, + objectStorage: options.objectStorage ?? null }) apps.push(app) @@ -76,10 +77,10 @@ test('GET /api/v1/ready checks the injected database', async () => { assert.equal(response.statusCode, 200) assert.deepEqual(response.json(), { code: 0, - msg: 'ready', + msg: 'degraded', data: { - status: 'ready', - checks: { database: 'up' } + status: 'degraded', + checks: { database: 'up', storage: 'not_configured' } } }) assert.equal(probeCount, 1) @@ -99,7 +100,7 @@ test('GET /api/v1/ready fails when no database is configured', async () => { msg: 'Service is not ready', data: { status: 'not_ready', - checks: { database: 'not_configured' } + checks: { database: 'not_configured', storage: 'not_configured' } } }) }) @@ -123,7 +124,7 @@ test('GET /api/v1/ready returns a sanitized failure when the database is down', msg: 'Service is not ready', data: { status: 'not_ready', - checks: { database: 'down' } + checks: { database: 'down', storage: 'not_configured' } } }) assert.doesNotMatch(response.body, /secret|stack|\.data/i) diff --git a/server/test/mail-provider.test.js b/server/test/mail-provider.test.js index affaa0b..b2f27fb 100644 --- a/server/test/mail-provider.test.js +++ b/server/test/mail-provider.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' import test from 'node:test' import { createMailProvider } from '../services/mail-provider.js' @@ -36,6 +37,46 @@ test('SMTP mail provider sends the registration code with hardened transport opt assert.match(message.html, /123456/) }) -test('mail provider stays disabled when SMTP is not configured', () => { +test('HTTPS relay takes priority and signs the exact JSON request body', async () => { + const secret = 'relay-secret-that-is-at-least-32-characters' + const now = Date.parse('2030-01-01T12:00:00.000Z') + let captured + const provider = createMailProvider({ + mailRelayUrl: 'https://platform.example/api/mail-relay', + mailRelaySecret: secret, + smtpUrl: 'smtps://unused.example.test', + now: () => now, + transportFactory() { + throw new Error('SMTP must not be used when relay is configured') + }, + async fetchImpl(url, options) { + captured = { url, options } + return { ok: true, status: 202 } + } + }) + + const payload = { + email: 'student@example.test', + code: '123456', + expiresAt: '2030-01-01T12:10:00.000Z' + } + const result = await provider.sendVerificationCode(payload) + const timestamp = String(Math.floor(now / 1000)) + const expected = createHmac('sha256', secret) + .update(`${timestamp}.`, 'utf8') + .update(captured.options.body, 'utf8') + .digest('hex') + + assert.equal(captured.url, 'https://platform.example/api/mail-relay') + assert.deepEqual(JSON.parse(captured.options.body), payload) + assert.equal(captured.options.headers['x-mail-relay-timestamp'], timestamp) + assert.equal( + captured.options.headers['x-mail-relay-signature'], + `sha256=${expected}` + ) + assert.deepEqual(result, { relayed: true, status: 202 }) +}) + +test('mail provider stays disabled when neither relay nor SMTP is configured', () => { assert.equal(createMailProvider({ smtpUrl: '' }), null) }) diff --git a/server/test/mail-relay.test.js b/server/test/mail-relay.test.js new file mode 100644 index 0000000..3c8b81c --- /dev/null +++ b/server/test/mail-relay.test.js @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import test from 'node:test' + +import { createMailRelayHandler } from '../../api/mail-relay.js' + +const NOW = Date.parse('2030-01-01T12:00:00.000Z') +const SECRET = 'relay-secret-that-is-at-least-32-characters' +const ENV = { + MAIL_RELAY_SECRET: `${SECRET}\r`, + SMTP_HOST: 'smtp.gmail.com\r', + SMTP_PORT: '465\r', + SMTP_SECURE: 'true\r', + SMTP_USER: 'owner@example.test\r', + SMTP_PASS: 'application-password\r', + MAIL_FROM: 'Platform \r' +} + +function signedRequest(payload, { timestamp, signature } = {}) { + const rawBody = JSON.stringify(payload) + const timestampText = String(timestamp ?? Math.floor(NOW / 1000)) + const digest = + signature ?? + createHmac('sha256', SECRET) + .update(`${timestampText}.`, 'utf8') + .update(rawBody, 'utf8') + .digest('hex') + return new globalThis.Request('https://platform.example/api/mail-relay', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-mail-relay-timestamp': timestampText, + 'x-mail-relay-signature': `sha256=${digest}` + }, + body: rawBody + }) +} + +test('mail relay accepts a valid signed verification payload', async () => { + let message + const handler = createMailRelayHandler({ + env: ENV, + now: () => NOW, + transportFactory() { + return { + async sendMail(payload) { + message = payload + return { messageId: 'accepted' } + } + } + } + }) + const response = await handler( + signedRequest({ + email: 'student@example.test', + code: '123456', + expiresAt: '2030-01-01T12:10:00.000Z' + }) + ) + + assert.equal(response.status, 202) + assert.deepEqual(await response.json(), { accepted: true }) + assert.equal(message.to, 'student@example.test') + assert.equal(message.subject, '国际中文学习平台注册验证码') + assert.match(message.text, /123456/) +}) + +test('mail relay rejects expired and invalid signatures before SMTP', async () => { + let transportCalls = 0 + const handler = createMailRelayHandler({ + env: ENV, + now: () => NOW, + transportFactory() { + transportCalls += 1 + return { sendMail: async () => ({}) } + } + }) + const payload = { + email: 'student@example.test', + code: '123456', + expiresAt: '2030-01-01T12:10:00.000Z' + } + + const expired = await handler( + signedRequest(payload, { + timestamp: Math.floor(NOW / 1000) - 301 + }) + ) + const invalid = await handler( + signedRequest(payload, { signature: '0'.repeat(64) }) + ) + + assert.equal(expired.status, 401) + assert.equal(invalid.status, 401) + assert.equal(transportCalls, 0) +}) + +test('mail relay rejects extra fields instead of sending arbitrary content', async () => { + const handler = createMailRelayHandler({ + env: ENV, + now: () => NOW, + transportFactory() { + throw new Error('Invalid payload must not construct SMTP') + } + }) + const response = await handler( + signedRequest({ + email: 'student@example.test', + code: '123456', + expiresAt: '2030-01-01T12:10:00.000Z', + subject: 'arbitrary subject' + }) + ) + + assert.equal(response.status, 400) +}) diff --git a/server/test/notifications.test.js b/server/test/notifications.test.js index cc2edc4..1c42fdc 100644 --- a/server/test/notifications.test.js +++ b/server/test/notifications.test.js @@ -5,14 +5,14 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { hashPassword } from '../lib/password.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' import notificationRoutes from '../routes/notifications.js' async function createTestApp(t) { - const db = createDatabase({ filename: ':memory:' }) + const db = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', db) @@ -30,14 +30,16 @@ async function createTestApp(t) { const users = {} for (const name of ['owner', 'other']) { const id = randomUUID() - db.prepare( - `INSERT INTO users ( + await db + .prepare( + `INSERT INTO users ( id, email, password_hash, role, display_name, status, created_at, updated_at ) VALUES (?, ?, ?, 'student', ?, 'active', ?, ?)` - ).run(id, `${name}@notification.test`, passwordHash, name, now, now) + ) + .run(id, `${name}@notification.test`, passwordHash, name, now, now) users[name] = { id, - token: createSession(db, id, { ttlSeconds: 3600 }).token + token: (await createSession(db, id, { ttlSeconds: 3600 })).token } } @@ -47,7 +49,7 @@ async function createTestApp(t) { id, user_id, type, title, body, link, read_at, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) - insert.run( + await insert.run( ids[0], users.owner.id, 'course.approved', @@ -57,7 +59,7 @@ async function createTestApp(t) { null, now ) - insert.run( + await insert.run( ids[1], users.owner.id, 'submission.graded', @@ -67,7 +69,7 @@ async function createTestApp(t) { now, now ) - insert.run( + await insert.run( ids[2], users.other.id, 'private', @@ -81,7 +83,7 @@ async function createTestApp(t) { await app.ready() t.after(async () => { await app.close() - db.close() + await db.close() }) return { app, db, users, ids } @@ -121,8 +123,11 @@ test('read operations are idempotent and cannot modify another user notification }) assert.equal(forbidden.statusCode, 404) assert.equal( - db.prepare('SELECT read_at FROM notifications WHERE id = ?').get(ids[2]) - .read_at, + ( + await db + .prepare('SELECT read_at FROM notifications WHERE id = ?') + .get(ids[2]) + ).read_at, null ) @@ -144,11 +149,13 @@ test('read operations are idempotent and cannot modify another user notification assert.equal(readAll.statusCode, 200) assert.equal(readAll.json().data.unread, 0) assert.equal( - db - .prepare( - 'SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND read_at IS NULL' - ) - .get(users.owner.id).count, + ( + await db + .prepare( + 'SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND read_at IS NULL' + ) + .get(users.owner.id) + ).count, 0 ) }) diff --git a/server/test/object-storage.test.js b/server/test/object-storage.test.js new file mode 100644 index 0000000..178394e --- /dev/null +++ b/server/test/object-storage.test.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createObjectStorage } from '../services/object-storage.js' + +test('R2 upload URLs do not request unsupported full-object CRC32 checksums', async () => { + const storage = createObjectStorage({ + s3Endpoint: 'https://example.r2.cloudflarestorage.com', + s3Region: 'auto', + s3Bucket: 'test-bucket', + s3AccessKeyId: 'test-access-key', + s3SecretAccessKey: 'test-secret-key', + s3ForcePathStyle: false + }) + + const uploadUrl = new URL( + await storage.createUploadUrl({ + key: 'tmp/example.png', + mimeType: 'image/png', + sizeBytes: 128 + }) + ) + + assert.equal(uploadUrl.searchParams.has('x-amz-checksum-crc32'), false) + assert.equal( + uploadUrl.searchParams.has('x-amz-sdk-checksum-algorithm'), + false + ) +}) diff --git a/server/test/profile.test.js b/server/test/profile.test.js index a67209f..1e893fe 100644 --- a/server/test/profile.test.js +++ b/server/test/profile.test.js @@ -5,7 +5,7 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { hashPassword } from '../lib/password.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' @@ -16,7 +16,7 @@ function authorization(token) { } async function createTestApp(t) { - const db = createDatabase({ filename: ':memory:' }) + const db = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', db) await app.register(cookie) @@ -36,28 +36,32 @@ async function createTestApp(t) { ['teacher', 'teacher'] ]) { const id = randomUUID() - db.prepare( - `INSERT INTO users ( + await db + .prepare( + `INSERT INTO users ( id, email, password_hash, role, display_name, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` - ).run(id, `${name}@profile.test`, passwordHash, role, name, now, now) + ) + .run(id, `${name}@profile.test`, passwordHash, role, name, now, now) users[name] = { id, - token: createSession(db, id, { ttlSeconds: 3600 }).token + token: (await createSession(db, id, { ttlSeconds: 3600 })).token } } - db.prepare( - `INSERT INTO teacher_profiles ( + await db + .prepare( + `INSERT INTO teacher_profiles ( user_id, school, title, experience_years, specialties_json, certificates_json, teaching_style_json, languages_json, verified_at, created_at, updated_at ) VALUES (?, '初始学校', '讲师', 2, '["口语"]', '[]', '[]', '["中文"]', ?, ?, ?)` - ).run(users.teacher.id, now, now, now) + ) + .run(users.teacher.id, now, now, now) await app.ready() t.after(async () => { await app.close() - db.close() + await db.close() }) return { app, db, users } } @@ -102,21 +106,25 @@ test('teacher can read and update professional profile fields atomically', async ]) assert.equal(updated.json().data.teacherProfile.verifiedAt, null) assert.equal( - db - .prepare( - 'SELECT hourly_rate_cents FROM teacher_profiles WHERE user_id = ?' - ) - .get(users.teacher.id).hourly_rate_cents, + ( + await db + .prepare( + 'SELECT hourly_rate_cents FROM teacher_profiles WHERE user_id = ?' + ) + .get(users.teacher.id) + ).hourly_rate_cents, 18800 ) assert.equal( - db - .prepare( - `SELECT COUNT(*) AS count + ( + await db + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'teacher.verification.reset' AND entity_id = ?` - ) - .get(users.teacher.id).count, + ) + .get(users.teacher.id) + ).count, 1 ) }) @@ -131,11 +139,13 @@ test('student cannot write teacher profile fields', async (t) => { }) assert.equal(response.statusCode, 403) assert.equal( - db - .prepare( - 'SELECT COUNT(*) AS count FROM teacher_profiles WHERE user_id = ?' - ) - .get(users.student.id).count, + ( + await db + .prepare( + 'SELECT COUNT(*) AS count FROM teacher_profiles WHERE user_id = ?' + ) + .get(users.student.id) + ).count, 0 ) }) diff --git a/server/test/realtime-classroom.test.js b/server/test/realtime-classroom.test.js index 2c6e683..85ec597 100644 --- a/server/test/realtime-classroom.test.js +++ b/server/test/realtime-classroom.test.js @@ -7,7 +7,7 @@ import cookie from '@fastify/cookie' import websocket from '@fastify/websocket' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' import realtimeRoutes from '../routes/realtime.js' @@ -20,8 +20,8 @@ function bearer(token) { return { authorization: `Bearer ${token}` } } -function addUser(database, { id = randomUUID(), role, displayName }) { - database +async function addUser(database, { id = randomUUID(), role, displayName }) { + await database .prepare( `INSERT INTO users ( id, email, password_hash, role, display_name, status @@ -29,7 +29,7 @@ function addUser(database, { id = randomUUID(), role, displayName }) { ) .run(id, `${role}-${id}@example.com`, 'x'.repeat(64), role, displayName) - const session = createSession(database, id, { ttlSeconds: 3600 }) + const session = await createSession(database, id, { ttlSeconds: 3600 }) return { id, role, @@ -39,7 +39,7 @@ function addUser(database, { id = randomUUID(), role, displayName }) { } } -function addClassroom(database, studentId, teacherId, overrides = {}) { +async function addClassroom(database, studentId, teacherId, overrides = {}) { const appointmentId = randomUUID() const classroomId = randomUUID() const now = Date.now() @@ -50,7 +50,7 @@ function addClassroom(database, studentId, teacherId, overrides = {}) { scheduledStart.getTime() + (overrides.durationMinutes ?? 60) * 60_000 ) - database + await database .prepare( `INSERT INTO appointments ( id, student_id, teacher_id, scheduled_start, scheduled_end, @@ -66,7 +66,7 @@ function addClassroom(database, studentId, teacherId, overrides = {}) { '实时课堂测试' ) - database + await database .prepare( `INSERT INTO classrooms (id, appointment_id, room_code, status) VALUES (?, ?, ?, 'scheduled')` @@ -127,10 +127,13 @@ function expectNoEvent(socket, predicate, timeoutMs = 200) { } async function createTestApp(t) { - const database = createDatabase({ filename: ':memory:' }) + const database = await createTestDatabase() const app = Fastify({ logger: false }) const sockets = new Set() app.decorate('db', database) + app.decorate('config', { + publicWebsocketOrigin: 'wss://api.example.test' + }) await app.register(cookie) await app.register(websocket) @@ -145,7 +148,7 @@ async function createTestApp(t) { t.after(async () => { for (const socket of sockets) socket.terminate() await app.close() - database.close() + await database.close() }) return { @@ -170,19 +173,19 @@ async function issueTicket(app, classroomId, user) { test('only classroom participants can issue tickets or read history', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '课堂学生' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '课堂教师' }) - const outsider = addUser(database, { + const outsider = await addUser(database, { role: 'student', displayName: '无关学生' }) - const classroomId = addClassroom(database, student.id, teacher.id) + const classroomId = await addClassroom(database, student.id, teacher.id) const ticket = await issueTicket(app, classroomId, outsider) assert.equal(ticket.response.statusCode, 403) @@ -205,15 +208,15 @@ test('only classroom participants can issue tickets or read history', async (t) test('participants cannot issue a ticket before the 30 minute join window', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '远期课堂学生' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '远期课堂教师' }) - const classroomId = addClassroom(database, student.id, teacher.id, { + const classroomId = await addClassroom(database, student.id, teacher.id, { startOffsetMinutes: 120 }) @@ -228,15 +231,15 @@ test('participants cannot issue a ticket before the 30 minute join window', asyn test('a short-lived WebSocket ticket is consumed exactly once', async (t) => { const { app, database, connect } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '一次性票据学生' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '一次性票据教师' }) - const classroomId = addClassroom(database, student.id, teacher.id) + const classroomId = await addClassroom(database, student.id, teacher.id) const issued = await issueTicket(app, classroomId, student) assert.equal(issued.response.statusCode, 201) @@ -246,12 +249,13 @@ test('a short-lived WebSocket ticket is consumed exactly once', async (t) => { const path = `/ws/classroom?ticket=${encodeURIComponent( issued.body.data.ticket )}` + assert.equal(issued.body.data.websocketUrl, `wss://api.example.test${path}`) const socket = await connect(path) assert.equal(socket.readyState, 1) await assert.rejects(app.injectWS(path), /Unexpected server response: 401/) - database + await database .prepare('UPDATE sessions SET revoked_at = ? WHERE id = ?') .run(new Date().toISOString(), student.sessionId) const closed = once(socket, 'close') @@ -262,15 +266,15 @@ test('a short-lived WebSocket ticket is consumed exactly once', async (t) => { test('two participants exchange a deduplicated message that remains in history', async (t) => { const { app, database, connect } = await createTestApp(t) - const student = addUser(database, { + const student = await addUser(database, { role: 'student', displayName: '消息学生' }) - const teacher = addUser(database, { + const teacher = await addUser(database, { role: 'teacher', displayName: '消息教师' }) - const classroomId = addClassroom(database, student.id, teacher.id) + const classroomId = await addClassroom(database, student.id, teacher.id) const teacherTicket = await issueTicket(app, classroomId, teacher) const studentTicket = await issueTicket(app, classroomId, student) @@ -320,12 +324,14 @@ test('two participants exchange a deduplicated message that remains in history', assert.equal((await duplicate).message.id, messageEvent.message.id) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM chat_messages + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM chat_messages WHERE classroom_id = ? AND client_message_id = ?` - ) - .get(classroomId, clientMessageId).count, + ) + .get(classroomId, clientMessageId) + ).count, 1 ) @@ -344,28 +350,28 @@ test('two participants exchange a deduplicated message that remains in history', test('chat broadcasts and RTC signalling never cross classroom boundaries', async (t) => { const { app, database, connect } = await createTestApp(t) - const firstStudent = addUser(database, { + const firstStudent = await addUser(database, { role: 'student', displayName: '第一课堂学生' }) - const firstTeacher = addUser(database, { + const firstTeacher = await addUser(database, { role: 'teacher', displayName: '第一课堂教师' }) - const secondStudent = addUser(database, { + const secondStudent = await addUser(database, { role: 'student', displayName: '第二课堂学生' }) - const secondTeacher = addUser(database, { + const secondTeacher = await addUser(database, { role: 'teacher', displayName: '第二课堂教师' }) - const firstClassroomId = addClassroom( + const firstClassroomId = await addClassroom( database, firstStudent.id, firstTeacher.id ) - const secondClassroomId = addClassroom( + const secondClassroomId = await addClassroom( database, secondStudent.id, secondTeacher.id @@ -409,20 +415,24 @@ test('chat broadcasts and RTC signalling never cross classroom boundaries', asyn await noChatLeak assert.equal( - database - .prepare( - 'SELECT COUNT(*) AS count FROM chat_messages WHERE classroom_id = ?' - ) - .get(secondClassroomId).count, + ( + await database + .prepare( + 'SELECT COUNT(*) AS count FROM chat_messages WHERE classroom_id = ?' + ) + .get(secondClassroomId) + ).count, 0 ) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM chat_messages + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM chat_messages WHERE classroom_id = ? AND client_message_id = ?` - ) - .get(firstClassroomId, clientMessageId).count, + ) + .get(firstClassroomId, clientMessageId) + ).count, 1 ) }) diff --git a/server/test/support/database.js b/server/test/support/database.js new file mode 100644 index 0000000..d12a0db --- /dev/null +++ b/server/test/support/database.js @@ -0,0 +1,50 @@ +import { randomUUID } from 'node:crypto' +import process from 'node:process' + +import pg from 'pg' + +import { createDatabase, DEFAULT_DATABASE_URL } from '../../db/database.js' +import { seedDatabase } from '../../db/seed-data.js' + +const { Client } = pg + +export async function createTestDatabase({ + seed = false, + migrate = true +} = {}) { + const connectionString = + process.env.TEST_DATABASE_URL || + process.env.DATABASE_URL || + DEFAULT_DATABASE_URL + const schema = `test_${randomUUID().replaceAll('-', '')}` + const administrator = new Client({ connectionString }) + await administrator.connect() + await administrator.query(`CREATE SCHEMA "${schema}"`) + await administrator.end() + + const database = await createDatabase({ + connectionString, + ssl: false, + max: 4, + schema, + migrate + }) + if (seed) await seedDatabase(database) + + const close = database.close.bind(database) + database.close = async () => { + if (!database.open) return + await close() + const cleanup = new Client({ connectionString }) + await cleanup.connect() + try { + await cleanup.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`) + } finally { + await cleanup.end() + } + } + database.testSchema = schema + return database +} + +export default createTestDatabase diff --git a/server/test/teacher-verification.test.js b/server/test/teacher-verification.test.js index ed52c48..2859e41 100644 --- a/server/test/teacher-verification.test.js +++ b/server/test/teacher-verification.test.js @@ -5,7 +5,7 @@ import test from 'node:test' import cookie from '@fastify/cookie' import Fastify from 'fastify' -import { createDatabase } from '../db/database.js' +import { createTestDatabase } from './support/database.js' import { createSession } from '../lib/session.js' import authPlugin from '../plugins/auth.js' import adminRoutes from '../routes/admin.js' @@ -16,21 +16,21 @@ function bearer(token) { return { authorization: `Bearer ${token}` } } -function addUser(database, role, displayName) { +async function addUser(database, role, displayName) { const id = randomUUID() - database + await database .prepare( `INSERT INTO users ( id, email, password_hash, role, display_name, status ) VALUES (?, ?, ?, ?, ?, 'active')` ) .run(id, `${role}-${id}@example.test`, 'x'.repeat(64), role, displayName) - const session = createSession(database, id, { ttlSeconds: 3600 }) + const session = await createSession(database, id, { ttlSeconds: 3600 }) return { id, headers: bearer(session.token) } } async function createTestApp(t) { - const database = createDatabase({ filename: ':memory:' }) + const database = await createTestDatabase() const app = Fastify({ logger: false }) app.decorate('db', database) @@ -48,7 +48,7 @@ async function createTestApp(t) { t.after(async () => { await app.close() - database.close() + await database.close() }) return { app, database } @@ -60,10 +60,10 @@ function futureIso(minutes) { test('administrator approval and revocation gate public discovery and booking', async (t) => { const { app, database } = await createTestApp(t) - const student = addUser(database, 'student', '学习者') - const teacher = addUser(database, 'teacher', '待认证教师') - const administrator = addUser(database, 'administrator', '认证审核员') - database + const student = await addUser(database, 'student', '学习者') + const teacher = await addUser(database, 'teacher', '待认证教师') + const administrator = await addUser(database, 'administrator', '认证审核员') + await database .prepare( `INSERT INTO teacher_profiles ( user_id, school, title, certificates_json, verified_at @@ -76,7 +76,7 @@ test('administrator approval and revocation gate public discovery and booking', JSON.stringify(['国际中文教师证书']) ) const courseId = randomUUID() - database + await database .prepare( `INSERT INTO courses ( id, teacher_id, title, status, published_at @@ -153,7 +153,7 @@ test('administrator approval and revocation gate public discovery and booking', assert.equal(approved.json().data.verificationStatus, 'verified') assert.ok(approved.json().data.verifiedAt) - const approvalAudit = database + const approvalAudit = await database .prepare( `SELECT actor_id, details_json, created_at FROM audit_logs @@ -164,12 +164,14 @@ test('administrator approval and revocation gate public discovery and booking', assert.equal(JSON.parse(approvalAudit.details_json).nextStatus, 'verified') assert.ok(approvalAudit.created_at) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM notifications + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND type = 'teacher.verification.approved'` - ) - .get(teacher.id).count, + ) + .get(teacher.id) + ).count, 1 ) @@ -231,7 +233,7 @@ test('administrator approval and revocation gate public discovery and booking', }) assert.equal(publicAfterRevocation.statusCode, 404) - const revokedAudit = database + const revokedAudit = await database .prepare( `SELECT actor_id, details_json, created_at FROM audit_logs @@ -242,12 +244,14 @@ test('administrator approval and revocation gate public discovery and booking', assert.equal(JSON.parse(revokedAudit.details_json).nextStatus, 'pending') assert.ok(revokedAudit.created_at) assert.equal( - database - .prepare( - `SELECT COUNT(*) AS count FROM notifications + ( + await database + .prepare( + `SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND type = 'teacher.verification.revoked'` - ) - .get(teacher.id).count, + ) + .get(teacher.id) + ).count, 1 ) }) diff --git a/src/api/platform.js b/src/api/platform.js index f4bc2bf..e710fc5 100644 --- a/src/api/platform.js +++ b/src/api/platform.js @@ -1,3 +1,4 @@ +import axios from 'axios' import request from '@/utils/request' async function dataRequest(config) { @@ -166,14 +167,36 @@ export const readAllNotifications = () => dataRequest({ method: 'POST', url: '/notifications/read-all' }) export async function uploadFile(file, category, onUploadProgress) { - const formData = new FormData() - formData.append('file', file) + const intent = await dataRequest({ + method: 'POST', + url: '/files/upload-intents', + data: { + category, + originalName: file.name, + mimeType: file.type, + sizeBytes: file.size + } + }) + + try { + await axios.put(intent.uploadUrl, file, { + headers: intent.headers, + onUploadProgress, + timeout: 10 * 60 * 1000, + withCredentials: false + }) + } catch (error) { + await dataRequest({ + method: 'DELETE', + url: `/files/upload-intents/${intent.id}` + }).catch(() => {}) + throw error + } + return dataRequest({ method: 'POST', - url: '/files', - params: { category }, - data: formData, - onUploadProgress + url: `/files/upload-intents/${intent.id}/complete`, + timeout: 2 * 60 * 1000 }) } diff --git a/src/router/index.js b/src/router/index.js index 1856707..6a96890 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -7,6 +7,12 @@ const roleHome = { administrator: '/administrator/courseDocking' } +const passwordResetRoute = { + student: { path: '/student/center/changePassword' }, + teacher: { path: '/teacher/user', query: { tab: 'security' } }, + administrator: { path: '/administrator/center/changePasswordAdmin' } +} + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ @@ -238,6 +244,18 @@ router.beforeEach(async (to) => { const currentRole = userStore.role + if ( + userStore.isAuthenticated && + currentRole && + userStore.profile?.mustResetPassword + ) { + const resetRoute = passwordResetRoute[currentRole] + const onResetRoute = + to.path === resetRoute.path && + (currentRole !== 'teacher' || to.query.tab === 'security') + if (!onResetRoute) return resetRoute + } + if (userStore.isAuthenticated && currentRole === 'student') { studentStore.setUserInfo(userStore.profile) } else { diff --git a/src/views/liveClass/liveClass.vue b/src/views/liveClass/liveClass.vue index 1104e6f..2d171d3 100644 --- a/src/views/liveClass/liveClass.vue +++ b/src/views/liveClass/liveClass.vue @@ -287,7 +287,9 @@ async function connectSocket() { try { const ticket = await createClassroomTicket(classroomId.value) if (intentionalClose) return - socket = new WebSocket(socketUrl(ticket.websocketPath)) + socket = new WebSocket( + ticket.websocketUrl || socketUrl(ticket.websocketPath) + ) socket.addEventListener('open', () => { connectionStatus.value = 'open' reconnectAttempts = 0 diff --git a/src/views/login/loginPage.vue b/src/views/login/loginPage.vue index 072f2af..710eda0 100644 --- a/src/views/login/loginPage.vue +++ b/src/views/login/loginPage.vue @@ -89,7 +89,16 @@ const handleLogin = async () => { ? requestedPath : null - await router.replace(safeRequestedPath || roleRoutes[selectedRole.value]) + const resetRoute = { + student: '/student/center/changePassword', + teacher: '/teacher/user?tab=security', + administrator: '/administrator/center/changePasswordAdmin' + } + await router.replace( + profile.mustResetPassword + ? resetRoute[profile.role] + : safeRequestedPath || roleRoutes[selectedRole.value] + ) } catch (error) { if (!error.messageShown) { ElMessage.error(error.response?.data?.msg || error.message || '登录失败') diff --git a/src/views/teacher/UserPage/UserPage.vue b/src/views/teacher/UserPage/UserPage.vue index 03a0700..23ac10a 100644 --- a/src/views/teacher/UserPage/UserPage.vue +++ b/src/views/teacher/UserPage/UserPage.vue @@ -1,10 +1,12 @@