diff --git a/backend/.env.example b/backend/.env.example index b64233f..13f25c2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/README.md b/backend/README.md index 3982722..feadff9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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") ``` @@ -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 | | @@ -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. @@ -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. diff --git a/backend/docs/API.md b/backend/docs/API.md index 5dccae1..7cc7398 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -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: @@ -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. | @@ -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. | @@ -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. diff --git a/backend/package-lock.json b/backend/package-lock.json index 79d4ba4..e5e29f5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,14 +1,15 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { + "@getbrevo/brevo": "^6.0.3", "axios": "^1.20.0", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", @@ -43,6 +44,14 @@ "node": ">=20" } }, + "node_modules/@getbrevo/brevo": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@getbrevo/brevo/-/brevo-6.0.3.tgz", + "integrity": "sha512-yWBLKoHC8P5HLZ8z6iRuqDYLpCRKPTUETnZOLZzNDWGFYL78y4SHOTxQVrKH5ROX7f4q+SQ19LD3GWtJlIuNXA==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.4.tgz", @@ -1594,21 +1603,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -2011,21 +2005,6 @@ "@types/webidl-conversions": "*" } }, - "node_modules/mongodb-memory-server-core/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/mongodb-memory-server-core/node_modules/bson": { "version": "6.10.4", "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", @@ -2054,56 +2033,6 @@ } } }, - "node_modules/mongodb-memory-server-core/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/mongodb-memory-server-core/node_modules/mongodb": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", @@ -2169,60 +2098,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true - }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/mongodb-memory-server-core/node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 561475d..86841e3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { @@ -15,6 +15,7 @@ "node": ">=20" }, "dependencies": { + "@getbrevo/brevo": "^6.0.3", "axios": "^1.20.0", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 20013c0..c257d61 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -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), @@ -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.' }) diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 440ed46..d1fbcdb 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -5,10 +5,7 @@ import { clearAuthCookie, setAuthCookie } from '../utils/tokens.js' export const authController = { signup: asyncHandler(async (req, res) => { - const { user, token } = await authService.register(req.body) - await authService.recordDevice(user, req.get('X-Device-ID')) - setAuthCookie(res, token) - created(res, user) + created(res, await authService.register(req.body)) }), login: asyncHandler(async (req, res) => { @@ -18,6 +15,28 @@ export const authController = { ok(res, user) }), + verifyEmail: asyncHandler(async (req, res) => { + const { user, token } = await authService.verifyEmail(req.body) + await authService.recordDevice(user, req.get('X-Device-ID')) + setAuthCookie(res, token) + ok(res, user) + }), + + resendVerification: asyncHandler(async (req, res) => { + await authService.resendVerification(req.body.email) + ok(res, { message: 'If the account needs verification, a code will be sent.' }) + }), + + forgotPassword: asyncHandler(async (req, res) => { + await authService.requestPasswordReset(req.body.email) + ok(res, { message: 'If an account exists for this email, a reset code will be sent.' }) + }), + + resetPassword: asyncHandler(async (req, res) => { + await authService.resetPassword(req.body) + ok(res, { message: 'Password reset. You can now log in.' }) + }), + logout: (req, res) => { clearAuthCookie(res) noContent(res) diff --git a/backend/src/controllers/contactController.js b/backend/src/controllers/contactController.js index 9b98167..40d7f16 100644 --- a/backend/src/controllers/contactController.js +++ b/backend/src/controllers/contactController.js @@ -3,6 +3,13 @@ import { asyncHandler } from '../utils/asyncHandler.js' import { created, ok } from '../utils/response.js' export const contactController = { - send: asyncHandler(async (req, res) => { await contactService.send(req.body); created(res, { received: true }) }), + send: asyncHandler(async (req, res) => { + await contactService.send(req.body, { + user: req.user, + ip: req.ip, + deviceId: req.get('X-Device-ID')?.slice(0, 128), + }) + created(res, { received: true }) + }), list: asyncHandler(async (req, res) => ok(res, await contactService.list(req.query.limit))), } diff --git a/backend/src/controllers/demoVideoController.js b/backend/src/controllers/demoVideoController.js new file mode 100644 index 0000000..149cffe --- /dev/null +++ b/backend/src/controllers/demoVideoController.js @@ -0,0 +1,11 @@ +import { demoVideoService } from '../services/demoVideoService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { created, noContent, ok } from '../utils/response.js' +import { sendFile } from '../utils/sendFile.js' + +export const demoVideoController = { + get: asyncHandler(async (req, res) => ok(res, await demoVideoService.get())), + upload: asyncHandler(async (req, res) => created(res, await demoVideoService.upload(req.body, req.file))), + remove: asyncHandler(async (req, res) => { await demoVideoService.remove(); noContent(res) }), + file: asyncHandler(async (req, res) => sendFile(req, res, await demoVideoService.getFile(), { cache: 'no-cache' })), +} diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index d751a16..7235a1e 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -47,13 +47,14 @@ export const openapi = { { name: 'Users' }, { name: 'Categories' }, { name: 'Videos' }, + { name: 'Demo Video' }, { name: 'Submissions' }, { name: 'Contact' }, { name: 'Admin' }, ], components: { securitySchemes: { - cookieAuth: { type: 'apiKey', in: 'cookie', name: 'token', description: 'HTTP-only JWT cookie set by login or signup.' }, + cookieAuth: { type: 'apiKey', in: 'cookie', name: 'signpak_token', description: 'HTTP-only JWT cookie set by login or email verification.' }, }, schemas: { Category: { @@ -74,10 +75,18 @@ export const openapi = { type: 'object', properties: { id: { type: 'string' }, 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 } }, }, }, + SignupInput: { + 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' }, + }, + }, AuthInput: { type: 'object', required: ['email', 'password'], properties: { @@ -98,11 +107,15 @@ export const openapi = { }, '/docs.json': { get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } } }, '/auth/signup': { - post: { tags: ['Auth'], summary: 'Create an account', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AuthInput' } } } }, responses: { 201: { description: 'Account created' }, 400: errorResponse } }, + 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 } }, }, '/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' } } } }, '/users/me': { @@ -130,11 +143,17 @@ export const openapi = { }, '/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 } }, + }, + '/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 } }, }, - '/contact': { post: { tags: ['Contact'], summary: 'Send a contact message', 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' }, 400: errorResponse } } }, + '/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], diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js index 9e3b9f6..ab53f3b 100644 --- a/backend/src/middlewares/authenticate.js +++ b/backend/src/middlewares/authenticate.js @@ -6,9 +6,10 @@ import { verifyToken } from '../utils/tokens.js' async function loadUser(req) { const token = req.cookies?.[AUTH_COOKIE] - const userId = token && verifyToken(token) + const session = token && verifyToken(token) // The user is re-read on every request, so a deleted account or changed role takes effect immediately. - return userId ? userRepo.findById(userId) : null + const user = session ? await userRepo.findById(session.userId) : null + return user && (user.authVersion || 0) === session.authVersion ? user : null } /** Requires a valid session. Sets req.user. */ diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js index 2246944..c40d53a 100644 --- a/backend/src/middlewares/upload.js +++ b/backend/src/middlewares/upload.js @@ -27,5 +27,8 @@ const build = ({ maxMb, rules, files }) => multer({ export const videoUpload = build({ maxMb: env.MAX_VIDEO_UPLOAD_MB, rules: { video: 'video/', poster: 'image/' }, files: 2 }) .fields([{ name: 'video', maxCount: 1 }, { name: 'poster', maxCount: 1 }]) +/** Dedicated public demo video, managed separately from lesson videos. */ +export const demoVideoUpload = build({ maxMb: env.MAX_VIDEO_UPLOAD_MB, rules: { video: 'video/' }, files: 1 }).single('video') + /** A contributor's recording. */ export const recordingUpload = build({ maxMb: env.MAX_RECORDING_UPLOAD_MB, rules: { recording: 'video/' }, files: 1 }).single('recording') diff --git a/backend/src/middlewares/validators/authSchemas.js b/backend/src/middlewares/validators/authSchemas.js index 8619394..fd1313b 100644 --- a/backend/src/middlewares/validators/authSchemas.js +++ b/backend/src/middlewares/validators/authSchemas.js @@ -16,3 +16,17 @@ export const loginSchema = z.object({ email: emailField, password: z.string({ error: 'Enter your password.' }).min(1, 'Enter your password.').max(200), }) + +export const emailOnlySchema = z.object({ email: emailField }) + +export const verifyEmailSchema = z.object({ + email: emailField, + code: z.string().regex(/^\d{6}$/, 'Enter the six-digit code from your email.'), +}) + +export const resetPasswordSchema = z.object({ + email: emailField, + code: z.string().regex(/^\d{6}$/, 'Enter the six-digit code from your email.'), + password: z.string({ error: 'Create a password.' }).min(8, 'Use at least 8 characters.').max(72, 'Use 72 characters or fewer.'), + confirmPassword: z.string(), +}).refine((data) => data.confirmPassword === data.password, { path: ['confirmPassword'], message: 'Passwords do not match.' }) diff --git a/backend/src/middlewares/validators/demoVideoSchemas.js b/backend/src/middlewares/validators/demoVideoSchemas.js new file mode 100644 index 0000000..ddf9a9a --- /dev/null +++ b/backend/src/middlewares/validators/demoVideoSchemas.js @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const uploadDemoVideoSchema = z.object({ + title: z.string({ error: 'Give the demo video a title.' }).trim().min(1, 'Give the demo video a title.').max(120, 'Keep the title under 120 characters.'), + durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), +}) diff --git a/backend/src/models/AccessRestriction.js b/backend/src/models/AccessRestriction.js index dcab8b0..5ac2a8d 100644 --- a/backend/src/models/AccessRestriction.js +++ b/backend/src/models/AccessRestriction.js @@ -6,7 +6,8 @@ const accessRestrictionSchema = new mongoose.Schema({ identifierHash: { type: String, required: true }, identifierHint: { type: String, required: true }, reason: { type: String, trim: true, maxlength: 500, default: '' }, - createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, + expiresAt: { type: Date, default: null }, }, { timestamps: true, versionKey: false, @@ -14,5 +15,6 @@ const accessRestrictionSchema = new mongoose.Schema({ }) accessRestrictionSchema.index({ type: 1, identifierHash: 1 }, { unique: true }) +accessRestrictionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }) export const AccessRestriction = mongoose.model('AccessRestriction', accessRestrictionSchema) \ No newline at end of file diff --git a/backend/src/models/ContactDailyQuota.js b/backend/src/models/ContactDailyQuota.js new file mode 100644 index 0000000..33fad1f --- /dev/null +++ b/backend/src/models/ContactDailyQuota.js @@ -0,0 +1,8 @@ +import mongoose from 'mongoose' + +const contactDailyQuotaSchema = new mongoose.Schema({ + _id: { type: String }, + count: { type: Number, default: 0, min: 0 }, +}, { versionKey: false }) + +export const ContactDailyQuota = mongoose.model('ContactDailyQuota', contactDailyQuotaSchema) diff --git a/backend/src/models/ContactMessage.js b/backend/src/models/ContactMessage.js index 229e71e..adadbd4 100644 --- a/backend/src/models/ContactMessage.js +++ b/backend/src/models/ContactMessage.js @@ -2,10 +2,16 @@ import mongoose from 'mongoose' import { jsonOptions } from '../utils/mongoose.js' const contactMessageSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, name: { type: String, required: true, trim: true, maxlength: 100 }, email: { type: String, required: true, lowercase: true, trim: true, maxlength: 254 }, message: { type: String, required: true, trim: true, maxlength: 2000 }, -}, { timestamps: true, toJSON: jsonOptions((doc, ret) => { delete ret.updatedAt; return ret }) }) +}, { timestamps: true, toJSON: jsonOptions((doc, ret) => { + ret.userId = ret.user ? String(ret.user) : null + delete ret.user + delete ret.updatedAt + return ret +}) }) contactMessageSchema.index({ createdAt: -1 }) diff --git a/backend/src/models/ContactUserQuota.js b/backend/src/models/ContactUserQuota.js new file mode 100644 index 0000000..dfd21d1 --- /dev/null +++ b/backend/src/models/ContactUserQuota.js @@ -0,0 +1,10 @@ +import mongoose from 'mongoose' + +const contactUserQuotaSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, required: true }, + day: { type: String, required: true }, +}, { versionKey: false }) + +contactUserQuotaSchema.index({ user: 1, day: 1 }, { unique: true }) + +export const ContactUserQuota = mongoose.model('ContactUserQuota', contactUserQuotaSchema) diff --git a/backend/src/models/DemoVideo.js b/backend/src/models/DemoVideo.js new file mode 100644 index 0000000..313c849 --- /dev/null +++ b/backend/src/models/DemoVideo.js @@ -0,0 +1,22 @@ +import mongoose from 'mongoose' +import { API_PREFIX } from '../config/constants.js' +import { jsonOptions } from '../utils/mongoose.js' +import { storedFileSchema } from './storedFile.js' + +const demoVideoSchema = new mongoose.Schema({ + _id: { type: String, default: 'current' }, + title: { type: String, required: true, trim: true, maxlength: 120 }, + durationSec: { type: Number, default: 0, min: 0 }, + videoFile: { type: storedFileSchema, required: true }, +}, { + timestamps: true, + toJSON: jsonOptions((doc, ret) => { + ret.videoUrl = `${API_PREFIX}/demo-video/file` + ret.size = ret.videoFile?.size ?? 0 + delete ret.videoFile + delete ret.updatedAt + return ret + }), +}) + +export const DemoVideo = mongoose.model('DemoVideo', demoVideoSchema) diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 4903105..7448259 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -7,6 +7,16 @@ const userSchema = new mongoose.Schema({ firstName: { type: String, required: true, trim: true, maxlength: 60 }, surname: { type: String, required: true, trim: true, maxlength: 60 }, passwordHash: { type: String, required: true, select: false }, + authVersion: { type: Number, default: 0 }, + emailVerified: { type: Boolean, default: true }, + emailVerificationCodeHash: { type: String, default: null, select: false }, + emailVerificationExpiresAt: { type: Date, default: null, select: false }, + emailVerificationAttempts: { type: Number, default: 0, select: false }, + emailVerificationSentAt: { type: Date, default: null, select: false }, + passwordResetCodeHash: { type: String, default: null, select: false }, + passwordResetExpiresAt: { type: Date, default: null, select: false }, + passwordResetAttempts: { type: Number, default: 0, select: false }, + passwordResetSentAt: { type: Date, default: null, select: false }, role: { type: String, enum: ROLE_LIST, default: ROLES.USER }, status: { type: String, enum: ['active', 'suspended'], default: 'active' }, statusReason: { type: String, trim: true, maxlength: 500, default: '' }, @@ -17,7 +27,10 @@ const userSchema = new mongoose.Schema({ }, }, { timestamps: true, - toJSON: jsonOptions((doc, ret) => { delete ret.passwordHash; delete ret.updatedAt; delete ret.lastDeviceId; return ret }), + toJSON: jsonOptions((doc, ret) => { + for (const key of ['passwordHash', 'authVersion', 'updatedAt', 'lastDeviceId', 'emailVerificationCodeHash', 'emailVerificationExpiresAt', 'emailVerificationAttempts', 'emailVerificationSentAt', 'passwordResetCodeHash', 'passwordResetExpiresAt', 'passwordResetAttempts', 'passwordResetSentAt']) delete ret[key] + return ret + }), }) export const User = mongoose.model('User', userSchema) diff --git a/backend/src/repositories/contactQuotaRepo.js b/backend/src/repositories/contactQuotaRepo.js new file mode 100644 index 0000000..ba67f2a --- /dev/null +++ b/backend/src/repositories/contactQuotaRepo.js @@ -0,0 +1,42 @@ +import { ContactDailyQuota } from '../models/ContactDailyQuota.js' +import { ContactUserQuota } from '../models/ContactUserQuota.js' +import { isDuplicateKeyError } from '../utils/mongoose.js' + +export const contactQuotaRepo = { + async claimUser(userId, day) { + try { + await ContactUserQuota.create({ user: userId, day }) + return true + } catch (error) { + if (isDuplicateKeyError(error)) return false + throw error + } + }, + + releaseUser(userId, day) { + return ContactUserQuota.deleteOne({ user: userId, day }) + }, + + async claimDaily(day, maximum) { + try { + await ContactDailyQuota.findOneAndUpdate( + { _id: day, count: { $lt: maximum } }, + { $inc: { count: 1 } }, + { upsert: true, new: true }, + ) + return true + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + const updated = await ContactDailyQuota.findOneAndUpdate( + { _id: day, count: { $lt: maximum } }, + { $inc: { count: 1 } }, + { new: true }, + ) + return Boolean(updated) + } + }, + + releaseDaily(day) { + return ContactDailyQuota.updateOne({ _id: day, count: { $gt: 0 } }, { $inc: { count: -1 } }) + }, +} diff --git a/backend/src/repositories/demoVideoRepo.js b/backend/src/repositories/demoVideoRepo.js new file mode 100644 index 0000000..3be3dd8 --- /dev/null +++ b/backend/src/repositories/demoVideoRepo.js @@ -0,0 +1,13 @@ +import { DemoVideo } from '../models/DemoVideo.js' + +const CURRENT = 'current' + +export const demoVideoRepo = { + find: () => DemoVideo.findById(CURRENT), + replace: (data) => DemoVideo.findOneAndReplace( + { _id: CURRENT }, + { _id: CURRENT, ...data }, + { new: true, upsert: true, runValidators: true }, + ), + remove: () => DemoVideo.findByIdAndDelete(CURRENT), +} diff --git a/backend/src/repositories/messageRepo.js b/backend/src/repositories/messageRepo.js index ea3e764..7cbebfb 100644 --- a/backend/src/repositories/messageRepo.js +++ b/backend/src/repositories/messageRepo.js @@ -2,5 +2,6 @@ import { ContactMessage } from '../models/ContactMessage.js' export const messageRepo = { create: (data) => ContactMessage.create(data), + remove: (id) => ContactMessage.deleteOne({ _id: id }), list: (limit) => ContactMessage.find().sort({ createdAt: -1 }).limit(limit), } diff --git a/backend/src/repositories/userRepo.js b/backend/src/repositories/userRepo.js index f38e091..b86a3e4 100644 --- a/backend/src/repositories/userRepo.js +++ b/backend/src/repositories/userRepo.js @@ -2,9 +2,11 @@ import { User } from '../models/User.js' export const userRepo = { findById: (id) => User.findById(id), - findByEmail: (email, { withPassword = false } = {}) => { + findByEmail: (email, { withPassword = false, withOtp = false } = {}) => { const query = User.findOne({ email: email.trim().toLowerCase() }) - return withPassword ? query.select('+passwordHash') : query + if (withPassword) query.select('+passwordHash') + if (withOtp) query.select('+emailVerificationCodeHash +emailVerificationExpiresAt +emailVerificationAttempts +emailVerificationSentAt +passwordResetCodeHash +passwordResetExpiresAt +passwordResetAttempts +passwordResetSentAt') + return query }, findManyByIds: (ids) => User.find({ _id: { $in: ids } }), create: (data) => User.create(data), diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 504c2ff..d9fecf9 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -3,12 +3,16 @@ import { authController } from '../controllers/authController.js' import { optionalAuthenticate } from '../middlewares/authenticate.js' import { authLimiter } from '../middlewares/rateLimiter.js' import { validate } from '../middlewares/validate.js' -import { loginSchema, signupSchema } from '../middlewares/validators/authSchemas.js' +import { emailOnlySchema, loginSchema, resetPasswordSchema, signupSchema, verifyEmailSchema } from '../middlewares/validators/authSchemas.js' const router = Router() router.post('/signup', authLimiter, validate({ body: signupSchema }), authController.signup) router.post('/login', authLimiter, validate({ body: loginSchema }), authController.login) +router.post('/verify-email', authLimiter, validate({ body: verifyEmailSchema }), authController.verifyEmail) +router.post('/resend-verification', authLimiter, validate({ body: emailOnlySchema }), authController.resendVerification) +router.post('/forgot-password', authLimiter, validate({ body: emailOnlySchema }), authController.forgotPassword) +router.post('/reset-password', authLimiter, validate({ body: resetPasswordSchema }), authController.resetPassword) router.post('/logout', authController.logout) router.get('/session', optionalAuthenticate, authController.session) diff --git a/backend/src/routes/contactRoutes.js b/backend/src/routes/contactRoutes.js index 3865d10..a531cc5 100644 --- a/backend/src/routes/contactRoutes.js +++ b/backend/src/routes/contactRoutes.js @@ -1,4 +1,5 @@ import { Router } from 'express' +import { optionalAuthenticate } from '../middlewares/authenticate.js' import { contactController } from '../controllers/contactController.js' import { contactLimiter } from '../middlewares/rateLimiter.js' import { validate } from '../middlewares/validate.js' @@ -6,6 +7,6 @@ import { contactSchema } from '../middlewares/validators/miscSchemas.js' const router = Router() -router.post('/', contactLimiter, validate({ body: contactSchema }), contactController.send) +router.post('/', contactLimiter, optionalAuthenticate, validate({ body: contactSchema }), contactController.send) export default router diff --git a/backend/src/routes/demoVideoRoutes.js b/backend/src/routes/demoVideoRoutes.js new file mode 100644 index 0000000..0638185 --- /dev/null +++ b/backend/src/routes/demoVideoRoutes.js @@ -0,0 +1,19 @@ +import { Router } from 'express' +import { ROLES } from '../config/constants.js' +import { demoVideoController } from '../controllers/demoVideoController.js' +import { authenticate } from '../middlewares/authenticate.js' +import { requireRole } from '../middlewares/requireRole.js' +import { uploadLimiter } from '../middlewares/rateLimiter.js' +import { validate } from '../middlewares/validate.js' +import { demoVideoUpload } from '../middlewares/upload.js' +import { uploadDemoVideoSchema } from '../middlewares/validators/demoVideoSchemas.js' + +const router = Router() +const adminOnly = [authenticate, requireRole(ROLES.ADMIN)] + +router.get('/', demoVideoController.get) +router.get('/file', demoVideoController.file) +router.post('/', ...adminOnly, uploadLimiter, demoVideoUpload, validate({ body: uploadDemoVideoSchema }), demoVideoController.upload) +router.delete('/', ...adminOnly, demoVideoController.remove) + +export default router diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index c1b3a00..ee3f7f2 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -6,6 +6,7 @@ import adminRoutes from './adminRoutes.js' import authRoutes from './authRoutes.js' import categoryRoutes from './categoryRoutes.js' import contactRoutes from './contactRoutes.js' +import demoVideoRoutes from './demoVideoRoutes.js' import submissionRoutes from './submissionRoutes.js' import userRoutes from './userRoutes.js' import videoRoutes from './videoRoutes.js' @@ -23,6 +24,7 @@ router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true } router.use('/auth', authRoutes) router.use('/users', userRoutes) router.use('/categories', categoryRoutes) +router.use('/demo-video', demoVideoRoutes) router.use('/videos', videoRoutes) router.use('/submissions', submissionRoutes) router.use('/contact', contactRoutes) diff --git a/backend/src/services/accessRestrictionService.js b/backend/src/services/accessRestrictionService.js index d83efa9..736c65a 100644 --- a/backend/src/services/accessRestrictionService.js +++ b/backend/src/services/accessRestrictionService.js @@ -14,7 +14,7 @@ function hint(type, value) { } export const accessRestrictionService = { - list: () => AccessRestriction.find().sort({ createdAt: -1 }), + list: () => AccessRestriction.find({ $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] }).sort({ createdAt: -1 }), async create({ type, value, reason }, adminId) { try { @@ -39,6 +39,34 @@ export const accessRestrictionService = { async isRequestRestricted(ip, deviceId) { const identifiers = [{ type: 'ip', identifierHash: hash(ip) }] if (deviceId) identifiers.push({ type: 'device', identifierHash: hash(deviceId) }) - return AccessRestriction.exists({ $or: identifiers }) + return AccessRestriction.exists({ $and: [ + { $or: identifiers }, + { $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] }, + ] }) + }, + + async temporarilyRestrictRequest(ip, deviceId, reason, durationMs) { + const target = deviceId ? { type: 'device', value: deviceId } : { type: 'ip', value: ip } + const filter = { type: target.type, identifierHash: hash(target.value) } + const expiresAt = new Date(Date.now() + durationMs) + let restriction = await AccessRestriction.findOne(filter) + if (restriction) { + if (!restriction.expiresAt) return + restriction.expiresAt = expiresAt + restriction.reason = reason + await restriction.save() + return + } + try { + await AccessRestriction.create({ ...filter, identifierHint: hint(target.type, target.value), reason, expiresAt }) + } catch (error) { + if (error?.code !== 11000) throw error + restriction = await AccessRestriction.findOne(filter) + if (restriction?.expiresAt) { + restriction.expiresAt = expiresAt + restriction.reason = reason + await restriction.save() + } + } }, } \ No newline at end of file diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js index 5312ce4..5119dfc 100644 --- a/backend/src/services/authService.js +++ b/backend/src/services/authService.js @@ -1,37 +1,134 @@ import bcrypt from 'bcrypt' +import { createHmac, randomInt, timingSafeEqual } from 'node:crypto' import { ROLES } from '../config/constants.js' import { env } from '../config/env.js' import { userRepo } from '../repositories/userRepo.js' -import { conflict, unauthorized } from '../utils/AppError.js' +import { badRequest, conflict, unauthorized } from '../utils/AppError.js' import { logger } from '../utils/logger.js' import { isDuplicateKeyError } from '../utils/mongoose.js' import { signToken } from '../utils/tokens.js' +import { sendOTPEmailBrevo } from './external/sendOTPBrevo.js' // Compared against when the email is unknown, so "no such user" takes as long as "wrong password". const DUMMY_HASH = bcrypt.hashSync('signpak-timing-guard', env.BCRYPT_ROUNDS) const emailTaken = () => conflict('An account with this email already exists. Log in instead.', { email: 'An account with this email already exists.' }) +const invalidCode = () => badRequest('That code is invalid or has expired. Request a new code and try again.') +const otpHash = (purpose, userId, otp) => createHmac('sha256', env.JWT_SECRET).update(`${purpose}:${userId}:${otp}`).digest('hex') +const clearOtp = (user, purpose) => { + const prefix = purpose === 'email-verification' ? 'emailVerification' : 'passwordReset' + user[`${prefix}CodeHash`] = null + user[`${prefix}ExpiresAt`] = null + user[`${prefix}Attempts`] = 0 +} +const matchesOtp = (stored, supplied, purpose, userId) => { + if (!stored) return false + const expected = Buffer.from(stored, 'hex') + const actual = Buffer.from(otpHash(purpose, userId, supplied), 'hex') + return expected.length === actual.length && timingSafeEqual(expected, actual) +} export const authService = { async register({ email, firstName, surname, password }) { if (await userRepo.findByEmail(email)) throw emailTaken() const passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS) try { - // `role` is never read from the request: everyone who signs up is a contributor. - const user = await userRepo.create({ email, firstName, surname, passwordHash, role: ROLES.USER }) - return { user, token: signToken(user.id) } + const user = await userRepo.create({ email, firstName, surname, passwordHash, role: ROLES.USER, emailVerified: false }) + const verificationEmailSent = await this.issueOtp(user, 'email-verification') + return { email: user.email, verificationEmailSent } } catch (error) { if (isDuplicateKeyError(error)) throw emailTaken() // two signups racing for the same email throw error } }, + async issueOtp(user, purpose) { + const prefix = purpose === 'email-verification' ? 'emailVerification' : 'passwordReset' + const otp = String(100000 + randomInt(900000)) + user[`${prefix}CodeHash`] = otpHash(purpose, user._id, otp) + user[`${prefix}ExpiresAt`] = new Date(Date.now() + env.AUTH_OTP_TTL_MINUTES * 60_000) + user[`${prefix}Attempts`] = 0 + user[`${prefix}SentAt`] = new Date() + await userRepo.save(user) + try { + await this.sendOTPEmail(user.email, otp, purpose) + return true + } catch (error) { + logger.error({ err: error, purpose }, 'Could not deliver authentication code') + clearOtp(user, purpose) + user[`${prefix}SentAt`] = null + await userRepo.save(user) + return false + } + }, + + sendOTPEmail: sendOTPEmailBrevo, + + async verifyEmail({ email, code }) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user || user.emailVerified) throw invalidCode() + const expiry = user.emailVerificationExpiresAt?.getTime() || 0 + if (!user.emailVerificationCodeHash || expiry < Date.now() || user.emailVerificationAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) { + clearOtp(user, 'email-verification') + await userRepo.save(user) + throw invalidCode() + } + if (!matchesOtp(user.emailVerificationCodeHash, code, 'email-verification', user._id)) { + user.emailVerificationAttempts += 1 + if (user.emailVerificationAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) clearOtp(user, 'email-verification') + await userRepo.save(user) + throw invalidCode() + } + user.emailVerified = true + clearOtp(user, 'email-verification') + await userRepo.save(user) + return { user, token: signToken(user.id, user.authVersion || 0) } + }, + + async resendVerification(email) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user || user.emailVerified) return false + const sentAt = user.emailVerificationSentAt?.getTime() || 0 + if (Date.now() - sentAt < env.AUTH_OTP_RESEND_SECONDS * 1000) return false + return this.issueOtp(user, 'email-verification') + }, + + async requestPasswordReset(email) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user) return false + const sentAt = user.passwordResetSentAt?.getTime() || 0 + if (Date.now() - sentAt < env.AUTH_OTP_RESEND_SECONDS * 1000) return false + return this.issueOtp(user, 'password-reset') + }, + + async resetPassword({ email, code, password }) { + const user = await userRepo.findByEmail(email, { withOtp: true }) + if (!user) throw invalidCode() + const expiry = user.passwordResetExpiresAt?.getTime() || 0 + if (!user.passwordResetCodeHash || expiry < Date.now() || user.passwordResetAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) { + clearOtp(user, 'password-reset') + await userRepo.save(user) + throw invalidCode() + } + if (!matchesOtp(user.passwordResetCodeHash, code, 'password-reset', user._id)) { + user.passwordResetAttempts += 1 + if (user.passwordResetAttempts >= env.AUTH_OTP_MAX_ATTEMPTS) clearOtp(user, 'password-reset') + await userRepo.save(user) + throw invalidCode() + } + user.passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS) + user.authVersion = (user.authVersion || 0) + 1 + clearOtp(user, 'password-reset') + await userRepo.save(user) + }, + async login({ email, password }) { const user = await userRepo.findByEmail(email, { withPassword: true }) const matches = await bcrypt.compare(password, user?.passwordHash || DUMMY_HASH) if (!user || !matches) throw unauthorized('That email and password do not match. Check them and try again.') if (user.status === 'suspended') throw unauthorized('This account is suspended.') - return { user, token: signToken(user.id) } + if (!user.emailVerified) throw unauthorized('Verify your email address before logging in.') + return { user, token: signToken(user.id, user.authVersion || 0) } }, async recordDevice(user, deviceId) { @@ -45,12 +142,13 @@ export const authService = { const existing = await userRepo.findByEmail(env.ADMIN_EMAIL) if (!existing) { const passwordHash = await bcrypt.hash(env.ADMIN_PASSWORD, env.BCRYPT_ROUNDS) - await userRepo.create({ email: env.ADMIN_EMAIL, firstName: env.ADMIN_FIRST_NAME, surname: env.ADMIN_SURNAME, passwordHash, role: ROLES.ADMIN }) + await userRepo.create({ email: env.ADMIN_EMAIL, firstName: env.ADMIN_FIRST_NAME, surname: env.ADMIN_SURNAME, passwordHash, role: ROLES.ADMIN, emailVerified: true }) logger.info({ email: env.ADMIN_EMAIL }, 'Admin account created from environment') - } else if (existing.role !== ROLES.ADMIN) { + } else if (existing.role !== ROLES.ADMIN || !existing.emailVerified) { existing.role = ROLES.ADMIN + existing.emailVerified = true await userRepo.save(existing) - logger.info({ email: env.ADMIN_EMAIL }, 'Existing account promoted to admin from environment') + logger.info({ email: env.ADMIN_EMAIL }, 'Existing account promoted or verified as admin from environment') } // An existing admin's password is intentionally left alone; changing ADMIN_PASSWORD later does not reset it. }, diff --git a/backend/src/services/contactService.js b/backend/src/services/contactService.js index 77b2a97..61ddf29 100644 --- a/backend/src/services/contactService.js +++ b/backend/src/services/contactService.js @@ -1,6 +1,56 @@ +import { env } from '../config/env.js' +import { contactQuotaRepo } from '../repositories/contactQuotaRepo.js' import { messageRepo } from '../repositories/messageRepo.js' +import { accessRestrictionService } from './accessRestrictionService.js' +import { sendContactWeb3Forms } from './external/sendContactWeb3Forms.js' +import { conflict, forbidden, tooManyRequests } from '../utils/AppError.js' + +const utcDay = () => new Date().toISOString().slice(0, 10) +const dailyLimitError = () => tooManyRequests('The contact form has reached its daily message limit. Try again tomorrow.') export const contactService = { - send: ({ name, email, message }) => messageRepo.create({ name, email, message }), + deliver: sendContactWeb3Forms, + + async send({ name, email, message }, { user, ip, deviceId }) { + const normalizedEmail = email.trim().toLowerCase() + if (!user || normalizedEmail !== user.email) { + await accessRestrictionService.temporarilyRestrictRequest( + ip, + deviceId, + user ? 'Contact form email does not match the signed-in account.' : 'Anonymous contact form submission.', + env.CONTACT_ABUSE_BLOCK_MINUTES * 60_000, + ) + throw forbidden(user ? 'Use the email address on your account to contact us.' : 'Log in with a verified account to contact us.') + } + + const day = utcDay() + const userClaimed = await contactQuotaRepo.claimUser(user._id, day) + if (!userClaimed) throw conflict('You can send one contact message per day.') + + let dailyClaimed = false + let savedMessage + try { + dailyClaimed = await contactQuotaRepo.claimDaily(day, env.CONTACT_DAILY_LIMIT) + if (!dailyClaimed) { + await accessRestrictionService.temporarilyRestrictRequest( + ip, + deviceId, + 'Daily contact message limit exceeded.', + env.CONTACT_ABUSE_BLOCK_MINUTES * 60_000, + ) + throw dailyLimitError() + } + + savedMessage = await messageRepo.create({ user: user._id, name, email: normalizedEmail, message }) + await this.deliver({ name, email: normalizedEmail, message }) + return savedMessage + } catch (error) { + if (savedMessage) await messageRepo.remove(savedMessage._id).catch(() => {}) + if (dailyClaimed) await contactQuotaRepo.releaseDaily(day) + await contactQuotaRepo.releaseUser(user._id, day) + throw error + } + }, + list: (limit) => messageRepo.list(limit), } diff --git a/backend/src/services/demoVideoService.js b/backend/src/services/demoVideoService.js new file mode 100644 index 0000000..fd05711 --- /dev/null +++ b/backend/src/services/demoVideoService.js @@ -0,0 +1,49 @@ +import { env } from '../config/env.js' +import { demoVideoRepo } from '../repositories/demoVideoRepo.js' +import { notFound, validationError } from '../utils/AppError.js' +import { sniffVideo } from '../utils/files.js' +import { logger } from '../utils/logger.js' +import { storageService } from './storage/index.js' + +export const demoVideoService = { + get: () => demoVideoRepo.find(), + + async upload(input, upload) { + if (!upload) throw validationError({ video: 'Choose a demo video to upload.' }) + const kind = await sniffVideo(upload.path) + if (!kind) throw validationError({ video: 'That does not look like an MP4, WebM or MOV video.' }) + + const videoFile = await storageService.save({ + tempPath: upload.path, + originalName: upload.originalname, + mimeType: kind.mimeType, + ext: kind.ext, + }, { folder: 'demo' }) + + try { + const current = await demoVideoRepo.find() + const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, videoFile }) + await storageService.remove(current?.videoFile) + return saved + } catch (error) { + await storageService.remove(videoFile) + throw error + } + }, + + async remove() { + const current = await demoVideoRepo.remove() + await storageService.remove(current?.videoFile) + }, + + async getFile() { + const current = await demoVideoRepo.find() + if (!current) throw notFound('No demo video is available.') + try { + return await storageService.describe(current.videoFile) + } catch (error) { + logger.error({ err: error, key: current.videoFile.key }, 'Demo video file could not be read') + throw notFound('The demo video file is missing.') + } + }, +} diff --git a/backend/src/services/external/sendContactWeb3Forms.js b/backend/src/services/external/sendContactWeb3Forms.js new file mode 100644 index 0000000..de5e72e --- /dev/null +++ b/backend/src/services/external/sendContactWeb3Forms.js @@ -0,0 +1,24 @@ +import { env } from '../../config/env.js' +import { AppError } from '../../utils/AppError.js' + +export async function sendContactWeb3Forms({ name, email, message }) { + if (!env.WEB3FORMS_ACCESS_KEY) throw new Error('WEB3FORMS_ACCESS_KEY is not configured.') + + const response = await fetch('https://api.web3forms.com/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + access_key: env.WEB3FORMS_ACCESS_KEY, + name, + email, + message, + subject: `SignPak Commons contact from ${name}`, + from_name: 'SignPak Commons Contact Form', + replyto: email, + }), + }) + const result = await response.json().catch(() => null) + if (!response.ok || !result?.success) { + throw new AppError(502, 'CONTACT_DELIVERY_FAILED', 'We could not deliver your message right now. Please try again later.') + } +} diff --git a/backend/src/services/external/sendOTPBrevo.js b/backend/src/services/external/sendOTPBrevo.js new file mode 100644 index 0000000..67dbdfe --- /dev/null +++ b/backend/src/services/external/sendOTPBrevo.js @@ -0,0 +1,28 @@ +import { BrevoClient } from '@getbrevo/brevo'; +import { env } from '../../config/env.js' + +export async function sendOTPEmailBrevo(userEmail, otp, purpose) { + if (!env.BREVO_API_KEY) throw new Error('BREVO_API_KEY is not configured.') + if (!env.BREVO_SENDER_EMAIL) throw new Error('BREVO_SENDER_EMAIL is not configured.') + const brevo = new BrevoClient({ apiKey: env.BREVO_API_KEY }) + const isReset = purpose === 'password-reset' + const subject = isReset ? 'Reset your SignPak Commons password' : 'Verify your SignPak Commons email' + const action = isReset ? 'reset your password' : 'verify your email address' + try { + await brevo.transactionalEmails.sendTransacEmail({ + sender: { name: env.BREVO_SENDER_NAME, email: env.BREVO_SENDER_EMAIL }, + to: [{ email: userEmail }], + subject, + htmlContent: ` +
Enter this code to ${action}. It expires in ${env.AUTH_OTP_TTL_MINUTES} minutes.
+${otp}
+If you didn't request this, you can ignore this email.
+Have questions about video contributions, recording specifications, or partnership opportunities? Reach out to our team.
- + {/* contact@pslai.org - + */} -Contact messages are limited to one per account each day. Sign in with a verified email address to send yours.
+