diff --git a/backend/README.md b/backend/README.md index feadff9..f41f96c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,113 +1,130 @@ -# SignPakCommons: backend +# SignPak Commons: backend -Express + Node.js + MongoDB (Mongoose). Cookie-based auth, local base-video storage, optional append-only Google Drive recording archives, layered architecture. +Express 4 + Node.js 22+ + MongoDB (Mongoose). Cookie-based authentication, +local-by-default media storage, optional Google Drive storage, and a +layered service/repository architecture. ## Run it ```bash cd backend npm install -cp .env.example .env # then fill in JWT_SECRET, ADMIN_PASSWORD, MONGODB_URI and service credentials -npm run dev # http://localhost:5000, health check at /api/v1/health -npm test # integration tests (see "Testing") +cp .env.example .env # set local MongoDB URI, JWT secret, and admin password +npm run dev # http://localhost:5000 +npm test # all API, unit, and storage suites ``` -Needs Node 20+ and a MongoDB (local install or an Atlas connection string). On start the API validates `.env` and tells you exactly what is missing, then connects, creates the admin account from `ADMIN_EMAIL` / `ADMIN_PASSWORD`, and listens. +Use Node.js 22+ and a MongoDB instance (local or Atlas). Environment values are +validated while loading the app. The standalone server binds its listener and +initializes MongoDB, selected storage drivers, and the configured admin. +Requests wait for the same shared initialization to finish. `server.js` also +exports a handler for Vercel. -## How a request flows +## Architecture -routes -> middlewares -> controllers -> services -> repositories -> models -> MongoDB +```text +routes -> middlewares -> controllers -> services -> repositories -> models -> MongoDB +``` -Dependencies only point down. Controllers never touch models; services never see `req`/`res`. +Dependencies point downward: controllers do not query models, services do not +read `req` or write `res`, and repositories encapsulate persistence without +business rules. -| Folder | Job | +| Folder | Responsibility | | --- | --- | -| `src/routes/` | Which URL + method goes where, and which guards it passes through | -| `src/middlewares/` | Auth, roles, validation (`validators/` holds the Zod schemas), uploads, rate limits, origin check, error handler | -| `src/controllers/` | Read the request, call one service, send `{ data }`. No logic | -| `src/services/` | All business rules. `storage/` is the file-storage driver layer | -| `src/repositories/` | The only place that queries the database. No rules | -| `src/models/` | Mongoose schemas, indexes, JSON shape | -| `src/utils/` | Typed errors, logger, tokens, file sniffing, Range streaming | -| `src/config/` | Validated env, DB connection, constants | - -`src/app.js` builds the app (no port), `server.js` connects and listens, so tests can import `app` directly. - -Interactive API documentation is available at `http://localhost:5000/api/v1/docs` when the server is running. The raw OpenAPI document is at `/api/v1/docs.json`. See [docs/API.md](docs/API.md) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the human-readable reference. +| `src/routes/` | Map URLs and methods to controllers and compose route guards. | +| `src/middlewares/` | Auth, roles, Zod validation, uploads, rate limits, origin/access checks, errors. | +| `src/controllers/` | Translate requests to service calls and standard responses. | +| `src/services/` | Business rules and coordination of repositories, storage, and providers. | +| `src/services/storage/` | Local and Google Drive storage implementations. | +| `src/repositories/` | MongoDB queries and persistence operations. | +| `src/models/` | Mongoose schemas, indexes, and JSON serialization. | +| `src/config/` | Validated environment, database connection, and shared constants. | +| `src/docs/openapi.js` | OpenAPI schema for the JSON spec and development Swagger UI. | +| `tests/` | One `.test.js` per API suite and focused unit/storage suites. | + +`src/app.js` composes the Express app without binding a port; +`src/services/initialization.js` initializes MongoDB, storage, and the configured +admin idempotently; `server.js` is the standalone and Vercel entry point. + +Read the full references: [API](docs/API.md), +[architecture](docs/ARCHITECTURE.md), [setup](docs/SETUP.md), and +[test-case inventory](docs/TEST-CASES.md). ## Responses ```jsonc -// success: data is the resource, or an array of them +// Success { "data": { "id": "...", "title": "..." } } -// failure: `fields` maps input names to messages, ready for form errors +// Failure { "error": { "code": "VALIDATION_ERROR", "message": "Some fields need attention.", "fields": { "email": "Enter a valid email address." } } } ``` -Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, `CONFLICT` 409, `PAYLOAD_TOO_LARGE` 413, `VALIDATION_ERROR` 422, `TOO_MANY_REQUESTS` 429, `INTERNAL_ERROR` 500. `DELETE` returns `204` with no body. Every response carries an `X-Request-Id` that matches the log line. - -## Endpoints (all under `/api/v1`) - -| Method + path | Who | Notes | -| --- | --- | --- | -| `GET /health` | anyone | 503 if the database is down | -| `POST /auth/signup` | anyone | Creates an unverified contributor and emails a verification code | -| `POST /auth/login` | anyone | Sets the cookie | -| `POST /auth/verify-email` | anyone | Verifies an email and sets the session cookie | -| `POST /auth/resend-verification` | anyone | Resends a verification code (generic response) | -| `POST /auth/forgot-password` | anyone | Sends a password-reset code (generic response) | -| `POST /auth/reset-password` | anyone | Resets password and invalidates existing sessions | -| `POST /auth/logout` | anyone | Clears the cookie | -| `GET /auth/session` | anyone | `{ data: user }`, or `{ data: null }` for visitors | -| `GET /users/me` | logged in | | -| `PATCH /users/me` | logged in | `{ connections: { github, linkedin } }`; a handle or a profile link; `null` disconnects | -| `GET /categories` | anyone | | -| `POST /categories`, `PATCH`/`DELETE /categories/:id` | admin | Deleting unassigns its videos, it does not delete them | -| `GET /videos`, `GET /videos/:id` | anyone | Visitors and contributors: published + categorised only. Admin: everything | -| `GET /videos/:id/poster` | anyone (same visibility) | | -| `GET /videos/:id/file` | anyone (same visibility) | Published reference videos stream with Range support | -| `POST /videos` | admin | `multipart/form-data`: `video` (required), `poster` (optional), `title`, `categoryId`, `level`, `status`, `durationSec` | -| `PATCH`/`DELETE /videos/:id` | admin | Moving to another category appends to the end of that path | -| `GET /submissions` | logged in | Contributors: their own. Admin: all | -| `POST /submissions` | logged in | `multipart/form-data`: `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, `duration` | -| `GET /submissions/:id/recording` | none | Deliberately unavailable; submissions are append-only archives | -| `POST /contact` | verified user | Account email must match; one per user/day and 25 total/day; delivered via Web3Forms | -| `GET /admin/users`, `/admin/stats?days=14`, `/admin/messages` | admin | | - -## Decisions worth knowing - -**Auth.** A signed JWT in an `httpOnly` cookie (`signpak_token`), so page scripts cannot read it. Signup requires email verification before login; OTPs are hashed in MongoDB, expire, and have bounded attempts. Password reset uses the same one-time-code policy and increments the session version to invalidate existing cookies. Configure `BREVO_API_KEY` and a verified `BREVO_SENDER_EMAIL` for transactional email. The user is reloaded from the database on every request, so role changes and deletions apply immediately. Login answers identically for "wrong password" and "no such email", and does the same amount of hashing work for both. - -**CSRF.** `SameSite=Lax` plus an `Origin` check: any `POST/PATCH/DELETE` carrying an `Origin` header outside `CLIENT_ORIGIN` is refused with 403. - -**Admin.** Created or promoted from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on every start. Signup ignores any `role` in the body. Changing `ADMIN_PASSWORD` later does not reset an existing admin's password. +Known errors include `BAD_REQUEST` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403), +`NOT_FOUND` (404), `CONFLICT` (409), `PAYLOAD_TOO_LARGE` (413), +`VALIDATION_ERROR` (422), `TOO_MANY_REQUESTS` (429), and `INTERNAL_ERROR` (500). +Successful deletes return `204` with no body. Responses include an +`X-Request-Id` for log correlation. -**The cooldown rule.** This is a data-collection tool, not a one-shot quiz: a contributor can submit the same video more than once (more varied takes make better training data), but not back to back. `SUBMISSION_COOLDOWN_MS` (default 30s, in `.env`) is the minimum gap between two submissions for the same `(user, video)`. It is enforced atomically, not by a simple read-then-write: `repositories/submissionRepo.js#claimCooldown` does a conditional `findOneAndUpdate` with `upsert: true` against a `SubmissionCooldown` collection that has a unique index on `(user, video)`. If a live cooldown already exists, the upsert's insert path collides with that unique index (`E11000`), which is treated as "still cooling down" — so three simultaneous submissions for the same video still produce exactly one success (verified: `tests/api.test.js`'s concurrent-submit test). A submission that fails after claiming the cooldown (a storage or DB error) releases it, so a failed attempt never costs the contributor their next try. Regardless of timing, no submission can ever be edited, deleted, or read back once archived. Admins receive archive metadata only. +## Endpoint overview -**Uploads.** Multer writes to `uploads/tmp`, the service checks the file's real type from its first bytes (MP4, WebM or MOV; JPEG, PNG or WebP for posters; the browser-supplied type is not trusted), then hands it to storage. Failures at any step leave no files behind. +All paths are under `/api/v1`. -**Storage and Google Drive.** Base videos use the readable storage driver selected by `STORAGE_DRIVER`. Submission recordings use the separate append-only driver selected by `ARCHIVE_STORAGE_DRIVER`; set it to `gdrive` with `GOOGLE_SERVICE_ACCOUNT_JSON` and `GOOGLE_DRIVE_FOLDER_ID`. MongoDB stores the archive pointer and metadata, while the API never reads, updates, or deletes the archived recording. Existing file records keep working because each one remembers its driver. - -**Stats.** Per-category counts only include submissions whose video still exists. Totals and daily activity always count everything. Days are UTC. Per-day bucketing is done in code from a timestamps-only query; if submissions reach hundreds of thousands, move it to a MongoDB aggregation. - -**Client-reported values.** `durationSec` and `duration` come from the browser until the ffmpeg step exists to measure them on the server. +| Method + path | Access | Notes | +| --- | --- | --- | +| `GET /health` | Public | Reports database readiness. | +| `/auth/*` | Public | Signup, login, email verification, password reset, logout, and session. | +| `GET/PATCH /users/me` | Authenticated user | Read profile and update supported connections. | +| `GET /categories`; `POST/PATCH/DELETE /categories/:id` | Public read; admin write | Deleting a category unassigns its videos. | +| `GET /videos`, `GET /videos/:id[/file/poster]` | Public or admin | Public visibility is published and categorised only; file streaming supports byte ranges. | +| `POST /videos`; `PATCH/DELETE /videos/:id` | Admin | Manage reference videos and optional posters. | +| `GET /demo-video[/file]`; `POST/DELETE /demo-video` | Public read; admin write | Singleton public walkthrough media. | +| `GET/POST /submissions` | Authenticated user | Contributors list their own; admins list all. New submissions are append-only. | +| `POST /contact` | Signed-in account | Email must match account; account and global daily quotas apply. | +| `/admin/users*`, `/admin/restrictions*`, `/admin/stats`, `/admin/messages` | Admin | Account moderation, access restrictions, statistics, and inbox. | + +See [API.md](docs/API.md) for the complete route table, limits, visibility, and +request behavior. The OpenAPI JSON is available at `/api/v1/docs.json` in every +environment. Interactive Swagger UI (`/api/v1/docs`, `/docs`, `/api-docs`) is only +mounted outside production. + +## Operational rules + +- Authentication uses an HTTP-only `signpak_token` cookie. Email verification is + required before login; password reset invalidates existing sessions. +- `CLIENT_ORIGIN` controls credentialed CORS and the check for browser + state-changing requests that carry an `Origin` header. +- The global, auth, contact, and upload rate limits can be disabled with + `RATE_LIMIT_ENABLED=false`; keep them enabled in production. +- `STORAGE_DRIVER` selects reference and demo media storage. + `ARCHIVE_STORAGE_DRIVER` independently selects append-only contributor archive + storage. Both default to local; Google Drive credentials can be shared or + configured separately for archives. +- Upload defaults are 90 MB for base/demo videos and 80 MB for contributor + recordings. File contents are verified by signature, not browser MIME type. +- `SUBMISSION_COOLDOWN_MS` defaults to 30 seconds per contributor/video pair. + Atomic claiming prevents simultaneous duplicate submissions; failed processing + releases the claim. +- Contact delivery uses Web3Forms. A signed-in account can send from its own + email; quotas are one per account per UTC day and 25 per UTC day globally by + default. Abusive attempts can temporarily restrict a device or IP. ## Testing -`npm test` runs the HTTP integration suite for auth, roles, uploads, Range streaming, the cooldown rule, stats, contact quotas, and cleanup of temporary files. Brevo and Web3Forms delivery are stubbed in tests; configure their environment keys to enable real email delivery. - -- Set `MONGODB_URI_TEST` to run against a MongoDB you already have (it uses that database and **drops it**, so point it at a throwaway one). -- Otherwise it starts `mongodb-memory-server`, which downloads a MongoDB binary on first use. - -## Not built yet +Run from `backend/`: -- **ffmpeg trimming and probing.** Submissions store `trimStart` / `trimEnd` / `mirrored` but the recording is saved untouched. +```bash +npm test +``` -## Housekeeping +The command runs Node's built-in test runner serially across every +`tests/*.test.js` file. The API suites use MongoDB Memory Server unless +`MONGODB_URI_TEST` is supplied. That configured database is dropped by the tests, +so use only a disposable database. Provider delivery is stubbed. The 56 cases and +their test types are documented in [docs/TEST-CASES.md](docs/TEST-CASES.md). -`package.json` still lists packages this code does not use: `bcryptjs`, `express-validator`, `morgan`, `pg`, `pg-hstore`, `sequelize`. `sequelize` is also the cause of the two moderate `npm audit` warnings (an old `uuid`). Safe to remove: +## Current limitation -```bash -npm uninstall bcryptjs express-validator morgan pg pg-hstore sequelize -``` +The backend stores submission trim and mirror metadata but does not use FFmpeg to +probe or physically edit the uploaded recording. diff --git a/backend/docs/API.md b/backend/docs/API.md index 7cc7398..c972b6f 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -1,32 +1,38 @@ # SignPak Commons API -The interactive API reference is available while the backend is running: - -- Swagger UI: `http://localhost:5000/api/v1/docs` -- Swagger UI aliases: `http://localhost:5000/docs` or `http://localhost:5000/api-docs` -- OpenAPI JSON: `http://localhost:5000/api/v1/docs.json` -- OpenAPI JSON aliases: `/docs.json` or `/api-docs.json` -- API base URL: `http://localhost:5000/api/v1` - -The OpenAPI document is maintained in [`src/docs/openapi.js`](../src/docs/openapi.js). Keep it synchronized with the route files and validators when changing the API. - -## Authentication - -Login and successful email verification set an HTTP-only `signpak_token` cookie. Signup creates an unverified account and emails a six-digit code; browser clients must verify it before logging in. Browser requests must send credentials. - -The cookie is cleared by `POST /auth/logout`. `GET /auth/session` is public and returns the current user when a valid session exists, or `null` for a visitor. - -Verification and password reset codes are stored as keyed hashes, expire after `AUTH_OTP_TTL_MINUTES`, and have bounded attempts and resend intervals. Password reset responses do not reveal whether an email exists. Configure `BREVO_API_KEY`, `BREVO_SENDER_EMAIL` (a verified Brevo sender), and optionally `BREVO_SENDER_NAME` in the backend environment. +The API is mounted below `/api/v1` (for example, `http://localhost:5000/api/v1`). +Its OpenAPI document is maintained in [`src/docs/openapi.js`](../src/docs/openapi.js). +In development, interactive Swagger UI is available at `/api/v1/docs`, `/docs`, and +`/api-docs`. Raw OpenAPI JSON is available at `/api/v1/docs.json`, `/docs.json`, +and `/api-docs.json` in all environments. Swagger UI is disabled in production. + +## Authentication and cookies + +Successful login and email verification set an HTTP-only `signpak_token` cookie. +Signup creates an unverified account and sends a six-digit verification code; the +account must be verified before login. Browser clients must send requests with +credentials. + +`GET /auth/session` returns the current user or `null` for a visitor. `POST +/auth/logout` clears the cookie. Authenticated requests reload the user from +MongoDB, so role, suspension, deletion, and session-version changes take effect +immediately. + +Verification and password-reset codes are stored as keyed hashes, expire according +to `AUTH_OTP_TTL_MINUTES`, and are subject to the configured attempt and resend +limits. Forgot-password and resend-verification endpoints use generic responses to +avoid disclosing account existence. Transactional email uses Brevo; configure +`BREVO_API_KEY` and a verified `BREVO_SENDER_EMAIL`. ## Response format -JSON success responses use: +Successful JSON responses use a `data` envelope: ```json { "data": { "id": "..." } } ``` -Errors use a structured object suitable for form-level and field-level messages: +Errors use a consistent shape with optional field errors: ```json { @@ -38,52 +44,118 @@ Errors use a structured object suitable for form-level and field-level messages: } ``` -`204 No Content` responses have no body. Responses include `X-Request-Id`, which can be supplied by a client and matched against server logs. +`DELETE` operations that succeed return `204 No Content` with no response body. +Every request gets an `X-Request-Id` response header. A caller may supply the same +header to correlate a request with structured server logs. + +## Endpoint reference -## Endpoint summary +All paths below are relative to `/api/v1`. | Method | Path | Access | Purpose | | --- | --- | --- | --- | -| GET | `/health` | Public | Check API and database status. | -| POST | `/auth/signup` | Public | Create an unverified contributor and send a verification code. | -| POST | `/auth/login` | Public | Create a session. | -| POST | `/auth/verify-email` | Public | Verify a code and establish a session. | -| POST | `/auth/resend-verification` | Public | Resend a verification code with a generic response. | -| POST | `/auth/forgot-password` | Public | Request a password reset code with a generic response. | -| POST | `/auth/reset-password` | Public | Reset password and invalidate existing sessions. | -| POST | `/auth/logout` | Public | Clear the session. | -| GET | `/auth/session` | Public | Read the current session. | -| GET/PATCH | `/users/me` | User | Read or update the current profile. | +| GET | `/health` | Public | Report API uptime and database connectivity; returns `503` while the database is unavailable. | +| POST | `/auth/signup` | Public | Create an unverified contributor and send an email code. | +| POST | `/auth/login` | Public | Authenticate and set the session cookie. | +| POST | `/auth/verify-email` | Public | Verify an email code and establish a session. | +| POST | `/auth/resend-verification` | Public | Request another verification code. | +| POST | `/auth/forgot-password` | Public | Request a password-reset code. | +| POST | `/auth/reset-password` | Public | Reset a password and invalidate older sessions. | +| POST | `/auth/logout` | Public | Clear the session cookie. | +| GET | `/auth/session` | Public | Return the current user or `null`. | +| GET | `/users/me` | Authenticated user | Read the signed-in user's profile. | +| PATCH | `/users/me` | Authenticated user | Update supported profile connections. | | GET | `/categories` | Public | List categories. | -| POST/PATCH/DELETE | `/categories`, `/categories/:id` | Admin | Manage categories. | -| GET | `/videos`, `/videos/:id` | Public | List or read visible video metadata. | -| GET | `/videos/:id/file` | Public when published | Stream a published reference video. | -| GET | `/videos/:id/poster` | Public when published | Stream a published poster. | -| POST/PATCH/DELETE | `/videos`, `/videos/:id` | Admin | Upload and manage base videos. | -| GET | `/demo-video`, `/demo-video/file` | Public | Read metadata or stream the current walkthrough video. | -| POST/DELETE | `/demo-video` | Admin | Upload/replace or delete the walkthrough video. | -| GET/POST | `/submissions` | User | List or create contributor recordings. | -| GET | `/submissions/:id/recording` | None | Deliberately unavailable; submission archives are append-only. | -| POST | `/contact` | Verified user | Send a message from the account email; one per account per UTC day. | -| GET | `/admin/users` | Admin | List users. | -| GET | `/admin/stats` | Admin | Read dashboard statistics. | -| GET | `/admin/messages` | Admin | Read contact messages. | - -## Uploads - -Video creation uses `multipart/form-data` with `video`, optional `poster`, `title`, optional `categoryId`, `status`, and `durationSec`. Submission creation uses `multipart/form-data` with `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration`. - -The public demo video is a separate singleton asset, independent of the lesson library. Admin uploads use `multipart/form-data` with `video`, `title`, and optional `durationSec`; uploading replaces the current demo asset. - -The server validates file signatures, size limits, metadata, and route IDs. Client-reported durations are currently accepted for trimming metadata; server-side media probing and trimming are future work. - -Contact messages are delivered to the recipient configured by the `WEB3FORMS_ACCESS_KEY` in Web3Forms and are also retained in the admin inbox. Only authenticated users can send, the submitted email must match their account, each account can send one message per UTC day, and total successful deliveries are capped at 25 per UTC day. Anonymous or email-mismatched attempts, and attempts after the global cap, temporarily restrict that device (or IP if no device ID is supplied) for 10 minutes. - -## Visibility rules - -- Anonymous users see only published videos assigned to a category. -- Admins can list and manage draft, published, and unassigned videos. -- Contributor submissions are private to their owner in `/submissions`. -- Submission recordings are never readable through the API, including by admins. Admins can access the MongoDB submission record and its archive metadata. -- Archive paths follow `commons/{userId}_{sequence}/{category_slug}/{video_slug}/{video_slug}.{ext}`. A repeated submission for the same user and video increments `sequence` instead of overwriting the previous file. -- Video reference files are public only when the corresponding video passes the same published visibility check. +| POST | `/categories` | Admin | Create a category. | +| PATCH | `/categories/:id` | Admin | Update a category. | +| DELETE | `/categories/:id` | Admin | Delete a category and unassign its videos. | +| GET | `/videos` | Public or admin | List published, categorised videos publicly; admins can list all. | +| GET | `/videos/:id` | Public or admin | Read a visible video. | +| GET | `/videos/:id/file` | Public or admin | Stream a visible video, including byte-range requests. | +| GET | `/videos/:id/poster` | Public or admin | Stream a visible video's poster, if present. | +| POST | `/videos` | Admin | Upload a reference video and optional poster. | +| PATCH | `/videos/:id` | Admin | Update a video, including status or category. | +| DELETE | `/videos/:id` | Admin | Delete a reference video and its base files; submissions remain. | +| GET | `/demo-video` | Public | Read the current public demo-video metadata or `null`. | +| GET | `/demo-video/file` | Public | Stream the current demo video. | +| POST | `/demo-video` | Admin | Create or replace the public demo video. | +| DELETE | `/demo-video` | Admin | Delete the demo video. | +| GET | `/submissions` | Authenticated user | List own submissions; admins can list all. | +| POST | `/submissions` | Authenticated user | Create an append-only recording submission. | +| POST | `/contact` | Signed-in account | Send a contact message using the email on the account. | +| GET | `/admin/users` | Admin | List accounts. | +| PATCH | `/admin/users/:id` | Admin | Change an account's active/suspended status. | +| DELETE | `/admin/users/:id` | Admin | Delete an account and its submissions, cooldowns, and archived recordings. | +| GET | `/admin/restrictions` | Admin | List active device and IP restrictions. | +| POST | `/admin/restrictions` | Admin | Create a permanent device or IP restriction. | +| DELETE | `/admin/restrictions/:id` | Admin | Remove a restriction. | +| GET | `/admin/stats` | Admin | Read dashboard totals, activity, and recent submissions. | +| GET | `/admin/messages` | Admin | List retained contact messages; accepts the validated `limit` query. | + +There is intentionally no submission read, update, or delete endpoint, including +for admins. The archive is append-only and archived media is not streamed through +the API. + +## Request bodies and uploads + +Authentication and profile request schemas are defined in +[`src/middlewares/validators/`](../src/middlewares/validators/). Signup accepts +`firstName`, `surname`, `email`, `password`, and optional `confirmPassword`; +client-supplied roles are ignored. Profile updates accept supported `connections` +fields for GitHub and LinkedIn; `null` disconnects a connection. + +The video and demo-video upload routes use `multipart/form-data`: + +| Field | Video | Demo video | +| --- | --- | --- | +| `video` | Required video file | Required video file | +| `poster` | Optional image file | Not accepted | +| `title` | Required title | Required title | +| `categoryId` | Optional category ID | Not accepted | +| `status` | Optional video status | Not accepted | +| `durationSec` | Optional duration in seconds | Optional duration in seconds | + +Submission creation uses `multipart/form-data` with a required `recording` file +and `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration` fields. Only +published videos assigned to a category can receive a submission. Durations and +trim metadata are currently client-reported; the backend does not transcode or +physically trim the uploaded file. + +Uploads are bounded by `MAX_VIDEO_UPLOAD_MB` and +`MAX_RECORDING_UPLOAD_MB` (defaults: 90 MB and 80 MB). Video signatures are +checked as MP4, WebM, or MOV. Poster signatures are checked as JPEG, PNG, or WebP; +posters must not exceed 2 MB. The browser-provided MIME type is not treated as +proof of file content. + +## Contact, rate limits, and access restrictions + +Contact requires a signed-in account and a submitted email matching that account. +Each account can send one message per UTC day; successful deliveries are limited +to `CONTACT_DAILY_LIMIT` per UTC day (default 25). Delivery uses Web3Forms and +successful messages are retained for the admin inbox. Anonymous or mismatched +requests, and requests after the global daily cap, temporarily restrict the +request's device ID or IP for `CONTACT_ABUSE_BLOCK_MINUTES` (default 10). + +When `RATE_LIMIT_ENABLED=true`, the API has a global limit of 1,000 requests per +15 minutes, auth endpoints allow 20 attempts per 15 minutes, contact allows 5 +requests per hour, and upload endpoints allow 60 requests per 15 minutes. The +contact-account and daily delivery quotas are separate from these HTTP limits. +Admin routes remain accessible when an IP/device restriction is active so an +administrator can recover by removing a restriction. + +## Visibility and data rules + +- Anonymous visitors see only published videos assigned to a category. Admins can + see drafts and unassigned videos. +- A video's file and poster use the same visibility check as its metadata. +- Contributors see only their own submission metadata. Admins can see all + submissions and archive metadata; contributor responses omit archive paths. +- Archived recording bytes are never readable through the API. +- Deleting a category unassigns videos rather than deleting them. Deleting a + reference video keeps submissions associated with that video on record. +- A contributor may submit the same video again after the + `SUBMISSION_COOLDOWN_MS` cooldown (default 30 seconds). Concurrent submissions + for the same account/video pair are atomically limited. + +For the detailed OpenAPI schemas and examples, use the raw OpenAPI document or +the development Swagger UI. diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index bda34ab..6dd53bf 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -1,66 +1,129 @@ # SignPak Commons Backend Architecture -## Runtime +## Runtime and startup -The backend is an Express application running on Node.js 20+ with MongoDB through Mongoose. `server.js` owns process startup, environment validation, database connection, admin bootstrap, and the listening port. `src/app.js` builds and exports the HTTP application so integration tests can exercise the same middleware and routes without opening a port. +The backend is an Express 4 application using Node.js 22+ and MongoDB through +Mongoose. Environment validation lives in `src/config/env.js`. The HTTP app is +composed in `src/app.js`; it exports an Express app without binding its own port so +that tests and serverless hosts can import it. + +`server.js` is the deployment entry point. It starts the standalone HTTP listener +and exports a request handler for Vercel. Background initialization is idempotent +and shared between startup and requests: `src/services/initialization.js` +connects MongoDB, initializes the selected storage drivers, and ensures the +configured admin exists. The app-level initialization middleware awaits this +setup before processing a request and returns a `503 SERVICE_UNAVAILABLE` response +if initialization fails. ## Request flow ```text HTTP request - -> app middleware (logging, security headers, CORS, compression, parsing) - -> API prefix middleware (rate limit, origin verification) - -> route guards (authentication, role checks, validation, upload parsing) - -> controller - -> service - -> repository - -> Mongoose model - -> MongoDB + -> app initialization + -> request ID and structured logging + -> Helmet, CORS, compression, JSON parsing, cookies + -> API-wide rate limit, origin check, device/IP restriction check + -> route authentication, role check, validation, upload parsing + -> controller + -> service + -> repository + -> Mongoose model + -> MongoDB ``` -The dependency direction is one way: controllers do not query models, services do not read `req` or write `res`, and repositories do not contain business rules. +The dependency direction is one-way: controllers do not query models, services do +not read `req` or write `res`, and repositories encapsulate persistence without +owning business policy. ## Responsibilities | Layer | Location | Responsibility | | --- | --- | --- | -| Application | `src/app.js` | Compose global middleware, API routes, and error handling. | -| Routes | `src/routes/` | Map HTTP methods and paths to guards and controllers. Swagger UI is exposed at `/api/v1/docs`. | -| Middleware | `src/middlewares/` | Authentication, roles, validation, uploads, rate limits, origin checks, and errors. | -| Controllers | `src/controllers/` | Translate HTTP input to a service call and return the standard response envelope. | -| Services | `src/services/` | Enforce domain rules, coordinate repositories, and manage stored files. | +| Runtime | `server.js` | Export the serverless handler and start the standalone listener. | +| Application | `src/app.js` | Compose initialization, middleware, API/docs routes, and error handling. | +| Routes | `src/routes/` | Map paths to controllers and compose route-level guards and validation. | +| Middleware | `src/middlewares/` | Authentication, roles, input validation, uploads, rate limiting, origin checks, access restrictions, and error mapping. | +| Controllers | `src/controllers/` | Translate HTTP input to service calls and standard response helpers. | +| Services | `src/services/` | Enforce domain rules and coordinate persistence, storage, and external providers. | | Repositories | `src/repositories/` | Encapsulate MongoDB queries and persistence operations. | -| Models | `src/models/` | Define Mongoose schemas, indexes, and public JSON serialization. | -| Storage | `src/services/storage/` | Hide file storage behind save, stat, stream, and remove operations. | -| Configuration | `src/config/` | Validate environment variables, database state, and shared constants. | -| Documentation | `src/docs/`, `docs/` | Maintain the OpenAPI contract and human-readable system documentation. | - -## API boundary - -All application endpoints are mounted below `/api/v1`. Successful JSON responses use `{ "data": value }`; validation and application failures use `{ "error": { ... } }`. Mutating requests with an `Origin` header must come from a configured client origin. Every request receives an `X-Request-Id` for support and log correlation. - -The authentication token is stored in the `httpOnly` `signpak_token` cookie. The server reloads the user from MongoDB on authenticated requests, so role and account changes take effect without waiting for a token refresh. See [API.md](./API.md) for the endpoint contract and `/api/v1/docs` for the interactive OpenAPI reference. - -## Data and media - -MongoDB stores users, categories, videos, submissions, cooldown records, and contact messages. Video and recording bytes are stored through the storage driver; MongoDB stores a driver reference, key, MIME type, and size rather than file contents. - -Published videos assigned to a category are visible to anonymous visitors. Their metadata, posters, and reference files can be read by the public demo and library. Draft or unassigned videos remain restricted to admins. Contributor recordings are never publicly playable. - -Submission recordings use a separate append-only archive path when `ARCHIVE_STORAGE_DRIVER=gdrive`: `commons/{userId}_{sequence}/{category_slug}/{video_slug}/{video_slug}.{ext}`. The API creates the archive object and stores its metadata in MongoDB, but exposes no read, update, or delete operation for the recording. Admin submission responses include the archive path for operational access; contributor responses do not. - -Uploads are first written to a temporary directory, checked by file signatures, and moved into storage only after validation. Failure cleanup removes temporary and already-stored files where necessary. - -## Security controls - -- Helmet security headers and disabled `x-powered-by`. -- CORS with an explicit client-origin allowlist and credentials enabled. -- SameSite HTTP-only JWT cookies plus origin verification for state-changing requests. -- Global and operation-specific rate limits. -- Zod validation for JSON, multipart fields, route IDs, and query parameters. -- Role checks for admin routes and archive metadata access. -- File type sniffing instead of trusting browser-provided MIME types. - -## Testing and extension points - -`npm test` runs the HTTP integration suite. The storage driver contract is the extension point for adding a remote provider such as Google Drive. The OpenAPI definition in `src/docs/openapi.js` should be updated whenever a public route, request shape, response shape, or security rule changes. +| Models | `src/models/` | Define Mongoose schemas, indexes, and JSON serialization. | +| Storage | `src/services/storage/` | Provide local and Google Drive implementations behind storage operations. | +| Configuration | `src/config/` | Validate environment variables, manage DB connection, and define constants. | +| OpenAPI | `src/docs/openapi.js` | Describe API operations and schemas used by Swagger UI and raw JSON. | +| Tests | `tests/*.test.js` | Separate HTTP API suites and focused utility, storage, and service tests. | +| Documentation | `docs/` | Human-readable API, setup, architecture, and test references. | + +## API boundary and middleware + +Application routes are mounted under `/api/v1`. JSON success responses use +`{ "data": value }`; known and unknown failures are normalized to +`{ "error": { "code", "message", "fields?" } }`. `204` responses have no body. +Every request gets an `X-Request-Id` which is included in structured request logs. + +The app applies security headers with Helmet, explicit credentialed CORS, response +compression, JSON body parsing, cookie parsing, and request logging. A global +limiter applies to `/api/v1`; auth, contact, and upload endpoints have additional +limits. State-changing browser requests carrying an `Origin` must use a configured +`CLIENT_ORIGIN`. Requests are also checked against hashed device/IP restrictions; +admin routes are exempt from this restriction middleware to allow recovery. + +Authentication uses the HTTP-only `signpak_token` cookie. Authenticated requests +reload the account from MongoDB, so role changes, suspensions, deletion, and +session-version changes take effect promptly. See [API.md](./API.md) for route +access and request behavior. + +## Persistence and storage + +MongoDB stores users, categories, reference videos, submissions, cooldown claims, +contact messages and quotas, and access restrictions. File bytes are managed +through storage drivers; MongoDB records the storage driver and opaque key along +with media metadata. + +The `STORAGE_DRIVER` controls readable reference videos, posters, and the demo +video. `ARCHIVE_STORAGE_DRIVER` independently controls contributor recording +archives. Both default to local storage. A Google Drive archive may use dedicated +archive OAuth credentials or service-account JSON, and can fall back to shared +Google credentials and a shared folder when archive-specific settings are absent. + +Submission recordings are append-only. The archive folder is based on contributor +and per-video sequence, category slug, and video slug. MongoDB keeps the archive +reference; the API never streams or mutates the archived file. Contributor +responses omit archive paths, while admins can see archive metadata. Account +deletion removes that account's archive objects and related submissions; deleting +a base video does not remove submissions. + +Uploads first land in a temporary directory. Multer limits file size and count; +services then inspect file signatures before moving valid assets into the selected +storage driver. Failure paths remove temporary files and clean up files already +stored during a failed multi-step operation. + +## Security and operational controls + +- JWTs are placed in HTTP-only cookies; password reset invalidates existing + sessions. +- Password login avoids revealing whether an email exists. +- Verification and password-reset codes are stored as keyed hashes, expire, and + have bounded attempts and resend intervals. +- Zod validates bodies, query parameters, and route IDs. +- File signatures, rather than supplied MIME types, determine accepted video and + image formats. +- Access restrictions store hashed identifiers and expose only short hints. +- Contact requires a signed-in account email match and enforces per-account and + global daily quotas; abusive attempts may create a temporary device/IP block. +- `TRUST_PROXY` controls Express proxy awareness and should match the hosting + topology used for IP-based rate limits and restrictions. + +## Testing and API documentation + +`npm test` runs Node's test runner serially across all `tests/*.test.js`. Each +HTTP integration suite has its own file and a fresh API test context; +`apiContext.js` stubs email/contact providers and provisions isolated test +fixtures. By default, those suites use MongoDB Memory Server. Set +`MONGODB_URI_TEST` to use an existing disposable database instead—the tests drop +it. Focused unit and filesystem/storage integration tests use isolated temporary +directories. See [TEST-CASES.md](./TEST-CASES.md) for the case inventory. + +Raw OpenAPI JSON is available at `/api/v1/docs.json` in every environment. +Interactive Swagger UI is only mounted outside production. Update +`src/docs/openapi.js` alongside routes, validators, and this API reference when an +endpoint contract changes. diff --git a/backend/docs/TEST-CASES.md b/backend/docs/TEST-CASES.md new file mode 100644 index 0000000..5d5d015 --- /dev/null +++ b/backend/docs/TEST-CASES.md @@ -0,0 +1,237 @@ +# Backend test cases + +The backend suite uses Node's built-in test runner. It currently contains **56 test +cases** across twelve test files. Each named API test suite has its own `.test.js` +file and starts with a fresh test database and account fixtures: + +| Test file | Cases | Main test type | +| --- | ---: | --- | +| `tests/api-basics.test.js` | 4 | HTTP integration tests | +| `tests/api-demo-video.test.js` | 1 | HTTP integration tests | +| `tests/api-auth.test.js` | 8 | HTTP integration tests | +| `tests/api-profile-connections.test.js` | 1 | HTTP integration tests | +| `tests/api-categories.test.js` | 2 | HTTP integration tests | +| `tests/api-videos.test.js` | 6 | HTTP integration tests | +| `tests/api-submissions.test.js` | 8 | HTTP integration tests | +| `tests/api-admin-stats-contact.test.js` | 4 | HTTP integration tests | +| `tests/api-admin-user-controls.test.js` | 4 | HTTP integration tests | +| `tests/safeName.test.js` | 3 | Unit tests | +| `tests/localStorage.test.js` | 6 | Filesystem/storage integration tests | +| `tests/videoService.test.js` | 9 | Unit tests | + +## Test types + +- **HTTP integration** exercises the Express app through HTTP requests and checks + interactions between routing, middleware, services, MongoDB, and local storage. + The suite uses MongoDB Memory Server by default. Email and contact-delivery + providers are stubbed. +- **Unit** checks a focused function or service rule, with external dependencies + stubbed where needed. +- **Filesystem/storage integration** exercises the local storage driver or storage + service against temporary directories and real files. + +## API suite files — HTTP integration tests + +Each API suite file uses `tests/apiContext.js` to start the app with a fresh test +database, create representative admin and contributor accounts, and clean up the +database and server afterward. The suite-specific fixtures are initialized within +their respective test file. Each API case below is an HTTP integration test. + +### `tests/api-basics.test.js` — Basics + +1. **root advertises the API and favicon is ignored** — Checks the root response, + favicon handling, both documentation UI routes, and both OpenAPI JSON routes. +2. **health check reports the database** — Checks that the health endpoint reports + a connected database. +3. **unknown routes and bad JSON use the error shape** — Checks 404 handling and + malformed JSON handling, including their standard error codes. +4. **cross-origin writes are refused, allowed origin passes** — Checks that a + disallowed request origin is rejected while the configured client origin can + perform a write. + +### `tests/api-demo-video.test.js` — Demo video + +5. **admins can upload, replace, and delete the public demo video** — Checks public + empty and file responses, admin-only upload, replacement and cleanup, and that + the demo video is separate from the regular video library. + +### `tests/api-auth.test.js` — Authentication + +6. **signup validates every field** — Checks required signup fields, password + confirmation, and minimum password length. +7. **signup sets an httpOnly cookie and never leaks the hash or accepts a role** — + Checks that signup does not create a session before verification, ignores a + submitted admin role, verifies email, sets secure cookie attributes, and does + not expose the password hash. +8. **password reset codes are one-time and replace the old password** — Checks the + generic reset-request response, invalid-code handling, password replacement, + code reuse rejection, and invalidation of the old session. +9. **duplicate emails conflict, case-insensitively** — Checks that signup rejects + an address that already exists with different letter casing. +10. **login: wrong password and unknown email give the same answer** — Checks + equivalent unauthorized responses for an unknown account and a wrong + password. +11. **session, logout and tampered cookies** — Checks anonymous and authenticated + session responses, logout, and rejection of an invalid session cookie. +12. **the env admin exists once, has the admin role, and re-running is harmless** + — Checks environment-admin bootstrap is idempotent and assigns the admin role. +13. **admin-only routes reject contributors and visitors** — Checks role and + authentication enforcement on an admin endpoint. + +### `tests/api-profile-connections.test.js` — Profile connections + +14. **normalises, validates, and disconnects** — Checks GitHub and LinkedIn URL + normalization, invalid-handle validation, clearing one connection without + changing the other, and authentication requirements. + +### `tests/api-categories.test.js` — Categories + +15. **only admins write; names are unique regardless of case** — Checks role + access, category creation, case-insensitive uniqueness, and invalid tone + validation. +16. **update and public listing** — Checks category updates, duplicate-name and + empty-update validation, public listing, and invalid ID validation. + +### `tests/api-videos.test.js` — Videos + +17. **upload needs a real video file** — Checks missing, fake, and invalid-category + uploads, admin access, and cleanup of rejected temporary uploads. +18. **admin uploads with and without a poster; the API hides storage internals** — + Checks upload responses and defaults, ordering, optional posters, public + resource URLs, storage metadata privacy, and stored-file counts. +19. **visibility: contributors and visitors only see published, categorised + videos** — Checks that admins can see all videos while contributors and + visitors only see published videos assigned to a category. +20. **published files stream publicly with Range support** — Checks full and + partial video responses, suffix ranges, unsatisfiable ranges, content types, + and hidden draft files. +21. **posters are served as images** — Checks public poster delivery and the + missing-poster response. +22. **editing: moving categories appends to the end of that path** — Checks video + movement and ordering, invalid category/status handling, draft updates, and + admin-only editing. + +### `tests/api-submissions.test.js` — Submissions and cooldown + +23. **archive and database failures release cooldown and remove any archived + file** — Checks rollback and cleanup when archive storage or database creation + fails after the cooldown is claimed. +24. **input is checked before anything is stored** — Checks authentication, + required and valid recording content, trim ranges, video IDs and visibility, + plus absence of orphaned files on rejection. +25. **a valid submission is stored and listed** — Checks successful submission + metadata, recording privacy, archive storage, contributor ownership, and + admin visibility. +26. **COOLDOWN: a second submission for the same video, too soon, is refused and + stores nothing** — Checks cooldown rejection and temporary/archive file + cleanup. +27. **COOLDOWN: concurrent double-submits for the same video produce exactly one + submission** — Checks atomic cooldown behavior under concurrent uploads and + cleanup of losing uploads. +28. **COOLDOWN: once it elapses, the same video can be submitted again, as a new, + separate recording** — Checks that a later submission succeeds as a distinct + append-only record after the cooldown expires. +29. **a submission can never be edited, deleted, or read once archived** — Checks + that submission mutation and recording playback endpoints remain unavailable + to contributors and admins. +30. **deleting a base video keeps contributors' submissions on record** — Checks + that deleting the reference video removes its file and record without + removing archived submissions. + +### `tests/api-admin-stats-contact.test.js` — Admin stats and contact + +31. **stats add up** — Checks admin access, aggregate totals, daily activity, + category counts, recent submissions, handling of deleted videos, and the + allowed day range. +32. **deleting a category unassigns its videos instead of deleting them** — + Checks that category deletion keeps videos but makes unassigned videos + invisible to contributors. +33. **contact delivery requires matching account email, enforces quotas, and + blocks abusive devices** — Checks contact validation, delivery failure + handling, matching account email, per-user and global daily quotas, anonymous + access rejection, device blocking, message listing, and admin-only access. +34. **oversized uploads are rejected with 413** — Checks payload-too-large + responses and temporary-file cleanup. + +### `tests/api-admin-user-controls.test.js` — Admin user controls and access restrictions + +35. **suspending a user blocks its existing session and login; reactivation + restores access** — Checks self-suspension protection, suspension effects on + sessions and login, and reactivation. +36. **deleting a user removes their submissions, cooldowns, and stored + recording** — Checks account deletion cleanup across database records, + cooldowns, archive storage, sessions, and admin listings. +37. **device restrictions reject matching IDs, expose only a hint, and can be + removed** — Checks device restriction creation, hashed-identifier privacy, + blocking, duplicate detection, and removal. +38. **IP restrictions block API traffic while admin routes remain available for + recovery** — Checks IP validation and restriction enforcement while + preserving admin access to remove restrictions. + +## Utility, storage, and video-service test files + +These tests call functions and services directly rather than exercising HTTP +routes. The cases are separated by the behavior under test. + +### `tests/safeName.test.js` — Safe names (unit tests) + +39. **safeName uses its fallback for nullish and blank values** — Checks fallback + behavior for nullish, whitespace-only, and fully sanitized names. +40. **safeName replaces reserved path characters and collapses whitespace** — + Checks removal of path-reserved characters, whitespace normalization, and + string conversion of non-string values. +41. **safeName caps its output at 100 characters** — Checks the maximum safe-name + length. + +### `tests/localStorage.test.js` — Local driver and storage service (filesystem/storage integration tests) + +42. **local driver stores named files in folders and appends suffixes on + collisions** — Checks directory placement, readable keys, collision naming, + and file contents. +43. **local driver handles named files without an extension and dotfiles** — + Checks collision naming for extensionless names and dotfiles. +44. **local driver generates unique UUID keys when no file name is supplied** — + Checks generated key format and uniqueness. +45. **local driver rejects paths escaping the storage root and keeps the source + file** — Checks rejection of traversal through file names or folder names and + confirms rejected saves do not consume the source file. +46. **local driver stat reports missing files and remove is idempotent** — Checks + missing-file errors from stat and harmless removal of an absent key. +47. **storageService forwards named folder and file name to the local driver** — + Checks storage-service metadata, destination key, file size, and cleanup. + +### `tests/videoService.test.js` — Video service (unit tests) + +48. **video create names video and poster from title and category safely** — + Checks sanitized names, category folder selection, poster naming, storage + calls, and category ordering. +49. **video create uses Unassigned and Untitled fallbacks when optional labels + are absent** — Checks fallback folder and file names, default ordering, and + absent poster metadata. +50. **video create selects detected video and image extensions** — Checks + content-detected extensions for WebM, MOV, PNG, and WebP rather than relying + on the original file name. +51. **video create rejects a missing upload, unsupported content, and invalid + categories before storage** — Checks validation errors and confirms storage is + not called for invalid input. +52. **video create rejects oversized or unsupported posters before storage** — + Checks poster size and real-content validation before any storage call. +53. **video create accepts a poster exactly at the size limit** — Checks that a + poster at the configured maximum size is accepted. +54. **video create surfaces filesystem errors instead of reporting invalid video + content** — Checks missing-file I/O errors are preserved and storage is not + called. +55. **video create removes already stored files when poster storage fails** — + Checks cleanup of the stored video when the subsequent poster save fails. +56. **video create removes video and poster if the database write fails** — + Checks cleanup of both stored files when database record creation fails. + +## Running the suite + +From `backend/`, run: + +```bash +npm test +``` + +This executes `node --test --test-concurrency=1` across all `.test.js` files. diff --git a/backend/docs/setup.md b/backend/docs/setup.md index 37a8b27..fd54fc2 100644 --- a/backend/docs/setup.md +++ b/backend/docs/setup.md @@ -6,7 +6,7 @@ This guide explains how to install, configure, run, test, and deploy the SignPak Install: -- Node.js 20 or newer +- Node.js 22 or newer - npm - MongoDB 7+ locally, or a MongoDB Atlas cluster - Git @@ -80,7 +80,7 @@ URL-encode special characters in the database username or password. | `NODE_ENV` | Set manually: `development`, `test`, or `production` | Selects runtime behavior and production cookie defaults. | | `PORT` | Set manually; default `5000` | Port used by the Express server. | | `LOG_LEVEL` | Set manually: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, or `silent` | Controls structured log verbosity. | -| `TRUST_PROXY` | Set `true` behind Render, Railway, nginx, or another reverse proxy | Makes rate limiting use the original client IP. | +| `TRUST_PROXY` | Set `true` behind a trusted reverse proxy | Enables Express proxy awareness for client IPs used by rate limits and access restrictions. | | `CLIENT_ORIGIN` | Frontend URL, for example `http://localhost:5173` | CORS allowlist and state-changing request origin check. Separate multiple origins with commas. | ### Authentication and cookies @@ -92,6 +92,9 @@ URL-encode special characters in the database username or password. | `COOKIE_SECURE` | Usually leave unset; production defaults to `true` | Sends cookies only over HTTPS when true. Use false only for local HTTP development. | | `COOKIE_SAMESITE` | Usually `lax`; use `none` only for cross-site HTTPS deployment | Controls cross-site cookie behavior. `none` requires `COOKIE_SECURE=true`. | | `BCRYPT_ROUNDS` | Set manually from `4` to `15`; default `12` | Password hashing cost. Increase only when deployment CPU allows it. | +| `AUTH_OTP_TTL_MINUTES` | Set manually; default `5`, range `1`–`30` | Lifetime of email verification and password-reset codes. | +| `AUTH_OTP_RESEND_SECONDS` | Set manually; default `60`, range `0`–`3600` | Minimum interval between verification-code resend requests. | +| `AUTH_OTP_MAX_ATTEMPTS` | Set manually; default `5`, range `1`–`10` | Maximum code verification attempts. | Generate a JWT secret: @@ -116,11 +119,24 @@ The admin is ensured on every startup. Changing `ADMIN_PASSWORD` later does not | --- | --- | --- | | `STORAGE_DRIVER` | `local` or `gdrive` | Storage for readable base videos and posters. Use `local` for development. | | `ARCHIVE_STORAGE_DRIVER` | `local` or `gdrive` | Append-only storage for contributor recordings. Use `gdrive` for production archives. | -| `UPLOAD_DIR` | Local filesystem path; default `uploads` | Temporary uploads and local storage root. | -| `GOOGLE_SERVICE_ACCOUNT_JSON` | Download from Google Cloud, described below | Service-account credentials used when either storage driver is `gdrive`. | -| `GOOGLE_DRIVE_FOLDER_ID` | The ID of the shared Google Drive root folder | Parent folder for application-created Drive folders. | -| `MAX_VIDEO_UPLOAD_MB` | Set manually; default `300` | Maximum base-video upload size. | -| `MAX_RECORDING_UPLOAD_MB` | Set manually; default `100` | Maximum contributor-recording upload size. | +| `UPLOAD_DIR` | Local filesystem path; default `uploads` | Local storage root; temporary uploads are kept in its `tmp` subdirectory. | +| `MAX_VIDEO_UPLOAD_MB` | Set manually; default `90` | Maximum reference-video or demo-video upload size, in MiB. | +| `MAX_RECORDING_UPLOAD_MB` | Set manually; default `80` | Maximum contributor-recording upload size, in MiB. | +| `GOOGLE_SERVICE_ACCOUNT_JSON` | Service-account JSON from Google Cloud | Shared Google Drive credentials, available as fallback for either driver. | +| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client configuration | Shared OAuth client ID; set with the corresponding secret and refresh token. | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client configuration | Shared OAuth client secret. | +| `GOOGLE_OAUTH_REFRESH_TOKEN` | OAuth consent flow | Shared OAuth refresh token. | +| `GOOGLE_DRIVE_FOLDER_ID` | ID of the shared Google Drive root folder | Shared parent folder; archive storage can fall back to this folder. | +| `GOOGLE_ARCHIVE_OAUTH_CLIENT_ID` | Optional second Google OAuth client | Archive-specific OAuth client ID. | +| `GOOGLE_ARCHIVE_OAUTH_CLIENT_SECRET` | Optional second Google OAuth client | Archive-specific OAuth client secret. | +| `GOOGLE_ARCHIVE_OAUTH_REFRESH_TOKEN` | OAuth consent flow for archive account | Archive-specific refresh token. | +| `GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON` | Optional second service-account JSON | Archive-specific service-account credentials. | +| `GOOGLE_ARCHIVE_DRIVE_FOLDER_ID` | Optional archive folder ID | Archive-specific parent folder; falls back to `GOOGLE_DRIVE_FOLDER_ID`. | + +For each OAuth account, provide **all three** values (client ID, client secret, +refresh token). The archive driver prefers complete archive-specific OAuth +credentials; otherwise it can use archive-specific service-account JSON, then the +shared credentials. ### Application controls @@ -128,10 +144,20 @@ The admin is ensured on every startup. Changing `ADMIN_PASSWORD` later does not | --- | --- | --- | | `RATE_LIMIT_ENABLED` | `true` or `false`; default `true` | Enables API and upload rate limits. Keep enabled outside local debugging. | | `SUBMISSION_COOLDOWN_MS` | Set manually; default `30000` | Minimum delay between submissions for the same user and reference video. | +| `WEB3FORMS_ACCESS_KEY` | Create an access key at Web3Forms | Required in production for contact-message delivery. | +| `CONTACT_DAILY_LIMIT` | Set manually; default `25` | Maximum successfully delivered messages per UTC day. | +| `CONTACT_USER_DAILY_LIMIT` | Set manually; default `1` | Maximum contact messages per account per UTC day. | +| `CONTACT_ABUSE_BLOCK_MINUTES` | Set manually; default `10`, range `1`–`1440` | Temporary device/IP restriction for anonymous, mismatched-email, or over-limit contact attempts. | + +In production, `BREVO_API_KEY`, a valid `BREVO_SENDER_EMAIL`, and +`WEB3FORMS_ACCESS_KEY` are required. The OTP, contact, and upload values above +have validated ranges; see `.env.example` for the development template. ## 6. Google Drive append-only archives -Google Drive is optional for base videos and submission archives. When `ARCHIVE_STORAGE_DRIVER=gdrive`, contributor recordings are created under this structure: +Google Drive is optional for base videos and submission archives. When +`ARCHIVE_STORAGE_DRIVER=gdrive`, contributor recordings are created under this +structure: ```text {GOOGLE_DRIVE_FOLDER_ID}/ @@ -144,34 +170,43 @@ Google Drive is optional for base videos and submission archives. When `ARCHIVE_ A repeated submission receives the next sequence number instead of replacing an existing file. The API writes the file and stores its pointer and metadata in MongoDB. It does not expose a read, update, or delete route for archived recordings. -### Create Google credentials +### Configure Google credentials -1. Open [Google Cloud Console](https://console.cloud.google.com/). -2. Create or select a project. -3. Open **APIs & Services > Library** and enable **Google Drive API**. -4. Open **IAM & Admin > Service Accounts**. -5. Create a service account for the backend. -6. Create a JSON key under **Keys > Add key > Create new key > JSON**. -7. Store the downloaded JSON in a secret manager. Do not commit the JSON file. -8. Create a folder in Google Drive and copy its ID from the folder URL: +1. Open [Google Cloud Console](https://console.cloud.google.com/), create or select + a project, and enable **Google Drive API**. +2. Choose either a service account or OAuth credentials for each configured + driver. Service-account JSON can be stored in a secret manager as one + environment value. OAuth requires a client ID, client secret, and refresh + token generated through the Google OAuth consent flow. +3. Create a folder in Google Drive and copy its ID from the folder URL: ```text https://drive.google.com/drive/folders/FOLDER_ID ``` -9. Share that folder with the service account email, usually shaped like `name@project.iam.gserviceaccount.com`. -10. Give the service account permission to create files and folders. -11. Put the complete JSON document into the deployment environment as one value: +4. For service-account credentials, share the folder with the service account + email (usually `name@project.iam.gserviceaccount.com`) and grant permission to + create files and folders. +5. Set the selected storage driver to `gdrive`, the corresponding folder ID, and + its credentials. For shared service-account credentials: ```env ARCHIVE_STORAGE_DRIVER=gdrive -GOOGLE_DRIVE_FOLDER_ID=FOLDER_ID +GOOGLE_ARCHIVE_DRIVE_FOLDER_ID=FOLDER_ID GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account",...} ``` -For hosting platforms that provide a secret editor, paste the JSON there. For local development, keep it in `.env` only. Do not put the JSON in `public/`, source control, or a frontend environment variable. +When using the archive-specific service-account JSON, set +`GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON` instead of the shared +`GOOGLE_SERVICE_ACCOUNT_JSON`. For a separate OAuth account, use the +`GOOGLE_ARCHIVE_OAUTH_*` variables. Keep all credentials in a host secret manager +or local `.env`; never put them in `public/`, source control, or frontend +environment variables. -`STORAGE_DRIVER=gdrive` can also be used for base videos, but base videos need to remain readable by the public demo or authenticated library. Use `ARCHIVE_STORAGE_DRIVER=gdrive` when the intended behavior is write-only contributor archives. +`STORAGE_DRIVER=gdrive` stores reference and demo videos, which must remain +readable by their intended users. `ARCHIVE_STORAGE_DRIVER=gdrive` controls +contributor recording archives independently. Archive-specific OAuth credentials +and folder IDs allow archives to use a separate Google account. ## 7. Start the backend @@ -187,13 +222,15 @@ Production-style start: npm start ``` -The startup sequence is: +For a standalone Node process, `server.js` binds the HTTP listener and starts +service initialization. Requests await the same shared initialization promise +before routing. On Vercel, `server.js` exports an Express-compatible handler and +the first request performs initialization. Initialization does the following: -1. Validate environment variables. -2. Connect to MongoDB. -3. Initialize the selected storage drivers. +1. Validate environment variables while loading the app. +2. Connect to MongoDB if it is not already connected. +3. Initialize selected storage drivers. 4. Create or promote the configured admin account. -5. Start listening on `PORT`. Check the service: @@ -233,7 +270,7 @@ The browser must send credentials for the HTTP-only authentication cookie. In pr - Set the exact production frontend URL in `CLIENT_ORIGIN`. - Use HTTPS with `COOKIE_SECURE=true`. - Keep `COOKIE_SAMESITE=lax` when frontend and API share a site; use `none` only when required by cross-site deployment. -- Use `ARCHIVE_STORAGE_DRIVER=gdrive` with a restricted service account for append-only recordings. +- Use `ARCHIVE_STORAGE_DRIVER=gdrive` with dedicated archive credentials and a restricted Drive folder for append-only recordings. - Keep `RATE_LIMIT_ENABLED=true`. - Confirm the upload limits match available storage and request limits. - Never commit `.env`, Google service-account JSON, JWT secrets, or database credentials. diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index 7235a1e..78a7946 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -1,25 +1,26 @@ +const ref = (name) => ({ $ref: `#/components/schemas/${name}` }) const envelope = (schema = {}) => ({ type: 'object', + required: ['data'], properties: { data: schema }, }) - -const idParameter = { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string', pattern: '^[a-f0-9]{24}$' }, -} - +const jsonResponse = (description, schema) => ({ + description, + content: { 'application/json': { schema: envelope(schema) } }, +}) const errorResponse = { description: 'Error response', content: { 'application/json': { schema: { type: 'object', + required: ['error'], properties: { error: { type: 'object', + required: ['code', 'message'], properties: { + code: { type: 'string' }, message: { type: 'string' }, fields: { type: 'object', additionalProperties: { type: 'string' } }, }, @@ -29,9 +30,21 @@ const errorResponse = { }, }, } - +const idParameter = { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', pattern: '^[a-f\\d]{24}$' }, +} const userSecurity = [{ cookieAuth: [] }] const adminSecurity = userSecurity +const authErrors = { 400: errorResponse, 422: errorResponse, 429: errorResponse } +const adminErrors = { 401: errorResponse, 403: errorResponse, 422: errorResponse } +const videoContent = { + 'video/mp4': {}, + 'video/webm': {}, + 'video/quicktime': {}, +} export const openapi = { openapi: '3.0.3', @@ -54,118 +67,558 @@ export const openapi = { ], components: { securitySchemes: { - cookieAuth: { type: 'apiKey', in: 'cookie', name: 'signpak_token', description: 'HTTP-only JWT cookie set by login or email verification.' }, + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'signpak_token', + description: 'HTTP-only JWT cookie set by login or email verification.', + }, }, schemas: { Category: { type: 'object', + required: ['id', 'label', 'copy', 'tone'], + properties: { + id: { type: 'string', pattern: '^[a-f\\d]{24}$' }, + label: { type: 'string' }, + copy: { type: 'string' }, + tone: { type: 'string', enum: ['yellow', 'blue', 'red', 'green'] }, + }, + }, + CategoryCreateInput: { + type: 'object', + required: ['label'], + properties: { + label: { type: 'string', minLength: 1, maxLength: 60 }, + copy: { type: 'string', maxLength: 240, default: '' }, + tone: { type: 'string', enum: ['yellow', 'blue', 'red', 'green'], default: 'yellow' }, + }, + }, + CategoryUpdateInput: { + type: 'object', + minProperties: 1, properties: { - id: { type: 'string' }, label: { type: 'string' }, copy: { type: 'string' }, tone: { type: 'string', enum: ['yellow', 'blue', 'red', 'green'] }, + label: { type: 'string', minLength: 1, maxLength: 60 }, + copy: { type: 'string', maxLength: 240 }, + tone: { type: 'string', enum: ['yellow', 'blue', 'red', 'green'] }, }, }, Video: { type: 'object', + required: ['id', 'title', 'status', 'categoryId', 'order', 'durationSec', 'videoUrl', 'poster', 'size'], properties: { - id: { type: 'string' }, title: { type: 'string' }, status: { type: 'string', enum: ['draft', 'published'] }, - categoryId: { type: 'string', nullable: true }, order: { type: 'integer' }, durationSec: { type: 'number' }, - videoUrl: { type: 'string' }, poster: { type: 'string', nullable: true }, size: { type: 'integer' }, + id: { type: 'string', pattern: '^[a-f\\d]{24}$' }, + title: { type: 'string' }, + status: { type: 'string', enum: ['draft', 'published'] }, + categoryId: { type: 'string', nullable: true }, + order: { type: 'integer' }, + durationSec: { type: 'number' }, + videoUrl: { type: 'string' }, + poster: { type: 'string', nullable: true }, + size: { type: 'integer' }, + }, + }, + VideoUpdateInput: { + type: 'object', + minProperties: 1, + properties: { + title: { type: 'string', minLength: 1, maxLength: 120 }, + status: { type: 'string', enum: ['draft', 'published'] }, + categoryId: { type: 'string', nullable: true, pattern: '^[a-f\\d]{24}$' }, + order: { type: 'integer', minimum: 0 }, }, }, User: { type: 'object', properties: { - id: { type: 'string' }, firstName: { type: 'string' }, surname: { type: 'string' }, email: { type: 'string', format: 'email' }, + id: { type: 'string', pattern: '^[a-f\\d]{24}$' }, + firstName: { type: 'string' }, + surname: { type: 'string' }, + email: { type: 'string', format: 'email' }, emailVerified: { type: 'boolean' }, - role: { type: 'string', enum: ['user', 'admin'] }, status: { type: 'string', enum: ['active', 'suspended'] }, statusReason: { type: 'string' }, - connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } }, + role: { type: 'string', enum: ['user', 'admin'] }, + status: { type: 'string', enum: ['active', 'suspended'] }, + statusReason: { type: 'string' }, + connections: { + type: 'object', + properties: { + github: { type: 'string', nullable: true }, + linkedin: { type: 'string', nullable: true }, + }, + }, }, }, SignupInput: { - type: 'object', required: ['firstName', 'surname', 'email', 'password'], + type: 'object', + required: ['firstName', 'surname', 'email', 'password'], properties: { - firstName: { type: 'string', maxLength: 60 }, surname: { type: 'string', maxLength: 60 }, - email: { type: 'string', format: 'email' }, password: { type: 'string', format: 'password', minLength: 8 }, confirmPassword: { type: 'string' }, + firstName: { type: 'string', minLength: 1, maxLength: 60 }, + surname: { type: 'string', minLength: 1, maxLength: 60 }, + email: { type: 'string', format: 'email' }, + password: { type: 'string', format: 'password', minLength: 8, maxLength: 72 }, + confirmPassword: { type: 'string', description: 'Optional; if supplied, must match password.' }, }, }, - AuthInput: { - type: 'object', required: ['email', 'password'], + LoginInput: { + type: 'object', + required: ['email', 'password'], properties: { - firstName: { type: 'string', maxLength: 60 }, surname: { type: 'string', maxLength: 60 }, - email: { type: 'string', format: 'email' }, password: { type: 'string', format: 'password', minLength: 8 }, confirmPassword: { type: 'string' }, + email: { type: 'string', format: 'email' }, + password: { type: 'string', format: 'password', minLength: 1, maxLength: 200 }, + }, + }, + EmailInput: { + type: 'object', + required: ['email'], + properties: { email: { type: 'string', format: 'email' } }, + }, + VerifyEmailInput: { + type: 'object', + required: ['email', 'code'], + properties: { + email: { type: 'string', format: 'email' }, + code: { type: 'string', pattern: '^\\d{6}$' }, + }, + }, + ResetPasswordInput: { + type: 'object', + required: ['email', 'code', 'password', 'confirmPassword'], + properties: { + email: { type: 'string', format: 'email' }, + code: { type: 'string', pattern: '^\\d{6}$' }, + password: { type: 'string', format: 'password', minLength: 8, maxLength: 72 }, + confirmPassword: { type: 'string', format: 'password', minLength: 1 }, + }, + }, + UpdateProfileInput: { + type: 'object', + required: ['connections'], + properties: { + connections: { + type: 'object', + properties: { + github: { type: 'string', nullable: true, description: 'GitHub username or profile URL; null disconnects.' }, + linkedin: { type: 'string', nullable: true, description: 'LinkedIn profile slug or URL; null disconnects.' }, + }, + }, + }, + }, + VideoUploadInput: { + type: 'object', + required: ['video', 'title'], + properties: { + video: { type: 'string', format: 'binary', description: 'MP4, WebM, or MOV video.' }, + poster: { type: 'string', format: 'binary', description: 'Optional JPEG, PNG, or WebP image (up to 2 MiB).' }, + title: { type: 'string', minLength: 1, maxLength: 120 }, + categoryId: { type: 'string', nullable: true, pattern: '^[a-f\\d]{24}$' }, + status: { type: 'string', enum: ['draft', 'published'], default: 'published' }, + durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 }, + }, + }, + DemoVideo: { + type: 'object', + required: ['id', 'title', 'durationSec', 'videoUrl', 'size'], + properties: { + id: { type: 'string', enum: ['current'] }, + title: { type: 'string', maxLength: 120 }, + durationSec: { type: 'number' }, + videoUrl: { type: 'string' }, + size: { type: 'integer' }, + }, + }, + DemoVideoUploadInput: { + type: 'object', + required: ['video', 'title'], + properties: { + video: { type: 'string', format: 'binary', description: 'MP4, WebM, or MOV video.' }, + title: { type: 'string', minLength: 1, maxLength: 120 }, + durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 }, }, }, Submission: { type: 'object', - properties: { id: { type: 'string' }, videoId: { type: 'string' }, submittedAt: { type: 'string', format: 'date-time' }, duration: { type: 'number' }, mirrored: { type: 'boolean' } }, + properties: { + id: { type: 'string', pattern: '^[a-f\\d]{24}$' }, + userId: { type: 'string', pattern: '^[a-f\\d]{24}$' }, + videoId: { type: 'string', pattern: '^[a-f\\d]{24}$' }, + trimStart: { type: 'number' }, + trimEnd: { type: 'number' }, + mirrored: { type: 'boolean' }, + duration: { type: 'number' }, + submittedAt: { type: 'string', format: 'date-time' }, + size: { type: 'integer' }, + mimeType: { type: 'string', enum: ['video/mp4', 'video/webm', 'video/quicktime'] }, + archivePath: { type: 'string', nullable: true, description: 'Included for admins only.' }, + }, + }, + SubmissionInput: { + type: 'object', + required: ['recording', 'videoId', 'trimStart', 'trimEnd', 'duration'], + properties: { + recording: { type: 'string', format: 'binary', description: 'MP4, WebM, or MOV recording.' }, + videoId: { type: 'string', pattern: '^[a-f\\d]{24}$' }, + trimStart: { type: 'number', minimum: 0 }, + trimEnd: { type: 'number', minimum: 0, description: 'Must be greater than trimStart and no more than 0.5 seconds beyond duration.' }, + mirrored: { type: 'boolean', default: false }, + duration: { type: 'number', minimum: 0, maximum: 600, exclusiveMinimum: true }, + }, + }, + ContactInput: { + type: 'object', + required: ['name', 'email', 'message'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + email: { type: 'string', format: 'email' }, + message: { type: 'string', minLength: 1, maxLength: 2000 }, + }, + }, + Error: { + type: 'object', + required: ['error'], + properties: { + error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + fields: { type: 'object', additionalProperties: { type: 'string' } }, + }, + }, + }, }, - Error: { type: 'object', properties: { error: { type: 'object' } } }, }, }, paths: { '/health': { - get: { tags: ['Health'], summary: 'Check API and database health', responses: { 200: { description: 'Healthy', content: { 'application/json': { schema: envelope({ type: 'object' }) } } }, 503: { description: 'Database unavailable' } } }, + get: { + tags: ['Health'], + summary: 'Check API and database health', + responses: { + 200: jsonResponse('Healthy', { type: 'object', properties: { status: { type: 'string', enum: ['ok'] }, database: { type: 'string', enum: ['connected'] }, uptime: { type: 'integer' } } }), + 503: { description: 'Database unavailable' }, + }, + }, + }, + '/docs.json': { + get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } }, }, - '/docs.json': { get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } } }, '/auth/signup': { - post: { tags: ['Auth'], summary: 'Create an unverified account and send a code', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/SignupInput' } } } }, responses: { 201: { description: 'Account pending email verification' }, 400: errorResponse } }, + post: { + tags: ['Auth'], + summary: 'Create an unverified account and send a code', + requestBody: { required: true, content: { 'application/json': { schema: ref('SignupInput') } } }, + responses: { 201: jsonResponse('Account pending email verification', ref('User')), ...authErrors }, + }, }, '/auth/login': { - post: { tags: ['Auth'], summary: 'Log in and set the HTTP-only cookie', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 200: { description: 'Logged in' }, 401: errorResponse } }, - }, - '/auth/verify-email': { post: { tags: ['Auth'], summary: 'Verify an email code and establish a session', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email', 'code'], properties: { email: { type: 'string', format: 'email' }, code: { type: 'string', pattern: '^\\d{6}$' } } } } } }, responses: { 200: { description: 'Verified and logged in' }, 400: errorResponse } } }, - '/auth/resend-verification': { post: { tags: ['Auth'], summary: 'Resend an email verification code', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } } } }, responses: { 200: { description: 'Generic response' } } } }, - '/auth/forgot-password': { post: { tags: ['Auth'], summary: 'Request a password reset code', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } } } }, responses: { 200: { description: 'Generic response' } } } }, - '/auth/reset-password': { post: { tags: ['Auth'], summary: 'Reset a password using a one-time code', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email', 'code', 'password', 'confirmPassword'], properties: { email: { type: 'string', format: 'email' }, code: { type: 'string', pattern: '^\\d{6}$' }, password: { type: 'string', format: 'password', minLength: 8 }, confirmPassword: { type: 'string', format: 'password' } } } } } }, responses: { 200: { description: 'Password reset' }, 400: errorResponse } } }, - '/auth/logout': { post: { tags: ['Auth'], summary: 'Clear the session cookie', responses: { 204: { description: 'Logged out' } } } }, - '/auth/session': { get: { tags: ['Auth'], summary: 'Get the current session', responses: { 200: { description: 'Session state' } } } }, + post: { + tags: ['Auth'], + summary: 'Log in and set the HTTP-only cookie', + requestBody: { required: true, content: { 'application/json': { schema: ref('LoginInput') } } }, + responses: { 200: jsonResponse('Logged in', ref('User')), 401: errorResponse, 429: errorResponse }, + }, + }, + '/auth/verify-email': { + post: { + tags: ['Auth'], + summary: 'Verify an email code and establish a session', + requestBody: { required: true, content: { 'application/json': { schema: ref('VerifyEmailInput') } } }, + responses: { 200: jsonResponse('Verified and logged in', ref('User')), ...authErrors }, + }, + }, + '/auth/resend-verification': { + post: { + tags: ['Auth'], + summary: 'Resend an email verification code', + requestBody: { required: true, content: { 'application/json': { schema: ref('EmailInput') } } }, + responses: { 200: jsonResponse('Generic response', { type: 'object', properties: { message: { type: 'string' } } }), 429: errorResponse }, + }, + }, + '/auth/forgot-password': { + post: { + tags: ['Auth'], + summary: 'Request a password reset code', + requestBody: { required: true, content: { 'application/json': { schema: ref('EmailInput') } } }, + responses: { 200: jsonResponse('Generic response', { type: 'object', properties: { message: { type: 'string' } } }), 429: errorResponse }, + }, + }, + '/auth/reset-password': { + post: { + tags: ['Auth'], + summary: 'Reset a password using a one-time code', + requestBody: { required: true, content: { 'application/json': { schema: ref('ResetPasswordInput') } } }, + responses: { 200: jsonResponse('Password reset', { type: 'object', properties: { message: { type: 'string' } } }), ...authErrors }, + }, + }, + '/auth/logout': { + post: { tags: ['Auth'], summary: 'Clear the session cookie', responses: { 204: { description: 'Logged out' } } }, + }, + '/auth/session': { + get: { + tags: ['Auth'], + summary: 'Get the current session', + security: [{ cookieAuth: [] }, {}], + responses: { 200: jsonResponse('Current user or null for a visitor', { allOf: [ref('User')], nullable: true }), 429: errorResponse }, + }, + }, '/users/me': { - get: { tags: ['Users'], summary: 'Get the current user', security: userSecurity, responses: { 200: { description: 'Current user' }, 401: errorResponse } }, - patch: { tags: ['Users'], summary: 'Update profile connections', security: userSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { connections: { type: 'object' } } } } } }, responses: { 200: { description: 'Updated user' }, 400: errorResponse } }, + get: { + tags: ['Users'], + summary: 'Get the current user', + security: userSecurity, + responses: { 200: jsonResponse('Current user', ref('User')), 401: errorResponse }, + }, + patch: { + tags: ['Users'], + summary: 'Update profile connections', + security: userSecurity, + requestBody: { required: true, content: { 'application/json': { schema: ref('UpdateProfileInput') } } }, + responses: { 200: jsonResponse('Updated user', ref('User')), 401: errorResponse, 422: errorResponse }, + }, }, '/categories': { - get: { tags: ['Categories'], summary: 'List categories', responses: { 200: { description: 'Categories', content: { 'application/json': { schema: envelope({ type: 'array', items: { $ref: '#/components/schemas/Category' } }) } } } } }, - post: { tags: ['Categories'], summary: 'Create a category', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Category' } } } }, responses: { 201: { description: 'Created' }, 403: errorResponse } }, + get: { + tags: ['Categories'], + summary: 'List categories', + responses: { 200: jsonResponse('Categories', { type: 'array', items: ref('Category') }) }, + }, + post: { + tags: ['Categories'], + summary: 'Create a category', + security: adminSecurity, + requestBody: { required: true, content: { 'application/json': { schema: ref('CategoryCreateInput') } } }, + responses: { 201: jsonResponse('Created', ref('Category')), ...adminErrors, 409: errorResponse }, + }, }, '/categories/{id}': { parameters: [idParameter], - patch: { tags: ['Categories'], summary: 'Update a category', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Category' } } } }, responses: { 200: { description: 'Updated' }, 404: errorResponse } }, - delete: { tags: ['Categories'], summary: 'Delete a category', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 404: errorResponse } }, + patch: { + tags: ['Categories'], + summary: 'Update a category', + security: adminSecurity, + requestBody: { required: true, content: { 'application/json': { schema: ref('CategoryUpdateInput') } } }, + responses: { 200: jsonResponse('Updated', ref('Category')), ...adminErrors, 404: errorResponse, 409: errorResponse }, + }, + delete: { + tags: ['Categories'], + summary: 'Delete a category and unassign its videos', + security: adminSecurity, + responses: { 204: { description: 'Deleted' }, ...adminErrors, 404: errorResponse }, + }, }, '/videos': { - get: { tags: ['Videos'], summary: 'List visible videos', description: 'Anonymous callers receive published videos assigned to a category. Admins receive all videos.', responses: { 200: { description: 'Videos', content: { 'application/json': { schema: envelope({ type: 'array', items: { $ref: '#/components/schemas/Video' } }) } } } } }, - post: { tags: ['Videos'], summary: 'Upload a base video', security: adminSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['video', 'title'], properties: { video: { type: 'string', format: 'binary' }, poster: { type: 'string', format: 'binary' }, title: { type: 'string' }, categoryId: { type: 'string' }, status: { type: 'string', enum: ['draft', 'published'] }, durationSec: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, + get: { + tags: ['Videos'], + summary: 'List visible videos', + description: 'Anonymous callers receive published videos assigned to a category. Admins receive all videos.', + responses: { 200: jsonResponse('Videos', { type: 'array', items: ref('Video') }), 429: errorResponse }, + }, + post: { + tags: ['Videos'], + summary: 'Upload a base video', + security: adminSecurity, + requestBody: { required: true, content: { 'multipart/form-data': { schema: ref('VideoUploadInput') } } }, + responses: { 201: jsonResponse('Created', ref('Video')), ...adminErrors, 413: errorResponse }, + }, }, '/videos/{id}': { parameters: [idParameter], - get: { tags: ['Videos'], summary: 'Get one visible video', responses: { 200: { description: 'Video' }, 404: errorResponse } }, - patch: { tags: ['Videos'], summary: 'Update video metadata', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Video' } } } }, responses: { 200: { description: 'Updated' }, 404: errorResponse } }, - delete: { tags: ['Videos'], summary: 'Delete a video and its files', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 404: errorResponse } }, + get: { + tags: ['Videos'], + summary: 'Get one visible video', + responses: { 200: jsonResponse('Video', ref('Video')), 404: errorResponse }, + }, + patch: { + tags: ['Videos'], + summary: 'Update video metadata', + security: adminSecurity, + requestBody: { required: true, content: { 'application/json': { schema: ref('VideoUpdateInput') } } }, + responses: { 200: jsonResponse('Updated video', ref('Video')), ...adminErrors, 404: errorResponse }, + }, + delete: { + tags: ['Videos'], + summary: 'Delete a video and its files', + security: adminSecurity, + responses: { 204: { description: 'Deleted; existing submissions remain' }, ...adminErrors, 404: errorResponse }, + }, + }, + '/videos/{id}/file': { + parameters: [idParameter], + get: { + tags: ['Videos'], + summary: 'Stream a visible video file', + description: 'Supports HTTP byte-range requests.', + responses: { + 200: { description: 'Video stream', content: videoContent }, + 206: { description: 'Partial video stream for a byte-range request', content: videoContent }, + 404: errorResponse, + }, + }, + }, + '/videos/{id}/poster': { + parameters: [idParameter], + get: { + tags: ['Videos'], + summary: 'Stream a visible video poster', + responses: { + 200: { description: 'Image stream', content: { 'image/jpeg': {}, 'image/png': {}, 'image/webp': {} } }, + 404: errorResponse, + }, + }, }, - '/videos/{id}/file': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video file', responses: { 200: { description: 'Video stream', content: { 'video/mp4': {} } }, 404: errorResponse } } }, - '/videos/{id}/poster': { parameters: [idParameter], get: { tags: ['Videos'], summary: 'Stream a visible video poster', responses: { 200: { description: 'Image stream' }, 404: errorResponse } } }, '/demo-video': { - get: { tags: ['Demo Video'], summary: 'Get the public demo video metadata', responses: { 200: { description: 'Current demo video, or null when none is set' } } }, - post: { tags: ['Demo Video'], summary: 'Upload or replace the public demo video', security: adminSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['video', 'title'], properties: { video: { type: 'string', format: 'binary' }, title: { type: 'string', maxLength: 120 }, durationSec: { type: 'number' } } } } } }, responses: { 201: { description: 'Uploaded' }, 422: errorResponse } }, - delete: { tags: ['Demo Video'], summary: 'Delete the public demo video', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 403: errorResponse } }, + get: { + tags: ['Demo Video'], + summary: 'Get the public demo video metadata', + responses: { 200: jsonResponse('Current demo video, or null when none is set', { allOf: [ref('DemoVideo')], nullable: true }) }, + }, + post: { + tags: ['Demo Video'], + summary: 'Upload or replace the public demo video', + security: adminSecurity, + requestBody: { required: true, content: { 'multipart/form-data': { schema: ref('DemoVideoUploadInput') } } }, + responses: { 201: jsonResponse('Uploaded', ref('DemoVideo')), ...adminErrors, 413: errorResponse }, + }, + delete: { + tags: ['Demo Video'], + summary: 'Delete the public demo video', + security: adminSecurity, + responses: { 204: { description: 'Deleted' }, ...adminErrors }, + }, + }, + '/demo-video/file': { + get: { + tags: ['Demo Video'], + summary: 'Stream the public demo video', + description: 'Supports HTTP byte-range requests.', + responses: { + 200: { description: 'Video stream', content: videoContent }, + 206: { description: 'Partial video stream for a byte-range request', content: videoContent }, + 404: errorResponse, + }, + }, }, - '/demo-video/file': { get: { tags: ['Demo Video'], summary: 'Stream the public demo video', responses: { 200: { description: 'Video stream', content: { 'video/mp4': {} } }, 404: errorResponse } } }, '/submissions': { - get: { tags: ['Submissions'], summary: 'List the current user submissions', security: userSecurity, responses: { 200: { description: 'Submissions' }, 401: errorResponse } }, - post: { tags: ['Submissions'], summary: 'Submit a recording', security: userSecurity, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['recording', 'videoId'], properties: { recording: { type: 'string', format: 'binary' }, videoId: { type: 'string' }, trimStart: { type: 'number' }, trimEnd: { type: 'number' }, mirrored: { type: 'boolean' }, duration: { type: 'number' } } } } } }, responses: { 201: { description: 'Created' }, 400: errorResponse } }, + get: { + tags: ['Submissions'], + summary: 'List the current user submissions', + description: 'Contributors receive their own submissions; admins receive all submissions and archive metadata.', + security: userSecurity, + responses: { 200: jsonResponse('Submissions', { type: 'array', items: ref('Submission') }), 401: errorResponse }, + }, + post: { + tags: ['Submissions'], + summary: 'Submit a recording', + security: userSecurity, + requestBody: { required: true, content: { 'multipart/form-data': { schema: ref('SubmissionInput') } } }, + responses: { 201: jsonResponse('Created', ref('Submission')), 401: errorResponse, 404: errorResponse, 409: errorResponse, 413: errorResponse, 422: errorResponse }, + }, + }, + '/contact': { + post: { + tags: ['Contact'], + summary: 'Send a contact message from the authenticated account email', + description: 'Requires a signed-in account and an email matching that account.', + security: userSecurity, + requestBody: { required: true, content: { 'application/json': { schema: ref('ContactInput') } } }, + responses: { 201: jsonResponse('Message sent and saved to the admin inbox', { type: 'object' }), 403: errorResponse, 409: errorResponse, 422: errorResponse, 429: errorResponse }, + }, + }, + '/admin/users': { + get: { + tags: ['Admin'], + summary: 'List all users', + security: adminSecurity, + responses: { 200: jsonResponse('Users', { type: 'array', items: ref('User') }), ...adminErrors }, + }, }, - '/contact': { post: { tags: ['Contact'], summary: 'Send a contact message from the authenticated account email', security: userSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['name', 'email', 'message'], properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' }, message: { type: 'string' } } } } } }, responses: { 201: { description: 'Message sent to Web3Forms and saved to the admin inbox' }, 403: errorResponse, 409: errorResponse, 429: errorResponse } } }, - '/admin/users': { get: { tags: ['Admin'], summary: 'List all users', security: adminSecurity, responses: { 200: { description: 'Users' }, 403: errorResponse } } }, '/admin/users/{id}': { parameters: [idParameter], - patch: { tags: ['Admin'], summary: 'Activate or suspend a user', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['active', 'suspended'] }, reason: { type: 'string', maxLength: 500 } } } } } }, responses: { 200: { description: 'Updated user' }, 403: errorResponse, 404: errorResponse } }, - delete: { tags: ['Admin'], summary: 'Delete a user and their submitted recordings', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 403: errorResponse, 404: errorResponse } }, + patch: { + tags: ['Admin'], + summary: 'Activate or suspend a user', + security: adminSecurity, + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', enum: ['active', 'suspended'] }, + reason: { type: 'string', maxLength: 500 }, + }, + }, + }, + }, + }, + responses: { 200: jsonResponse('Updated user', ref('User')), ...adminErrors, 404: errorResponse, 409: errorResponse }, + }, + delete: { + tags: ['Admin'], + summary: 'Delete a user and their submitted recordings', + security: adminSecurity, + responses: { 204: { description: 'Deleted' }, ...adminErrors, 404: errorResponse, 409: errorResponse }, + }, }, '/admin/restrictions': { - get: { tags: ['Admin'], summary: 'List IP and device restrictions', security: adminSecurity, responses: { 200: { description: 'Restrictions' } } }, - post: { tags: ['Admin'], summary: 'Restrict an IP address or device ID', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['type', 'value'], properties: { type: { type: 'string', enum: ['ip', 'device'] }, value: { type: 'string' }, reason: { type: 'string', maxLength: 500 } } } } } }, responses: { 201: { description: 'Restriction created' }, 422: errorResponse } }, + get: { + tags: ['Admin'], + summary: 'List IP and device restrictions', + security: adminSecurity, + responses: { 200: jsonResponse('Restrictions', { type: 'array', items: { type: 'object' } }), ...adminErrors }, + }, + post: { + tags: ['Admin'], + summary: 'Restrict an IP address or device ID', + security: adminSecurity, + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['type', 'value'], + properties: { + type: { type: 'string', enum: ['ip', 'device'] }, + value: { type: 'string', minLength: 1, maxLength: 128 }, + reason: { type: 'string', maxLength: 500 }, + }, + }, + }, + }, + }, + responses: { 201: jsonResponse('Restriction created', { type: 'object' }), ...adminErrors }, + }, + }, + '/admin/restrictions/{id}': { + parameters: [idParameter], + delete: { + tags: ['Admin'], + summary: 'Remove an IP or device restriction', + security: adminSecurity, + responses: { 204: { description: 'Removed' }, ...adminErrors, 404: errorResponse }, + }, + }, + '/admin/stats': { + get: { + tags: ['Admin'], + summary: 'Get dashboard statistics', + security: adminSecurity, + parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', minimum: 7, maximum: 90, default: 14 } }], + responses: { 200: jsonResponse('Statistics', { type: 'object' }), ...adminErrors }, + }, + }, + '/admin/messages': { + get: { + tags: ['Admin'], + summary: 'List contact messages', + security: adminSecurity, + parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }], + responses: { 200: jsonResponse('Messages', { type: 'array', items: { type: 'object' } }), ...adminErrors }, + }, }, - '/admin/restrictions/{id}': { parameters: [idParameter], delete: { tags: ['Admin'], summary: 'Remove an IP or device restriction', security: adminSecurity, responses: { 204: { description: 'Removed' }, 404: errorResponse } } }, - '/admin/stats': { get: { tags: ['Admin'], summary: 'Get dashboard statistics', security: adminSecurity, parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', minimum: 7, maximum: 90, default: 14 } }], responses: { 200: { description: 'Statistics' }, 403: errorResponse } } }, - '/admin/messages': { get: { tags: ['Admin'], summary: 'List contact messages', security: adminSecurity, parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }], responses: { 200: { description: 'Messages' }, 403: errorResponse } } }, }, } diff --git a/backend/src/services/storage/localDriver.js b/backend/src/services/storage/localDriver.js index 869431e..ec75c84 100644 --- a/backend/src/services/storage/localDriver.js +++ b/backend/src/services/storage/localDriver.js @@ -21,6 +21,9 @@ export function createLocalDriver(rootDir) { // Keys are generated by us, but never let one escape the storage root. const resolveKey = (key) => { + if (typeof key !== 'string' || key.split(/[\\/]/).some((segment) => segment === '.' || segment === '..')) { + throw new Error('Invalid storage key') + } const full = path.resolve(root, key) if (full !== root && !full.startsWith(root + path.sep)) throw new Error('Invalid storage key') return full diff --git a/backend/tests/api-admin-stats-contact.test.js b/backend/tests/api-admin-stats-contact.test.js new file mode 100644 index 0000000..f19ae4c --- /dev/null +++ b/backend/tests/api-admin-stats-contact.test.js @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) +before(async () => { + const context = getContext() + const admin = context.admin + daily = (await admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data + work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data + const makeVideo = async (title, categoryId, status = 'published') => { + const result = await admin.upload('/videos', videoForm({ title, categoryId, status, durationSec: '3' })) + assert.equal(result.status, 201, JSON.stringify(result.body)) + return result.body.data + } + video1 = await makeVideo('First sign', daily.id) + video2 = await makeVideo('Second sign', daily.id) + draft = await makeVideo('Secret draft', daily.id, 'draft') + await makeVideo('Unassigned', '') + await context.createVerifiedUser({ firstName: 'Eve', surname: 'Hacker', email: 'eve@example.com' }) + const submissionForm = (videoId) => recordingForm({ videoId, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' }) + assert.equal((await context.contributor.upload('/submissions', submissionForm(video1.id))).status, 201) + await new Promise((resolve) => setTimeout(resolve, Number(process.env.SUBMISSION_COOLDOWN_MS || 200) + 30)) + assert.equal((await context.contributor.upload('/submissions', submissionForm(video1.id))).status, 201) + assert.equal((await context.other.upload('/submissions', submissionForm(video2.id))).status, 201) + assert.equal((await admin.delete(`/videos/${video2.id}`)).status, 204) +}) +describe('admin stats and contact', () => { + test('stats add up', async () => { + assert.equal((await contributor.get('/admin/stats')).status, 403) + const res = await admin.get('/admin/stats?days=7') + assert.equal(res.status, 200) + const { totals, activity, byCategory, recent } = res.body.data + assert.equal(totals.contributors, 3) // sara, omar, eve + assert.equal(totals.submissions, 3) // sara's two takes on video1 (cooldown test), plus omar's one on video2 + assert.equal(totals.categories, 2) + assert.equal(totals.videos, 3) + assert.equal(totals.publishedVideos, 2) // video 1 and the unassigned one; the draft is not counted + assert.equal(activity.length, 7) + assert.equal(activity.reduce((sum, d) => sum + d.count, 0), 3) + assert.equal(activity.at(-1).date, new Date().toISOString().slice(0, 10)) + // Omar's video was deleted earlier, so it counts towards totals and activity but no longer belongs to a category. + assert.equal(byCategory.find((c) => c.categoryId === daily.id).count, 2) // both of sara's video1 submissions + assert.equal(byCategory.find((c) => c.categoryId === work.id).count, 0) + assert.equal(recent.length, 3) + assert.ok(['Sara Ahmed', 'Omar Khan'].includes(recent[0].contributor.name)) + assert.equal(recent.find((r) => r.video === null)?.category, null) // the deleted video + assert.equal((await admin.get('/admin/stats?days=1000')).status, 422) + }) + + test('deleting a category unassigns its videos instead of deleting them', async () => { + assert.equal((await admin.delete(`/categories/${daily.id}`)).status, 204) + const kept = await admin.get(`/videos/${video1.id}`) + assert.equal(kept.status, 200) + assert.equal(kept.body.data.categoryId, null) + assert.equal((await contributor.get(`/videos/${video1.id}`)).status, 404) // hidden from contributors now + }) + + test('contact delivery requires matching account email, enforces quotas, and blocks abusive devices', async () => { + const bad = await contributor.post('/contact', { name: '', email: 'nope', message: '' }) + assert.equal(bad.status, 422) + assert.ok(bad.body.error.fields.email) + assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Provider failure' })).status, 500) + const sent = await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Hello!' }) + assert.equal(sent.status, 201) + assert.equal(contactDeliveries.length, 1) + assert.equal(contactDeliveries[0].email, 'sara@example.com') + assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Again today' })).status, 409) + + const mismatchDevice = 'device-contact-email-mismatch' + assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'other@example.com', message: 'Wrong email' }, { headers: { 'x-device-id': mismatchDevice } })).status, 403) + assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': mismatchDevice } })).status, 403) + + const today = new Date().toISOString().slice(0, 10) + await ContactDailyQuota.findByIdAndUpdate(today, { count: 25 }, { upsert: true }) + const cappedDevice = 'device-contact-global-cap' + assert.equal((await other.post('/contact', { name: 'Omar Khan', email: 'omar@example.com', message: 'Over cap' }, { headers: { 'x-device-id': cappedDevice } })).status, 429) + assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': cappedDevice } })).status, 403) + + const anonymousDevice = 'device-contact-anonymous-test' + assert.equal((await new Client(base).post('/contact', { name: 'Guest', email: 'guest@example.com', message: 'No session' }, { headers: { 'x-device-id': anonymousDevice } })).status, 403) + assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': anonymousDevice } })).status, 403) + + const list = await admin.get('/admin/messages') + assert.equal(list.status, 200) + assert.ok(list.body.data.some((m) => m.name === 'Sara Ahmed' && m.message === 'Hello!' && m.userId)) + assert.equal((await contributor.get('/admin/messages')).status, 403) + }) + + test('oversized uploads are rejected with 413', async () => { + const res = await admin.upload('/videos', videoForm({ title: 'Huge' }, { video: webm(6 * 1024 * 1024) })) + assert.equal(res.status, 413) + assert.equal(res.body.error.code, 'PAYLOAD_TOO_LARGE') + assert.deepEqual(filesIn('tmp'), []) + }) +}) diff --git a/backend/tests/api-admin-user-controls.test.js b/backend/tests/api-admin-user-controls.test.js new file mode 100644 index 0000000..5e6520e --- /dev/null +++ b/backend/tests/api-admin-user-controls.test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) +before(async () => { + const context = getContext() + const category = (await context.admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data + const video = await context.admin.upload('/videos', videoForm({ title: 'First sign', categoryId: category.id, durationSec: '3' })) + assert.equal(video.status, 201, JSON.stringify(video.body)) + const submission = await context.other.upload('/submissions', recordingForm({ videoId: video.body.data.id, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' })) + assert.equal(submission.status, 201, JSON.stringify(submission.body)) +}) +describe('admin user controls and access restrictions', () => { + test('suspending a user blocks its existing session and login; reactivation restores access', async () => { + const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id + const selfId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'admin@signpak.test').id + assert.equal((await admin.patch(`/admin/users/${selfId}`, { status: 'suspended' })).status, 403) + const suspended = await admin.patch(`/admin/users/${otherId}`, { status: 'suspended', reason: 'Repeated abuse' }) + assert.equal(suspended.status, 200) + assert.equal(suspended.body.data.status, 'suspended') + assert.equal(suspended.body.data.statusReason, 'Repeated abuse') + assert.equal((await other.get('/users/me')).status, 401) + assert.equal((await new Client(base).post('/auth/login', { email: 'omar@example.com', password: 'password1' })).status, 401) + assert.equal((await admin.patch(`/admin/users/${otherId}`, { status: 'active' })).body.data.status, 'active') + assert.equal((await other.get('/users/me')).status, 200) + }) + + test('deleting a user removes their submissions, cooldowns, and stored recording', async () => { + const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id + const recording = (await Submission.findOne({ user: otherId })).recording + assert.ok(await SubmissionCooldown.countDocuments({ user: otherId })) + assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 204) + assert.equal(await Submission.countDocuments({ user: otherId }), 0) + assert.equal(await SubmissionCooldown.countDocuments({ user: otherId }), 0) + await assert.rejects(() => storageService.describe(recording)) + assert.equal((await other.get('/users/me')).status, 401) + assert.equal((await admin.get('/admin/users')).body.data.some((user) => user.id === otherId), false) + assert.equal((await admin.get('/submissions')).body.data.length, 0) + assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 404) + }) + + test('device restrictions reject matching IDs, expose only a hint, and can be removed', async () => { + const deviceId = 'device-test-1234567890' + const deviceLogin = new Client(base) + await deviceLogin.post('/auth/login', { email: 'sara@example.com', password: 'password1' }, { headers: { 'x-device-id': deviceId } }) + const sara = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'sara@example.com') + assert.equal(sara.lastDeviceId, deviceId) + assert.equal((await deviceLogin.get('/auth/session')).body.data.lastDeviceId, undefined) + const made = await admin.post('/admin/restrictions', { type: 'device', value: deviceId, reason: 'Abuse' }) + assert.equal(made.status, 201) + assert.equal(made.body.data.identifierHint, 'device-t...7890') + assert.equal(made.body.data.identifierHash, undefined) + const blocked = new Client(base) + assert.equal((await blocked.get('/videos', { headers: { 'x-device-id': deviceId } })).status, 403) + const duplicate = await admin.post('/admin/restrictions', { type: 'device', value: deviceId }) + assert.equal(duplicate.status, 409) + assert.equal((await admin.delete(`/admin/restrictions/${made.body.data.id}`)).status, 204) + assert.equal((await blocked.get('/videos', { headers: { 'x-device-id': deviceId } })).status, 200) + }) + + test('IP restrictions block API traffic while admin routes remain available for recovery', async () => { + assert.equal((await admin.post('/admin/restrictions', { type: 'ip', value: 'not-an-ip' })).status, 422) + const made = await admin.post('/admin/restrictions', { type: 'ip', value: '::ffff:127.0.0.1' }) + assert.equal(made.status, 201) + assert.equal((await new Client(base).get('/videos')).status, 403) + assert.equal((await admin.get('/admin/restrictions')).status, 200) + assert.equal((await admin.delete(`/admin/restrictions/${made.body.data.id}`)).status, 204) + assert.equal((await new Client(base).get('/videos')).status, 200) + }) +}) diff --git a/backend/tests/api-auth.test.js b/backend/tests/api-auth.test.js new file mode 100644 index 0000000..fa59b5e --- /dev/null +++ b/backend/tests/api-auth.test.js @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) + +describe('auth', () => { + test('signup validates every field', async () => { + const res = await new Client(base).post('/auth/signup', {}) + assert.equal(res.status, 422) + assert.equal(res.body.error.code, 'VALIDATION_ERROR') + for (const field of ['firstName', 'surname', 'email', 'password']) assert.ok(res.body.error.fields[field], `missing error for ${field}`) + const mismatch = await new Client(base).post('/auth/signup', { ...contributorData, email: 'x@example.com', confirmPassword: 'different' }) + assert.equal(mismatch.body.error.fields.confirmPassword, 'Passwords do not match.') + const short = await new Client(base).post('/auth/signup', { ...contributorData, email: 'x@example.com', password: 'short', confirmPassword: undefined }) + assert.equal(short.body.error.fields.password, 'Use at least 8 characters.') + }) + + test('signup sets an httpOnly cookie and never leaks the hash or accepts a role', async () => { + const c = new Client(base) + eveSession = c + const res = await c.post('/auth/signup', { firstName: 'Eve', surname: 'Hacker', email: 'eve@example.com', password: 'password1', role: 'admin' }) + assert.equal(res.status, 201) + assert.equal(res.body.data.email, 'eve@example.com') + assert.equal(res.body.data.verificationEmailSent, true) + assert.equal(res.setCookie.length, 0) + assert.equal((await c.post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401) + assert.equal((await c.post('/auth/verify-email', { email: 'eve@example.com', code: '000000' })).status, 400) + const verified = await c.post('/auth/verify-email', { email: 'eve@example.com', code: deliveredCodes.get('eve@example.com:email-verification') }) + assert.equal(verified.status, 200) + assert.equal(verified.body.data.role, 'user') + assert.equal(verified.body.data.emailVerified, true) + assert.equal(res.body.data.passwordHash, undefined) + assert.match(verified.setCookie[0], /HttpOnly/i) + assert.match(verified.setCookie[0], /SameSite=Lax/i) + assert.equal((await c.get('/admin/stats')).status, 403) + }) + + test('password reset codes are one-time and replace the old password', async () => { + const resetRequested = await new Client(base).post('/auth/forgot-password', { email: 'eve@example.com' }) + const unknownRequested = await new Client(base).post('/auth/forgot-password', { email: 'nobody@example.com' }) + assert.equal(resetRequested.status, 200) + assert.deepEqual(resetRequested.body, unknownRequested.body) + const code = deliveredCodes.get('eve@example.com:password-reset') + const wrong = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code: '000000', password: 'password2', confirmPassword: 'password2' }) + assert.equal(wrong.status, 400) + const reset = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code, password: 'password2', confirmPassword: 'password2' }) + assert.equal(reset.status, 200) + assert.equal((await new Client(base).post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401) + assert.equal((await new Client(base).post('/auth/login', { email: 'eve@example.com', password: 'password2' })).status, 200) + assert.equal((await eveSession.get('/users/me')).status, 401) + const reused = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code, password: 'password3', confirmPassword: 'password3' }) + assert.equal(reused.status, 400) + }) + + test('duplicate emails conflict, case-insensitively', async () => { + const res = await new Client(base).post('/auth/signup', { ...contributorData, email: 'SARA@example.com' }) + assert.equal(res.status, 409) + assert.ok(res.body.error.fields.email) + }) + + test('login: wrong password and unknown email give the same answer', async () => { + const wrong = await new Client(base).post('/auth/login', { email: 'sara@example.com', password: 'nope-nope' }) + const unknown = await new Client(base).post('/auth/login', { email: 'ghost@example.com', password: 'nope-nope' }) + assert.equal(wrong.status, 401) + assert.equal(unknown.status, 401) + assert.equal(wrong.body.error.message, unknown.body.error.message) + }) + + test('session, logout and tampered cookies', async () => { + const c = new Client(base) + assert.equal((await c.get('/auth/session')).body.data, null) + assert.equal((await c.post('/auth/login', { email: 'SARA@example.com', password: 'password1' })).status, 200) + assert.equal((await c.get('/auth/session')).body.data.firstName, 'Sara') + assert.equal((await c.post('/auth/logout')).status, 204) + assert.equal((await c.get('/auth/session')).body.data, null) + c.cookies.set('signpak_token', 'not.a.jwt') + assert.equal((await c.get('/users/me')).status, 401) + }) + + test('the env admin exists once, has the admin role, and re-running is harmless', async () => { + await authService.ensureAdminFromEnv() + const users = (await admin.get('/admin/users')).body.data + assert.equal(users.filter((u) => u.email === 'admin@signpak.test').length, 1) + assert.equal(users.find((u) => u.email === 'admin@signpak.test').role, 'admin') + }) + + test('admin-only routes reject contributors and visitors', async () => { + assert.equal((await contributor.get('/admin/users')).status, 403) + assert.equal((await new Client(base).get('/admin/users')).status, 401) + }) +}) diff --git a/backend/tests/api-basics.test.js b/backend/tests/api-basics.test.js new file mode 100644 index 0000000..93d5854 --- /dev/null +++ b/backend/tests/api-basics.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) + +describe('basics', () => { + test('root advertises the API and favicon is ignored', async () => { + const origin = new URL(base).origin + const root = await new Client(origin).get('/') + assert.equal(root.status, 200) + assert.equal(root.body.data.api, '/api/v1') + assert.equal(root.body.data.docs, '/docs') + assert.equal((await new Client(origin).get('/favicon.ico')).status, 204) + assert.equal((await new Client(origin).get('/docs')).status, 200) + assert.equal((await new Client(origin).get('/api-docs')).status, 200) + assert.equal((await new Client(origin).get('/docs.json')).body.openapi, '3.0.3') + assert.equal((await new Client(origin).get('/api-docs.json')).body.openapi, '3.0.3') + }) + + test('health check reports the database', async () => { + const res = await new Client(base).get('/health') + assert.equal(res.status, 200) + assert.deepEqual(res.body.data.database, 'connected') + }) + + test('unknown routes and bad JSON use the error shape', async () => { + const c = new Client(base) + const missing = await c.get('/nope') + assert.equal(missing.status, 404) + assert.equal(missing.body.error.code, 'NOT_FOUND') + const bad = await c.request('POST', '/auth/login', { headers: { 'content-type': 'application/json' }, form: '{oops' }) + assert.equal(bad.status, 400) + assert.equal(bad.body.error.code, 'BAD_REQUEST') + }) + + test('cross-origin writes are refused, allowed origin passes', async () => { + const c = new Client(base) + const evil = await c.request('POST', '/auth/logout', { headers: { origin: 'https://evil.example' } }) + assert.equal(evil.status, 403) + const fine = await c.request('POST', '/auth/logout', { headers: { origin: 'http://localhost:5173' } }) + assert.equal(fine.status, 204) + }) +}) diff --git a/backend/tests/api-categories.test.js b/backend/tests/api-categories.test.js new file mode 100644 index 0000000..c45d1e8 --- /dev/null +++ b/backend/tests/api-categories.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) + +describe('categories', () => { + test('only admins write; names are unique regardless of case', async () => { + assert.equal((await contributor.post('/categories', { label: 'Nope' })).status, 403) + const made = await admin.post('/categories', { label: 'Daily phrases', copy: 'Everyday.', tone: 'yellow' }) + assert.equal(made.status, 201) + daily = made.body.data + assert.equal(daily.labelKey, undefined) + work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data + const dup = await admin.post('/categories', { label: 'daily PHRASES' }) + assert.equal(dup.status, 409) + assert.ok(dup.body.error.fields.label) + assert.equal((await admin.post('/categories', { label: 'X', tone: 'purple' })).status, 422) + }) + + test('update and public listing', async () => { + const res = await admin.patch(`/categories/${work.id}`, { copy: 'Meetings.' }) + assert.equal(res.body.data.copy, 'Meetings.') + assert.equal((await admin.patch(`/categories/${work.id}`, { label: 'Daily Phrases' })).status, 409) + assert.equal((await admin.patch(`/categories/${work.id}`, {})).status, 422) + const list = await new Client(base).get('/categories') + assert.equal(list.body.data.length, 2) + assert.equal((await admin.patch('/categories/not-an-id', { copy: 'x' })).status, 422) + }) +}) diff --git a/backend/tests/api-demo-video.test.js b/backend/tests/api-demo-video.test.js new file mode 100644 index 0000000..22d360c --- /dev/null +++ b/backend/tests/api-demo-video.test.js @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) + +describe('demo video', () => { + test('admins can upload, replace, and delete the public demo video', async () => { + const visitor = new Client(base) + assert.equal((await visitor.get('/demo-video')).body.data, null) + assert.equal((await contributor.upload('/demo-video', videoForm({ title: 'Not allowed' }))).status, 403) + + const first = await admin.upload('/demo-video', videoForm({ title: 'How to sign', durationSec: '8' })) + assert.equal(first.status, 201, JSON.stringify(first.body)) + assert.equal(first.body.data.title, 'How to sign') + assert.equal(first.body.data.videoUrl, '/api/v1/demo-video/file') + const firstFile = await visitor.get('/demo-video/file', { raw: true }) + assert.equal(firstFile.status, 200) + assert.deepEqual(firstFile.body, webm()) + assert.equal((await visitor.get('/videos')).body.data.some((video) => video.title === 'How to sign'), false) + + const replacement = await admin.upload('/demo-video', videoForm({ title: 'A new walkthrough' }, { video: mp4() })) + assert.equal(replacement.status, 201) + assert.equal(replacement.body.data.title, 'A new walkthrough') + assert.equal(filesIn('demo').length, 1) + const replacementFile = await visitor.get('/demo-video/file', { raw: true }) + assert.equal(replacementFile.headers.get('content-type'), 'video/mp4') + assert.deepEqual(replacementFile.body, mp4()) + + assert.equal((await admin.delete('/demo-video')).status, 204) + assert.equal((await visitor.get('/demo-video')).body.data, null) + assert.equal((await visitor.get('/demo-video/file')).status, 404) + assert.deepEqual(filesIn('demo'), []) + }) +}) diff --git a/backend/tests/api-profile-connections.test.js b/backend/tests/api-profile-connections.test.js new file mode 100644 index 0000000..93e4abb --- /dev/null +++ b/backend/tests/api-profile-connections.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) + +describe('profile connections', () => { + test('normalises, validates, and disconnects', async () => { + const ok = await contributor.patch('/users/me', { connections: { github: 'https://github.com/octocat', linkedin: 'linkedin.com/in/sara-ahmed' } }) + assert.equal(ok.status, 200) + assert.equal(ok.body.data.connections.github, 'octocat') + assert.equal(ok.body.data.connections.linkedin, 'https://www.linkedin.com/in/sara-ahmed') + const bad = await contributor.patch('/users/me', { connections: { github: 'not a user!!' } }) + assert.equal(bad.status, 422) + assert.ok(bad.body.error.fields['connections.github']) + const off = await contributor.patch('/users/me', { connections: { github: null } }) + assert.equal(off.body.data.connections.github, null) + assert.equal(off.body.data.connections.linkedin, 'https://www.linkedin.com/in/sara-ahmed') // untouched + assert.equal((await new Client(base).patch('/users/me', { connections: {} })).status, 401) + }) +}) diff --git a/backend/tests/api-submissions.test.js b/backend/tests/api-submissions.test.js new file mode 100644 index 0000000..caf49eb --- /dev/null +++ b/backend/tests/api-submissions.test.js @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) +before(async () => { + const context = getContext() + const admin = context.admin + daily = (await admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data + work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data + const makeVideo = async (title, categoryId, status = 'published') => { + const result = await admin.upload('/videos', videoForm({ title, categoryId, status, durationSec: '3' })) + assert.equal(result.status, 201, JSON.stringify(result.body)) + return result.body.data + } + video1 = await makeVideo('First sign', daily.id) + video2 = await makeVideo('Second sign', daily.id) + draft = await makeVideo('Secret draft', daily.id, 'draft') +}) +describe('submissions and the cooldown rule', () => { + const good = () => ({ videoId: video1.id, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' }) + + test('archive and database failures release cooldown and remove any archived file', async () => { + const originalArchive = storageService.archive + const originalCreate = submissionRepo.create + const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id + const cooldownFilter = { user: otherId, video: video1.id } + let failedRecording + try { + storageService.archive = async () => { throw new Error('archive unavailable') } + const archiveFailure = await other.upload('/submissions', recordingForm(good())) + assert.equal(archiveFailure.status, 500) + assert.equal(await SubmissionCooldown.countDocuments(cooldownFilter), 0) + + storageService.archive = originalArchive + submissionRepo.create = async (data) => { + failedRecording = data.recording + throw new Error('database unavailable') + } + const databaseFailure = await other.upload('/submissions', recordingForm(good())) + assert.equal(databaseFailure.status, 500) + assert.equal(await SubmissionCooldown.countDocuments(cooldownFilter), 0) + await assert.rejects(() => storageService.describe(failedRecording)) + assert.deepEqual(filesIn('tmp'), []) + } finally { + storageService.archive = originalArchive + submissionRepo.create = originalCreate + } + }) + + test('input is checked before anything is stored', async () => { + const before = filesIn('recordings').length + assert.equal((await new Client(base).upload('/submissions', recordingForm(good()))).status, 401) + assert.equal((await contributor.upload('/submissions', recordingForm(good(), { file: null }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm(good(), { file: Buffer.from('plain text pretending to be video') }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), trimStart: '2', trimEnd: '1' }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), trimEnd: '30' }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), videoId: 'nope' }))).status, 422) + assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), videoId: draft.id }))).status, 404) + assert.equal(filesIn('recordings').length, before) + assert.deepEqual(filesIn('tmp'), []) + }) + + test('a valid submission is stored and listed', async () => { + const res = await contributor.upload('/submissions', recordingForm(good())) + assert.equal(res.status, 201, JSON.stringify(res.body)) + submission = res.body.data + assert.equal(submission.videoId, video1.id) + assert.equal(submission.mirrored, true) + assert.equal(submission.trimEnd, 2.5) + assert.equal(submission.recording, undefined) + assert.equal(filesIn('commons').length, 1) + const mine = await contributor.get('/submissions') + assert.equal(mine.body.data.length, 1) + assert.equal((await other.get('/submissions')).body.data.length, 0) + assert.equal((await admin.get('/submissions')).body.data.length, 1) + }) + + test('COOLDOWN: a second submission for the same video, too soon, is refused and stores nothing', async () => { + const again = await contributor.upload('/submissions', recordingForm(good(), { file: mp4() })) + assert.equal(again.status, 409) + assert.match(again.body.error.message, /wait \d+s/i) + assert.equal(filesIn('commons').length, 1) + assert.deepEqual(filesIn('tmp'), []) + }) + + test('COOLDOWN: concurrent double-submits for the same video produce exactly one submission', async () => { + const results = await Promise.all([1, 2, 3].map(() => other.upload('/submissions', recordingForm({ ...good(), videoId: video2.id })))) + assert.deepEqual(results.map((r) => r.status).sort(), [201, 409, 409]) + assert.equal(filesIn('commons').length, 2, 'losing uploads must not leave orphaned archive folders') + assert.equal((await other.get('/submissions')).body.data.length, 1) + }) + + test('COOLDOWN: once it elapses, the same video can be submitted again, as a new, separate recording', async () => { + await new Promise((resolve) => setTimeout(resolve, Number(process.env.SUBMISSION_COOLDOWN_MS) + 20)) + const res = await contributor.upload('/submissions', recordingForm(good(), { file: mp4() })) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.notEqual(res.body.data.id, submission.id) + const mine = await contributor.get('/submissions') + assert.equal(mine.body.data.length, 2, 'both recordings for this video are kept, not merged or replaced') + }) + + test('a submission can never be edited, deleted, or read once archived', async () => { + assert.equal((await contributor.patch(`/submissions/${submission.id}`, { trimEnd: 1 })).status, 404) + assert.equal((await contributor.delete(`/submissions/${submission.id}`)).status, 404) + assert.equal((await contributor.get(`/submissions/${submission.id}/recording`)).status, 404) + assert.equal((await admin.get(`/submissions/${submission.id}/recording`)).status, 404) + }) + + test('deleting a base video keeps contributors\' submissions on record', async () => { + const before = filesIn('videos').length + assert.equal((await admin.delete(`/videos/${video2.id}`)).status, 204) + assert.equal(filesIn('videos').length, before - 1, 'the base video file is removed from storage') + assert.equal((await admin.get(`/videos/${video2.id}`)).status, 404) + assert.equal((await other.get('/submissions')).body.data.length, 1) + }) +}) diff --git a/backend/tests/api-videos.test.js b/backend/tests/api-videos.test.js new file mode 100644 index 0000000..ccbdd07 --- /dev/null +++ b/backend/tests/api-videos.test.js @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { before, describe, test } from 'node:test' +import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' +import { useApiContext } from './apiContext.js' + +const getContext = useApiContext() +let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries +let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo +let daily, work, video1, video2, draft, submission, eveSession + +before(() => { + const context = getContext() + ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) +}) +before(async () => { + const context = getContext() + daily = (await context.admin.post('/categories', { label: 'Daily phrases', copy: 'Everyday.', tone: 'yellow' })).body.data + work = (await context.admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data +}) +describe('videos', () => { + test('upload needs a real video file', async () => { + const none = await admin.upload('/videos', videoForm({ title: 'No file' }, { video: null })) + assert.equal(none.status, 422) + assert.ok(none.body.error.fields.video) + const fake = await admin.upload('/videos', videoForm({ title: 'Fake' }, { video: Buffer.from('this is just text, not a video at all') })) + assert.equal(fake.status, 422) + assert.match(fake.body.error.fields.video, /MP4, WebM or MOV/) + const noTitle = await admin.upload('/videos', videoForm({ level: 'Beginner' })) + assert.equal(noTitle.status, 422) + assert.ok(noTitle.body.error.fields.title) + const missingCategory = await admin.upload('/videos', videoForm({ title: 'Ghost cat', categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })) + assert.equal(missingCategory.status, 422) + assert.equal((await contributor.upload('/videos', videoForm({ title: 'Nope' }))).status, 403) + assert.equal((await new Client(base).upload('/videos', videoForm({ title: 'Nope' }))).status, 401) + assert.deepEqual(filesIn('videos'), [], 'rejected uploads must not leave files behind') + assert.deepEqual(filesIn('tmp'), [], 'rejected uploads must not leave temp files behind') + }) + + test('admin uploads with and without a poster; the API hides storage internals', async () => { + const a = await admin.upload('/videos', videoForm({ title: 'Nice to meet you', categoryId: daily.id, level: 'Beginner', durationSec: '134' }, { poster: jpeg() })) + assert.equal(a.status, 201, JSON.stringify(a.body)) + video1 = a.body.data + assert.equal(video1.categoryId, daily.id) + assert.equal(video1.order, 1) + assert.equal(video1.durationSec, 134) + assert.equal(video1.videoUrl, `/api/v1/videos/${video1.id}/file`) + assert.equal(video1.poster, `/api/v1/videos/${video1.id}/poster`) + assert.equal(video1.videoFile, undefined) + assert.equal(video1.size, webm().length) + const b = await admin.upload('/videos', videoForm({ title: 'A warm introduction', categoryId: daily.id }, { video: mp4() })) + video2 = b.body.data + assert.equal(video2.order, 2) + assert.equal(video2.poster, null) + assert.equal(video2.status, 'published') + draft = (await admin.upload('/videos', videoForm({ title: 'Secret draft', categoryId: daily.id, status: 'draft' }))).body.data + const loose = (await admin.upload('/videos', videoForm({ title: 'Unassigned', categoryId: '' }))).body.data + assert.equal(loose.categoryId, null) + const storedVideos = filesIn('videos') + assert.equal(storedVideos.filter((file) => !file.includes('(poster).')).length, 4) + assert.equal(storedVideos.filter((file) => file.includes('(poster).')).length, 1, 'the poster is stored alongside its video') + assert.deepEqual(filesIn('tmp'), []) + }) + + test('visibility: contributors and visitors only see published, categorised videos', async () => { + const titles = (res) => res.body.data.map((v) => v.title).sort() + assert.deepEqual(titles(await admin.get('/videos')), ['A warm introduction', 'Nice to meet you', 'Secret draft', 'Unassigned']) + assert.deepEqual(titles(await contributor.get('/videos')), ['A warm introduction', 'Nice to meet you']) + assert.deepEqual(titles(await new Client(base).get('/videos')), ['A warm introduction', 'Nice to meet you']) + assert.equal((await contributor.get(`/videos/${draft.id}`)).status, 404) + assert.equal((await admin.get(`/videos/${draft.id}`)).status, 200) + }) + + test('published files stream publicly with Range support', async () => { + const full = await new Client(base).get(`/videos/${video1.id}/file`, { raw: true }) + assert.equal(full.status, 200) + assert.equal(full.headers.get('accept-ranges'), 'bytes') + assert.equal(full.headers.get('content-type'), 'video/webm') + assert.deepEqual(full.body, webm()) + const part = await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=10-19' } }) + assert.equal(part.status, 206) + assert.equal(part.headers.get('content-range'), `bytes 10-19/${webm().length}`) + assert.deepEqual(part.body, webm().subarray(10, 20)) + const tail = await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=-5' } }) + assert.deepEqual(tail.body, webm().subarray(-5)) + assert.equal((await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=99999-' } })).status, 416) + assert.equal((await contributor.get(`/videos/${draft.id}/file`)).status, 404) + const mp4res = await contributor.get(`/videos/${video2.id}/file`, { raw: true }) + assert.equal(mp4res.headers.get('content-type'), 'video/mp4') + }) + + test('posters are served as images', async () => { + const res = await new Client(base).get(`/videos/${video1.id}/poster`, { raw: true }) + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'image/jpeg') + assert.equal((await new Client(base).get(`/videos/${video2.id}/poster`)).status, 404) + }) + + test('editing: moving categories appends to the end of that path', async () => { + const moved = await admin.patch(`/videos/${video2.id}`, { categoryId: work.id }) + assert.equal(moved.body.data.categoryId, work.id) + assert.equal(moved.body.data.order, 1) + const back = await admin.patch(`/videos/${video2.id}`, { categoryId: daily.id }) + assert.ok(back.body.data.order > draft.order, 'moved video goes after everything already in the path') + assert.equal((await admin.patch(`/videos/${video2.id}`, { status: 'archived' })).status, 422) + assert.equal((await admin.patch(`/videos/${video2.id}`, { categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })).status, 422) + const unpublished = await admin.patch(`/videos/${draft.id}`, { title: 'Still a draft' }) + assert.equal(unpublished.body.data.title, 'Still a draft') + assert.equal((await contributor.patch(`/videos/${video2.id}`, { title: 'hax' })).status, 403) + }) +}) diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js deleted file mode 100644 index d0e9bbf..0000000 --- a/backend/tests/api.test.js +++ /dev/null @@ -1,579 +0,0 @@ -import assert from 'node:assert/strict' -import { after, before, describe, test } from 'node:test' -import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' - -const { default: app } = await import('../src/app.js') -const { connectDb, disconnectDb } = await import('../src/config/db.js') -const { authService } = await import('../src/services/authService.js') -const { contactService } = await import('../src/services/contactService.js') -const deliveredCodes = new Map() -const { ContactDailyQuota } = await import('../src/models/ContactDailyQuota.js') -const { storageService } = await import('../src/services/storage/index.js') -const { submissionRepo } = await import('../src/repositories/submissionRepo.js') -const { Submission } = await import('../src/models/Submission.js') -const { SubmissionCooldown } = await import('../src/models/SubmissionCooldown.js') -const { default: mongoose } = await import('mongoose') - -let server, memory, base, admin, contributor, other -let eveSession -const contactDeliveries = [] -const asJson = (res) => res.body -const contributorData = { firstName: 'Sara', surname: 'Ahmed', email: 'Sara@Example.com', password: 'password1', confirmPassword: 'password1' } - -before(async () => { - authService.sendOTPEmail = async (email, code, purpose) => deliveredCodes.set(`${email.toLowerCase()}:${purpose}`, code) - contactService.deliver = async (payload) => { - if (payload.message === 'Provider failure') throw new Error('provider unavailable') - contactDeliveries.push(payload) - } - let uri = process.env.MONGODB_URI_TEST - if (!uri) { - const { MongoMemoryServer } = await import('mongodb-memory-server') - memory = await MongoMemoryServer.create() - uri = memory.getUri('signpak_test') - } - await connectDb(uri) - await mongoose.connection.dropDatabase() - await Promise.all(Object.values(mongoose.models).map((model) => model.init())) - await storageService.init() - await authService.ensureAdminFromEnv() - server = app.listen(0) - base = `http://127.0.0.1:${server.address().port}/api/v1` - - admin = new Client(base) - assert.equal((await admin.post('/auth/login', { email: 'admin@signpak.test', password: 'Admin@12345' })).status, 200) - contributor = new Client(base) - assert.equal((await contributor.post('/auth/signup', contributorData)).status, 201) - assert.equal((await contributor.get('/users/me')).status, 401) - assert.equal((await contributor.post('/auth/verify-email', { email: contributorData.email, code: deliveredCodes.get('sara@example.com:email-verification') })).status, 200) - other = new Client(base) - await other.post('/auth/signup', { firstName: 'Omar', surname: 'Khan', email: 'omar@example.com', password: 'password1' }) - await other.post('/auth/verify-email', { email: 'omar@example.com', code: deliveredCodes.get('omar@example.com:email-verification') }) -}) - -after(async () => { - server?.close() - await mongoose.connection.dropDatabase().catch(() => {}) - await disconnectDb() - await memory?.stop() -}) - -describe('basics', () => { - test('root advertises the API and favicon is ignored', async () => { - const origin = new URL(base).origin - const root = await new Client(origin).get('/') - assert.equal(root.status, 200) - assert.equal(root.body.data.api, '/api/v1') - assert.equal(root.body.data.docs, '/docs') - assert.equal((await new Client(origin).get('/favicon.ico')).status, 204) - assert.equal((await new Client(origin).get('/docs')).status, 200) - assert.equal((await new Client(origin).get('/api-docs')).status, 200) - assert.equal((await new Client(origin).get('/docs.json')).body.openapi, '3.0.3') - assert.equal((await new Client(origin).get('/api-docs.json')).body.openapi, '3.0.3') - }) - - test('health check reports the database', async () => { - const res = await new Client(base).get('/health') - assert.equal(res.status, 200) - assert.deepEqual(res.body.data.database, 'connected') - }) - - test('unknown routes and bad JSON use the error shape', async () => { - const c = new Client(base) - const missing = await c.get('/nope') - assert.equal(missing.status, 404) - assert.equal(missing.body.error.code, 'NOT_FOUND') - const bad = await c.request('POST', '/auth/login', { headers: { 'content-type': 'application/json' }, form: '{oops' }) - assert.equal(bad.status, 400) - assert.equal(bad.body.error.code, 'BAD_REQUEST') - }) - - test('cross-origin writes are refused, allowed origin passes', async () => { - const c = new Client(base) - const evil = await c.request('POST', '/auth/logout', { headers: { origin: 'https://evil.example' } }) - assert.equal(evil.status, 403) - const fine = await c.request('POST', '/auth/logout', { headers: { origin: 'http://localhost:5173' } }) - assert.equal(fine.status, 204) - }) -}) - -describe('demo video', () => { - test('admins can upload, replace, and delete the public demo video', async () => { - const visitor = new Client(base) - assert.equal((await visitor.get('/demo-video')).body.data, null) - assert.equal((await contributor.upload('/demo-video', videoForm({ title: 'Not allowed' }))).status, 403) - - const first = await admin.upload('/demo-video', videoForm({ title: 'How to sign', durationSec: '8' })) - assert.equal(first.status, 201, JSON.stringify(first.body)) - assert.equal(first.body.data.title, 'How to sign') - assert.equal(first.body.data.videoUrl, '/api/v1/demo-video/file') - const firstFile = await visitor.get('/demo-video/file', { raw: true }) - assert.equal(firstFile.status, 200) - assert.deepEqual(firstFile.body, webm()) - assert.equal((await visitor.get('/videos')).body.data.some((video) => video.title === 'How to sign'), false) - - const replacement = await admin.upload('/demo-video', videoForm({ title: 'A new walkthrough' }, { video: mp4() })) - assert.equal(replacement.status, 201) - assert.equal(replacement.body.data.title, 'A new walkthrough') - assert.equal(filesIn('demo').length, 1) - const replacementFile = await visitor.get('/demo-video/file', { raw: true }) - assert.equal(replacementFile.headers.get('content-type'), 'video/mp4') - assert.deepEqual(replacementFile.body, mp4()) - - assert.equal((await admin.delete('/demo-video')).status, 204) - assert.equal((await visitor.get('/demo-video')).body.data, null) - assert.equal((await visitor.get('/demo-video/file')).status, 404) - assert.deepEqual(filesIn('demo'), []) - }) -}) - -describe('auth', () => { - test('signup validates every field', async () => { - const res = await new Client(base).post('/auth/signup', {}) - assert.equal(res.status, 422) - assert.equal(res.body.error.code, 'VALIDATION_ERROR') - for (const field of ['firstName', 'surname', 'email', 'password']) assert.ok(res.body.error.fields[field], `missing error for ${field}`) - const mismatch = await new Client(base).post('/auth/signup', { ...contributorData, email: 'x@example.com', confirmPassword: 'different' }) - assert.equal(mismatch.body.error.fields.confirmPassword, 'Passwords do not match.') - const short = await new Client(base).post('/auth/signup', { ...contributorData, email: 'x@example.com', password: 'short', confirmPassword: undefined }) - assert.equal(short.body.error.fields.password, 'Use at least 8 characters.') - }) - - test('signup sets an httpOnly cookie and never leaks the hash or accepts a role', async () => { - const c = new Client(base) - eveSession = c - const res = await c.post('/auth/signup', { firstName: 'Eve', surname: 'Hacker', email: 'eve@example.com', password: 'password1', role: 'admin' }) - assert.equal(res.status, 201) - assert.equal(res.body.data.email, 'eve@example.com') - assert.equal(res.body.data.verificationEmailSent, true) - assert.equal(res.setCookie.length, 0) - assert.equal((await c.post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401) - assert.equal((await c.post('/auth/verify-email', { email: 'eve@example.com', code: '000000' })).status, 400) - const verified = await c.post('/auth/verify-email', { email: 'eve@example.com', code: deliveredCodes.get('eve@example.com:email-verification') }) - assert.equal(verified.status, 200) - assert.equal(verified.body.data.role, 'user') - assert.equal(verified.body.data.emailVerified, true) - assert.equal(res.body.data.passwordHash, undefined) - assert.match(verified.setCookie[0], /HttpOnly/i) - assert.match(verified.setCookie[0], /SameSite=Lax/i) - assert.equal((await c.get('/admin/stats')).status, 403) - }) - - test('password reset codes are one-time and replace the old password', async () => { - const resetRequested = await new Client(base).post('/auth/forgot-password', { email: 'eve@example.com' }) - const unknownRequested = await new Client(base).post('/auth/forgot-password', { email: 'nobody@example.com' }) - assert.equal(resetRequested.status, 200) - assert.deepEqual(resetRequested.body, unknownRequested.body) - const code = deliveredCodes.get('eve@example.com:password-reset') - const wrong = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code: '000000', password: 'password2', confirmPassword: 'password2' }) - assert.equal(wrong.status, 400) - const reset = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code, password: 'password2', confirmPassword: 'password2' }) - assert.equal(reset.status, 200) - assert.equal((await new Client(base).post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401) - assert.equal((await new Client(base).post('/auth/login', { email: 'eve@example.com', password: 'password2' })).status, 200) - assert.equal((await eveSession.get('/users/me')).status, 401) - const reused = await new Client(base).post('/auth/reset-password', { email: 'eve@example.com', code, password: 'password3', confirmPassword: 'password3' }) - assert.equal(reused.status, 400) - }) - - test('duplicate emails conflict, case-insensitively', async () => { - const res = await new Client(base).post('/auth/signup', { ...contributorData, email: 'SARA@example.com' }) - assert.equal(res.status, 409) - assert.ok(res.body.error.fields.email) - }) - - test('login: wrong password and unknown email give the same answer', async () => { - const wrong = await new Client(base).post('/auth/login', { email: 'sara@example.com', password: 'nope-nope' }) - const unknown = await new Client(base).post('/auth/login', { email: 'ghost@example.com', password: 'nope-nope' }) - assert.equal(wrong.status, 401) - assert.equal(unknown.status, 401) - assert.equal(wrong.body.error.message, unknown.body.error.message) - }) - - test('session, logout and tampered cookies', async () => { - const c = new Client(base) - assert.equal((await c.get('/auth/session')).body.data, null) - assert.equal((await c.post('/auth/login', { email: 'SARA@example.com', password: 'password1' })).status, 200) - assert.equal((await c.get('/auth/session')).body.data.firstName, 'Sara') - assert.equal((await c.post('/auth/logout')).status, 204) - assert.equal((await c.get('/auth/session')).body.data, null) - c.cookies.set('signpak_token', 'not.a.jwt') - assert.equal((await c.get('/users/me')).status, 401) - }) - - test('the env admin exists once, has the admin role, and re-running is harmless', async () => { - await authService.ensureAdminFromEnv() - const users = (await admin.get('/admin/users')).body.data - assert.equal(users.filter((u) => u.email === 'admin@signpak.test').length, 1) - assert.equal(users.find((u) => u.email === 'admin@signpak.test').role, 'admin') - }) - - test('admin-only routes reject contributors and visitors', async () => { - assert.equal((await contributor.get('/admin/users')).status, 403) - assert.equal((await new Client(base).get('/admin/users')).status, 401) - }) -}) - -describe('profile connections', () => { - test('normalises, validates, and disconnects', async () => { - const ok = await contributor.patch('/users/me', { connections: { github: 'https://github.com/octocat', linkedin: 'linkedin.com/in/sara-ahmed' } }) - assert.equal(ok.status, 200) - assert.equal(ok.body.data.connections.github, 'octocat') - assert.equal(ok.body.data.connections.linkedin, 'https://www.linkedin.com/in/sara-ahmed') - const bad = await contributor.patch('/users/me', { connections: { github: 'not a user!!' } }) - assert.equal(bad.status, 422) - assert.ok(bad.body.error.fields['connections.github']) - const off = await contributor.patch('/users/me', { connections: { github: null } }) - assert.equal(off.body.data.connections.github, null) - assert.equal(off.body.data.connections.linkedin, 'https://www.linkedin.com/in/sara-ahmed') // untouched - assert.equal((await new Client(base).patch('/users/me', { connections: {} })).status, 401) - }) -}) - -let daily, work, video1, video2, draft - -describe('categories', () => { - test('only admins write; names are unique regardless of case', async () => { - assert.equal((await contributor.post('/categories', { label: 'Nope' })).status, 403) - const made = await admin.post('/categories', { label: 'Daily phrases', copy: 'Everyday.', tone: 'yellow' }) - assert.equal(made.status, 201) - daily = made.body.data - assert.equal(daily.labelKey, undefined) - work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data - const dup = await admin.post('/categories', { label: 'daily PHRASES' }) - assert.equal(dup.status, 409) - assert.ok(dup.body.error.fields.label) - assert.equal((await admin.post('/categories', { label: 'X', tone: 'purple' })).status, 422) - }) - - test('update and public listing', async () => { - const res = await admin.patch(`/categories/${work.id}`, { copy: 'Meetings.' }) - assert.equal(res.body.data.copy, 'Meetings.') - assert.equal((await admin.patch(`/categories/${work.id}`, { label: 'Daily Phrases' })).status, 409) - assert.equal((await admin.patch(`/categories/${work.id}`, {})).status, 422) - const list = await new Client(base).get('/categories') - assert.equal(list.body.data.length, 2) - assert.equal((await admin.patch('/categories/not-an-id', { copy: 'x' })).status, 422) - }) -}) - -describe('videos', () => { - test('upload needs a real video file', async () => { - const none = await admin.upload('/videos', videoForm({ title: 'No file' }, { video: null })) - assert.equal(none.status, 422) - assert.ok(none.body.error.fields.video) - const fake = await admin.upload('/videos', videoForm({ title: 'Fake' }, { video: Buffer.from('this is just text, not a video at all') })) - assert.equal(fake.status, 422) - assert.match(fake.body.error.fields.video, /MP4, WebM or MOV/) - const noTitle = await admin.upload('/videos', videoForm({ level: 'Beginner' })) - assert.equal(noTitle.status, 422) - assert.ok(noTitle.body.error.fields.title) - const missingCategory = await admin.upload('/videos', videoForm({ title: 'Ghost cat', categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })) - assert.equal(missingCategory.status, 422) - assert.equal((await contributor.upload('/videos', videoForm({ title: 'Nope' }))).status, 403) - assert.equal((await new Client(base).upload('/videos', videoForm({ title: 'Nope' }))).status, 401) - assert.deepEqual(filesIn('videos'), [], 'rejected uploads must not leave files behind') - assert.deepEqual(filesIn('tmp'), [], 'rejected uploads must not leave temp files behind') - }) - - test('admin uploads with and without a poster; the API hides storage internals', async () => { - const a = await admin.upload('/videos', videoForm({ title: 'Nice to meet you', categoryId: daily.id, level: 'Beginner', durationSec: '134' }, { poster: jpeg() })) - assert.equal(a.status, 201, JSON.stringify(a.body)) - video1 = a.body.data - assert.equal(video1.categoryId, daily.id) - assert.equal(video1.order, 1) - assert.equal(video1.durationSec, 134) - assert.equal(video1.videoUrl, `/api/v1/videos/${video1.id}/file`) - assert.equal(video1.poster, `/api/v1/videos/${video1.id}/poster`) - assert.equal(video1.videoFile, undefined) - assert.equal(video1.size, webm().length) - const b = await admin.upload('/videos', videoForm({ title: 'A warm introduction', categoryId: daily.id }, { video: mp4() })) - video2 = b.body.data - assert.equal(video2.order, 2) - assert.equal(video2.poster, null) - assert.equal(video2.status, 'published') - draft = (await admin.upload('/videos', videoForm({ title: 'Secret draft', categoryId: daily.id, status: 'draft' }))).body.data - const loose = (await admin.upload('/videos', videoForm({ title: 'Unassigned', categoryId: '' }))).body.data - assert.equal(loose.categoryId, null) - assert.equal(filesIn('videos').length, 4) - assert.equal(filesIn('posters').length, 1) - assert.deepEqual(filesIn('tmp'), []) - }) - - test('visibility: contributors and visitors only see published, categorised videos', async () => { - const titles = (res) => res.body.data.map((v) => v.title).sort() - assert.deepEqual(titles(await admin.get('/videos')), ['A warm introduction', 'Nice to meet you', 'Secret draft', 'Unassigned']) - assert.deepEqual(titles(await contributor.get('/videos')), ['A warm introduction', 'Nice to meet you']) - assert.deepEqual(titles(await new Client(base).get('/videos')), ['A warm introduction', 'Nice to meet you']) - assert.equal((await contributor.get(`/videos/${draft.id}`)).status, 404) - assert.equal((await admin.get(`/videos/${draft.id}`)).status, 200) - }) - - test('published files stream publicly with Range support', async () => { - const full = await new Client(base).get(`/videos/${video1.id}/file`, { raw: true }) - assert.equal(full.status, 200) - assert.equal(full.headers.get('accept-ranges'), 'bytes') - assert.equal(full.headers.get('content-type'), 'video/webm') - assert.deepEqual(full.body, webm()) - const part = await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=10-19' } }) - assert.equal(part.status, 206) - assert.equal(part.headers.get('content-range'), `bytes 10-19/${webm().length}`) - assert.deepEqual(part.body, webm().subarray(10, 20)) - const tail = await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=-5' } }) - assert.deepEqual(tail.body, webm().subarray(-5)) - assert.equal((await contributor.get(`/videos/${video1.id}/file`, { raw: true, headers: { range: 'bytes=99999-' } })).status, 416) - assert.equal((await contributor.get(`/videos/${draft.id}/file`)).status, 404) - const mp4res = await contributor.get(`/videos/${video2.id}/file`, { raw: true }) - assert.equal(mp4res.headers.get('content-type'), 'video/mp4') - }) - - test('posters are served as images', async () => { - const res = await new Client(base).get(`/videos/${video1.id}/poster`, { raw: true }) - assert.equal(res.status, 200) - assert.equal(res.headers.get('content-type'), 'image/jpeg') - assert.equal((await new Client(base).get(`/videos/${video2.id}/poster`)).status, 404) - }) - - test('editing: moving categories appends to the end of that path', async () => { - const moved = await admin.patch(`/videos/${video2.id}`, { categoryId: work.id }) - assert.equal(moved.body.data.categoryId, work.id) - assert.equal(moved.body.data.order, 1) - const back = await admin.patch(`/videos/${video2.id}`, { categoryId: daily.id }) - assert.ok(back.body.data.order > draft.order, 'moved video goes after everything already in the path') - assert.equal((await admin.patch(`/videos/${video2.id}`, { status: 'archived' })).status, 422) - assert.equal((await admin.patch(`/videos/${video2.id}`, { categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })).status, 422) - const unpublished = await admin.patch(`/videos/${draft.id}`, { title: 'Still a draft' }) - assert.equal(unpublished.body.data.title, 'Still a draft') - assert.equal((await contributor.patch(`/videos/${video2.id}`, { title: 'hax' })).status, 403) - }) -}) - -let submission - -describe('submissions and the cooldown rule', () => { - const good = () => ({ videoId: video1.id, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' }) - - test('archive and database failures release cooldown and remove any archived file', async () => { - const originalArchive = storageService.archive - const originalCreate = submissionRepo.create - const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id - const cooldownFilter = { user: otherId, video: video1.id } - let failedRecording - try { - storageService.archive = async () => { throw new Error('archive unavailable') } - const archiveFailure = await other.upload('/submissions', recordingForm(good())) - assert.equal(archiveFailure.status, 500) - assert.equal(await SubmissionCooldown.countDocuments(cooldownFilter), 0) - - storageService.archive = originalArchive - submissionRepo.create = async (data) => { - failedRecording = data.recording - throw new Error('database unavailable') - } - const databaseFailure = await other.upload('/submissions', recordingForm(good())) - assert.equal(databaseFailure.status, 500) - assert.equal(await SubmissionCooldown.countDocuments(cooldownFilter), 0) - await assert.rejects(() => storageService.describe(failedRecording)) - assert.deepEqual(filesIn('tmp'), []) - } finally { - storageService.archive = originalArchive - submissionRepo.create = originalCreate - } - }) - - test('input is checked before anything is stored', async () => { - const before = filesIn('recordings').length - assert.equal((await new Client(base).upload('/submissions', recordingForm(good()))).status, 401) - assert.equal((await contributor.upload('/submissions', recordingForm(good(), { file: null }))).status, 422) - assert.equal((await contributor.upload('/submissions', recordingForm(good(), { file: Buffer.from('plain text pretending to be video') }))).status, 422) - assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), trimStart: '2', trimEnd: '1' }))).status, 422) - assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), trimEnd: '30' }))).status, 422) - assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), videoId: 'nope' }))).status, 422) - assert.equal((await contributor.upload('/submissions', recordingForm({ ...good(), videoId: draft.id }))).status, 404) - assert.equal(filesIn('recordings').length, before) - assert.deepEqual(filesIn('tmp'), []) - }) - - test('a valid submission is stored and listed', async () => { - const res = await contributor.upload('/submissions', recordingForm(good())) - assert.equal(res.status, 201, JSON.stringify(res.body)) - submission = res.body.data - assert.equal(submission.videoId, video1.id) - assert.equal(submission.mirrored, true) - assert.equal(submission.trimEnd, 2.5) - assert.equal(submission.recording, undefined) - assert.equal(filesIn('commons').length, 1) - const mine = await contributor.get('/submissions') - assert.equal(mine.body.data.length, 1) - assert.equal((await other.get('/submissions')).body.data.length, 0) - assert.equal((await admin.get('/submissions')).body.data.length, 1) - }) - - test('COOLDOWN: a second submission for the same video, too soon, is refused and stores nothing', async () => { - const again = await contributor.upload('/submissions', recordingForm(good(), { file: mp4() })) - assert.equal(again.status, 409) - assert.match(again.body.error.message, /wait \d+s/i) - assert.equal(filesIn('commons').length, 1) - assert.deepEqual(filesIn('tmp'), []) - }) - - test('COOLDOWN: concurrent double-submits for the same video produce exactly one submission', async () => { - const results = await Promise.all([1, 2, 3].map(() => other.upload('/submissions', recordingForm({ ...good(), videoId: video2.id })))) - assert.deepEqual(results.map((r) => r.status).sort(), [201, 409, 409]) - assert.equal(filesIn('commons').length, 2, 'losing uploads must not leave orphaned archive folders') - assert.equal((await other.get('/submissions')).body.data.length, 1) - }) - - test('COOLDOWN: once it elapses, the same video can be submitted again, as a new, separate recording', async () => { - await new Promise((resolve) => setTimeout(resolve, Number(process.env.SUBMISSION_COOLDOWN_MS) + 20)) - const res = await contributor.upload('/submissions', recordingForm(good(), { file: mp4() })) - assert.equal(res.status, 201, JSON.stringify(res.body)) - assert.notEqual(res.body.data.id, submission.id) - const mine = await contributor.get('/submissions') - assert.equal(mine.body.data.length, 2, 'both recordings for this video are kept, not merged or replaced') - }) - - test('a submission can never be edited, deleted, or read once archived', async () => { - assert.equal((await contributor.patch(`/submissions/${submission.id}`, { trimEnd: 1 })).status, 404) - assert.equal((await contributor.delete(`/submissions/${submission.id}`)).status, 404) - assert.equal((await contributor.get(`/submissions/${submission.id}/recording`)).status, 404) - assert.equal((await admin.get(`/submissions/${submission.id}/recording`)).status, 404) - }) - - test('deleting a base video keeps contributors\' submissions on record', async () => { - const before = filesIn('videos').length - assert.equal((await admin.delete(`/videos/${video2.id}`)).status, 204) - assert.equal(filesIn('videos').length, before - 1, 'the base video file is removed from storage') - assert.equal((await admin.get(`/videos/${video2.id}`)).status, 404) - assert.equal((await other.get('/submissions')).body.data.length, 1) - }) -}) - -describe('admin stats and contact', () => { - test('stats add up', async () => { - assert.equal((await contributor.get('/admin/stats')).status, 403) - const res = await admin.get('/admin/stats?days=7') - assert.equal(res.status, 200) - const { totals, activity, byCategory, recent } = res.body.data - assert.equal(totals.contributors, 3) // sara, omar, eve - assert.equal(totals.submissions, 3) // sara's two takes on video1 (cooldown test), plus omar's one on video2 - assert.equal(totals.categories, 2) - assert.equal(totals.videos, 3) - assert.equal(totals.publishedVideos, 2) // video 1 and the unassigned one; the draft is not counted - assert.equal(activity.length, 7) - assert.equal(activity.reduce((sum, d) => sum + d.count, 0), 3) - assert.equal(activity.at(-1).date, new Date().toISOString().slice(0, 10)) - // Omar's video was deleted earlier, so it counts towards totals and activity but no longer belongs to a category. - assert.equal(byCategory.find((c) => c.categoryId === daily.id).count, 2) // both of sara's video1 submissions - assert.equal(byCategory.find((c) => c.categoryId === work.id).count, 0) - assert.equal(recent.length, 3) - assert.ok(['Sara Ahmed', 'Omar Khan'].includes(recent[0].contributor.name)) - assert.equal(recent.find((r) => r.video === null)?.category, null) // the deleted video - assert.equal((await admin.get('/admin/stats?days=1000')).status, 422) - }) - - test('deleting a category unassigns its videos instead of deleting them', async () => { - assert.equal((await admin.delete(`/categories/${daily.id}`)).status, 204) - const kept = await admin.get(`/videos/${video1.id}`) - assert.equal(kept.status, 200) - assert.equal(kept.body.data.categoryId, null) - assert.equal((await contributor.get(`/videos/${video1.id}`)).status, 404) // hidden from contributors now - }) - - test('contact delivery requires matching account email, enforces quotas, and blocks abusive devices', async () => { - const bad = await contributor.post('/contact', { name: '', email: 'nope', message: '' }) - assert.equal(bad.status, 422) - assert.ok(bad.body.error.fields.email) - assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Provider failure' })).status, 500) - const sent = await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Hello!' }) - assert.equal(sent.status, 201) - assert.equal(contactDeliveries.length, 1) - assert.equal(contactDeliveries[0].email, 'sara@example.com') - assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Again today' })).status, 409) - - const mismatchDevice = 'device-contact-email-mismatch' - assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'other@example.com', message: 'Wrong email' }, { headers: { 'x-device-id': mismatchDevice } })).status, 403) - assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': mismatchDevice } })).status, 403) - - const today = new Date().toISOString().slice(0, 10) - await ContactDailyQuota.findByIdAndUpdate(today, { count: 25 }, { upsert: true }) - const cappedDevice = 'device-contact-global-cap' - assert.equal((await other.post('/contact', { name: 'Omar Khan', email: 'omar@example.com', message: 'Over cap' }, { headers: { 'x-device-id': cappedDevice } })).status, 429) - assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': cappedDevice } })).status, 403) - - const anonymousDevice = 'device-contact-anonymous-test' - assert.equal((await new Client(base).post('/contact', { name: 'Guest', email: 'guest@example.com', message: 'No session' }, { headers: { 'x-device-id': anonymousDevice } })).status, 403) - assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': anonymousDevice } })).status, 403) - - const list = await admin.get('/admin/messages') - assert.equal(list.status, 200) - assert.ok(list.body.data.some((m) => m.name === 'Sara Ahmed' && m.message === 'Hello!' && m.userId)) - assert.equal((await contributor.get('/admin/messages')).status, 403) - }) - - test('oversized uploads are rejected with 413', async () => { - const res = await admin.upload('/videos', videoForm({ title: 'Huge' }, { video: webm(6 * 1024 * 1024) })) - assert.equal(res.status, 413) - assert.equal(res.body.error.code, 'PAYLOAD_TOO_LARGE') - assert.deepEqual(filesIn('tmp'), []) - }) -}) - -describe('admin user controls and access restrictions', () => { - test('suspending a user blocks its existing session and login; reactivation restores access', async () => { - const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id - const selfId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'admin@signpak.test').id - assert.equal((await admin.patch(`/admin/users/${selfId}`, { status: 'suspended' })).status, 403) - const suspended = await admin.patch(`/admin/users/${otherId}`, { status: 'suspended', reason: 'Repeated abuse' }) - assert.equal(suspended.status, 200) - assert.equal(suspended.body.data.status, 'suspended') - assert.equal(suspended.body.data.statusReason, 'Repeated abuse') - assert.equal((await other.get('/users/me')).status, 401) - assert.equal((await new Client(base).post('/auth/login', { email: 'omar@example.com', password: 'password1' })).status, 401) - assert.equal((await admin.patch(`/admin/users/${otherId}`, { status: 'active' })).body.data.status, 'active') - assert.equal((await other.get('/users/me')).status, 200) - }) - - test('deleting a user removes their submissions, cooldowns, and stored recording', async () => { - const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id - const recording = (await Submission.findOne({ user: otherId })).recording - assert.ok(await SubmissionCooldown.countDocuments({ user: otherId })) - assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 204) - assert.equal(await Submission.countDocuments({ user: otherId }), 0) - assert.equal(await SubmissionCooldown.countDocuments({ user: otherId }), 0) - await assert.rejects(() => storageService.describe(recording)) - assert.equal((await other.get('/users/me')).status, 401) - assert.equal((await admin.get('/admin/users')).body.data.some((user) => user.id === otherId), false) - assert.equal((await admin.get('/submissions')).body.data.length, 2) - assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 404) - }) - - test('device restrictions reject matching IDs, expose only a hint, and can be removed', async () => { - const deviceId = 'device-test-1234567890' - const deviceLogin = new Client(base) - await deviceLogin.post('/auth/login', { email: 'sara@example.com', password: 'password1' }, { headers: { 'x-device-id': deviceId } }) - const sara = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'sara@example.com') - assert.equal(sara.lastDeviceId, deviceId) - assert.equal((await deviceLogin.get('/auth/session')).body.data.lastDeviceId, undefined) - const made = await admin.post('/admin/restrictions', { type: 'device', value: deviceId, reason: 'Abuse' }) - assert.equal(made.status, 201) - assert.equal(made.body.data.identifierHint, 'device-t...7890') - assert.equal(made.body.data.identifierHash, undefined) - const blocked = new Client(base) - assert.equal((await blocked.get('/videos', { headers: { 'x-device-id': deviceId } })).status, 403) - const duplicate = await admin.post('/admin/restrictions', { type: 'device', value: deviceId }) - assert.equal(duplicate.status, 409) - assert.equal((await admin.delete(`/admin/restrictions/${made.body.data.id}`)).status, 204) - assert.equal((await blocked.get('/videos', { headers: { 'x-device-id': deviceId } })).status, 200) - }) - - test('IP restrictions block API traffic while admin routes remain available for recovery', async () => { - assert.equal((await admin.post('/admin/restrictions', { type: 'ip', value: 'not-an-ip' })).status, 422) - const made = await admin.post('/admin/restrictions', { type: 'ip', value: '::ffff:127.0.0.1' }) - assert.equal(made.status, 201) - assert.equal((await new Client(base).get('/videos')).status, 403) - assert.equal((await admin.get('/admin/restrictions')).status, 200) - assert.equal((await admin.delete(`/admin/restrictions/${made.body.data.id}`)).status, 204) - assert.equal((await new Client(base).get('/videos')).status, 200) - }) -}) diff --git a/backend/tests/apiContext.js b/backend/tests/apiContext.js new file mode 100644 index 0000000..6264432 --- /dev/null +++ b/backend/tests/apiContext.js @@ -0,0 +1,138 @@ +import { after, before } from 'node:test' +import { Client } from './helpers.js' + +process.env.NODE_ENV = 'test' +process.env.MONGODB_URI = process.env.MONGODB_URI_TEST || 'mongodb://placeholder/unused' +process.env.JWT_SECRET = 'test-secret-test-secret-test-secret-123456' +process.env.ADMIN_EMAIL = 'admin@signpak.test' +process.env.ADMIN_PASSWORD = 'Admin@12345' +process.env.BCRYPT_ROUNDS = '4' +process.env.RATE_LIMIT_ENABLED = 'false' +process.env.CLIENT_ORIGIN = 'http://localhost:5173' + +const deliveredCodes = new Map() +const contactDeliveries = [] + +const dependencies = Promise.all([ + import('../src/app.js'), + import('../src/config/db.js'), + import('../src/services/authService.js'), + import('../src/services/contactService.js'), + import('../src/services/storage/index.js'), + import('../src/models/ContactDailyQuota.js'), + import('../src/models/Submission.js'), + import('../src/models/SubmissionCooldown.js'), + import('../src/repositories/submissionRepo.js'), + import('mongoose'), +]) + +export function useApiContext() { + let context + before(async () => { + const [ + { default: app }, + { connectDb, disconnectDb }, + { authService }, + { contactService }, + { storageService }, + { ContactDailyQuota }, + { Submission }, + { SubmissionCooldown }, + { submissionRepo }, + { default: mongoose }, + ] = await dependencies + + authService.sendOTPEmail = async (email, code, purpose) => { + deliveredCodes.set(`${email.toLowerCase()}:${purpose}`, code) + } + contactService.deliver = async (payload) => { + if (payload.message === 'Provider failure') throw new Error('provider unavailable') + contactDeliveries.push(payload) + } + + let memory + let uri = process.env.MONGODB_URI_TEST + if (!uri) { + const { MongoMemoryServer } = await import('mongodb-memory-server') + memory = await MongoMemoryServer.create() + uri = memory.getUri('signpak_test') + } + await connectDb(uri) + await mongoose.connection.dropDatabase() + await Promise.all(Object.values(mongoose.models).map((model) => model.init())) + await storageService.init() + await authService.ensureAdminFromEnv() + + const server = app.listen(0) + await new Promise((resolve, reject) => { + server.once('listening', resolve) + server.once('error', reject) + }) + const base = `http://127.0.0.1:${server.address().port}/api/v1` + const admin = new Client(base) + const login = await admin.post('/auth/login', { + email: process.env.ADMIN_EMAIL, + password: process.env.ADMIN_PASSWORD, + }) + if (login.status !== 200) throw new Error(`Test admin login failed: ${login.status}`) + + const createVerifiedUser = async ({ firstName, surname, email, password = 'password1' }) => { + const user = new Client(base) + const signup = await user.post('/auth/signup', { firstName, surname, email, password }) + if (signup.status !== 201) throw new Error(`Test user signup failed: ${signup.status}`) + const normalizedEmail = email.toLowerCase() + const verified = await user.post('/auth/verify-email', { + email: normalizedEmail, + code: deliveredCodes.get(`${normalizedEmail}:email-verification`), + }) + if (verified.status !== 200) throw new Error(`Test user verification failed: ${verified.status}`) + return user + } + + const contributor = await createVerifiedUser({ + firstName: 'Sara', + surname: 'Ahmed', + email: 'Sara@Example.com', + }) + const other = await createVerifiedUser({ + firstName: 'Omar', + surname: 'Khan', + email: 'omar@example.com', + }) + + context = { + app, + base, + admin, + contributor, + other, + createVerifiedUser, + deliveredCodes, + contactDeliveries, + ContactDailyQuota, + Submission, + SubmissionCooldown, + submissionRepo, + storageService, + authService, + mongoose, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()) + }) + await mongoose.connection.dropDatabase() + await disconnectDb() + await memory?.stop() + }, + } + }) + + after(async () => { + if (context) await context.close() + }) + + return () => { + if (!context) throw new Error('API test context is not initialized.') + return context + } +} diff --git a/backend/tests/localStorage.test.js b/backend/tests/localStorage.test.js new file mode 100644 index 0000000..62f2845 --- /dev/null +++ b/backend/tests/localStorage.test.js @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { createLocalDriver } from '../src/services/storage/localDriver.js' +import { storageService, temporaryDirectory } from './videoServiceTestHelpers.js' +test('local driver stores named files in folders and appends suffixes on collisions', async (t) => { + const directory = await temporaryDirectory(t) + const root = path.join(directory, 'storage') + const driver = createLocalDriver(root) + await driver.init() + + const keys = [] + for (let index = 0; index < 3; index += 1) { + const tempPath = path.join(directory, `video-${index}.tmp`) + await fs.writeFile(tempPath, `video ${index}`) + const { key } = await driver.save({ + tempPath, + folder: 'videos/Travel', + fileName: 'My video.mp4', + ext: '.mp4', + }) + keys.push(key) + } + + assert.deepEqual(keys, [ + 'videos/Travel/My video.mp4', + 'videos/Travel/My video (2).mp4', + 'videos/Travel/My video (3).mp4', + ]) + assert.deepEqual(await Promise.all(keys.map(async (key) => { + const filePath = path.join(root, ...key.split('/')) + return fs.readFile(filePath, 'utf8') + })), ['video 0', 'video 1', 'video 2']) +}) + +test('local driver handles named files without an extension and dotfiles', async (t) => { + const directory = await temporaryDirectory(t) + const driver = createLocalDriver(path.join(directory, 'storage')) + await driver.init() + + const save = async (fileName, suffix) => { + const tempPath = path.join(directory, `${suffix}.tmp`) + await fs.writeFile(tempPath, suffix) + return (await driver.save({ tempPath, folder: 'videos', fileName })).key + } + + assert.equal(await save('Untitled', 'first'), 'videos/Untitled') + assert.equal(await save('Untitled', 'second'), 'videos/Untitled (2)') + assert.equal(await save('.hidden', 'hidden-first'), 'videos/.hidden') + assert.equal(await save('.hidden', 'hidden-second'), 'videos/.hidden (2)') +}) + +test('local driver generates unique UUID keys when no file name is supplied', async (t) => { + const directory = await temporaryDirectory(t) + const driver = createLocalDriver(path.join(directory, 'storage')) + await driver.init() + const keys = [] + + for (const suffix of ['a', 'b']) { + const tempPath = path.join(directory, `${suffix}.tmp`) + await fs.writeFile(tempPath, suffix) + keys.push((await driver.save({ tempPath, folder: 'videos', ext: '.webm' })).key) + } + + assert.notEqual(keys[0], keys[1]) + for (const key of keys) assert.match(key, /^videos\/[0-9a-f-]{36}\.webm$/i) +}) + +test('local driver rejects paths escaping the storage root and keeps the source file', async (t) => { + const directory = await temporaryDirectory(t) + const root = path.join(directory, 'storage') + const driver = createLocalDriver(root) + await driver.init() + const tempPath = path.join(directory, 'upload.tmp') + await fs.writeFile(tempPath, 'content') + + await assert.rejects( + driver.save({ tempPath, folder: 'videos', fileName: '../outside.mp4' }), + /Invalid storage key/, + ) + await assert.rejects( + driver.save({ tempPath, folder: '../outside', fileName: 'video.mp4' }), + /Invalid storage key/, + ) + assert.equal(await fs.readFile(tempPath, 'utf8'), 'content') +}) + +test('local driver stat reports missing files and remove is idempotent', async (t) => { + const directory = await temporaryDirectory(t) + const driver = createLocalDriver(path.join(directory, 'storage')) + await driver.init() + + await assert.rejects(driver.stat('missing.mp4'), { code: 'ENOENT' }) + await driver.remove('missing.mp4') +}) + +test('storageService forwards named folder and file name to the local driver', async (t) => { + const directory = await temporaryDirectory(t) + const tempPath = path.join(directory, 'upload.tmp') + await fs.writeFile(tempPath, 'named upload') + t.after(() => fs.rm(process.env.UPLOAD_DIR, { recursive: true, force: true })) + await storageService.init() + + const stored = await storageService.save( + { tempPath, originalName: 'browser-name.mp4', mimeType: 'video/mp4', ext: '.mp4' }, + { folder: 'videos/Travel', fileName: 'Named video.mp4' }, + ) + + assert.equal(stored.key, 'videos/Travel/Named video.mp4') + assert.equal(stored.originalName, 'browser-name.mp4') + assert.equal(stored.size, 'named upload'.length) + await storageService.remove(stored) +}) diff --git a/backend/tests/safeName.test.js b/backend/tests/safeName.test.js new file mode 100644 index 0000000..2049a44 --- /dev/null +++ b/backend/tests/safeName.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { safeName } from '../src/utils/files.js' +test('safeName uses its fallback for nullish and blank values', () => { + assert.equal(safeName(null), 'Untitled') + assert.equal(safeName(undefined), 'Untitled') + assert.equal(safeName(' \t\n '), 'Untitled') + assert.equal(safeName('\\/:*?"<>|'), 'Untitled') + assert.equal(safeName('', 'Unnamed category'), 'Unnamed category') +}) + +test('safeName replaces reserved path characters and collapses whitespace', () => { + assert.equal(safeName(' Summer\\Travel: Sea / Sky*? "<>| '), 'Summer Travel Sea Sky') + assert.equal(safeName(0), '0') + assert.equal(safeName(false), 'false') +}) + +test('safeName caps its output at 100 characters', () => { + assert.equal(safeName('x'.repeat(120)), 'x'.repeat(100)) +}) diff --git a/backend/tests/videoStorageNames.test.js b/backend/tests/videoService.test.js similarity index 55% rename from backend/tests/videoStorageNames.test.js rename to backend/tests/videoService.test.js index b81e22d..ee905c3 100644 --- a/backend/tests/videoStorageNames.test.js +++ b/backend/tests/videoService.test.js @@ -1,187 +1,8 @@ import assert from 'node:assert/strict' -import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { createLocalDriver } from '../src/services/storage/localDriver.js' -import { safeName } from '../src/utils/files.js' - -process.env.NODE_ENV = 'test' -process.env.STORAGE_DRIVER = 'local' -process.env.ARCHIVE_STORAGE_DRIVER = 'local' -process.env.UPLOAD_DIR = path.join(os.tmpdir(), `signpak-storage-test-${process.pid}`) -process.env.MONGODB_URI = 'mongodb://127.0.0.1/signpak-test' -process.env.JWT_SECRET = 'test-secret-that-is-longer-than-thirty-two-characters' -process.env.ADMIN_EMAIL = 'admin@example.com' -process.env.ADMIN_PASSWORD = 'test-password-123' - -const [{ videoService }, { categoryRepo }, { videoRepo }, { storageService }] = await Promise.all([ - import('../src/services/videoService.js'), - import('../src/repositories/categoryRepo.js'), - import('../src/repositories/videoRepo.js'), - import('../src/services/storage/index.js'), -]) - -async function temporaryDirectory(t) { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'signpak-test-')) - t.after(() => fs.rm(directory, { recursive: true, force: true })) - return directory -} - -function replace(t, object, property, value) { - const original = object[property] - object[property] = value - t.after(() => { object[property] = original }) -} - -async function writeVideo(directory, name = 'upload.mp4', type = 'mp4') { - const filePath = path.join(directory, name) - const brand = type === 'mov' ? 'qt ' : 'isom' - const header = type === 'webm' - ? Buffer.from([0x1a, 0x45, 0xdf, 0xa3]) - : Buffer.concat([Buffer.from([0, 0, 0, 0]), Buffer.from('ftyp'), Buffer.from(brand)]) - await fs.writeFile(filePath, header) - return { path: filePath, originalname: name, size: header.length } -} - -async function writeImage(directory, name, type = 'jpeg') { - const filePath = path.join(directory, name) - const image = { - jpeg: Buffer.from([0xff, 0xd8, 0xff, 0]), - png: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]), - webp: Buffer.from('RIFF0000WEBP'), - }[type] - await fs.writeFile(filePath, image) - return { path: filePath, originalname: name, size: image.length } -} - -test('safeName uses its fallback for nullish and blank values', () => { - assert.equal(safeName(null), 'Untitled') - assert.equal(safeName(undefined), 'Untitled') - assert.equal(safeName(' \t\n '), 'Untitled') - assert.equal(safeName('\\/:*?"<>|'), 'Untitled') - assert.equal(safeName('', 'Unnamed category'), 'Unnamed category') -}) - -test('safeName replaces reserved path characters and collapses whitespace', () => { - assert.equal(safeName(' Summer\\Travel: Sea / Sky*? "<>| '), 'Summer Travel Sea Sky') - assert.equal(safeName(0), '0') - assert.equal(safeName(false), 'false') -}) - -test('safeName caps its output at 100 characters', () => { - assert.equal(safeName('x'.repeat(120)), 'x'.repeat(100)) -}) - -test('local driver stores named files in folders and appends suffixes on collisions', async (t) => { - const directory = await temporaryDirectory(t) - const root = path.join(directory, 'storage') - const driver = createLocalDriver(root) - await driver.init() - - const keys = [] - for (let index = 0; index < 3; index += 1) { - const tempPath = path.join(directory, `video-${index}.tmp`) - await fs.writeFile(tempPath, `video ${index}`) - const { key } = await driver.save({ - tempPath, - folder: 'videos/Travel', - fileName: 'My video.mp4', - ext: '.mp4', - }) - keys.push(key) - } - - assert.deepEqual(keys, [ - 'videos/Travel/My video.mp4', - 'videos/Travel/My video (2).mp4', - 'videos/Travel/My video (3).mp4', - ]) - assert.deepEqual(await Promise.all(keys.map(async (key) => { - const filePath = path.join(root, ...key.split('/')) - return fs.readFile(filePath, 'utf8') - })), ['video 0', 'video 1', 'video 2']) -}) - -test('local driver handles named files without an extension and dotfiles', async (t) => { - const directory = await temporaryDirectory(t) - const driver = createLocalDriver(path.join(directory, 'storage')) - await driver.init() - - const save = async (fileName, suffix) => { - const tempPath = path.join(directory, `${suffix}.tmp`) - await fs.writeFile(tempPath, suffix) - return (await driver.save({ tempPath, folder: 'videos', fileName })).key - } - - assert.equal(await save('Untitled', 'first'), 'videos/Untitled') - assert.equal(await save('Untitled', 'second'), 'videos/Untitled (2)') - assert.equal(await save('.hidden', 'hidden-first'), 'videos/.hidden') - assert.equal(await save('.hidden', 'hidden-second'), 'videos/.hidden (2)') -}) - -test('local driver generates unique UUID keys when no file name is supplied', async (t) => { - const directory = await temporaryDirectory(t) - const driver = createLocalDriver(path.join(directory, 'storage')) - await driver.init() - const keys = [] - - for (const suffix of ['a', 'b']) { - const tempPath = path.join(directory, `${suffix}.tmp`) - await fs.writeFile(tempPath, suffix) - keys.push((await driver.save({ tempPath, folder: 'videos', ext: '.webm' })).key) - } - - assert.notEqual(keys[0], keys[1]) - for (const key of keys) assert.match(key, /^videos\/[0-9a-f-]{36}\.webm$/i) -}) - -test('local driver rejects paths escaping the storage root and keeps the source file', async (t) => { - const directory = await temporaryDirectory(t) - const root = path.join(directory, 'storage') - const driver = createLocalDriver(root) - await driver.init() - const tempPath = path.join(directory, 'upload.tmp') - await fs.writeFile(tempPath, 'content') - - await assert.rejects( - driver.save({ tempPath, folder: 'videos', fileName: '../outside.mp4' }), - /Invalid storage key/, - ) - await assert.rejects( - driver.save({ tempPath, folder: '../outside', fileName: 'video.mp4' }), - /Invalid storage key/, - ) - assert.equal(await fs.readFile(tempPath, 'utf8'), 'content') -}) - -test('local driver stat reports missing files and remove is idempotent', async (t) => { - const directory = await temporaryDirectory(t) - const driver = createLocalDriver(path.join(directory, 'storage')) - await driver.init() - - await assert.rejects(driver.stat('missing.mp4'), { code: 'ENOENT' }) - await driver.remove('missing.mp4') -}) - -test('storageService forwards named folder and file name to the local driver', async (t) => { - const directory = await temporaryDirectory(t) - const tempPath = path.join(directory, 'upload.tmp') - await fs.writeFile(tempPath, 'named upload') - t.after(() => fs.rm(process.env.UPLOAD_DIR, { recursive: true, force: true })) - await storageService.init() - - const stored = await storageService.save( - { tempPath, originalName: 'browser-name.mp4', mimeType: 'video/mp4', ext: '.mp4' }, - { folder: 'videos/Travel', fileName: 'Named video.mp4' }, - ) - - assert.equal(stored.key, 'videos/Travel/Named video.mp4') - assert.equal(stored.originalName, 'browser-name.mp4') - assert.equal(stored.size, 'named upload'.length) - await storageService.remove(stored) -}) - +import { categoryRepo, replace, storageService, temporaryDirectory, videoRepo, videoService, writeImage, writeVideo } from './videoServiceTestHelpers.js' test('video create names video and poster from title and category safely', async (t) => { const directory = await temporaryDirectory(t) const upload = await writeVideo(directory) diff --git a/backend/tests/videoServiceTestHelpers.js b/backend/tests/videoServiceTestHelpers.js new file mode 100644 index 0000000..3f4fe2c --- /dev/null +++ b/backend/tests/videoServiceTestHelpers.js @@ -0,0 +1,53 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +process.env.NODE_ENV = 'test' +process.env.STORAGE_DRIVER = 'local' +process.env.ARCHIVE_STORAGE_DRIVER = 'local' +process.env.UPLOAD_DIR = path.join(os.tmpdir(), `signpak-storage-test-${process.pid}`) +process.env.MONGODB_URI = 'mongodb://127.0.0.1/signpak-test' +process.env.JWT_SECRET = 'test-secret-that-is-longer-than-thirty-two-characters' +process.env.ADMIN_EMAIL = 'admin@example.com' +process.env.ADMIN_PASSWORD = 'test-password-123' + +export const [{ videoService }, { categoryRepo }, { videoRepo }, { storageService }] = + await Promise.all([ + import('../src/services/videoService.js'), + import('../src/repositories/categoryRepo.js'), + import('../src/repositories/videoRepo.js'), + import('../src/services/storage/index.js'), + ]) + +export async function temporaryDirectory(t) { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'signpak-test-')) + t.after(() => fs.promises.rm(directory, { recursive: true, force: true })) + return directory +} + +export function replace(t, object, property, value) { + const original = object[property] + object[property] = value + t.after(() => { object[property] = original }) +} + +export async function writeVideo(directory, name = 'upload.mp4', type = 'mp4') { + const filePath = path.join(directory, name) + const brand = type === 'mov' ? 'qt ' : 'isom' + const header = type === 'webm' + ? Buffer.from([0x1a, 0x45, 0xdf, 0xa3]) + : Buffer.concat([Buffer.from([0, 0, 0, 0]), Buffer.from('ftyp'), Buffer.from(brand)]) + await fs.promises.writeFile(filePath, header) + return { path: filePath, originalname: name, size: header.length } +} + +export async function writeImage(directory, name, type = 'jpeg') { + const filePath = path.join(directory, name) + const image = { + jpeg: Buffer.from([0xff, 0xd8, 0xff, 0]), + png: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]), + webp: Buffer.from('RIFF0000WEBP'), + }[type] + await fs.promises.writeFile(filePath, image) + return { path: filePath, originalname: name, size: image.length } +} diff --git a/frontend/README.md b/frontend/README.md index 47d05d8..7d71f10 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,113 +1,134 @@ -# SignPakCommons: frontend +# SignPak Commons: frontend -React 19 + Vite + Tailwind CSS v4 + React Router. The frontend uses the Express + MongoDB backend through `src/services/api.js`. +React 19 single-page application built with Vite, React Router, and Tailwind CSS +v4. It provides the public project pages, contributor recording workflow, and +admin workspace. The Express API owns accounts, categories, videos, and submitted +recordings; the browser keeps local preferences, a device ID, notifications, +and an unsubmitted recording draft. -## Run it +## Run locally + +Use Node.js 20.19+ (see `package.json` for the current engine requirement). ```bash cd frontend npm install -npm run dev # http://localhost:5173 +npm run dev # http://localhost:5173 npm run lint npm run build +npm run preview # serve the production build locally ``` -## Routes +Vite proxies `/api` to `http://localhost:5000` during development. Start the +backend separately, or set `VITE_API_ORIGIN` to another API origin. -| Path | Who | What | -| --------------------------- | ---------- | ------------------------------------------------ | -| `/` | public | Landing: hero, about, policy, contact | -| `/demo` | public | "How it works" walkthrough video | -| `/signup`, `/login` | logged out | Account forms | -| `/verify-email` | logged out | Verify signup code sent by email | -| `/forgot-password` | logged out | Request a code and reset password | -| `/home` | learner | Categories | -| `/library/:categoryId` | learner | Videos in a category (search, filter, sort) | -| `/watch?v=` | learner | Player and recorder | -| `/watch/edit?v=` | learner | Review, trim, submit | -| `/profile` | learner | Progress, submissions, GitHub / LinkedIn | -| `/admin` | admin | Dashboard | -| `/admin/videos` | admin | Upload, preview, edit, publish, delete | -| `/admin/categories` | admin | Create categories, assign videos | - -## Folder layout +## Routes +| Route | Access | Page | +| --- | --- | --- | +| `/` | Public | Landing page; about, contact, and policy are in-page sections. | +| `/demo` | Public | Walkthrough video. | +| `/faq` | Public | Frequently asked questions. | +| `/login`, `/signup` | Signed out | Sign in or register; redirects signed-in users. | +| `/verify-email`, `/forgot-password` | Signed out | Email verification and password recovery. | +| `/home` | Signed in | Category overview. | +| `/library/:categoryId` | Signed in | Videos in a category. | +| `/watch?v=:videoId` | Signed in | Watch a reference video and record a take. | +| `/watch/edit?v=:videoId` | Signed in | Review, trim, and submit the current take. | +| `/profile` | Signed in | Contribution history and profile connections. | +| `/admin` | Admin | Admin dashboard. | +| `/admin/users` | Admin | Account controls. | +| `/admin/videos` | Admin | Manage reference videos and the demo video. | +| `/admin/categories` | Admin | Manage categories. | + +Route declarations live in `src/App.jsx`; shared path names and URL builders live +in `src/routes/appRoutes.js`. Auth guards in `src/components/RouteGuards.jsx` +redirect visitors who do not meet the route's access requirement. + +## Backend connection + +All HTTP requests go through `src/services/api.js`; endpoint paths are composed +in `src/services/apiRoutes.js`. Requests include credentials for the HTTP-only +session cookie, attach a browser device ID when available, unwrap the API's +`{ data }` success envelope, and surface server error messages and field errors. +JSON and multipart upload requests use the same request helper. + +| Variable | Purpose | +| --- | --- | +| `VITE_API_ORIGIN` | Optional API origin, e.g. `https://api.example.com`. Empty by default; requests use the Vite `/api` proxy locally. | +| `VITE_SUBMISSION_COOLDOWN_MS` | Optional client countdown duration in milliseconds. Defaults to `30000`; the backend independently enforces its cooldown. | + +Put local Vite variables in `frontend/.env.local` (do not commit secrets there). +Vite variables prefixed with `VITE_` are exposed in the browser bundle, so never +put credentials or private keys in them. Backend variables are documented in +[`../backend/.env.example`](../backend/.env.example). + +Contributor recordings stay in memory as a `Blob` while the user moves between +the player and editor; closing the tab discards an unsubmitted take. On +submission, the frontend uploads the video and trim/mirror metadata to the +backend. The backend validates and stores the recording and enforces the +submission cooldown. Theme preference, device ID, and notifications are stored +locally in the browser; notifications are not synchronized between devices. + +## Structure + +```text src/ - components/ shared UI (Header, Footer, Field, players, route guards) - context/ Auth, Library and Recording state - hooks/ useRecorder (camera + MediaRecorder) - config/ production constants used by the UI - pages/ one file per screen, admin/ for the workspace - services/ api.js: the only file that talks to "the server" - styles/ all styling; index.css just imports these - utils/ formatting, validation, IndexedDB and video helpers - -## Styling - -No inline utility classes in JSX. Components use semantic class names (`.btn`, `.lesson-card`, `.recorder`...) defined in `src/styles/*.css` with Tailwind's `@apply`. Colours and fonts are tokens in `styles/theme.css`, so a palette change is one file. - -## Backend integration - -Every server call goes through `src/services/api.js`. Requests use the `/api/v1` backend routes, include the HTTP-only session cookie, unwrap the `{ data }` response envelope, and support multipart video and recording uploads. - -- Set `VITE_API_ORIGIN` when the deployed frontend and backend use different origins. Leave it empty during local development to use the Vite `/api` proxy. -- Backend environment values are documented in `../backend/.env.example`. -- Admin video uploads are stored by the backend; the browser does not keep the base library in IndexedDB. -- Recording submissions upload the recorded `Blob` with trim metadata. The backend enforces the submission cooldown. -- Notifications remain browser-local until a persisted backend notification API is added. -- Signup requires a Brevo-delivered email code before the API establishes a session; password recovery uses a separate one-time code. -- Contact messages require a verified signed-in account and must use its email address; the backend limits accounts to one message per UTC day and all messages to 25 per UTC day. - -## Theming - -Light and dark, both derived from the same analogous palette (deep green → turquoise → cyan), with white for contrast text. All app colours are CSS custom properties in `src/styles/theme.css`: one block of values for light, one for dark under `:root[data-theme='dark']`. Components never use raw hex; they use semantic Tailwind tokens (`bg-page`, `text-heading`, `border-line`...) that resolve to whichever theme is active, so a look change only ever touches `theme.css`. - -- **Switching:** the header's sun/moon button, powered by `context/ThemeProvider.jsx`. Before a person picks, the theme follows their OS setting (`prefers-color-scheme`) and keeps following it live. Once they click the toggle, that choice is saved to `localStorage` and wins from then on, on every page, until they change it again. -- **No flash on load:** a small inline script in `index.html` reads the saved theme (or the OS setting) and sets it on `` before the page paints, before React runs. -- **Cyan needed help:** `#0096FF` alone is only ~3.1:1 against white, below the 4.5:1 that body text needs, so it's used for large accents (focus rings, chart lines, gradients) and for the `--accent-ink` shade instead, a deeper blue in light mode and a lighter one in dark mode, both checked at 4.5:1+. Every text/background pair in both themes was checked against WCAG AA; running `node e2e/audit.mjs`-style checks (see below) currently reports zero contrast violations across all 14 screens × 2 themes. - -## Fonts - -Google Fonts, loaded in `index.html`: - -- **Barlow Condensed** (600/700) — headlines and big numbers (`.display`) -- **Quicksand** (500/600/700) — buttons, labels, nav, anything UI chrome (`font-ui`) -- **Inter** (400–700) — body copy, the readable default - -## Verifying this yourself - -This redesign was checked, not just eyeballed: - -- **axe-core** ran against all 14 pages/states in both themes: 0 accessibility violations (contrast, labels, roles). -- The existing 74-check Playwright journey suite (signup → record → trim → submit → admin) still passes after the restyle. -- 26 additional checks cover theme switching (OS-follow, persistence, no-flash-on-load, keyboard operation) and font loading. -- A phone-width (375px) sweep confirmed no page causes horizontal scroll. - -These checks used local test scripts, not shipped in this codebase. + App.jsx Route tree and page composition + main.jsx Browser entry point and provider setup + components/ Shared layouts, controls, navigation, and route guards + config/ UI configuration + context/ Auth, library, recording, notification, and theme state + hooks/ Reusable hooks, including camera/MediaRecorder handling + pages/ Public and contributor pages + pages/admin/ Admin workspace pages + routes/ Client-side path names and URL builders + services/ Backend API client and endpoint builders + seo/ Static route SEO metadata and helpers + styles/ Theme tokens and feature stylesheets + utils/ Validation, media, and browser storage helpers +docs/ + ARCHITECTURE.md Runtime, state, data flow, and deployment overview +``` -## Data collection, not a course +## Styling and themes -This is a video **data-collection** tool, not a learning platform: there's no curriculum, no "complete this lesson," nothing to finish. Terminology reflects that throughout — a **category** groups **videos** by topic, and a **contributor** can record any video as many times as they like. +The app uses semantic component classes and CSS custom properties rather than +scattering colors through JSX. `src/index.css` imports the stylesheets in +`src/styles/`; theme tokens are defined in `src/styles/theme.css`. +`ThemeProvider` follows the operating-system preference until a user selects a +theme, then saves that choice in `localStorage`. An inline bootstrap in +`index.html` applies the theme before the first paint. -**Recording again — the 30-second cooldown.** A contributor can submit more than one recording for the same video, but not back-to-back. The Player UI shows a countdown, and the backend atomically enforces the same cooldown through `SUBMISSION_COOLDOWN_MS`. +## SEO and prerendering -## Routing +`src/seo/seoCatalog.mjs` is the source for static page metadata and the production +site URL. Replace its placeholder `SITE_URL` before deployment. The build runs +`npm run seo:generate` automatically to produce the sitemap, robots file, and +React Snap route list. After building, `npm run snap` can prerender those public +routes; it requires a working headless Chromium installation. -- **`src/routes/appRoutes.js`** — every client-side path the app links to, plus builders for the ones that take an id (`watchUrl(id)`, `watchEditUrl(id)`, `categoryUrl(id)`). Components import from here rather than writing `` `/watch?v=${id}` `` inline. -- **A video's address is `/watch?v=`**, not `/lesson/` — the same shape YouTube uses. A query parameter keeps a video's URL independent of anything else about it (which category it's in, its position in a list), so links stay valid even if the catalog is reorganised. Editing a take is `/watch/edit?v=`. -- **`src/services/apiRoutes.js`** — the real backend's endpoint paths (from `backend/README.md`), collected in one file for when `services/api.js` stops using `localStorage` and starts calling Express. Nothing imports this yet; it's ready for that day. +## Deployment -## SEO +`vercel.json` rewrites direct route requests to the SPA entry point so refreshing +URLs such as `/admin/categories` loads the client router instead of returning a +host-level 404. Configure the frontend build with `frontend/` as its root when +deploying this app separately, set `VITE_API_ORIGIN` for a separately hosted +backend, and configure the backend's `CLIENT_ORIGIN` to the exact frontend +origin. API cookies require compatible HTTPS and cookie settings. -- **`src/seo/seoCatalog.mjs`** — the list of static, public routes worth a search engine's time (home, demo, FAQ; login/signup/profile are listed but marked `noindex` since they're per-user or auth-only). Update `SITE_URL` here before deploying — it's currently a placeholder. -- **`src/seo/seoUtils.js`** — a `useSeo(path)` hook that sets the page title, meta description and canonical link from the catalog. Called once per static page (see `Landing.jsx`, `Demo.jsx`, `Faq.jsx`). -- **`npm run seo:generate`** (`scripts/generateSeoAssets.js`) reads the catalog and writes `public/sitemap.xml`, `public/robots.txt` and `react-snap-routes.json`. It runs automatically before `npm run build`, so Vite copies the generated assets into `dist/`. These local production outputs are ignored by Git; update `src/seo/seoCatalog.mjs` instead. -- **`npm run snap`** (`scripts/runReactSnap.js`) prerenders those same routes to static HTML with `react-snap`, so the built `dist/` has real markup instead of an empty `
` for search engines and no-JS clients. Run `npm run build` first. Not part of `postbuild` — it needs headless Chromium and is slow, so it's a separate, manual step. This was tested end-to-end in a sandbox with no real network access: `react-snap` bundles an old Puppeteer (1.20.0, from ~2019) whose own Chromium download was skipped in favour of pointing `PUPPETEER_EXECUTABLE_PATH` at an already-installed Chromium, plus `--no-sandbox` (needed because the whole container runs as root). None of that should be necessary on a normal machine or CI runner with regular internet access; a plain `npm install && npm run build && npm run snap` should just work. One thing worth knowing: `react-snap` bundles a genuinely old, unmaintained Puppeteer with known CVEs in its own dependency tree (`npm audit` will flag it) — that's a property of the tool itself, not something this integration adds on top of it. +## Checks -## FAQ +The frontend package currently provides `npm run lint`, `npm run build`, +`npm run seo:generate`, and `npm run snap`. It does not define an automated +frontend test script. -`/faq` (`src/pages/Faq.jsx`), a plain accordion (native `
`/``, no extra JS) answering real questions about contributing: what the project is, whether it's a course (it isn't), the cooldown, privacy of recordings, and who is behind it. Linked from the header for both visitors and contributors. +## Project model -## Notifications +SignPak Commons is a video data-collection tool: categories group reference +videos, and contributors can submit multiple recordings over time. A cooldown +prevents back-to-back submissions for the same contributor/video pair; it does +not mark a video as completed or prevent future takes. -The bell in the header (`components/NotificationBell.jsx`, `context/NotificationProvider.jsx`) is browser-local and survives a reload. Cross-device notification persistence requires a future backend notification API. +See [the architecture guide](docs/ARCHITECTURE.md) for provider composition, +data ownership, URL conventions, and browser-local state. diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index b60b5d3..4388207 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -1,208 +1,152 @@ -# Frontend Architecture +# Frontend architecture -## Purpose +## Purpose and runtime -This frontend is a React application for SignPakCommons, a video data collection and contributor workflow. The app supports public browsing, authenticated contributor flows, recording and submission, admin management, and SEO-friendly static pages. +The frontend is a React 19 single-page application built with Vite, React Router, +and Tailwind CSS v4. It contains public information pages, authenticated +contributor workflows, and an admin workspace. The backend API is the source of +truth for accounts, categories, reference videos, and submitted recordings. -The project uses: +The frontend requires Node.js 20.19 or newer, as declared by +`package.json`. Its package scripts are `dev`, `lint`, `build`, `preview`, +`seo:generate`, and `snap`; there is no frontend test script. -- React 19 -- Vite -- Tailwind CSS v4 -- React Router -- Context-based state management +## Bootstrap and providers -## Runtime Architecture +`src/main.jsx` mounts `src/App.jsx` inside `StrictMode`, `ThemeProvider`, +`BrowserRouter`, `AuthProvider`, `NotificationProvider`, `LibraryProvider`, and +`RecordingProvider`. This order lets the inner providers consume auth state and +lets route screens access the shared contexts. -### 1. App bootstrap +`src/App.jsx` defines nested route groups and renders the shared `AppLayout`. +`src/routes/appRoutes.js` centralizes the path constants and URL builders: -The application starts in `src/main.jsx`. +- `categoryUrl(id)` builds `/library/:categoryId`. +- `watchUrl(id)` builds `/watch?v=:videoId`. +- `watchEditUrl(id)` builds `/watch/edit?v=:videoId`. -- Wraps the app in `ThemeProvider` -- Mounts the app inside `BrowserRouter` -- Installs the authentication, notification, library, and recording providers in nested order -- Renders the root app component +Video identity is carried in the query string so links do not depend on category +or display ordering. `/contact` and `/policy` redirect to sections on the landing +page; they are not separate page screens. -This pattern keeps global app state centralized and predictable while preserving a simple top-level composition model. +### Route access -### 2. Route composition +`src/components/RouteGuards.jsx` implements three declarative access guards: -The route tree lives in `src/App.jsx`. +- `GuestOnly` allows signed-out visitors through auth pages and sends signed-in + users to their original destination or `/home`. +- `RequireAuth` waits for the session check and redirects visitors to `/login`. +- `RequireAdmin` allows the admin role and otherwise redirects to `/home`. -The app uses nested route groups: +The route tree includes public landing, demo, and FAQ pages; signed-out login, +signup, email verification, and password recovery pages; signed-in home, library, +watch, editor, and profile pages; and admin dashboard, user, video, and category +pages. -- Public routes: landing, demo, FAQ, login, signup -- Authenticated contributor routes: home, library, watch, profile, editor -- Admin-only routes: dashboard, video management, category management +## State and ownership -Access control is enforced by route guards: +React context providers coordinate shared state without an external state +management library. -- `RequireAuth` redirects unauthenticated users to login -- `RequireAdmin` restricts admin-only screens -- `GuestOnly` prevents signed-in users from visiting login/signup flows again +| Provider | Responsibility and persistence | +| --- | --- | +| `AuthProvider` | Loads the current session from the API and exposes login, signup, email verification, logout, and connection updates. | +| `LibraryProvider` | Loads categories, visible videos, and the current user's submissions; provides derived category, contribution, and cooldown state plus admin mutations. | +| `RecordingProvider` | Holds unsubmitted recording blobs and trim/mirror edits in memory, keyed by video ID. These drafts are discarded on submit, replacement, user change, or tab close. | +| `NotificationProvider` | Stores per-user notification items and read state in browser `localStorage`; notifications do not sync across devices. | +| `ThemeProvider` | Follows the OS theme until a user selects one, then persists the selection locally. | -This keeps screen-level permissions declarative and consistent across the UI. +Browser-local data is limited to preferences, the generated device ID, local +notifications, and the active unsubmitted recording draft. The backend owns +persistent account, library, and submission data. -## State Management +## API and data flow -The app relies primarily on React context providers instead of a full external state library. +`src/services/api.js` is the HTTP client and `src/services/apiRoutes.js` builds +endpoint URLs. Pages and providers use the service methods rather than calling +`fetch` directly. -### Auth +The client: -- `src/context/AuthContext.js` -- `src/context/AuthProvider.jsx` +- Adds `credentials: 'include'` so the HTTP-only `signpak_token` cookie is sent. +- Sends `X-Device-ID` when browser storage is available. +- Sends JSON for structured requests and `FormData` for video uploads. +- Returns the backend's `data` envelope value and throws errors containing the + server message, status, and field-level validation messages. +- Converts API-relative media URLs to absolute URLs when `VITE_API_ORIGIN` is + configured. -Owns user session state, auth checks, loading state, and admin status. UI route guards read directly from this context. +Vite proxies `/api` to `http://localhost:5000` for local development. The +optional `VITE_API_ORIGIN` variable selects a separately hosted API; leave it +empty when using the local proxy. `VITE_SUBMISSION_COOLDOWN_MS` sets the UI's +countdown duration and defaults to 30,000 ms. This countdown is only a user +interface hint; the backend independently enforces the cooldown. -### Library +### Recording flow -- `src/context/LibraryContext.js` -- `src/context/LibraryProvider.jsx` +`useRecorder` captures camera and microphone media through browser media APIs. +The `RecordingProvider` keeps the current take available while a contributor +moves between `/watch` and `/watch/edit`. The editor applies trim and mirror +settings as submission metadata, then uploads the recording blob and metadata +through the API service. The backend stores the uploaded recording and metadata; +it does not physically trim or transcode the file. -Tracks the category/video browsing workflow and related UI state. +## UI and styling -### Recording +`src/components/` holds shared layouts, navigation, form fields, buttons, +dialogs, notifications, and route guards. `src/pages/` contains route-level +screens; `src/pages/admin/` contains the admin workspace. -- `src/context/RecordingContext.js` -- `src/context/RecordingProvider.jsx` +`src/index.css` imports feature stylesheets from `src/styles/`. Components +primarily use semantic selectors and shared theme tokens. `theme.css` defines +light and dark CSS custom properties. `ThemeProvider` applies the active theme +to the document root, while an inline bootstrap in `index.html` selects the +saved or OS preference before the first paint. -Controls recording-session state, playback/editing flow, and submission-related UI state. +## SEO and prerendering -### Notifications +`src/seo/seoCatalog.mjs` is the source for static route metadata and the +canonical site URL. Only indexable entries in the catalog are included in the +sitemap and React Snap route list; entries marked `noindex` are excluded. Set +`SITE_URL` to the deployed frontend origin before generating production SEO +assets. -- `src/context/NotificationContext.js` -- `src/context/NotificationProvider.jsx` +`npm run seo:generate` writes the sitemap, robots file, and +`react-snap-routes.json`; it runs as part of `npm run build`. `npm run snap` +prerenders the generated public route list into `dist/` and requires the built +site plus an available headless Chromium executable. -Handles per-user notifications and read state, mirroring the local persistence pattern used elsewhere in the app. +## Hosting and client-side routing -### Theme +The Vite development server provides the `/api` proxy. For Vercel, `vercel.json` +rewrites application routes to `/` so that direct navigation or a reload on a +client-side route such as `/admin/categories` serves the SPA entry point. When +the frontend and API are deployed on different origins, configure +`VITE_API_ORIGIN` in the frontend build environment and set the backend's +`CLIENT_ORIGIN` to the frontend origin. Cross-site session cookies require HTTPS +and compatible backend cookie settings. -- `src/context/ThemeContext.js` -- `src/context/ThemeProvider.jsx` - -Manages light/dark mode and persists user preference with local storage while honoring the OS preference on first load. - -## UI and Layout Structure - -### Shared shell - -The app uses a shared layout system from `src/components/Layouts.jsx` and a set of reusable pieces such as: - -- `Header` -- `Footer` -- `AuthShell` -- `NotificationBell` -- `ThemeToggle` -- `RouteGuards` -- UI primitives like `Field`, `PageState`, and `ButtonLink` - -This keeps navigation, auth framing, and page-level chrome consistent across route groups. - -### Page organization - -Pages are organized by feature: - -- `src/pages/` for public and user-facing views -- `src/pages/admin/` for admin screens and nested admin layout - -Examples: - -- `Landing`, `Demo`, `Faq` -- `Login`, `Signup`, `Home` -- `CategoryBrowser`, `Player`, `RecordingEditor`, `ProfileDashboard` -- `AdminDashboard`, `AdminVideos`, `AdminCategories` - -Each page is mostly self-contained and uses shared providers and reusable UI primitives instead of a large global component tree. - -## Data Flow - -The frontend is designed around a service boundary rather than direct fetch calls scattered across components. - -### Service layer - -The project expects data access to happen through service modules under `src/services/`. - -That boundary keeps the UI independent from transport details while the app uses the live Express API. Browser-local state is limited to preferences and intentionally local notifications. - -### Live API strategy - -Core users, categories, videos, submissions, and contact messages are managed by the backend API. The frontend keeps only theme/preferences and notifications in browser storage. - -## Styling Architecture - -The app uses Tailwind CSS with semantic component classes and theme tokens rather than scattered raw color literals. - -### Design approach - -- Utility classes are not the primary pattern for this app -- CSS is organized around semantic selectors such as `.btn`, `.lesson-card`, `.recorder`, and theme-related tokens -- The app uses CSS custom properties to support light/dark mode -- Theme values are centralized and switchable from a single theme definition - -This makes style updates easier and reduces the risk of inconsistent colors or mismatched ui states. - -## SEO and static generation - -The frontend includes dedicated SEO support under `src/seo/` and scripts in the project root of the frontend. - -### SEO flow - -- `src/seo/seoCatalog.mjs` defines public routes and metadata -- `scripts/generateSeoAssets.js` generates assets like `sitemap.xml` and `robots.txt` -- `npm run seo:generate` runs before build -- `npm run snap` can prerender pages for static HTML output - -This architecture supports discoverability and strong initial metadata for public pages while keeping route metadata data-driven. - -## Routing and URL conventions - -The app uses path-based navigation and query-string IDs for media items. - -Examples: - -- `/` landing page -- `/demo` walkthrough page -- `/home` contributor dashboard -- `/library/:categoryId` category browsing -- `/watch?v=` media playback -- `/watch/edit?v=` editing flow -- `/admin` admin dashboard - -This design separates content identity from route structure, making links less brittle as categories or catalog ordering change. - -## Folder Map +## Project map ```text frontend/ src/ - App.jsx # route layout and screen composition - main.jsx # app bootstrap and provider nesting - components/ # reusable UI and layout pieces - context/ # global state providers - hooks/ # custom React hooks - pages/ # route-level screens - pages/admin/ # admin screens - routes/ # route helpers and path definitions - services/ # API/service boundary - seo/ # metadata and SEO catalog - styles/ # theme and shared CSS - utils/ # helpers and shared utilities - config/ # production UI constants + App.jsx # Route tree and page composition + main.jsx # Browser entry and provider nesting + components/ # Shared layout and UI pieces + config/ # UI configuration + context/ # Shared state providers and contexts + hooks/ # Reusable UI and media hooks + pages/ # Public and contributor screens + pages/admin/ # Admin screens + routes/ # Client-side paths and URL builders + services/ # API client and endpoint builders + seo/ # SEO metadata and helpers + styles/ # Theme tokens and feature styles + utils/ # Shared browser and media utilities docs/ - architecture.md # this document + ARCHITECTURE.md # This guide scripts/ - generateSeoAssets.js - runReactSnap.js + generateSeoAssets.js # Sitemap, robots, and prerender route generation + runReactSnap.js # Static route prerendering ``` - -## Architectural Principles - -1. Keep route-level access logic declarative with guards. -2. Keep global app state in context providers instead of prop drilling. -3. Centralize backend communication behind service boundaries. -4. Keep shared UI and theme tokens reusable across screens. -5. Separate navigation, content state, and provider orchestration so the app is easy to evolve. - -## Summary - -The frontend is a modular React application organized around a clear provider-based state model, route-driven screens, reusable UI shells, and a live backend service boundary. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1411a79..dee19df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", diff --git a/frontend/package.json b/frontend/package.json index 1611f8f..967f1cb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.0", + "version": "1.0.1", "type": "module", "engines": { "node": ">=20.19.0" diff --git a/frontend/src/pages/RecordingEditor.jsx b/frontend/src/pages/RecordingEditor.jsx index ca12597..766aa0f 100644 --- a/frontend/src/pages/RecordingEditor.jsx +++ b/frontend/src/pages/RecordingEditor.jsx @@ -42,6 +42,8 @@ function Editor({ video, recording, onEdit, onSubmit }) { const preview = useRef(null) const { start, end, mirrored } = recording.edit const total = recording.duration + const [trimRange, setTrimRange] = useState({ start, end }) + const [trimApplied, setTrimApplied] = useState(true) const [playing, setPlaying] = useState(false) const [time, setTime] = useState(start) const [confirming, setConfirming] = useState(false) @@ -55,26 +57,32 @@ function Editor({ video, recording, onEdit, onSubmit }) { const fix = () => { if (element.duration !== Infinity) return element.currentTime = 1e101 - element.addEventListener('timeupdate', () => { element.currentTime = start }, { once: true }) + element.addEventListener('timeupdate', () => { element.currentTime = trimRange.start }, { once: true }) } element.addEventListener('loadedmetadata', fix) return () => element.removeEventListener('loadedmetadata', fix) - }, [recording.url, start]) + }, [recording.url, trimRange.start]) const togglePlay = () => { const element = preview.current if (!element.paused) { element.pause(); return } - if (element.currentTime < start || element.currentTime >= end - 0.05) element.currentTime = start + if (element.currentTime < trimRange.start || element.currentTime >= trimRange.end - 0.05) element.currentTime = trimRange.start element.play().catch(() => {}) } const onTimeUpdate = (event) => { const element = event.currentTarget setTime(element.currentTime) - if (!element.paused && element.currentTime >= end) { element.pause(); element.currentTime = end } + if (!element.paused && element.currentTime >= trimRange.end) { element.pause(); element.currentTime = trimRange.end } } const change = (patch) => { - onEdit(patch) - if (preview.current) preview.current.currentTime = patch.start ?? patch.end ?? 0 + setTrimRange((current) => ({ ...current, ...patch })) + setTrimApplied(false) + if (preview.current) preview.current.currentTime = patch.start ?? patch.end ?? trimRange.start + } + const applyTrim = () => { + onEdit(trimRange) + setTrimApplied(true) + if (preview.current) preview.current.currentTime = trimRange.start } const submit = async () => { setBusy(true) @@ -93,24 +101,25 @@ function Editor({ video, recording, onEdit, onSubmit }) {
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 1e60927..9e6f533 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -14,7 +14,8 @@ .admin-user-table { min-width: 58rem; } .admin-row-actions { @apply flex flex-wrap gap-2; } .admin-row-actions .btn, .restriction-list .btn { @apply min-h-9 px-3 py-1.5 text-xs; } - .admin-row-actions .is-danger { @apply text-danger; } + .admin-row-actions .is-danger { @apply bg-danger-soft text-danger shadow-soft; } + .admin-row-actions .is-danger:hover:not(:disabled) { background-color: color-mix(in srgb, var(--danger) 14%, var(--danger-soft)); } .admin-status-reason { @apply mt-1 block max-w-52 text-xs text-muted; } .admin-restriction-form { @apply grid gap-x-4 sm:grid-cols-2; } .admin-restriction-form .btn { @apply mt-13 self-start; } @@ -86,7 +87,8 @@ .video-row-info small { @apply mt-1 block text-sm text-muted; } .row-actions { @apply flex flex-wrap gap-x-1 gap-y-1 text-sm; } .row-actions button { @apply rounded-full px-3 py-1.5 font-semibold text-muted transition hover:bg-panel hover:text-heading; } - .row-actions .is-danger { @apply text-danger; } + .row-actions .is-danger { @apply bg-danger-soft text-danger shadow-soft; } + .row-actions .is-danger:hover { background-color: color-mix(in srgb, var(--danger) 14%, var(--danger-soft)); } .video-row-preview { @apply mt-4 block aspect-video w-full max-w-xl rounded-2xl bg-black; } .video-row-edit { @apply mt-2 grid grid-cols-1 gap-x-5 sm:grid-cols-2; } .row-form-actions { @apply mt-6 flex flex-wrap gap-2 sm:col-span-2; } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 84b7060..41cec9e 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -34,7 +34,9 @@ .btn-light { @apply bg-white text-deep shadow-soft hover:bg-turquoise; } .btn-outline { @apply border-line-strong text-heading hover:border-accent-ink hover:bg-surface; } .btn-ghost { @apply px-4 text-heading hover:bg-panel; } - .btn-danger { @apply bg-danger text-danger-ink hover:brightness-110; } + .btn-danger { @apply bg-danger-soft text-danger shadow-soft; border-color: color-mix(in srgb, var(--danger) 28%, transparent); } + .btn-danger:hover:not(:disabled), .btn.is-danger:hover:not(:disabled) { background-color: color-mix(in srgb, var(--danger) 14%, var(--danger-soft)); } + .btn.is-danger { @apply bg-danger-soft text-danger shadow-soft; border-color: color-mix(in srgb, var(--danger) 28%, transparent); } /* ---------- Forms ---------- */ .field { @apply mt-5 block; } @@ -61,7 +63,7 @@ /* ---------- Small pieces ---------- */ .progress { @apply h-2.5 w-full overflow-hidden rounded-full bg-line; } .progress-bar { @apply block h-full rounded-full transition-[width] duration-500; background: var(--progress); } - .avatar { @apply grid shrink-0 place-items-center rounded-full p-[2px] font-ui font-bold; background: linear-gradient(135deg, #feda75, #fa7e1e 28%, #d62976 55%, #962fbf 78%, #4f5bd5); } + .avatar { @apply grid shrink-0 place-items-center rounded-full p-[2px] font-ui font-bold; background: linear-gradient(135deg, var(--color-deep), var(--color-turquoise) 52%, var(--color-deep)); } .avatar-initials { @apply grid h-full w-full place-items-center rounded-full bg-surface text-heading; } .avatar-sm { @apply h-10 w-10 text-sm; } .avatar-md { @apply h-12 w-12 text-base; } diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index eba30d7..66831d1 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -38,6 +38,9 @@ .notif-item small { @apply mt-0.5 block text-sm text-muted; } .notif-item time { @apply mt-1 block font-ui text-xs font-semibold text-faint; } .notif-empty { @apply px-4 py-8 text-center text-sm text-muted; } + @media (max-width: 767px) { + .notif-panel { @apply fixed left-4 right-4 top-[calc(72px+0.75rem)] w-auto max-w-none; } + } .menu-toggle { @apply grid h-11 w-11 place-items-center rounded-full text-xl text-heading hover:bg-panel md:hidden; } .mobile-nav { @apply mx-auto mb-4 grid w-[calc(100%-2.5rem)] gap-1 rounded-2xl border border-line bg-surface p-2 shadow-lift md:hidden; } .mobile-nav a, .mobile-nav button { @apply rounded-xl px-4 py-3 text-left font-ui text-base font-semibold text-heading hover:bg-panel; } diff --git a/frontend/src/styles/player.css b/frontend/src/styles/player.css index ea7e8ac..dc98ab4 100644 --- a/frontend/src/styles/player.css +++ b/frontend/src/styles/player.css @@ -30,7 +30,8 @@ .recorder-video.is-hidden { @apply hidden; } .recorder-empty { @apply px-4 text-center; } .rec-dot { @apply mr-1 text-danger; } - .recorder-delete { @apply mx-auto my-4 block rounded-full px-3 py-1 text-sm font-semibold text-muted underline underline-offset-4 hover:text-danger; } + .recorder-delete { @apply mx-auto my-4 block rounded-full px-4 py-2 text-sm font-semibold text-danger shadow-soft transition; background-color: var(--danger-soft); } + .recorder-delete:hover { background-color: color-mix(in srgb, var(--danger) 14%, var(--danger-soft)); } .recorder-save { @apply mt-2; } .cooldown .btn-block { @apply mt-6; } .cooldown-copy, .cooldown .recorder-copy { @apply mt-4 max-w-xs text-base leading-relaxed text-muted; } @@ -55,6 +56,7 @@ .toggle input { @apply h-5 w-5; accent-color: var(--brand); } :root[data-theme='dark'] .toggle input { accent-color: var(--color-turquoise); } .editor-submit { @apply mt-6; } + .trim-confirm { @apply mt-2; } .editor-back { @apply mx-auto mt-5 block text-center text-sm font-semibold text-muted underline-offset-4 hover:text-accent-ink hover:underline; } /* Two overlapping range inputs form the dual-handle trim slider. Only the thumbs take pointer events. */ @@ -72,6 +74,11 @@ .trim input:focus-visible::-webkit-slider-thumb { outline: 3px solid var(--focus); outline-offset: 2px; } .trim input:focus-visible::-moz-range-thumb { outline: 3px solid var(--focus); outline-offset: 2px; } + @media (max-width: 767px) { + .recorder-stage { aspect-ratio: 3 / 4; max-height: min(75svh, 34rem); } + .recorder-video { @apply object-contain; } + } + /* After submitting */ .submitted { @apply py-20 sm:py-28; } .submitted-check { @apply mb-6 grid h-16 w-16 place-items-center rounded-full bg-success-soft text-2xl text-success; } diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..408821b --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/" + } + ] +}