diff --git a/backend/.env.example b/backend/.env.example index 5d924ce..87c403e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -63,7 +63,7 @@ UPLOAD_DIR=uploads # GOOGLE_ARCHIVE_DRIVE_FOLDER_ID=the-second-account-folder-id MAX_VIDEO_UPLOAD_MB=90 -MAX_RECORDING_UPLOAD_MB=80 +MAX_RECORDING_UPLOAD_MB=100 RATE_LIMIT_ENABLED=true diff --git a/backend/README.md b/backend/README.md index d60fbcc..db35d6a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -75,13 +75,13 @@ All paths are under `/api/v1`. | --- | --- | --- | | `GET /health` | Public | Reports database readiness. | | `/auth/*` | Public | Signup, login, email verification, password reset, logout, and session. | -| `GET/PATCH /users/me` | Authenticated user | Read profile and update supported connections. | +| `GET/PATCH/DELETE /users/me` | Authenticated user | Read or update profile; contributors can delete their account after confirming their password. Submitted recordings remain stored. | | `GET /categories`; `POST/PATCH/DELETE /categories/:id` | Public read; admin write | Deleting a category unassigns its videos. | | `GET /videos`, `GET /videos/:id[/file/poster]` | Public or admin | Public visibility is published and categorised only; file streaming supports byte ranges. | | `POST /videos`; `PATCH/DELETE /videos/:id` | Admin | Manage reference videos and optional posters. | | `GET /demo-video[/file]`; `POST/DELETE /demo-video` | Public read; admin write | Singleton public walkthrough media. | | `GET/POST /submissions` | Authenticated user | Contributors list their own; admins list all. New submissions are append-only. | -| `POST /contact` | Signed-in account | Email must match account; account and global daily quotas apply. | +| `POST /contact` | Signed-in account or recording-deletion request | Other topics require the account email; deletion requests can be submitted without signing in at any time. | | `/admin/users*`, `/admin/restrictions*`, `/admin/stats`, `/admin/messages` | Admin | Account moderation, access restrictions, statistics, and inbox. | See [API.md](docs/API.md) for the complete route table, limits, visibility, and @@ -95,6 +95,11 @@ mounted outside production. followed by consent acceptance before a new account is activated and signed in. Existing contributors must accept the current consent version before submitting; password reset invalidates existing sessions. +- Account deletion removes sign-in access and the account record but deliberately + retains submitted recordings and contribution data. A recording/data deletion + request can be sent through the contact form before or after account deletion, + without a deadline. Recordings already used in a released model cannot be + removed from that model. - `CLIENT_ORIGIN` controls credentialed CORS and the check for browser state-changing requests that carry an `Origin` header. - The global, auth, contact, and upload rate limits can be disabled with @@ -103,7 +108,7 @@ mounted outside production. `ARCHIVE_STORAGE_DRIVER` independently selects append-only contributor archive storage. Both default to local; Google Drive credentials can be shared or configured separately for archives. -- Upload defaults are 90 MB for base/demo videos and 80 MB for contributor +- Upload defaults are 90 MB for base/demo videos and 100 MB for contributor recordings. File contents are verified by signature, not browser MIME type. - Reference and demo-video uploads accept browser-reported `width` and `height` in pixels, each from 0 to 16,384. Zero represents unknown dimensions; these @@ -127,7 +132,7 @@ npm test The command runs Node's built-in test runner serially across every `tests/*.test.js` file. The API suites use MongoDB Memory Server unless `MONGODB_URI_TEST` is supplied. That configured database is dropped by the tests, -so use only a disposable database. Provider delivery is stubbed. The 60 cases and +so use only a disposable database. Provider delivery is stubbed. The 61 cases and their test types are documented in [docs/TEST-CASES.md](docs/TEST-CASES.md). ## Current limitation diff --git a/backend/docs/API.md b/backend/docs/API.md index 9ba95d6..61f9419 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -72,6 +72,7 @@ All paths below are relative to `/api/v1`. | GET | `/auth/session` | Public | Return the current user or `null`. | | GET | `/users/me` | Authenticated user | Read the signed-in user's profile. | | PATCH | `/users/me` | Authenticated user | Update supported profile connections. | +| DELETE | `/users/me` | Contributor | Delete the account after confirming the current password; submitted recordings and contribution data remain stored. | | POST | `/users/me/consent` | Authenticated user | Accept the current notice for an existing account. | | GET | `/categories` | Public | List categories. | | POST | `/categories` | Admin | Create a category. | @@ -90,10 +91,10 @@ All paths below are relative to `/api/v1`. | DELETE | `/demo-video` | Admin | Delete the demo video. | | GET | `/submissions` | Authenticated user | List own submissions; admins can list all. | | POST | `/submissions` | Authenticated user | Create an append-only recording submission. | -| POST | `/contact` | Signed-in account | Send a contact message using the email on the account. | +| POST | `/contact` | Signed-in account or recording-deletion request | Other topics require the account email; deletion requests can be submitted without signing in before or after account deletion, at any time. | | GET | `/admin/users` | Admin | List accounts. | | PATCH | `/admin/users/:id` | Admin | Change an account's active/suspended status. | -| DELETE | `/admin/users/:id` | Admin | Delete an account and its submissions, cooldowns, and archived recordings. | +| DELETE | `/admin/users/:id` | Admin | Delete an account and its cooldown claims; submitted recordings and contribution data remain stored. | | GET | `/admin/restrictions` | Admin | List active device and IP restrictions. | | POST | `/admin/restrictions` | Admin | Create a permanent device or IP restriction. | | DELETE | `/admin/restrictions/:id` | Admin | Remove a restriction. | @@ -102,7 +103,11 @@ All paths below are relative to `/api/v1`. There is intentionally no submission read, update, or delete endpoint, including for admins. The archive is append-only and archived media is not streamed through -the API. +the API. Account deletion leaves submissions intact; a separate contact request +is required to ask the team to review stored-recording and contribution-data +deletion. Requests may be sent before or after account deletion, without a +deadline. Recordings already used in a released model cannot be removed from +that model. ## Request bodies and uploads @@ -135,10 +140,11 @@ Submission creation uses `multipart/form-data` with a required `recording` file and `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration` fields. Only published videos assigned to a category can receive a submission. Durations and trim metadata are currently client-reported; the backend does not transcode or -physically trim the uploaded file. +physically trim the uploaded file. Submission duration must not exceed 600 +seconds (10 minutes). Uploads are bounded by `MAX_VIDEO_UPLOAD_MB` and -`MAX_RECORDING_UPLOAD_MB` (defaults: 90 MB and 80 MB). Video signatures are +`MAX_RECORDING_UPLOAD_MB` (defaults: 90 MB and 100 MB). Video signatures are checked as MP4, WebM, or MOV. Poster signatures are checked as JPEG, PNG, or WebP; posters must not exceed 2 MB. The browser-provided MIME type is not treated as proof of file content. diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 6dd53bf..4a65b42 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -89,8 +89,11 @@ Submission recordings are append-only. The archive folder is based on contributo and per-video sequence, category slug, and video slug. MongoDB keeps the archive reference; the API never streams or mutates the archived file. Contributor responses omit archive paths, while admins can see archive metadata. Account -deletion removes that account's archive objects and related submissions; deleting -a base video does not remove submissions. +deletion removes the account and its cooldown claims but retains its submissions +and archived recording objects. The contact form accepts a separate +recording/data deletion request before or after account deletion, at any time; +requests made after account deletion may require the team to verify and manually +match the former contributor. Deleting a base video does not remove submissions. Uploads first land in a temporary directory. Multer limits file size and count; services then inspect file signatures before moving valid assets into the selected diff --git a/backend/docs/TEST-CASES.md b/backend/docs/TEST-CASES.md index c182007..25ae43b 100644 --- a/backend/docs/TEST-CASES.md +++ b/backend/docs/TEST-CASES.md @@ -1,6 +1,6 @@ # Backend test cases -The backend suite uses Node's built-in test runner. It currently contains **60 test +The backend suite uses Node's built-in test runner. It currently contains **61 test cases** across thirteen test files. Each named API test suite has its own `.test.js` file and starts with a fresh test database and account fixtures: @@ -15,7 +15,7 @@ file and starts with a fresh test database and account fixtures: | `tests/api-videos.test.js` | 6 | HTTP integration tests | | `tests/api-submissions.test.js` | 8 | HTTP integration tests | | `tests/api-admin-stats-contact.test.js` | 4 | HTTP integration tests | -| `tests/api-admin-user-controls.test.js` | 4 | HTTP integration tests | +| `tests/api-admin-user-controls.test.js` | 5 | HTTP integration tests | | `tests/safeName.test.js` | 3 | Unit tests | | `tests/localStorage.test.js` | 6 | Filesystem/storage integration tests | | `tests/videoService.test.js` | 9 | Unit tests | @@ -153,7 +153,8 @@ their respective test file. Each API case below is an HTTP integration test. 34. **contact delivery requires matching account email, enforces quotas, and blocks abusive devices** — Checks contact validation, delivery failure handling, matching account email, per-user and global daily quotas, anonymous - access rejection, device blocking, message listing, and admin-only access. + recording-deletion requests, rejection of other anonymous topics, device + blocking, message listing, and admin-only access. 35. **oversized uploads are rejected with 413** — Checks payload-too-large responses and temporary-file cleanup. @@ -162,13 +163,16 @@ their respective test file. Each API case below is an HTTP integration test. 36. **suspending a user blocks its existing session and login; reactivation restores access** — Checks self-suspension protection, suspension effects on sessions and login, and reactivation. -37. **deleting a user removes their submissions, cooldowns, and stored - recording** — Checks account deletion cleanup across database records, - cooldowns, archive storage, sessions, and admin listings. -38. **device restrictions reject matching IDs, expose only a hint, and can be +37. **contributors can delete their account without removing submitted + recordings** — Checks current-password confirmation, session invalidation, + account removal, retained submission/archive data, and cooldown cleanup. +38. **admin deletion removes the account and cooldowns but retains submissions + and recordings** — Checks admin account removal, retained archive files, + cooldown cleanup, and updated account/submission listings. +39. **device restrictions reject matching IDs, expose only a hint, and can be removed** — Checks device restriction creation, hashed-identifier privacy, blocking, duplicate detection, and removal. -39. **IP restrictions block API traffic while admin routes remain available for +40. **IP restrictions block API traffic while admin routes remain available for recovery** — Checks IP validation and restriction enforcement while preserving admin access to remove restrictions. @@ -179,63 +183,63 @@ routes. The cases are separated by the behavior under test. ### `tests/safeName.test.js` — Safe names (unit tests) -40. **safeName uses its fallback for nullish and blank values** — Checks fallback +41. **safeName uses its fallback for nullish and blank values** — Checks fallback behavior for nullish, whitespace-only, and fully sanitized names. -41. **safeName replaces reserved path characters and collapses whitespace** — +42. **safeName replaces reserved path characters and collapses whitespace** — Checks removal of path-reserved characters, whitespace normalization, and string conversion of non-string values. -42. **safeName caps its output at 100 characters** — Checks the maximum safe-name +43. **safeName caps its output at 100 characters** — Checks the maximum safe-name length. ### `tests/localStorage.test.js` — Local driver and storage service (filesystem/storage integration tests) -43. **local driver stores named files in folders and appends suffixes on +44. **local driver stores named files in folders and appends suffixes on collisions** — Checks directory placement, readable keys, collision naming, and file contents. -44. **local driver handles named files without an extension and dotfiles** — +45. **local driver handles named files without an extension and dotfiles** — Checks collision naming for extensionless names and dotfiles. -45. **local driver generates unique UUID keys when no file name is supplied** — +46. **local driver generates unique UUID keys when no file name is supplied** — Checks generated key format and uniqueness. -46. **local driver rejects paths escaping the storage root and keeps the source +47. **local driver rejects paths escaping the storage root and keeps the source file** — Checks rejection of traversal through file names or folder names and confirms rejected saves do not consume the source file. -47. **local driver stat reports missing files and remove is idempotent** — Checks +48. **local driver stat reports missing files and remove is idempotent** — Checks missing-file errors from stat and harmless removal of an absent key. -48. **storageService forwards named folder and file name to the local driver** — +49. **storageService forwards named folder and file name to the local driver** — Checks storage-service metadata, destination key, file size, and cleanup. ### `tests/videoService.test.js` — Video service (unit tests) -49. **video create names video and poster from title and category safely** — +50. **video create names video and poster from title and category safely** — Checks sanitized names, category folder selection, poster naming, storage calls, and category ordering. -50. **video create uses Unassigned and Untitled fallbacks when optional labels +51. **video create uses Unassigned and Untitled fallbacks when optional labels are absent** — Checks fallback folder and file names, default ordering, and absent poster metadata. -51. **video create selects detected video and image extensions** — Checks +52. **video create selects detected video and image extensions** — Checks content-detected extensions for WebM, MOV, PNG, and WebP rather than relying on the original file name. -52. **video create rejects a missing upload, unsupported content, and invalid +53. **video create rejects a missing upload, unsupported content, and invalid categories before storage** — Checks validation errors and confirms storage is not called for invalid input. -53. **video create rejects oversized or unsupported posters before storage** — +54. **video create rejects oversized or unsupported posters before storage** — Checks poster size and real-content validation before any storage call. -54. **video create accepts a poster exactly at the size limit** — Checks that a +55. **video create accepts a poster exactly at the size limit** — Checks that a poster at the configured maximum size is accepted. -55. **video create surfaces filesystem errors instead of reporting invalid video +56. **video create surfaces filesystem errors instead of reporting invalid video content** — Checks missing-file I/O errors are preserved and storage is not called. -56. **video create removes already stored files when poster storage fails** — +57. **video create removes already stored files when poster storage fails** — Checks cleanup of the stored video when the subsequent poster save fails. -57. **video create removes video and poster if the database write fails** — +58. **video create removes video and poster if the database write fails** — Checks cleanup of both stored files when database record creation fails. -58. **creates the account only after consent and records the acceptance time** — +59. **creates the account only after consent and records the acceptance time** — Checks that verification alone cannot log in, current consent activates the account, the acceptance time and version are stored, and consent tokens cannot be replayed. -59. **declining consent discards the unfinished signup** — Checks the pending +60. **declining consent discards the unfinished signup** — Checks the pending account is removed and its email can be used for a new signup. -60. **legacy accounts without consent cannot submit until they accept** — Checks +61. **legacy accounts without consent cannot submit until they accept** — Checks that older accounts are blocked from submitting until they accept the current notice. diff --git a/backend/docs/setup.md b/backend/docs/setup.md index f5c3f44..16b77c0 100644 --- a/backend/docs/setup.md +++ b/backend/docs/setup.md @@ -121,7 +121,7 @@ The admin is ensured on every startup. Changing `ADMIN_PASSWORD` later does not | `ARCHIVE_STORAGE_DRIVER` | `local` or `gdrive` | Append-only storage for contributor recordings. Use `gdrive` for production archives. | | `UPLOAD_DIR` | Local filesystem path; default `uploads` | Local storage root; temporary uploads are kept in its `tmp` subdirectory. | | `MAX_VIDEO_UPLOAD_MB` | Set manually; default `90` | Maximum reference-video or demo-video upload size, in MiB. | -| `MAX_RECORDING_UPLOAD_MB` | Set manually; default `80` | Maximum contributor-recording upload size, in MiB. | +| `MAX_RECORDING_UPLOAD_MB` | Set manually; default `100` | Maximum contributor-recording upload size, in MiB. | | `GOOGLE_SERVICE_ACCOUNT_JSON` | Service-account JSON from Google Cloud | Shared Google Drive credentials, available as fallback for either driver. | | `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client configuration | Shared OAuth client ID; set with the corresponding secret and refresh token. | | `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client configuration | Shared OAuth client secret. | diff --git a/backend/package-lock.json b/backend/package-lock.json index 0f374a2..65e3605 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "backend", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "@getbrevo/brevo": "^6.0.3", diff --git a/backend/package.json b/backend/package.json index b47ef68..5480c01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.3", + "version": "1.0.4", "description": "SignPakCommons API: Express, Node.js and MongoDB", "main": "server.js", "scripts": { diff --git a/backend/src/config/constants.js b/backend/src/config/constants.js index 9b6428f..f31d9f8 100644 --- a/backend/src/config/constants.js +++ b/backend/src/config/constants.js @@ -11,6 +11,6 @@ export const VIDEO_STATUS_LIST = Object.values(VIDEO_STATUS) export const POSTER_MAX_BYTES = 2 * 1024 * 1024 export const MAX_RECORDING_SECONDS = 600 // Bump when the consent notice text changes materially; contributors are then asked to accept it again. -export const CONSENT_VERSION = '2026-10' +export const CONSENT_VERSION = '2026-10-15' // Client-reported durations are rounded, so allow a little slack when checking trim ranges. export const TRIM_TOLERANCE_SECONDS = 0.5 diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 2a2694c..f79a037 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -76,7 +76,7 @@ const schema = z.object({ GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON: z.string().optional(), GOOGLE_ARCHIVE_DRIVE_FOLDER_ID: z.string().optional(), MAX_VIDEO_UPLOAD_MB: z.coerce.number().positive().default(90), - MAX_RECORDING_UPLOAD_MB: z.coerce.number().positive().default(80), + MAX_RECORDING_UPLOAD_MB: z.coerce.number().positive().default(100), RATE_LIMIT_ENABLED: bool.default(true), SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000), diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index 0a5dbb6..904cc03 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -1,11 +1,17 @@ import { userService } from '../services/userService.js' import { asyncHandler } from '../utils/asyncHandler.js' import { noContent, ok } from '../utils/response.js' +import { clearAuthCookie } from '../utils/tokens.js' export const userController = { me: (req, res) => ok(res, req.user), updateMe: asyncHandler(async (req, res) => ok(res, await userService.updateConnections(req.user, req.body.connections))), acceptConsent: asyncHandler(async (req, res) => ok(res, await userService.acceptConsent(req.user, req.body.version))), + removeMe: asyncHandler(async (req, res) => { + await userService.removeSelf(req.user.id, req.body.password) + clearAuthCookie(res) + noContent(res) + }), list: asyncHandler(async (req, res) => ok(res, await userService.listAll())), updateStatus: asyncHandler(async (req, res) => ok(res, await userService.updateStatus(req.params.id, req.user, req.body))), remove: asyncHandler(async (req, res) => { await userService.remove(req.params.id, req.user); noContent(res) }), diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index 236c29e..39e8f24 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -281,6 +281,7 @@ export const openapi = { type: 'object', required: ['name', 'email', 'message'], properties: { + topic: { type: 'string', enum: ['general', 'partnership', 'recording-deletion'], default: 'general', description: 'Unauthenticated visitors may use only recording-deletion.' }, name: { type: 'string', minLength: 1, maxLength: 100 }, email: { type: 'string', format: 'email' }, message: { type: 'string', minLength: 1, maxLength: 2000 }, @@ -425,6 +426,14 @@ export const openapi = { requestBody: { required: true, content: { 'application/json': { schema: ref('UpdateProfileInput') } } }, responses: { 200: jsonResponse('Updated user', ref('User')), 401: errorResponse, 422: errorResponse }, }, + delete: { + tags: ['Users'], + summary: 'Delete the current contributor account', + description: 'Requires the current password. Removes the account and invalidates the session, but leaves submitted recordings and contribution data in the archive. Data-deletion requests can be sent through the recording/data deletion contact topic at any time, including after account deletion.', + security: userSecurity, + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['password'], properties: { password: { type: 'string', minLength: 1, maxLength: 128 } } } } } }, + responses: { 204: { description: 'Account deleted; existing submissions remain' }, 401: errorResponse, 403: errorResponse, 404: errorResponse, 422: errorResponse }, + }, }, '/users/me/consent': { post: { @@ -577,9 +586,9 @@ export const openapi = { '/contact': { post: { tags: ['Contact'], - summary: 'Send a contact message from the authenticated account email', - description: 'Requires a signed-in account and an email matching that account.', - security: userSecurity, + summary: 'Send a contact message or recording/data deletion request', + description: 'General and partnership messages require a signed-in account and matching account email. The recording-deletion topic may be submitted before or after account deletion, without signing in; provide the account email so the team can identify the data request. Requests after account deletion may require additional information to verify and match the recordings. Recordings already used in a released model cannot be removed from that model.', + security: [...userSecurity, {}], requestBody: { required: true, content: { 'application/json': { schema: ref('ContactInput') } } }, responses: { 201: jsonResponse('Message sent and saved to the admin inbox', { type: 'object' }), 403: errorResponse, 409: errorResponse, 422: errorResponse, 429: errorResponse }, }, @@ -617,7 +626,7 @@ export const openapi = { }, delete: { tags: ['Admin'], - summary: 'Delete a user and their submitted recordings', + summary: 'Delete a user account while retaining submitted recordings', security: adminSecurity, responses: { 204: { description: 'Deleted' }, ...adminErrors, 404: errorResponse, 409: errorResponse }, }, diff --git a/backend/src/middlewares/validators/miscSchemas.js b/backend/src/middlewares/validators/miscSchemas.js index af09c9e..e4ba561 100644 --- a/backend/src/middlewares/validators/miscSchemas.js +++ b/backend/src/middlewares/validators/miscSchemas.js @@ -2,6 +2,7 @@ import { z } from 'zod' import { emailField } from './common.js' export const contactSchema = z.object({ + topic: z.enum(['general', 'partnership', 'recording-deletion']).default('general'), name: z.string({ error: 'Enter your name.' }).trim().min(1, 'Enter your name.').max(100, 'Keep your name under 100 characters.'), email: emailField, message: z.string({ error: 'Write a message.' }).trim().min(1, 'Write a message.').max(2000, 'Keep your message under 2000 characters.'), diff --git a/backend/src/middlewares/validators/userSchemas.js b/backend/src/middlewares/validators/userSchemas.js index 9c6acd8..07fbd86 100644 --- a/backend/src/middlewares/validators/userSchemas.js +++ b/backend/src/middlewares/validators/userSchemas.js @@ -29,3 +29,7 @@ export const acceptConsentSchema = z.object({ accepted: z.literal(true, { error: 'You must accept the consent notice to continue.' }), version: z.string({ error: 'Missing consent version.' }).min(1).max(40), }) + +export const deleteAccountSchema = z.object({ + password: z.string({ error: 'Enter your current password.' }).min(1, 'Enter your current password.').max(128), +}) diff --git a/backend/src/models/ContactMessage.js b/backend/src/models/ContactMessage.js index adadbd4..9220e1b 100644 --- a/backend/src/models/ContactMessage.js +++ b/backend/src/models/ContactMessage.js @@ -3,6 +3,7 @@ import { jsonOptions } from '../utils/mongoose.js' const contactMessageSchema = new mongoose.Schema({ user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, + topic: { type: String, enum: ['general', 'partnership', 'recording-deletion'], default: 'general' }, 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 }, diff --git a/backend/src/repositories/submissionRepo.js b/backend/src/repositories/submissionRepo.js index 4c49306..3fc09e0 100644 --- a/backend/src/repositories/submissionRepo.js +++ b/backend/src/repositories/submissionRepo.js @@ -9,14 +9,7 @@ export const submissionRepo = { recent: (limit) => Submission.find().sort({ createdAt: -1 }).limit(limit), countForPair: (userId, videoId) => Submission.countDocuments({ user: userId, video: videoId }), count: () => Submission.countDocuments(), - async removeForUser(userId) { - const submissions = await Submission.find({ user: userId }).select('recording') - await Promise.all([ - Submission.deleteMany({ user: userId }), - SubmissionCooldown.deleteMany({ user: userId }), - ]) - return submissions.map((submission) => submission.recording) - }, + clearCooldownsForUser: (userId) => SubmissionCooldown.deleteMany({ user: userId }), // Just the timestamps, for bucketing into days. createdSince: (date) => Submission.find({ createdAt: { $gte: date } }).select('createdAt').lean(), countsByVideo: () => Submission.aggregate([{ $group: { _id: '$video', count: { $sum: 1 } } }]), diff --git a/backend/src/repositories/userRepo.js b/backend/src/repositories/userRepo.js index b86a3e4..58eb6b9 100644 --- a/backend/src/repositories/userRepo.js +++ b/backend/src/repositories/userRepo.js @@ -1,7 +1,11 @@ import { User } from '../models/User.js' export const userRepo = { - findById: (id) => User.findById(id), + findById: (id, { withPassword = false } = {}) => { + const query = User.findById(id) + if (withPassword) query.select('+passwordHash') + return query + }, findByEmail: (email, { withPassword = false, withOtp = false } = {}) => { const query = User.findOne({ email: email.trim().toLowerCase() }) if (withPassword) query.select('+passwordHash') diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js index fa93d50..a83bab8 100644 --- a/backend/src/routes/userRoutes.js +++ b/backend/src/routes/userRoutes.js @@ -2,13 +2,14 @@ import { Router } from 'express' import { userController } from '../controllers/userController.js' import { authenticate } from '../middlewares/authenticate.js' import { validate } from '../middlewares/validate.js' -import { acceptConsentSchema, updateMeSchema } from '../middlewares/validators/userSchemas.js' +import { acceptConsentSchema, deleteAccountSchema, updateMeSchema } from '../middlewares/validators/userSchemas.js' const router = Router() router.use(authenticate) router.get('/me', userController.me) router.patch('/me', validate({ body: updateMeSchema }), userController.updateMe) +router.delete('/me', validate({ body: deleteAccountSchema }), userController.removeMe) router.post('/me/consent', validate({ body: acceptConsentSchema }), userController.acceptConsent) export default router diff --git a/backend/src/services/contactService.js b/backend/src/services/contactService.js index 61ddf29..7ea00a7 100644 --- a/backend/src/services/contactService.js +++ b/backend/src/services/contactService.js @@ -7,25 +7,39 @@ 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.') +const topicLabels = { + general: 'General inquiry', + partnership: 'Partnership', + 'recording-deletion': 'Request recording/data deletion', +} export const contactService = { deliver: sendContactWeb3Forms, - async send({ name, email, message }, { user, ip, deviceId }) { + async send({ topic, name, email, message }, { user, ip, deviceId }) { const normalizedEmail = email.trim().toLowerCase() - if (!user || normalizedEmail !== user.email) { + if (user && normalizedEmail !== user.email) { + await accessRestrictionService.temporarilyRestrictRequest( + ip, + deviceId, + 'Contact form email does not match the signed-in account.', + env.CONTACT_ABUSE_BLOCK_MINUTES * 60_000, + ) + throw forbidden('Use the email address on your account to contact us.') + } + if (!user && topic !== 'recording-deletion') { await accessRestrictionService.temporarilyRestrictRequest( ip, deviceId, - user ? 'Contact form email does not match the signed-in account.' : 'Anonymous contact form submission.', + 'Anonymous contact form submission outside the recording/data deletion topic.', 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.') + throw forbidden('Sign in to send this contact topic.') } const day = utcDay() - const userClaimed = await contactQuotaRepo.claimUser(user._id, day) - if (!userClaimed) throw conflict('You can send one contact message per day.') + const userClaimed = user ? await contactQuotaRepo.claimUser(user._id, day) : false + if (user && !userClaimed) throw conflict('You can send one contact message per day.') let dailyClaimed = false let savedMessage @@ -41,13 +55,13 @@ export const contactService = { throw dailyLimitError() } - savedMessage = await messageRepo.create({ user: user._id, name, email: normalizedEmail, message }) - await this.deliver({ name, email: normalizedEmail, message }) + savedMessage = await messageRepo.create({ user: user?._id ?? null, topic, name, email: normalizedEmail, message }) + await this.deliver({ name, email: normalizedEmail, topic: topicLabels[topic], 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) + if (userClaimed) await contactQuotaRepo.releaseUser(user._id, day) throw error } }, diff --git a/backend/src/services/external/sendContactWeb3Forms.js b/backend/src/services/external/sendContactWeb3Forms.js index de5e72e..7bdc56a 100644 --- a/backend/src/services/external/sendContactWeb3Forms.js +++ b/backend/src/services/external/sendContactWeb3Forms.js @@ -1,7 +1,7 @@ import { env } from '../../config/env.js' import { AppError } from '../../utils/AppError.js' -export async function sendContactWeb3Forms({ name, email, message }) { +export async function sendContactWeb3Forms({ name, email, topic, message }) { if (!env.WEB3FORMS_ACCESS_KEY) throw new Error('WEB3FORMS_ACCESS_KEY is not configured.') const response = await fetch('https://api.web3forms.com/submit', { @@ -12,7 +12,7 @@ export async function sendContactWeb3Forms({ name, email, message }) { name, email, message, - subject: `SignPak Commons contact from ${name}`, + subject: `SignPak Commons: ${topic} — ${name}`, from_name: 'SignPak Commons Contact Form', replyto: email, }), diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js index 10e918a..6ec8892 100644 --- a/backend/src/services/userService.js +++ b/backend/src/services/userService.js @@ -1,8 +1,8 @@ +import bcrypt from 'bcrypt' import { CONSENT_VERSION, ROLES } from '../config/constants.js' import { submissionRepo } from '../repositories/submissionRepo.js' import { userRepo } from '../repositories/userRepo.js' -import { storageService } from './storage/index.js' -import { conflict, forbidden, notFound } from '../utils/AppError.js' +import { conflict, forbidden, notFound, unauthorized } from '../utils/AppError.js' export const userService = { /** Records the date and time the contributor accepted the consent notice (kept on the first acceptance of a version). */ @@ -46,8 +46,16 @@ export const userService = { if (user.role === ROLES.ADMIN && user.status === 'active' && await userRepo.countActiveAdmins() <= 1) { throw conflict('The last active administrator cannot be deleted.') } - const recordings = await submissionRepo.removeForUser(user._id) + await submissionRepo.clearCooldownsForUser(user._id) + await userRepo.remove(user._id) + }, + + async removeSelf(id, password) { + const user = await userRepo.findById(id, { withPassword: true }) + if (!user) throw notFound('That user does not exist.') + if (user.role !== ROLES.USER) throw forbidden('Administrators cannot delete their own account here.') + if (!await bcrypt.compare(password, user.passwordHash)) throw unauthorized('The password does not match this account.') + await submissionRepo.clearCooldownsForUser(user._id) await userRepo.remove(user._id) - await Promise.all(recordings.map((recording) => storageService.remove(recording))) }, } diff --git a/backend/tests/api-admin-stats-contact.test.js b/backend/tests/api-admin-stats-contact.test.js index 5b2c4af..7ca38cb 100644 --- a/backend/tests/api-admin-stats-contact.test.js +++ b/backend/tests/api-admin-stats-contact.test.js @@ -74,8 +74,18 @@ describe('admin stats and contact', () => { assert.equal(sent.status, 201) assert.equal(contactDeliveries.length, 1) assert.equal(contactDeliveries[0].email, 'sara@example.com') + assert.equal(contactDeliveries[0].topic, 'General inquiry') assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'sara@example.com', message: 'Again today' })).status, 409) + const deletionRequest = await new Client(base).post('/contact', { + topic: 'recording-deletion', + name: 'Former contributor', + email: 'former@example.com', + message: 'Please delete my recordings and contribution data.', + }) + assert.equal(deletionRequest.status, 201) + assert.equal(contactDeliveries.at(-1).topic, 'Request recording/data deletion') + const mismatchDevice = 'device-contact-email-mismatch' assert.equal((await contributor.post('/contact', { name: 'Sara Ahmed', email: 'other@example.com', message: 'Wrong email' }, { headers: { 'x-device-id': mismatchDevice } })).status, 403) assert.equal((await new Client(base).get('/health', { headers: { 'x-device-id': mismatchDevice } })).status, 403) @@ -93,6 +103,7 @@ describe('admin stats and contact', () => { const list = await admin.get('/admin/messages') assert.equal(list.status, 200) assert.ok(list.body.data.some((m) => m.name === 'Sara Ahmed' && m.message === 'Hello!' && m.userId)) + assert.ok(list.body.data.some((m) => m.name === 'Former contributor' && m.topic === 'recording-deletion' && m.userId === null)) assert.equal((await contributor.get('/admin/messages')).status, 403) }) diff --git a/backend/tests/api-admin-user-controls.test.js b/backend/tests/api-admin-user-controls.test.js index 9487730..b665683 100644 --- a/backend/tests/api-admin-user-controls.test.js +++ b/backend/tests/api-admin-user-controls.test.js @@ -2,11 +2,13 @@ import assert from 'node:assert/strict' import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' +import { User } from '../src/models/User.js' const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession +let uploadedVideoId before(async () => { const context = await getContext() @@ -17,6 +19,7 @@ before(async () => { const category = (await context.admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data const video = await context.admin.upload('/videos', videoForm({ title: 'First sign', categoryId: category.id, durationSec: '3' })) assert.equal(video.status, 201, JSON.stringify(video.body)) + uploadedVideoId = video.body.data.id const submission = await context.other.upload('/submissions', recordingForm({ videoId: video.body.data.id, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'true' })) assert.equal(submission.status, 201, JSON.stringify(submission.body)) }) @@ -35,17 +38,33 @@ describe('admin user controls and access restrictions', () => { assert.equal((await other.get('/users/me')).status, 200) }) - test('deleting a user removes their submissions, cooldowns, and stored recording', async () => { + test('contributors can delete their account without removing submitted recordings', async () => { + const self = await createVerifiedUser({ firstName: 'Laila', surname: 'Khan', email: 'laila@example.com' }) + const upload = await self.upload('/submissions', recordingForm({ videoId: uploadedVideoId, trimStart: '0', trimEnd: '2.5', duration: '3', mirrored: 'false' })) + assert.equal(upload.status, 201, JSON.stringify(upload.body)) + const account = (await self.get('/users/me')).body.data + const storedSubmission = await Submission.findById(upload.body.data.id) + + assert.equal((await self.delete('/users/me', { json: { password: 'wrong-password' } })).status, 401) + assert.equal((await self.delete('/users/me', { json: { password: 'password1' } })).status, 204) + assert.equal((await self.get('/users/me')).status, 401) + assert.equal(await User.exists({ _id: account.id }), null) + assert.equal(await Submission.countDocuments({ user: account.id }), 1) + assert.ok(await storageService.describe(storedSubmission.recording)) + assert.equal(await SubmissionCooldown.countDocuments({ user: account.id }), 0) + }) + + test('admin deletion removes the account and cooldowns but retains submissions and recordings', async () => { const otherId = (await admin.get('/admin/users')).body.data.find((user) => user.email === 'omar@example.com').id const recording = (await Submission.findOne({ user: otherId })).recording assert.ok(await SubmissionCooldown.countDocuments({ user: otherId })) assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 204) - assert.equal(await Submission.countDocuments({ user: otherId }), 0) + assert.equal(await Submission.countDocuments({ user: otherId }), 1) assert.equal(await SubmissionCooldown.countDocuments({ user: otherId }), 0) - await assert.rejects(() => storageService.describe(recording)) + assert.ok(await storageService.describe(recording)) assert.equal((await other.get('/users/me')).status, 401) assert.equal((await admin.get('/admin/users')).body.data.some((user) => user.id === otherId), false) - assert.equal((await admin.get('/submissions')).body.data.length, 0) + assert.equal((await admin.get('/submissions')).body.data.length, 2) assert.equal((await admin.delete(`/admin/users/${otherId}`)).status, 404) }) diff --git a/frontend/README.md b/frontend/README.md index d8f3563..03199d2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -53,6 +53,15 @@ the navigation state and offers accept or decline. Accepting creates and signs in the account; declining discards the pending signup. The consent version and acceptance timestamp are stored by the backend. +Contributors can delete their account from the profile after confirming their +current password. Account deletion removes sign-in access, but submitted +recordings and related contribution data remain stored. A separate +“Request recording/data deletion” topic on the home page contact form can be +used at any time before or after account deletion; it is available without +sign-in and asks for the account email. Requests made after account deletion +may require the team to verify and manually match the former contributor. +Recordings already used in a released model cannot be removed from that model. + When uploading a base video, admins must either select a JPEG, PNG, or WebP poster image (up to 2 MB), or explicitly check the option to use a frame captured automatically from the video. @@ -68,6 +77,7 @@ JSON and multipart upload requests use the same request helper. | Variable | Purpose | | --- | --- | | `VITE_API_ORIGIN` | Optional API origin, e.g. `https://api.example.com`. Empty by default; requests use the Vite `/api` proxy locally. | +| `VITE_MAX_RECORDING_UPLOAD_MB` | Optional client-side recording size limit in MiB. Defaults to `100`; keep it equal to the backend's `MAX_RECORDING_UPLOAD_MB`. Recordings also stop automatically at 10 minutes, and the upload queue rejects any take exceeding either limit. | | `VITE_SUBMISSION_COOLDOWN_MS` | Optional client countdown duration in milliseconds. Defaults to `30000`; the backend independently enforces its cooldown. | Put local Vite variables in `frontend/.env.local` (do not commit secrets there). diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index c60a019..e7359e4 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -140,6 +140,13 @@ frame before media metadata loads, then prefers the dimensions reported by the loaded video. This keeps portrait, square, and landscape videos in their native aspect ratio while fitting the player layout. +Contributors can delete their account from the profile after confirming their +password; submitted recordings and contribution data are retained. The public +contact form accepts a dedicated recording/data deletion topic without sign-in +at any time, including after account deletion. Requests sent after deletion may +require the team to verify and manually match the former contributor. Data +already incorporated into a released model cannot be removed from that model. + ## UI and styling `src/components/` holds shared layouts, navigation, form fields, buttons, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c936c1c..8deb11f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", diff --git a/frontend/package.json b/frontend/package.json index f443496..3e712c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.6", + "version": "1.0.7", "type": "module", "engines": { "node": ">=20.19.0" diff --git a/frontend/src/components/RecorderPanel.jsx b/frontend/src/components/RecorderPanel.jsx index 7307912..0e8e135 100644 --- a/frontend/src/components/RecorderPanel.jsx +++ b/frontend/src/components/RecorderPanel.jsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from 'react' import { useRecorder } from '../hooks/useRecorder' import { formatTime } from '../utils/format' +import { MAX_RECORDING_SECONDS } from '../utils/limits' import { Alert, Arrow, Button } from './ui' /** Camera preview, record/stop, delete and re-record for one video. */ export default function RecorderPanel({ video, baseRef, aspect, onSaveAndEdit }) { - const { recording, status, error, elapsed, stream, prepare, start, stop, discard } = useRecorder(video.id) + const { recording, status, error, notice, elapsed, stream, prepare, start, stop, discard } = useRecorder(video.id) const live = useRef(null) const countdownTimer = useRef(null) const starting = useRef(false) @@ -56,6 +57,7 @@ export default function RecorderPanel({ video, baseRef, aspect, onSaveAndEdit })

