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
15 changes: 15 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ JWT_EXPIRES_DAYS=7
COOKIE_SAMESITE=lax
BCRYPT_ROUNDS=12

# Brevo transactional email. Verify this sender in your Brevo account.
BREVO_API_KEY=
BREVO_SENDER_EMAIL=auth@signpakcommons.org
BREVO_SENDER_NAME=SignPak Commons
# One-time verification/reset codes expire after this many minutes.
AUTH_OTP_TTL_MINUTES=5
AUTH_OTP_RESEND_SECONDS=60
AUTH_OTP_MAX_ATTEMPTS=5

# Web3Forms contact form delivery; get the access key from web3forms.com.
WEB3FORMS_ACCESS_KEY=
CONTACT_DAILY_LIMIT=25
CONTACT_USER_DAILY_LIMIT=1
CONTACT_ABUSE_BLOCK_MINUTES=10

# The admin account is created (or promoted) from these on every start.
ADMIN_EMAIL=admin@signpak.dev
ADMIN_PASSWORD=
Expand Down
14 changes: 9 additions & 5 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Express + Node.js + MongoDB (Mongoose). Cookie-based auth, local base-video stor
```bash
cd backend
npm install
cp .env.example .env # then fill in JWT_SECRET, ADMIN_PASSWORD and MONGODB_URI
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")
```
Expand Down Expand Up @@ -52,8 +52,12 @@ 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 contributor. Sets the cookie |
| `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 | |
Expand All @@ -68,12 +72,12 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404,
| `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` | anyone | Rate limited: 5 per hour per IP |
| `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. 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.
**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.

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

## Testing

`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.
`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.
Expand Down
18 changes: 15 additions & 3 deletions backend/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ The OpenAPI document is maintained in [`src/docs/openapi.js`](../src/docs/openap

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

## Response format

JSON success responses use:
Expand Down Expand Up @@ -43,8 +45,12 @@ Errors use a structured object suitable for form-level and field-level messages:
| Method | Path | Access | Purpose |
| --- | --- | --- | --- |
| GET | `/health` | Public | Check API and database status. |
| POST | `/auth/signup` | Public | Create a contributor account and session. |
| 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. |
Expand All @@ -54,9 +60,11 @@ Errors use a structured object suitable for form-level and field-level messages:
| 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` | Public | Send a contact message. |
| 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. |
Expand All @@ -65,8 +73,12 @@ Errors use a structured object suitable for form-level and field-level messages:

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.
Expand Down
147 changes: 11 additions & 136 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.1.0",
"description": "SignPakCommons API: Express, Node.js and MongoDB",
"main": "server.js",
"scripts": {
Expand All @@ -15,6 +15,7 @@
"node": ">=20"
},
"dependencies": {
"@getbrevo/brevo": "^6.0.3",
"axios": "^1.20.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
Expand Down
19 changes: 19 additions & 0 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ const schema = z.object({
COOKIE_SECURE: bool.optional(),
COOKIE_SAMESITE: z.enum(['lax', 'strict', 'none']).default('lax'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(12),
BREVO_API_KEY: z.string().optional(),
BREVO_SENDER_EMAIL: z.email().optional(),
BREVO_SENDER_NAME: z.string().min(1).default('SignPak Commons'),
AUTH_OTP_TTL_MINUTES: z.coerce.number().int().min(1).max(30).default(5),
AUTH_OTP_RESEND_SECONDS: z.coerce.number().int().min(0).max(3600).default(60),
AUTH_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5),
WEB3FORMS_ACCESS_KEY: z.string().optional(),
CONTACT_DAILY_LIMIT: z.coerce.number().int().min(1).default(25),
CONTACT_USER_DAILY_LIMIT: z.coerce.number().int().min(1).default(1),
CONTACT_ABUSE_BLOCK_MINUTES: z.coerce.number().int().min(1).max(1440).default(10),

ADMIN_EMAIL: z.email({ error: 'ADMIN_EMAIL is required and must be a valid email' }),
ADMIN_PASSWORD: z.string({ error: 'ADMIN_PASSWORD is required (at least 8 characters)' }).min(8, 'ADMIN_PASSWORD must be at least 8 characters').max(72),
Expand All @@ -40,6 +50,15 @@ const schema = z.object({
// (like BCRYPT_ROUNDS) so tests do not have to wait 30 real seconds.
SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000),
}).superRefine((data, context) => {
if (data.NODE_ENV === 'production' && !data.BREVO_API_KEY) {
context.addIssue({ code: 'custom', path: ['BREVO_API_KEY'], message: 'Required in production for email verification and password reset.' })
}
if (data.NODE_ENV === 'production' && !data.BREVO_SENDER_EMAIL) {
context.addIssue({ code: 'custom', path: ['BREVO_SENDER_EMAIL'], message: 'Required in production; use a verified Brevo sender address.' })
}
if (data.NODE_ENV === 'production' && !data.WEB3FORMS_ACCESS_KEY) {
context.addIssue({ code: 'custom', path: ['WEB3FORMS_ACCESS_KEY'], message: 'Required in production to deliver contact messages.' })
}
const needsGoogle = data.STORAGE_DRIVER === 'gdrive' || data.ARCHIVE_STORAGE_DRIVER === 'gdrive'
if (needsGoogle && !data.GOOGLE_SERVICE_ACCOUNT_JSON) {
context.addIssue({ code: 'custom', path: ['GOOGLE_SERVICE_ACCOUNT_JSON'], message: 'Required when STORAGE_DRIVER=gdrive.' })
Expand Down
Loading
Loading