Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions frontend/.gitignore → .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
node_modules/
.env
uploads/

# Logs
logs
*.log
Expand All @@ -13,8 +17,8 @@ dist-ssr
*.local

# Generated from src/seo/seoCatalog.mjs during the build.
public/sitemap.xml
public/robots.txt
frontend/public/sitemap.xml
frontend/public/robots.txt
react-snap-routes.json

# Editor directories and files
Expand All @@ -28,4 +32,4 @@ react-snap-routes.json
*.sln
*.sw?

.env
%SystemDrive%/
10 changes: 10 additions & 0 deletions .vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
dist
coverage
backend
uploads
*.log
.env
.env.*
!.env.example
.vscode
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Copy this file to .env and fill it in. Never commit .env.

NODE_ENV=development
PORT=5000
Expand Down Expand Up @@ -31,5 +32,9 @@ STORAGE_DRIVER=local
UPLOAD_DIR=uploads
MAX_VIDEO_UPLOAD_MB=300
MAX_RECORDING_UPLOAD_MB=100
SUBMISSION_COOLDOWN_MS=30000

RATE_LIMIT_ENABLED=true

# Minimum gap, in milliseconds, between two submissions for the same (user, video).
SUBMISSION_COOLDOWN_MS=30000
3 changes: 0 additions & 3 deletions backend/.gitignore

This file was deleted.

19 changes: 10 additions & 9 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Dependencies only point down. Controllers never touch models; services never see

`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.

## Responses

```jsonc
Expand All @@ -50,22 +52,22 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404,
| Method + path | Who | Notes |
| --- | --- | --- |
| `GET /health` | anyone | 503 if the database is down |
| `POST /auth/signup` | anyone | Always creates a learner. Sets the cookie |
| `POST /auth/signup` | anyone | Always creates a contributor. Sets the cookie |
| `POST /auth/login` | anyone | Sets the cookie |
| `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 learners: published + categorised only. Admin: everything |
| `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` | logged in | Streams with Range support |
| `POST /videos` | admin | `multipart/form-data`: `video` (required), `poster` (optional), `title`, `categoryId`, `status`, `durationSec` |
| `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 | Learners: their own. Admin: all |
| `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` | admin | Learners can never play a submission back |
| `GET /submissions/:id/recording` | admin | Contributors can never play a submission back |
| `POST /contact` | anyone | Rate limited: 5 per hour per IP |
| `GET /admin/users`, `/admin/stats?days=14`, `/admin/messages` | admin | |

Expand All @@ -77,7 +79,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404,

**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.

**The lock rule.** A learner can submit a lesson once, ever. It is enforced twice: the service checks first, and a unique index on `(user, video)` settles races (three simultaneous submits produce exactly one, and the losers' files are cleaned up). There is no update or delete route for submissions.
**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 or deleted once made, and only an admin can watch one back.

**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.

Expand All @@ -89,15 +91,14 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404,

## Testing

`npm test` runs `tests/api.test.js` (30 tests): auth, roles, uploads, Range streaming, the lock rule, stats, contact, cleanup of temp files. It calls the real HTTP stack.
`npm test` runs `tests/api.test.js` (31 tests): auth, roles, uploads, Range streaming, the cooldown rule (including a real elapsed-cooldown resubmission, sped up via `SUBMISSION_COOLDOWN_MS=200` in `tests/helpers.js`), stats, contact, cleanup of temp files. It calls the real HTTP stack.

- 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

- **ffmpeg trimming and probing.** Submissions store `trimStart` / `trimEnd` / `mirrored` but the recording is saved untouched.
- **Frontend hookup.** `frontend/src/services/api.js` still uses the mock. When it switches: add a Vite proxy for `/api` to `http://localhost:5000` (keeps the cookie same-origin), send `credentials: 'include'`, and note that lists now come back as `{ data: [...] }`.

## Housekeeping

Expand Down
76 changes: 76 additions & 0 deletions backend/docs/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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

Signup and login set an HTTP-only `signpak_token` cookie. Browser clients must send requests with credentials enabled. Swagger UI can describe the cookie scheme, but browser authentication is normally established by calling `/auth/signup` or `/auth/login` first.

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.

## Response format

JSON success responses use:

```json
{ "data": { "id": "..." } }
```

Errors use a structured object suitable for form-level and field-level messages:

```json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Some fields need attention.",
"fields": { "email": "Enter a valid email address." }
}
}
```

`204 No Content` responses have no body. Responses include `X-Request-Id`, which can be supplied by a client and matched against server logs.

## Endpoint summary

| Method | Path | Access | Purpose |
| --- | --- | --- | --- |
| GET | `/health` | Public | Check API and database status. |
| POST | `/auth/signup` | Public | Create a contributor account and session. |
| POST | `/auth/login` | Public | Create a session. |
| 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 | `/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/POST | `/submissions` | User | List or create contributor recordings. |
| GET | `/submissions/:id/recording` | Admin | Stream a contributor recording. |
| POST | `/contact` | Public | Send a contact message. |
| 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 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.

## 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`.
- Only admins can stream submission recordings.
- Video reference files are public only when the corresponding video passes the same published visibility check.
64 changes: 64 additions & 0 deletions backend/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SignPak Commons Backend Architecture

## Runtime

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.

## 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
```

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.

## 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. |
| 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; only admins can access a submission recording.

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 submission recording playback.
- 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.
Loading
Loading