{isRecording ? `Recording ${formatTime(elapsed)}` : countdown !== null ? `Starting in ${countdown}…` : recording ? 'Recording ready' : 'Your turn'} + {isRecording && / {formatTime(MAX_RECORDING_SECONDS)} max}

Record
yourself.

@@ -84,6 +86,7 @@ export default function RecorderPanel({ video, baseRef, aspect, onSaveAndEdit }) )} {error && {error}} + {notice && !isRecording && {notice}} {recording && !isRecording && } + } {isAdmin && (
Workspace @@ -120,6 +142,36 @@ export default function ProfileDashboard() { )} + + {deleteDialogOpen &&
{ if (event.target === event.currentTarget && !deleteBusy) setDeleteDialogOpen(false) }} + onKeyDown={(event) => { if (event.key === 'Escape' && !deleteBusy) setDeleteDialogOpen(false) }} + > +
+ Permanent action +

Delete your account?

+

This permanently removes your account and signs you out. Your submitted recordings and related contribution data will remain stored; deleting your account does not delete them. You may request data deletion through the home page contact form by choosing “Request recording/data deletion”, at any time before or after deleting your account. After account deletion, the team may need additional information to verify and match your former recordings. Recordings already used in a released model cannot be removed from that model.

+ + setDeletePassword(event.target.value)} + disabled={deleteBusy} + /> + {deleteError && {deleteError}} +
+ + +
+ +
+
} ) } diff --git a/frontend/src/pages/admin/AdminUsers.jsx b/frontend/src/pages/admin/AdminUsers.jsx index 036616a..1f634e3 100644 --- a/frontend/src/pages/admin/AdminUsers.jsx +++ b/frontend/src/pages/admin/AdminUsers.jsx @@ -45,7 +45,7 @@ export default function AdminUsers() { } async function deleteUser(target) { - if (!window.confirm(`Delete ${target.firstName} ${target.surname} and their submitted recordings? This cannot be undone.`)) return + if (!window.confirm(`Delete ${target.firstName} ${target.surname}'s account? This removes their sign-in access but leaves submitted recordings and contribution data stored. A separate recording/data deletion request is required to remove that data.`)) return setBusyId(target.id) setError('') setNotice('') diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 01f141b..2dba320 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -189,6 +189,7 @@ export const api = { }, users: { + async removeMe(password) { await request(apiRoutes.users.removeMe(), json('DELETE', { password })) }, async list() { return normalizeList(await request(apiRoutes.users.list())) }, async setStatus(id, patch) { return normalize(await request(apiRoutes.users.update(id), json('PATCH', patch))) }, async remove(id) { await request(apiRoutes.users.remove(id), { method: 'DELETE' }) }, diff --git a/frontend/src/services/apiRoutes.js b/frontend/src/services/apiRoutes.js index 52e0791..6b956e8 100644 --- a/frontend/src/services/apiRoutes.js +++ b/frontend/src/services/apiRoutes.js @@ -28,6 +28,7 @@ export const apiRoutes = { users: { me: () => `${API_BASE}/users/me`, + removeMe: () => `${API_BASE}/users/me`, consent: () => `${API_BASE}/users/me/consent`, list: () => `${API_BASE}/admin/users`, update: (id) => `${API_BASE}/admin/users/${id}`, diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 20d00e4..49efc88 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -96,6 +96,11 @@ .confirm { @apply mt-5 rounded-2xl border border-danger bg-danger-soft p-4 text-sm leading-6 text-ink; } .confirm-actions { @apply mt-3 flex flex-wrap gap-2; } + .account-delete-backdrop { @apply fixed inset-0 z-[100] grid place-items-center overflow-y-auto bg-black/60 p-4; } + .account-delete-dialog { @apply w-full max-w-xl rounded-3xl border border-danger bg-surface p-6 shadow-lift sm:p-8; } + .account-delete-dialog > p { @apply mt-4 text-base leading-relaxed text-muted; } + .account-delete-dialog .btn-danger { @apply mt-5; } + .account-delete-panel .btn-danger { @apply mt-5; } /* ---------- Feature blocks: always deep green, so their contents use fixed light colours ---------- */ .feature { background-color: var(--feature); color: #fff; } diff --git a/frontend/src/utils/consent.js b/frontend/src/utils/consent.js index 0ec4c09..5bb3800 100644 --- a/frontend/src/utils/consent.js +++ b/frontend/src/utils/consent.js @@ -1,5 +1,5 @@ /** Must match CONSENT_VERSION on the backend. Bump both when the notice changes materially. */ -export const CONSENT_VERSION = '2026-10' +export const CONSENT_VERSION = '2026-10-15' export const consentGiven = (user) => user?.consent?.version === CONSENT_VERSION && Boolean(user?.consent?.acceptedAt) @@ -7,7 +7,7 @@ export const consentPoints = [ { title: 'What your recordings are for', body: 'Your recordings are used to build the SignPAK Pakistan Sign Language (PSL) dataset and to train and test the sign recognition model. Each submission is linked to your account and the reference video, and stored using the storage service configured for this project (local storage or Google Drive).' }, { title: 'Your video shows you', body: 'A recording of your hands, face and upper body can identify you. Only record what you are comfortable sharing, and make sure nobody else appears in the frame.' }, { title: 'Non-commercial use', body: 'This is a non-profit, student-led project. Your recordings are not sold or shared with advertisers. When Google Drive storage is configured, Google processes and stores recordings as a service provider.' }, - { title: 'No watching or editing after submitting', body: 'Once submitted, a recording can no longer be watched or edited by you. You can ask the site admins through the contact form to delete your account and recordings.' }, - { title: 'Models already trained', body: 'If a recording has already been used to train a released model, it remains part of that model even after the file is deleted.' }, + { title: 'Account deletion and recording retention', body: 'Deleting your account removes your account and sign-in access, but does not delete submitted recordings or related contribution data. You can request deletion of that stored data through the contact form at any time, before or after deleting your account.' }, + { title: 'Recordings used in released models', body: 'Recordings already used in a released model cannot be removed from that released model, even if the stored recording and related data are deleted after a request.' }, { title: 'Security', body: 'We take reasonable security measures. As a university research project we cannot accept full liability in the unlikely event of a breach.' }, ] diff --git a/frontend/src/utils/limits.js b/frontend/src/utils/limits.js new file mode 100644 index 0000000..df5f03b --- /dev/null +++ b/frontend/src/utils/limits.js @@ -0,0 +1,18 @@ +/** Client-side recording limits; the backend enforces the upload size independently. */ +export const MAX_RECORDING_SECONDS = 600 +const configuredMaxRecordingMb = Number(import.meta.env.VITE_MAX_RECORDING_UPLOAD_MB) +export const MAX_RECORDING_MB = Number.isFinite(configuredMaxRecordingMb) && configuredMaxRecordingMb > 0 + ? configuredMaxRecordingMb + : 100 +export const MAX_RECORDING_BYTES = MAX_RECORDING_MB * 1024 * 1024 + +/** Returns a reason when a recording exceeds a submission limit. */ +export function recordingLimitProblem(recording) { + if (recording?.duration > MAX_RECORDING_SECONDS) { + return `Recordings can be at most ${MAX_RECORDING_SECONDS / 60} minutes. Record this video again with a shorter take.` + } + if (recording?.blob?.size > MAX_RECORDING_BYTES) { + return `This recording is larger than the ${MAX_RECORDING_MB} MB upload limit. Record this video again with a shorter take.` + } + return '' +} diff --git a/frontend/test/ProfileDeletion.test.jsx b/frontend/test/ProfileDeletion.test.jsx new file mode 100644 index 0000000..3d68f48 --- /dev/null +++ b/frontend/test/ProfileDeletion.test.jsx @@ -0,0 +1,68 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import ProfileDashboard from '../src/pages/ProfileDashboard' + +const { auth, deleteAccount } = vi.hoisted(() => ({ + auth: { value: null }, + deleteAccount: vi.fn(), +})) + +vi.mock('../src/context/AuthContext', () => ({ + useAuth: () => auth.value, +})) + +vi.mock('../src/context/LibraryContext', () => ({ + useLibrary: () => ({ + categories: [], + videosIn: () => [], + hasSubmission: () => false, + mySubmissions: [], + publishedVideos: [], + orderedPublished: [], + contributedCount: 0, + totalSubmissionCount: 0, + }), +})) + +function renderProfile() { + auth.value = { + user: { + id: 'user-1', + firstName: 'Sara', + surname: 'Khan', + email: 'sara@example.com', + createdAt: Date.now(), + connections: {}, + }, + isAdmin: false, + logout: vi.fn(), + deleteAccount, + saveConnections: vi.fn(), + } + return render() +} + +describe('profile account deletion', () => { + beforeEach(() => { + vi.clearAllMocks() + deleteAccount.mockResolvedValue(undefined) + }) + + afterEach(cleanup) + + it('warns that recordings remain and requires the current password in its confirmation dialog', async () => { + renderProfile() + fireEvent.click(screen.getByRole('button', { name: 'Delete account' })) + + const dialog = screen.getByRole('alertdialog', { name: 'Delete your account?' }) + expect(dialog.textContent).toContain('submitted recordings and related contribution data will remain stored') + expect(dialog.textContent).toContain('already used in a released model cannot be removed') + expect(dialog.textContent).toContain('Request recording/data deletion') + + fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'password1' } }) + fireEvent.click(screen.getByRole('button', { name: 'Permanently delete account' })) + + await waitFor(() => expect(deleteAccount).toHaveBeenCalledWith('password1')) + }) +}) diff --git a/frontend/test/RecorderPanel.test.jsx b/frontend/test/RecorderPanel.test.jsx index 83dd900..8a5144f 100644 --- a/frontend/test/RecorderPanel.test.jsx +++ b/frontend/test/RecorderPanel.test.jsx @@ -11,6 +11,7 @@ const defaultRecorder = { recording: null, status: 'idle', error: '', + notice: '', elapsed: 0, stream: null, prepare: vi.fn(), @@ -91,12 +92,19 @@ describe('RecorderPanel', () => { it('shows a live recording status and lets the user stop recording', () => { renderPanel({ status: 'recording', elapsed: 12 }) - expect(screen.getByText('Recording 00:12')).toBeTruthy() + expect(screen.getByText(/Recording 00:12/)).toBeTruthy() + expect(screen.getByText(/10:00 max/)).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /stop recording/i })) expect(defaultRecorder.stop).toHaveBeenCalledOnce() expect(screen.getByRole('button', { name: /save and edit video/i }).disabled).toBe(true) }) + it('shows a notice when the recorder automatically stops at a limit', () => { + renderPanel({ notice: 'Recording stopped at the 10-minute limit.' }) + + expect(screen.getByText('Recording stopped at the 10-minute limit.')).toBeTruthy() + }) + it('lets the user review, delete, or save a finished take', () => { const recording = { url: 'blob:recording-1' } const onSaveAndEdit = vi.fn() diff --git a/frontend/test/UploadQueueProvider.test.jsx b/frontend/test/UploadQueueProvider.test.jsx index 67129c4..2002e71 100644 --- a/frontend/test/UploadQueueProvider.test.jsx +++ b/frontend/test/UploadQueueProvider.test.jsx @@ -1,7 +1,9 @@ +import { useState } from 'react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import UploadQueueProvider from '../src/context/UploadQueueProvider' import { useUploadQueue } from '../src/context/UploadQueueContext' +import { MAX_RECORDING_BYTES, MAX_RECORDING_SECONDS } from '../src/utils/limits' const { api, notify } = vi.hoisted(() => ({ api: { submissions: { upload: vi.fn() } }, @@ -14,12 +16,20 @@ vi.mock('../src/context/NotificationContext', () => ({ useNotifications: () => ( function QueueHarness() { const queue = useUploadQueue() + const [enqueueResult, setEnqueueResult] = useState('') const recording = { blob: new Blob(['video']), edit: { start: 0, end: 2, mirrored: false }, duration: 2 } + const queueOverLimit = (overLimitRecording) => { + const result = queue.enqueue({ id: 'over-limit', title: 'Over-limit video' }, overLimitRecording) + setEnqueueResult(result.reason || 'queued') + } return ( <> + + + {enqueueResult} {queue.items.map(({ title, status }) => `${title}: ${status}`).join('; ')} ) @@ -46,6 +56,23 @@ describe('UploadQueueProvider', () => { vi.useRealTimers() }) + it.each([ + ['oversized recordings', 'Queue oversized', /larger than the .* upload limit/], + ['recordings longer than ten minutes', 'Queue too long', /at most 10 minutes/], + ])('refuses %s before uploading', (_, buttonName, expectedMessage) => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: buttonName })) + + expect(screen.getByLabelText('enqueue result').textContent).toMatch(expectedMessage) + expect(api.submissions.upload).not.toHaveBeenCalled() + expect(screen.getByLabelText('queued uploads').textContent).toBe('') + }) + it('serializes two queued uploads and retries a failure', async () => { const firstAttempt = deferred() const firstRetry = deferred() diff --git a/frontend/test/limits.test.js b/frontend/test/limits.test.js new file mode 100644 index 0000000..81191dd --- /dev/null +++ b/frontend/test/limits.test.js @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { MAX_RECORDING_BYTES, MAX_RECORDING_SECONDS, recordingLimitProblem } from '../src/utils/limits' + +describe('recordingLimitProblem', () => { + it('accepts a recording exactly at both limits', () => { + expect(recordingLimitProblem({ duration: MAX_RECORDING_SECONDS, blob: { size: MAX_RECORDING_BYTES } })).toBe('') + }) + + it('rejects a recording longer than ten minutes', () => { + expect(recordingLimitProblem({ duration: MAX_RECORDING_SECONDS + 1, blob: { size: 1 } })).toMatch(/10 minutes/) + }) + + it('rejects a recording larger than the configured upload limit', () => { + expect(recordingLimitProblem({ duration: 5, blob: { size: MAX_RECORDING_BYTES + 1 } })).toMatch(/upload limit/) + }) +})