diff --git a/backend/AGENTS.md b/backend/AGENTS.md
new file mode 100644
index 0000000..0ddff3b
--- /dev/null
+++ b/backend/AGENTS.md
@@ -0,0 +1,52 @@
+# Backend Agent Guide
+
+## Scope
+
+These instructions apply to work under `backend/`. Preserve the dependency
+direction: routes and middleware call controllers; controllers call services;
+services own domain rules and call repositories or integrations; repositories
+own persistence queries. Services must not read Express `req` or write `res`.
+
+## Commands
+
+Run from `backend/`:
+
+```sh
+npm run dev
+npm test
+npm test -- tests/api-videos.test.js
+```
+
+The test runner is Node's built-in serial runner. API tests use MongoDB Memory
+Server by default. If `MONGODB_URI_TEST` is configured, tests drop that
+database; only use a disposable test database.
+
+## Guardrails
+
+- Never commit `.env`, credentials, tokens, service-account JSON, or production
+ data. Environment variables are validated in `src/config/env.js`; update
+ `.env.example` and `docs/SETUP.md` when adding configuration.
+- Keep Zod request validation in `src/middlewares/validators/` and business
+ rules in services. Use shared response and `AppError` helpers.
+- Verify file contents from signatures, not supplied MIME types. Route uploads
+ through the storage service and preserve cleanup for partially completed
+ multi-file operations.
+- MongoDB stores domain records and opaque storage references; media bytes
+ belong to configured storage drivers. Preserve visibility checks for
+ metadata and media routes.
+- Redis is optional Upstash. Cache failures should be logged and fall back to
+ the authoritative database/storage path. Keep cache values small and apply
+ explicit TTLs.
+- Update `src/docs/openapi.js`, `docs/API.md`, tests, and relevant architecture
+ docs when changing endpoints or behavior. Do not silently change append-only
+ submission or account-data retention behavior.
+
+## References
+
+- [Backend docs index](docs/README.md)
+- [Setup and environment](docs/SETUP.md)
+- [API contract](docs/API.md)
+- [Architecture](docs/ARCHITECTURE.md)
+- [Module reference](docs/MODULE-REFERENCE.md)
+- [Design decisions](docs/design-docs/ARCHITECTURE-DECISIONS.md)
+- [Test-case inventory](docs/TEST-CASES.md)
diff --git a/backend/README.md b/backend/README.md
index db35d6a..e31caac 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -47,9 +47,10 @@ business rules.
`src/services/initialization.js` initializes MongoDB, storage, and the configured
admin idempotently; `server.js` is the standalone and Vercel entry point.
-Read the full references: [API](docs/API.md),
-[architecture](docs/ARCHITECTURE.md), [setup](docs/SETUP.md), and
-[test-case inventory](docs/TEST-CASES.md).
+Read the [documentation index](docs/README.md), [API](docs/API.md),
+[architecture](docs/ARCHITECTURE.md), [module reference](docs/MODULE-REFERENCE.md),
+[setup](docs/SETUP.md), [design decisions](docs/design-docs/ARCHITECTURE-DECISIONS.md),
+and [test-case inventory](docs/TEST-CASES.md).
## Responses
diff --git a/backend/docs/API.md b/backend/docs/API.md
index 61f9419..3ff9dcf 100644
--- a/backend/docs/API.md
+++ b/backend/docs/API.md
@@ -82,7 +82,7 @@ All paths below are relative to `/api/v1`.
| GET | `/videos/:id` | Public or admin | Read a visible video. |
| GET | `/videos/:id/file` | Public or admin | Stream a visible video, including byte-range requests. |
| GET | `/videos/:id/poster` | Public or admin | Stream a visible video's poster, if present. |
-| POST | `/videos` | Admin | Upload a reference video and optional poster. |
+| POST | `/videos` | Admin | Upload a reference video or register a direct HTTPS video URL; URL sources require a poster. |
| PATCH | `/videos/:id` | Admin | Update a video, including status or category. |
| DELETE | `/videos/:id` | Admin | Delete a reference video and its base files; submissions remain. |
| GET | `/demo-video` | Public | Read the current public demo-video metadata or `null`. |
@@ -117,12 +117,13 @@ Authentication and profile request schemas are defined in
client-supplied roles are ignored. Profile updates accept supported `connections`
fields for GitHub and LinkedIn; `null` disconnects a connection.
-The video and demo-video upload routes use `multipart/form-data`:
+The video and demo-video creation routes use `multipart/form-data`:
| Field | Video | Demo video |
| --- | --- | --- |
-| `video` | Required video file | Required video file |
-| `poster` | Optional image file | Not accepted |
+| `video` | Optional video file; provide this or `videoUrl`, but not both | Required video file |
+| `videoUrl` | Optional direct HTTPS MP4/WebM URL; provide this or `video`, but not both | Not accepted |
+| `poster` | Optional for uploaded files; required with `videoUrl` | Not accepted |
| `title` | Required title | Required title |
| `categoryId` | Optional category ID | Not accepted |
| `status` | Optional video status | Not accepted |
@@ -147,7 +148,15 @@ Uploads are bounded by `MAX_VIDEO_UPLOAD_MB` and
`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.
+proof of file content. External URLs must point directly to media playable in a
+native HTML `` element; YouTube and other watch-page URLs are not
+supported. The URL is stored in MongoDB. Its poster image is stored through the
+configured `STORAGE_DRIVER` and streamed from the API.
+
+Poster responses use a private five-minute browser cache. When Upstash Redis is
+configured, the backend also caches poster file metadata for five minutes; it
+does not cache poster image bytes. Redis errors are logged and fall back to
+reading metadata from the configured storage driver.
## Contact, rate limits, and access restrictions
diff --git a/backend/docs/MODULE-REFERENCE.md b/backend/docs/MODULE-REFERENCE.md
new file mode 100644
index 0000000..0608ada
--- /dev/null
+++ b/backend/docs/MODULE-REFERENCE.md
@@ -0,0 +1,39 @@
+# Backend module reference
+
+For system startup, request flow, and persistence details, see
+[ARCHITECTURE.md](ARCHITECTURE.md). This page maps common changes to their
+implementation layers.
+
+| Concern | Primary modules | Notes |
+| --- | --- | --- |
+| App composition and startup | `server.js`, `src/app.js`, `src/services/initialization.js` | App composition is importable without binding a listener; initialization is shared and idempotent. |
+| Routes and access policy | `src/routes/`, `src/middlewares/authenticate.js`, `src/middlewares/requireRole.js` | Route definitions compose auth and validation middleware. |
+| Request validation | `src/middlewares/validators/`, `src/middlewares/validate.js` | Zod schemas validate bodies, params, and queries. |
+| HTTP response/error boundary | `src/controllers/`, `src/utils/response.js`, `src/middlewares/errorHandler.js` | Controllers adapt HTTP; services should throw `AppError` for expected failures. |
+| Domain behavior | `src/services/` | Enforce permissions, visibility, retention, cooldown, and multi-step workflows here. |
+| MongoDB persistence | `src/models/`, `src/repositories/` | Schemas define serialization/indexes; repositories encapsulate queries. |
+| Video library | `src/models/Video.js`, `src/services/videoService.js`, `src/routes/videoRoutes.js` | Supports stored uploads and direct HTTPS video URLs; posters are stored files. |
+| File storage | `src/services/storage/`, `src/utils/sendFile.js` | Local and Google Drive drivers; `sendFile` handles byte ranges and headers. |
+| Redis cache | `src/services/cache/upstashRedis.js`, feature service | Optional Upstash Redis REST client. Feature services own key naming, TTL, validation, and fallback logging. |
+| Authentication and consent | `src/services/authService.js`, `src/controllers/authController.js`, auth validators | Signup does not activate until the current consent is accepted. |
+| Submissions and archive | `src/services/submissionService.js`, `src/repositories/submissionRepo.js` | Submission records are append-only; archive storage is selected independently. |
+| API specification | `src/docs/openapi.js`, `docs/API.md` | Update both with route, schema, or response changes. |
+| Tests | `tests/*.test.js` | API-level suites plus focused service, filesystem, and utility suites. |
+
+## Typical request path
+
+```text
+route -> middleware -> controller -> service -> repository/model
+ -> storage/provider integration
+```
+
+Keep domain policy in services and persistence mechanics in repositories or
+storage drivers. Preserve error propagation and log provider/cache failures
+using the established logger.
+
+## Validation checklist
+
+For behavior changes, add focused tests at the service layer and API boundary
+where practical. Run the relevant test file, then `npm test` for broader
+changes. API and storage test setup is described in
+[SETUP.md](SETUP.md) and the [test-case inventory](TEST-CASES.md).
diff --git a/backend/docs/README.md b/backend/docs/README.md
new file mode 100644
index 0000000..6dc3bca
--- /dev/null
+++ b/backend/docs/README.md
@@ -0,0 +1,11 @@
+# Backend documentation
+
+- [Setup](SETUP.md): local configuration, environment variables, and
+ deployment.
+- [API](API.md): endpoint access, request/response behavior, and limits.
+- [Architecture](ARCHITECTURE.md): startup, request flow, persistence, storage,
+ and security controls.
+- [Module reference](MODULE-REFERENCE.md): where to implement backend changes.
+- [Design decisions](design-docs/ARCHITECTURE-DECISIONS.md): current
+ architecture choices and trade-offs.
+- [Test-case inventory](TEST-CASES.md): API and unit test coverage.
diff --git a/backend/docs/design-docs/ARCHITECTURE-DECISIONS.md b/backend/docs/design-docs/ARCHITECTURE-DECISIONS.md
new file mode 100644
index 0000000..3416105
--- /dev/null
+++ b/backend/docs/design-docs/ARCHITECTURE-DECISIONS.md
@@ -0,0 +1,81 @@
+# Backend architecture decisions
+
+This is a record of current choices and their trade-offs, not a dated changelog.
+
+## Keep HTTP, domain, and persistence responsibilities separate
+
+**Decision:** Express routes compose middleware; controllers adapt HTTP inputs
+and responses; services enforce domain rules; repositories encapsulate
+MongoDB queries.
+
+**Reason and trade-off:** The same business behavior can be tested without an
+HTTP request, and API response formatting stays at the boundary. New features
+must be threaded through several layers rather than placing all logic in a
+route handler.
+
+**Implementation:** `src/routes/`, `src/controllers/`, `src/services/`, and
+`src/repositories/`.
+
+## Store media bytes outside MongoDB
+
+**Decision:** MongoDB stores metadata and opaque `{ driver, key }` file
+references. Local and Google Drive drivers provide file storage and streaming.
+Reference-video URLs may also be direct HTTPS media URLs; those URLs are stored
+as video metadata while their poster remains a stored file.
+
+**Reason and trade-off:** Database documents remain small and storage can be
+switched or separated by intent. External URLs avoid copying video bytes but
+depend on the remote host's availability and browser media/CORS behavior.
+
+**Implementation:** `src/models/Video.js`, `src/models/storedFile.js`,
+`src/services/storage/`, and `src/services/videoService.js`.
+
+## Preserve byte-range media streaming
+
+**Decision:** Stored video endpoints support HTTP Range requests; the storage
+driver exposes streams for requested byte ranges.
+
+**Reason and trade-off:** Browsers use range access for seeking and native
+playback. Storage drivers must preserve inclusive range semantics, MIME type,
+and file size.
+
+**Implementation:** `src/utils/sendFile.js` and
+`src/services/storage/`.
+
+## Treat contributor submissions as append-only records
+
+**Decision:** Accepted submission records and archived recording bytes are
+retained when an account or base video is deleted. A separate contact request
+allows the team to review recording/data deletion.
+
+**Reason and trade-off:** Contribution history and released model provenance
+remain stable. This increases responsibility for retention and requires an
+explicit process for deletion requests.
+
+**Implementation:** `src/services/submissionService.js`,
+`src/services/userService.js`, and `src/services/contactService.js`.
+
+## Make external integrations optional and failure-tolerant where safe
+
+**Decision:** Local file storage works without Google Drive; Redis caching is
+optional; transient cache failures fall back to authoritative reads. Cache
+values have explicit expirations and do not contain poster image bytes.
+
+**Reason and trade-off:** Local development and core reads do not require every
+provider to be configured. Cache outages can increase storage/database work,
+but should not make cached data appear authoritative.
+
+**Implementation:** `src/config/env.js`, `src/services/cache/upstashRedis.js`,
+`src/services/publicStatsService.js`, and `src/services/videoService.js`.
+
+## Put access and validation at the server boundary
+
+**Decision:** Route guards, authentication, Zod validation, role checks, file
+signature checks, rate limits, and origin checks are enforced server-side.
+Client-side restrictions improve UX but are not security controls.
+
+**Reason and trade-off:** API callers are not limited to the first-party UI.
+Routes and services must retain visibility checks even when the client already
+filters data.
+
+**Implementation:** `src/routes/`, `src/middlewares/`, and `src/services/`.
diff --git a/backend/docs/design-docs/README.md b/backend/docs/design-docs/README.md
new file mode 100644
index 0000000..f62cc41
--- /dev/null
+++ b/backend/docs/design-docs/README.md
@@ -0,0 +1,8 @@
+# Backend design documents
+
+These documents record current architectural decisions and their trade-offs.
+They complement the [architecture guide](../ARCHITECTURE.md) and do not replace
+the implementation or API contract. Update a decision when the behavior or
+trade-off changes; do not infer undocumented history.
+
+- [Architecture decisions](ARCHITECTURE-DECISIONS.md)
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 65e3605..126de5f 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "backend",
- "version": "1.0.4",
+ "version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"dependencies": {
"@getbrevo/brevo": "^6.0.3",
diff --git a/backend/package.json b/backend/package.json
index 5480c01..1ee40f5 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "backend",
- "version": "1.0.4",
+ "version": "1.0.5",
"description": "SignPakCommons API: Express, Node.js and MongoDB",
"main": "server.js",
"scripts": {
diff --git a/backend/src/controllers/videoController.js b/backend/src/controllers/videoController.js
index 897aa0c..501a100 100644
--- a/backend/src/controllers/videoController.js
+++ b/backend/src/controllers/videoController.js
@@ -20,5 +20,5 @@ export const videoController = {
update: asyncHandler(async (req, res) => ok(res, await videoService.update(req.params.id, req.body))),
remove: asyncHandler(async (req, res) => { await videoService.remove(req.params.id); noContent(res) }),
file: asyncHandler(async (req, res) => sendFile(req, res, await videoService.getFile(req.params.id, req.user))),
- poster: asyncHandler(async (req, res) => sendFile(req, res, await videoService.getPoster(req.params.id, req.user))),
+ poster: asyncHandler(async (req, res) => sendFile(req, res, await videoService.getPoster(req.params.id, req.user), { cache: 'private, max-age=300' })),
}
diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js
index 39e8f24..d58970d 100644
--- a/backend/src/docs/openapi.js
+++ b/backend/src/docs/openapi.js
@@ -116,6 +116,7 @@ export const openapi = {
width: { type: 'integer', description: 'Displayed width in pixels; 0 when unknown.' },
height: { type: 'integer', description: 'Displayed height in pixels; 0 when unknown.' },
videoUrl: { type: 'string' },
+ videoSource: { type: 'string', enum: ['stored', 'external'] },
poster: { type: 'string', nullable: true },
size: { type: 'integer' },
},
@@ -212,10 +213,12 @@ export const openapi = {
},
VideoUploadInput: {
type: 'object',
- required: ['video', 'title'],
+ required: ['title'],
+ description: 'Provide exactly one of video or videoUrl. External video URLs must point to a direct HTTPS video file and require a poster image.',
properties: {
video: { type: 'string', format: 'binary', description: 'MP4, WebM, or MOV video.' },
- poster: { type: 'string', format: 'binary', description: 'Optional JPEG, PNG, or WebP image (up to 2 MiB).' },
+ videoUrl: { type: 'string', format: 'uri', description: 'Direct HTTPS MP4/WebM URL; watch-page URLs are not supported.' },
+ poster: { type: 'string', format: 'binary', description: 'JPEG, PNG, or WebP image (up to 2 MiB); required when videoUrl is used.' },
title: { type: 'string', minLength: 1, maxLength: 120 },
categoryId: { type: 'string', nullable: true, pattern: '^[a-f\\d]{24}$' },
status: { type: 'string', enum: ['draft', 'published'], default: 'published' },
@@ -484,7 +487,7 @@ export const openapi = {
},
post: {
tags: ['Videos'],
- summary: 'Upload a base video',
+ summary: 'Add a base video from an upload or direct URL',
security: adminSecurity,
requestBody: { required: true, content: { 'multipart/form-data': { schema: ref('VideoUploadInput') } } },
responses: { 201: jsonResponse('Created', ref('Video')), ...adminErrors, 413: errorResponse },
diff --git a/backend/src/middlewares/validators/videoSchemas.js b/backend/src/middlewares/validators/videoSchemas.js
index 412a8b5..fc2ace1 100644
--- a/backend/src/middlewares/validators/videoSchemas.js
+++ b/backend/src/middlewares/validators/videoSchemas.js
@@ -6,6 +6,12 @@ const title = z.string({ error: 'Give the video a title.' }).trim().min(1, 'Give
const status = z.enum(VIDEO_STATUS_LIST, { error: `Status must be one of: ${VIDEO_STATUS_LIST.join(', ')}.` })
// An empty string (from a form's "Unassigned" option) means no category.
const categoryId = z.preprocess((value) => (value === '' ? null : value), objectId('category').nullable())
+const videoUrl = z.preprocess(
+ (value) => value === '' ? undefined : value,
+ z.url({ error: 'Enter a valid direct video URL.' })
+ .refine((value) => value.startsWith('https://'), 'Video URLs must use HTTPS.')
+ .optional(),
+)
const pixels = z.coerce.number({ error: 'Size must be a number.' }).int().min(0).max(16384).default(0)
@@ -13,6 +19,7 @@ export const createVideoSchema = z.object({
title,
status: status.default(VIDEO_STATUS.PUBLISHED),
categoryId: categoryId.optional().default(null),
+ videoUrl,
durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0),
width: pixels,
height: pixels,
diff --git a/backend/src/models/Video.js b/backend/src/models/Video.js
index 041c4c3..9da1627 100644
--- a/backend/src/models/Video.js
+++ b/backend/src/models/Video.js
@@ -12,7 +12,8 @@ const videoSchema = new mongoose.Schema({
// Displayed size in pixels as the browser reported it when the admin prepared the file; 0 means unknown (older uploads).
width: { type: Number, default: 0, min: 0 },
height: { type: Number, default: 0, min: 0 },
- videoFile: { type: storedFileSchema, required: true },
+ videoFile: { type: storedFileSchema, default: null },
+ externalVideoUrl: { type: String, trim: true, default: null },
posterFile: { type: storedFileSchema, default: null },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
}, {
@@ -21,14 +22,22 @@ const videoSchema = new mongoose.Schema({
toJSON: jsonOptions((doc, ret) => {
const base = `${API_PREFIX}/videos/${ret.id}`
ret.categoryId = ret.category ? String(ret.category) : null
- ret.videoUrl = `${base}/file`
+ ret.videoUrl = ret.externalVideoUrl || `${base}/file`
+ ret.videoSource = ret.externalVideoUrl ? 'external' : 'stored'
ret.poster = ret.posterFile ? `${base}/poster` : null
ret.size = ret.videoFile?.size ?? 0
- for (const key of ['category', 'videoFile', 'posterFile', 'createdBy', 'updatedAt']) delete ret[key]
+ for (const key of ['externalVideoUrl', 'category', 'videoFile', 'posterFile', 'createdBy', 'updatedAt']) delete ret[key]
return ret
}),
})
+videoSchema.pre('validate', function validateVideoSource(next) {
+ if (Boolean(this.videoFile) === Boolean(this.externalVideoUrl)) {
+ this.invalidate('videoFile', 'Provide either an uploaded video file or an external video URL.')
+ }
+ next()
+})
+
videoSchema.index({ status: 1, category: 1, order: 1 })
export const Video = mongoose.model('Video', videoSchema)
diff --git a/backend/src/services/storage/index.js b/backend/src/services/storage/index.js
index c7b04f8..534a962 100644
--- a/backend/src/services/storage/index.js
+++ b/backend/src/services/storage/index.js
@@ -139,13 +139,13 @@ export const storageService = {
},
/** Returns readable file stream descriptor by looking up stored driver name. */
- async describe(ref) {
+ async describe(ref, cachedMetadata) {
const driver = driverFor(ref.driver)
try {
- const { size } = await driver.stat(ref.key)
+ const { size } = cachedMetadata ?? await driver.stat(ref.key)
return {
size,
- mimeType: ref.mimeType,
+ mimeType: cachedMetadata?.mimeType ?? ref.mimeType,
createReadStream: (range) => driver.createReadStream(ref.key, range),
}
} catch (err) {
diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js
index d18bd5f..9a4c8c0 100644
--- a/backend/src/services/videoService.js
+++ b/backend/src/services/videoService.js
@@ -4,9 +4,12 @@ import { videoRepo } from '../repositories/videoRepo.js'
import { notFound, validationError } from '../utils/AppError.js'
import { safeName, sniffImage, sniffVideo } from '../utils/files.js'
import { logger } from '../utils/logger.js'
+import { upstashRedis } from './cache/upstashRedis.js'
import { storageService } from './storage/index.js'
const isAdmin = (user) => user?.role === ROLES.ADMIN
+const POSTER_CACHE_TTL_SECONDS = 5 * 60
+const posterCacheKey = (id) => `signpak:video-poster:v1:${id}`
async function assertCategoryExists(categoryId) {
if (categoryId && !(await categoryRepo.findById(categoryId))) throw validationError({ categoryId: 'That category does not exist.' })
@@ -20,9 +23,9 @@ async function findVisible(id, requester) {
return video
}
-async function describeOrNotFound(ref) {
+async function describeOrNotFound(ref, cachedMetadata) {
try {
- return await storageService.describe(ref)
+ return await storageService.describe(ref, cachedMetadata)
} catch (error) {
logger.error({ err: error, key: ref.key }, 'Stored file could not be read')
throw notFound('That file is missing.')
@@ -37,10 +40,19 @@ export const videoService = {
async create(admin, input, files) {
const upload = files?.video?.[0]
const posterUpload = files?.poster?.[0]
- if (!upload) throw validationError({ video: 'Choose a video to upload.' })
+ const externalVideoUrl = input.videoUrl?.trim() || null
+ if (!upload && !externalVideoUrl) {
+ throw validationError({ video: 'Choose a video to upload or provide a direct video URL.' })
+ }
+ if (Boolean(upload) === Boolean(externalVideoUrl)) {
+ throw validationError({ video: 'Provide either a video upload or a direct video URL.' })
+ }
+ if (externalVideoUrl && !posterUpload) {
+ throw validationError({ poster: 'Choose a poster image for an external video.' })
+ }
- const videoKind = await sniffVideo(upload.path)
- if (!videoKind) throw validationError({ video: 'That does not look like an MP4, WebM or MOV video.' })
+ const videoKind = upload ? await sniffVideo(upload.path) : null
+ if (upload && !videoKind) throw validationError({ video: 'That does not look like an MP4, WebM or MOV video.' })
let posterKind = null
if (posterUpload) {
if (posterUpload.size > POSTER_MAX_BYTES) throw validationError({ poster: 'Poster images must be under 2 MB.' })
@@ -54,11 +66,14 @@ export const videoService = {
const stored = []
try {
- const videoFile = await storageService.save(
- { tempPath: upload.path, originalName: upload.originalname, mimeType: videoKind.mimeType, ext: videoKind.ext },
- { folder, fileName: `${base}${videoKind.ext}` },
- )
- stored.push(videoFile)
+ let videoFile = null
+ if (upload) {
+ videoFile = await storageService.save(
+ { tempPath: upload.path, originalName: upload.originalname, mimeType: videoKind.mimeType, ext: videoKind.ext },
+ { folder, fileName: `${base}${videoKind.ext}` },
+ )
+ stored.push(videoFile)
+ }
let posterFile = null
if (posterUpload) {
posterFile = await storageService.save(
@@ -70,7 +85,7 @@ export const videoService = {
const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1
return await videoRepo.create({
title: input.title, status: input.status, durationSec: input.durationSec, width: input.width, height: input.height,
- category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id,
+ category: input.categoryId, order, videoFile, externalVideoUrl, posterFile, createdBy: admin._id,
})
} catch (error) {
await Promise.all(stored.map((ref) => storageService.remove(ref))) // no orphaned files if the database write fails
@@ -105,12 +120,47 @@ export const videoService = {
async getFile(id, requester) {
const video = await findVisible(id, requester)
+ if (!video.videoFile) throw notFound('That video is hosted externally.')
return describeOrNotFound(video.videoFile)
},
async getPoster(id, requester) {
const video = await findVisible(id, requester)
if (!video.posterFile) throw notFound('That video has no poster.')
- return describeOrNotFound(video.posterFile)
+ let cachedMetadata
+ if (upstashRedis.enabled) {
+ try {
+ const cached = await upstashRedis.get(posterCacheKey(video.id))
+ if (cached !== null) {
+ const metadata = JSON.parse(cached)
+ if (
+ metadata?.driver === video.posterFile.driver
+ && metadata?.key === video.posterFile.key
+ && Number.isSafeInteger(metadata.size)
+ && metadata.size >= 0
+ && metadata.mimeType === video.posterFile.mimeType
+ ) cachedMetadata = metadata
+ else logger.warn({ videoId: video.id }, 'Ignoring invalid cached poster metadata from Upstash Redis.')
+ }
+ } catch (error) {
+ logger.warn({ err: error, videoId: video.id }, 'Could not read poster metadata from Upstash Redis.')
+ }
+ }
+ if (cachedMetadata) return describeOrNotFound(video.posterFile, cachedMetadata)
+
+ const file = await describeOrNotFound(video.posterFile)
+ if (upstashRedis.enabled) {
+ try {
+ await upstashRedis.set(posterCacheKey(video.id), {
+ driver: video.posterFile.driver,
+ key: video.posterFile.key,
+ size: file.size,
+ mimeType: file.mimeType,
+ }, POSTER_CACHE_TTL_SECONDS)
+ } catch (error) {
+ logger.warn({ err: error, videoId: video.id }, 'Could not cache poster metadata in Upstash Redis.')
+ }
+ }
+ return file
},
}
diff --git a/backend/tests/api-videos.test.js b/backend/tests/api-videos.test.js
index b30770c..7fcdc6e 100644
--- a/backend/tests/api-videos.test.js
+++ b/backend/tests/api-videos.test.js
@@ -61,11 +61,56 @@ describe('videos', () => {
assert.deepEqual(filesIn('tmp'), [])
})
+ test('admin can register a direct HTTPS video URL with a stored poster', async () => {
+ const response = await admin.upload('/videos', videoForm({
+ title: 'External base video',
+ categoryId: daily.id,
+ videoUrl: 'https://media.example.test/sign.webm',
+ }, { video: null, poster: jpeg() }))
+
+ assert.equal(response.status, 201, JSON.stringify(response.body))
+ const external = response.body.data
+ assert.equal(external.videoUrl, 'https://media.example.test/sign.webm')
+ assert.equal(external.videoSource, 'external')
+ assert.equal(external.size, 0)
+ assert.equal(external.poster, `/api/v1/videos/${external.id}/poster`)
+ assert.equal(external.videoFile, undefined)
+ assert.equal((await new Client(base).get(`/videos/${external.id}/file`)).status, 404)
+
+ const poster = await new Client(base).get(`/videos/${external.id}/poster`, { raw: true })
+ assert.equal(poster.status, 200)
+ assert.equal(poster.headers.get('cache-control'), 'private, max-age=300')
+ assert.equal(poster.headers.get('content-type'), 'image/jpeg')
+ })
+
+ test('external video URLs require HTTPS and a poster, and cannot be combined with an upload', async () => {
+ const noPoster = await admin.upload('/videos', videoForm({
+ title: 'No poster',
+ videoUrl: 'https://media.example.test/sign.mp4',
+ }, { video: null }))
+ assert.equal(noPoster.status, 422)
+ assert.ok(noPoster.body.error.fields.poster)
+
+ const insecure = await admin.upload('/videos', videoForm({
+ title: 'Insecure source',
+ videoUrl: 'http://media.example.test/sign.mp4',
+ }, { video: null, poster: jpeg() }))
+ assert.equal(insecure.status, 422)
+ assert.ok(insecure.body.error.fields.videoUrl)
+
+ const bothSources = await admin.upload('/videos', videoForm({
+ title: 'Two sources',
+ videoUrl: 'https://media.example.test/sign.mp4',
+ }, { poster: jpeg() }))
+ assert.equal(bothSources.status, 422)
+ assert.ok(bothSources.body.error.fields.video)
+ })
+
test('visibility: contributors and visitors only see published, categorised videos', async () => {
const titles = (res) => res.body.data.map((v) => v.title).sort()
- assert.deepEqual(titles(await admin.get('/videos')), ['A warm introduction', 'Nice to meet you', 'Secret draft', 'Unassigned'])
- assert.deepEqual(titles(await contributor.get('/videos')), ['A warm introduction', 'Nice to meet you'])
- assert.deepEqual(titles(await new Client(base).get('/videos')), ['A warm introduction', 'Nice to meet you'])
+ assert.deepEqual(titles(await admin.get('/videos')), ['A warm introduction', 'External base video', 'Nice to meet you', 'Secret draft', 'Unassigned'])
+ assert.deepEqual(titles(await contributor.get('/videos')), ['A warm introduction', 'External base video', 'Nice to meet you'])
+ assert.deepEqual(titles(await new Client(base).get('/videos')), ['A warm introduction', 'External base video', 'Nice to meet you'])
assert.equal((await contributor.get(`/videos/${draft.id}`)).status, 404)
assert.equal((await admin.get(`/videos/${draft.id}`)).status, 200)
})
diff --git a/backend/tests/videoService.test.js b/backend/tests/videoService.test.js
index ed31fc1..ead4096 100644
--- a/backend/tests/videoService.test.js
+++ b/backend/tests/videoService.test.js
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
-import { categoryRepo, replace, storageService, temporaryDirectory, videoRepo, videoService, writeImage, writeVideo } from './videoServiceTestHelpers.js'
+import { categoryRepo, replace, storageService, temporaryDirectory, upstashRedis, videoRepo, videoService, writeImage, writeVideo } from './videoServiceTestHelpers.js'
test('video create names video and poster from title and category safely', async (t) => {
const directory = await temporaryDirectory(t)
const upload = await writeVideo(directory)
@@ -102,7 +102,7 @@ test('video create rejects a missing upload, unsupported content, and invalid ca
await assert.rejects(
videoService.create({}, {}, {}),
- (error) => error.code === 'VALIDATION_ERROR' && error.fields.video === 'Choose a video to upload.',
+ (error) => error.code === 'VALIDATION_ERROR' && error.fields.video === 'Choose a video to upload or provide a direct video URL.',
)
await assert.rejects(
videoService.create({}, {}, { video: [{ path: unsupported }] }),
@@ -119,6 +119,35 @@ test('video create rejects a missing upload, unsupported content, and invalid ca
assert.equal(saveCount, 0)
})
+test('poster metadata is cached in Redis for five minutes', async (t) => {
+ const enabledDescriptor = Object.getOwnPropertyDescriptor(upstashRedis, 'enabled')
+ Object.defineProperty(upstashRedis, 'enabled', { configurable: true, value: true })
+ t.after(() => Object.defineProperty(upstashRedis, 'enabled', enabledDescriptor))
+
+ const posterFile = { driver: 'gdrive', key: 'poster-id', mimeType: 'image/jpeg', size: 123 }
+ const video = { id: 'video-id', status: 'published', category: 'category-id', posterFile }
+ const writes = []
+ const described = []
+ replace(t, videoRepo, 'findById', async () => video)
+ replace(t, upstashRedis, 'get', async () => null)
+ replace(t, upstashRedis, 'set', async (...args) => writes.push(args))
+ replace(t, storageService, 'describe', async (_ref, metadata) => {
+ described.push(metadata)
+ return { size: 123, mimeType: 'image/jpeg', createReadStream: () => null }
+ })
+
+ await videoService.getPoster(video.id)
+
+ assert.deepEqual(described, [undefined])
+ assert.equal(writes[0][0], `signpak:video-poster:v1:${video.id}`)
+ assert.deepEqual(writes[0][1], { driver: 'gdrive', key: 'poster-id', size: 123, mimeType: 'image/jpeg' })
+ assert.equal(writes[0][2], 300)
+
+ replace(t, upstashRedis, 'get', async () => JSON.stringify(writes[0][1]))
+ await videoService.getPoster(video.id)
+ assert.deepEqual(described[1], writes[0][1])
+})
+
test('video create rejects oversized or unsupported posters before storage', async (t) => {
const directory = await temporaryDirectory(t)
let saveCount = 0
diff --git a/backend/tests/videoServiceTestHelpers.js b/backend/tests/videoServiceTestHelpers.js
index 3f4fe2c..1331084 100644
--- a/backend/tests/videoServiceTestHelpers.js
+++ b/backend/tests/videoServiceTestHelpers.js
@@ -11,12 +11,13 @@ process.env.JWT_SECRET = 'test-secret-that-is-longer-than-thirty-two-characters'
process.env.ADMIN_EMAIL = 'admin@example.com'
process.env.ADMIN_PASSWORD = 'test-password-123'
-export const [{ videoService }, { categoryRepo }, { videoRepo }, { storageService }] =
+export const [{ videoService }, { categoryRepo }, { videoRepo }, { storageService }, { upstashRedis }] =
await Promise.all([
import('../src/services/videoService.js'),
import('../src/repositories/categoryRepo.js'),
import('../src/repositories/videoRepo.js'),
import('../src/services/storage/index.js'),
+ import('../src/services/cache/upstashRedis.js'),
])
export async function temporaryDirectory(t) {
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
new file mode 100644
index 0000000..e81c573
--- /dev/null
+++ b/frontend/AGENTS.md
@@ -0,0 +1,52 @@
+# Frontend Agent Guide
+
+## Scope
+
+These instructions apply to work under `frontend/`. Start with the relevant
+screen, provider, service, hook, and test; follow existing patterns before
+adding abstractions. The Express API is authoritative for persisted users,
+categories, videos, and submissions.
+
+## Commands
+
+Run from `frontend/`:
+
+```sh
+npm run dev
+npm run lint
+npm test
+npm run build
+```
+
+Use the smallest relevant test file when practical. The Vite development server
+proxies `/api` to `http://localhost:5000`; start the backend separately.
+
+## Guardrails
+
+- Do not put credentials or private keys in `VITE_*` variables; they are exposed
+ in the browser bundle. Keep local secrets in an untracked `.env.local`.
+- Keep API calls in `src/services/api.js` and URL construction in
+ `src/services/apiRoutes.js`; surface backend error messages instead of
+ silently replacing failures with success-shaped defaults.
+- Auth-shell forms use React Hook Form with Zod resolvers. Validate auth request
+ payloads with the matching Zod schemas at the API-client boundary as well;
+ backend Zod validators remain the final authority.
+- Keep camera capture, unsubmitted recordings, and queued uploads in their
+ established providers. Unsubmitted recordings and the upload queue are
+ intentionally memory-only.
+- Preserve media lifecycle cleanup: release object URLs, stop camera tracks,
+ and do not revoke a video URL while its player still uses it.
+- External base videos are direct HTTPS media URLs, not embedded watch pages.
+ Browser-side Blob caching requires source CORS; keep the network-playback
+ fallback when caching is unavailable.
+- Update relevant frontend tests and docs when user-visible behavior or the API
+ contract changes. Do not edit generated build output in `dist/`.
+
+## References
+
+- [Frontend docs index](docs/README.md)
+- [Architecture](docs/ARCHITECTURE.md)
+- [Module reference](docs/MODULE-REFERENCE.md)
+- [Design decisions](docs/design-docs/ARCHITECTURE-DECISIONS.md)
+- [Frontend setup and routes](README.md)
+- [Backend API](../backend/docs/API.md)
diff --git a/frontend/README.md b/frontend/README.md
index 03199d2..fbc13dc 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -48,6 +48,10 @@ in `src/routes/appRoutes.js`. Auth guards in `src/components/RouteGuards.jsx`
redirect visitors who do not meet the route's access requirement and route
contributors without the current consent version through `/consent`.
+Auth-shell forms use React Hook Form with Zod resolvers. Authentication
+payloads are validated again in the API client, with backend Zod schemas as the
+final validation boundary.
+
After email verification, the frontend holds the short-lived consent token in
the navigation state and offers accept or decline. Accepting creates and signs
in the account; declining discards the pending signup. The consent version and
@@ -62,8 +66,10 @@ 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
+When adding a base video, admins can upload a video file or provide a direct
+HTTPS MP4/WebM file URL (watch-page links such as YouTube are not supported).
+External videos require a JPEG, PNG, or WebP poster image (up to 2 MB). For
+uploaded videos, admins can select a poster image or explicitly use a frame
captured automatically from the video.
## Backend connection
@@ -134,7 +140,10 @@ src/
test/
RecorderPanel.test.jsx Recorder workflow component tests
docs/
+ README.md Documentation index
ARCHITECTURE.md Runtime, state, data flow, and deployment overview
+ MODULE-REFERENCE.md Change-oriented map of frontend modules
+ design-docs/ Architecture decisions and trade-offs
```
## Styling and themes
diff --git a/frontend/docs/MODULE-REFERENCE.md b/frontend/docs/MODULE-REFERENCE.md
new file mode 100644
index 0000000..1cb3246
--- /dev/null
+++ b/frontend/docs/MODULE-REFERENCE.md
@@ -0,0 +1,34 @@
+# Frontend module reference
+
+For the complete runtime and data-flow explanation, see
+[ARCHITECTURE.md](ARCHITECTURE.md). This page is a change-oriented map.
+
+| Concern | Primary modules | Notes |
+| --- | --- | --- |
+| App entry, providers, and routes | `src/main.jsx`, `src/App.jsx`, `src/components/Layouts.jsx` | Provider nesting and route guards affect all pages. |
+| Route paths and access | `src/routes/appRoutes.js`, `src/components/RouteGuards.jsx` | Use shared route builders for internal links. |
+| Authentication | `src/context/AuthProvider.jsx`, `src/pages/Signup.jsx`, `src/pages/Consent.jsx`, `src/pages/VerifyEmail.jsx` | Auth forms use React Hook Form and `src/utils/authSchemas.js`; consent is required before activating a new signup. |
+| API transport and request validation | `src/services/api.js`, `src/services/apiRoutes.js`, `src/utils/validateApiInput.js` | Central place for credentials, response envelopes, normalization, endpoint paths, and auth request validation. Backend Zod remains authoritative. |
+| Library and categories | `src/context/LibraryProvider.jsx`, `src/pages/Home.jsx`, `src/pages/CategoryBrowser.jsx` | Library context owns loaded categories/videos and admin mutations. |
+| Admin video management | `src/pages/admin/AdminVideos.jsx`, `src/components/VideoPrep.jsx` | Supports uploaded media and direct HTTPS media URLs; URL sources require an uploaded poster. |
+| Video playback and cache | `src/pages/Player.jsx`, `src/components/BasePlayer.jsx`, `src/components/ResponsiveVideo.jsx`, `src/hooks/useCachedVideo.js`, `src/utils/videoCache.js` | One native player; Blob cache has 5-minute TTL and size limits. |
+| Camera and recording draft | `src/hooks/useRecorder.js`, `src/context/RecordingProvider.jsx`, `src/pages/RecordingEditor.jsx` | Unsubmitted recording blobs are memory-only. |
+| Background submission uploads | `src/context/UploadQueueProvider.jsx`, `src/components/UploadToaster.jsx` | One upload at a time, one waiting slot; queue does not persist across tabs. |
+| Theme and notifications | `src/context/ThemeProvider.jsx`, `src/context/NotificationProvider.jsx` | Browser-local preferences and per-user notification state. |
+| Styling | `src/styles/`, `src/index.css` | Global tokens and component styles are imported through `index.css`. |
+| Tests | `test/` | Vitest and Testing Library; use targeted test files before the full suite. |
+
+## API changes
+
+When an API contract changes, update the API client, relevant provider or page,
+tests, and both the backend OpenAPI/API references as appropriate. Backend
+routes and server-side validation remain authoritative; do not duplicate
+authorization rules in the browser as a security boundary.
+
+## Media URL behavior
+
+`video.videoUrl` may be a backend stream URL or a direct external HTTPS URL.
+External sources use native `` playback. The browser Blob cache needs
+CORS permission from a cross-origin source; if a fetch fails, the player falls
+back to the source URL. Poster images continue to use the backend poster
+endpoint.
diff --git a/frontend/docs/README.md b/frontend/docs/README.md
new file mode 100644
index 0000000..9661cef
--- /dev/null
+++ b/frontend/docs/README.md
@@ -0,0 +1,11 @@
+# Frontend documentation
+
+- [Architecture](ARCHITECTURE.md): application startup, routing, state
+ ownership, recording flow, and media lifecycle.
+- [Module reference](MODULE-REFERENCE.md): where to make changes by feature
+ and how the frontend modules connect.
+- [Design decisions](design-docs/ARCHITECTURE-DECISIONS.md): established
+ trade-offs and constraints.
+- [Frontend README](../README.md): setup, routes, environment variables, and
+ user-facing workflow notes.
+- [Backend API reference](../../backend/docs/API.md): server endpoint contract.
diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md
index e7359e4..111bdd8 100644
--- a/frontend/docs/architecture.md
+++ b/frontend/docs/architecture.md
@@ -80,6 +80,9 @@ The client:
server message, status, and field-level validation messages.
- Converts API-relative media URLs to absolute URLs when `VITE_API_ORIGIN` is
configured.
+- Uses shared Zod schemas to validate authentication form input and validates
+ authentication request payloads again at the API-client boundary. Backend
+ Zod validation remains authoritative.
Email verification returns a short-lived consent token rather than a session.
The signup consent screen submits that token and the current notice version;
@@ -117,7 +120,11 @@ back to the normal media URL if the download fails or exceeds the per-video
oldest entries to stay within a 250 MB total budget. A player holds its entry
while it is in use so an expired or evicted object URL is not revoked during
playback. The player also prefetches the next video in its category after the
-current video's load attempt finishes.
+current video's load attempt finishes. The player keeps one `` element
+mounted when switching videos, resetting playback state before it loads the
+next source. External direct HTTPS video URLs use this same cache when the
+source allows browser CORS; otherwise playback falls back to the source URL.
+External watch pages (such as YouTube pages) are not supported.
### Admin video preparation and responsive playback
@@ -140,6 +147,14 @@ 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.
+Admins can also register a direct HTTPS video URL rather than uploading the
+video bytes. The URL is stored with the video record in MongoDB; its poster
+image is uploaded through the configured storage driver (Google Drive when
+`STORAGE_DRIVER=gdrive`). Upstash Redis caches poster file metadata for five
+minutes, while the poster endpoint streams image bytes from storage and sends a
+matching five-minute browser cache header. Redis stores metadata, not image
+bytes.
+
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
diff --git a/frontend/docs/design-docs/ARCHITECTURE-DECISIONS.md b/frontend/docs/design-docs/ARCHITECTURE-DECISIONS.md
new file mode 100644
index 0000000..19c0b5c
--- /dev/null
+++ b/frontend/docs/design-docs/ARCHITECTURE-DECISIONS.md
@@ -0,0 +1,72 @@
+# Frontend architecture decisions
+
+This is a record of current choices and their trade-offs, not a dated changelog.
+
+## The API owns persistent domain data
+
+**Decision:** Accounts, categories, reference videos, and submitted recordings
+are loaded and changed through the backend API. React context coordinates
+session-level UI state; it is not a second persistent database.
+
+**Reason and trade-off:** One server authority keeps permissions, visibility,
+consent, and submission rules consistent across devices. The UI must handle
+network errors and may need to refresh after mutations.
+
+**Implementation:** `src/services/api.js`, `src/context/LibraryProvider.jsx`,
+and `src/context/AuthProvider.jsx`.
+
+## Keep unfinished capture work in memory
+
+**Decision:** An unsubmitted recording and its edits remain in memory while the
+user moves between the player and editor. The upload queue is also in memory,
+with one active and one waiting upload.
+
+**Reason and trade-off:** Camera media and recordings are sensitive and can be
+large. Avoiding persistent browser storage reduces data retention but means a
+tab close can lose unfinished work; the UI warns when uploads are pending.
+
+**Implementation:** `src/context/RecordingProvider.jsx` and
+`src/context/UploadQueueProvider.jsx`.
+
+## Use one native video element and browser-managed playback
+
+**Decision:** The player updates the media source on a persistent native
+`` element. It resets playback controls and current time when the
+selected video changes. It does not embed third-party watch pages in iframes.
+
+**Reason and trade-off:** Native video supports the recorder workflow,
+responsive layout, playback controls, and direct file URLs without coupling
+the player to a particular video platform. Direct external sources must be
+compatible with browser media playback.
+
+**Implementation:** `src/pages/Player.jsx`, `src/components/BasePlayer.jsx`,
+and `src/components/ResponsiveVideo.jsx`.
+
+## Cache recently played videos, but retain a network fallback
+
+**Decision:** Download cacheable reference videos as Blobs and hold them in a
+bounded in-memory cache for five minutes after download. Prefetch the next
+video. If caching fails, exceeds its limits, or the remote host disallows CORS,
+play from the original media URL.
+
+**Reason and trade-off:** The Blob cache makes repeat playback predictable
+across browser HTTP-cache implementations, but downloading whole files uses
+memory and requires CORS for cross-origin sources. Large videos and
+non-CORS sources therefore bypass the cache.
+
+**Implementation:** `src/utils/videoCache.js`, `src/hooks/useCachedVideo.js`,
+and `src/services/api.js`.
+
+## Keep upload preparation client-side
+
+**Decision:** The browser prepares admin-uploaded reference/demo videos,
+including optional trim/audio removal and poster-frame capture. The backend
+stores submitted dimensions and duration as metadata rather than probing or
+transcoding media.
+
+**Reason and trade-off:** This avoids a separate media-processing service.
+ffmpeg.wasm is a sizeable, network-loaded dependency, so the UI must retain an
+unchanged-upload path when processing is not available.
+
+**Implementation:** `src/components/VideoPrep.jsx`,
+`src/utils/processVideo.js`, and `src/utils/media.js`.
diff --git a/frontend/docs/design-docs/README.md b/frontend/docs/design-docs/README.md
new file mode 100644
index 0000000..b182cb9
--- /dev/null
+++ b/frontend/docs/design-docs/README.md
@@ -0,0 +1,9 @@
+# Frontend design documents
+
+These documents record current architectural decisions and the constraints
+behind them. They are not a substitute for source code or the
+[architecture guide](../ARCHITECTURE.md). Update a decision when its behavior
+or trade-off changes; do not treat undocumented dates or historical rationale
+as fact.
+
+- [Architecture decisions](ARCHITECTURE-DECISIONS.md)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8deb11f..c4e1bdc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,20 +1,23 @@
{
"name": "frontend",
- "version": "1.0.7",
+ "version": "1.0.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "1.0.7",
+ "version": "1.0.8",
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
+ "@hookform/resolvers": "^5.9.2",
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-hook-form": "^7.91.0",
"react-router-dom": "^7.9.0",
- "tailwindcss": "^4.3.3"
+ "tailwindcss": "^4.3.3",
+ "zod": "^4.6.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -138,6 +141,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -503,6 +507,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -526,6 +531,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -706,6 +712,116 @@
"node": ">=18.x"
}
},
+ "node_modules/@hookform/resolvers": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.2.tgz",
+ "integrity": "sha512-RtjTnZqsC38s4NKp2XO3fD8IA5uyN4tB7McQ5C6uiMPcV+WWO8F+wVa3e+BXUpH2ScgxghAlwFRMVqjT9WChAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "@sinclair/typebox": ">=0.25.24",
+ "@standard-schema/spec": "^1.0.0",
+ "@typeschema/main": ">=0.13.7",
+ "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
+ "ajv": "^8.12.0",
+ "ajv-errors": "^3.0.0",
+ "ajv-formats": "^2.1.1",
+ "arktype": "^2.0.0",
+ "ata-validator": "^1.23.0",
+ "class-transformer": ">=0.4.0",
+ "class-validator": ">=0.12.0",
+ "computed-types": "^1.0.0",
+ "effect": "^3.10.3",
+ "fluentvalidation-ts": "^3.0.0",
+ "fp-ts": "^2.7.0",
+ "io-ts": "^2.0.0",
+ "joi": "^17.0.0 || ^18.0.0",
+ "nope-validator": ">=0.12.0",
+ "react-hook-form": "^7.55.0",
+ "superstruct": ">=0.12.0",
+ "typanion": "^3.3.2",
+ "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
+ "vest": ">=6.0.0",
+ "yup": "^1.0.0",
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@sinclair/typebox": {
+ "optional": true
+ },
+ "@standard-schema/spec": {
+ "optional": true
+ },
+ "@typeschema/main": {
+ "optional": true
+ },
+ "@vinejs/vine": {
+ "optional": true
+ },
+ "ajv": {
+ "optional": true
+ },
+ "ajv-errors": {
+ "optional": true
+ },
+ "ajv-formats": {
+ "optional": true
+ },
+ "arktype": {
+ "optional": true
+ },
+ "ata-validator": {
+ "optional": true
+ },
+ "class-transformer": {
+ "optional": true
+ },
+ "class-validator": {
+ "optional": true
+ },
+ "computed-types": {
+ "optional": true
+ },
+ "effect": {
+ "optional": true
+ },
+ "fluentvalidation-ts": {
+ "optional": true
+ },
+ "fp-ts": {
+ "optional": true
+ },
+ "io-ts": {
+ "optional": true
+ },
+ "joi": {
+ "optional": true
+ },
+ "nope-validator": {
+ "optional": true
+ },
+ "superstruct": {
+ "optional": true
+ },
+ "typanion": {
+ "optional": true
+ },
+ "valibot": {
+ "optional": true
+ },
+ "vest": {
+ "optional": true
+ },
+ "yup": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1096,6 +1212,12 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1656,8 +1778,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
@@ -1712,8 +1833,9 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1724,6 +1846,7 @@
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.3.0"
}
@@ -1826,6 +1949,7 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1856,30 +1980,12 @@
"node": ">= 4.0.0"
}
},
- "node_modules/ajv": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
- "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -1890,7 +1996,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -1904,7 +2009,6 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -2045,6 +2149,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810",
@@ -2386,7 +2491,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -2451,7 +2556,6 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=6"
}
@@ -2477,8 +2581,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/dom-serializer": {
"version": "0.1.1",
@@ -2645,6 +2748,7 @@
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"workspaces": [
"packages/*"
],
@@ -2760,6 +2864,30 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/eslint/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
@@ -3614,6 +3742,7 @@
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
@@ -3678,13 +3807,6 @@
"node": ">=6"
}
},
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -3711,6 +3833,7 @@
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -4024,7 +4147,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -4479,7 +4601,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -4624,6 +4745,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4633,6 +4755,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.28.0"
},
@@ -4640,13 +4763,35 @@
"react": "^19.3.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.91.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.91.0.tgz",
+ "integrity": "sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-router": {
"version": "7.18.4",
@@ -5295,6 +5440,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
@@ -5620,11 +5766,11 @@
}
},
"node_modules/zod": {
- "version": "4.6.5",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
- "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
- "dev": true,
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/frontend/package.json b/frontend/package.json
index 3e712c1..a9ab953 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "1.0.7",
+ "version": "1.0.8",
"type": "module",
"engines": {
"node": ">=20.19.0"
@@ -19,11 +19,14 @@
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
+ "@hookform/resolvers": "^5.9.2",
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-hook-form": "^7.91.0",
"react-router-dom": "^7.9.0",
- "tailwindcss": "^4.3.3"
+ "tailwindcss": "^4.3.3",
+ "zod": "^4.6.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/frontend/src/components/BasePlayer.jsx b/frontend/src/components/BasePlayer.jsx
index 47a0272..75418e6 100644
--- a/frontend/src/components/BasePlayer.jsx
+++ b/frontend/src/components/BasePlayer.jsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { formatTime } from '../utils/format'
import { aspectOf } from '../utils/media'
import ResponsiveVideo from './ResponsiveVideo'
@@ -14,6 +14,23 @@ export default function BasePlayer({ video, videoRef, onAspect, src, loading, pr
const [speed, setSpeed] = useState(1)
const [failed, setFailed] = useState(false)
+ useEffect(() => {
+ const element = videoRef.current
+ if (element) {
+ element.pause()
+ element.currentTime = 0
+ element.playbackRate = 1
+ element.load()
+ }
+ // Reset player controls when reusing the same video element for another source.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setPlaying(false)
+ setTime(0)
+ setDuration(0)
+ setSpeed(1)
+ setFailed(false)
+ }, [video.id, videoRef])
+
const toggle = () => {
const element = videoRef.current
if (!element) return
@@ -48,9 +65,10 @@ export default function BasePlayer({ video, videoRef, onAspect, src, loading, pr
src={src}
poster={video.poster}
aspect={aspectOf(video)}
+ mediaKey={video.id}
onAspect={onAspect}
frameClassName="base-player-frame"
- crossOrigin="use-credentials"
+ crossOrigin={video.videoSource === 'external' ? undefined : 'use-credentials'}
className="base-player-video"
playsInline
autoPlay
diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx
index b299e94..4f4f977 100644
--- a/frontend/src/components/Field.jsx
+++ b/frontend/src/components/Field.jsx
@@ -1,7 +1,7 @@
-import { useId, useState } from 'react'
+import { forwardRef, useId, useState } from 'react'
/** Label + control + inline error. `as` can be "input" (default), "textarea" or "select". */
-export default function Field({ label, error, hint, id, type, as: Control = 'input', children, className = '', ...props }) {
+const Field = forwardRef(function Field({ label, error, hint, id, type, as: Control = 'input', children, className = '', ...props }, ref) {
const autoId = useId()
const fieldId = id || autoId
const errorId = `${fieldId}-error`
@@ -19,6 +19,7 @@ export default function Field({ label, error, hint, id, type, as: Control = 'inp
id={fieldId}
type={passwordVisible ? 'text' : 'password'}
className={`field-control field-${Control} field-password-input`}
+ ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : hint ? hintId : undefined}
{...props}
@@ -69,6 +70,7 @@ export default function Field({ label, error, hint, id, type, as: Control = 'inp
id={fieldId}
type={type}
className={`field-control field-${Control}`}
+ ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : hint ? hintId : undefined}
{...props}
@@ -84,4 +86,6 @@ export default function Field({ label, error, hint, id, type, as: Control = 'inp
) : null}
)
-}
+})
+
+export default Field
diff --git a/frontend/src/components/ResponsiveVideo.jsx b/frontend/src/components/ResponsiveVideo.jsx
index e6ee807..2aecccf 100644
--- a/frontend/src/components/ResponsiveVideo.jsx
+++ b/frontend/src/components/ResponsiveVideo.jsx
@@ -9,15 +9,16 @@ import { join } from './ui/join'
* taller than the viewport. Overlays (play buttons, timers) go in `children` and sit inside the frame.
*/
export default function ResponsiveVideo({
- aspect, maxHeight, videoRef, className, frameClassName, children, onAspect, onLoadedMetadata, ...props
+ aspect, maxHeight, mediaKey, videoRef, className, frameClassName, children, onAspect, onLoadedMetadata, ...props
}) {
- const [measured, setMeasured] = useState(null)
+ const [measurement, setMeasurement] = useState({ key: mediaKey, aspect: null })
+ const measured = measurement.key === mediaKey ? measurement.aspect : null
const ratio = measured ?? aspect ?? DEFAULT_ASPECT
const handleMetadata = (event) => {
const { videoWidth, videoHeight } = event.currentTarget
if (videoWidth && videoHeight) {
- setMeasured(videoWidth / videoHeight)
+ setMeasurement({ key: mediaKey, aspect: videoWidth / videoHeight })
onAspect?.(videoWidth / videoHeight)
}
onLoadedMetadata?.(event)
diff --git a/frontend/src/pages/Consent.jsx b/frontend/src/pages/Consent.jsx
index e84c0f3..04ffd10 100644
--- a/frontend/src/pages/Consent.jsx
+++ b/frontend/src/pages/Consent.jsx
@@ -1,10 +1,14 @@
-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
+import { useForm, useWatch } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
import AuthShell from '../components/AuthShell'
import { Alert, Button } from '../components/ui'
import { useAuth } from '../context/AuthContext'
import { paths } from '../routes/appRoutes'
import { CONSENT_VERSION, consentGiven, consentPoints } from '../utils/consent'
+import { consentFormSchema } from '../utils/authSchemas'
+import { applyAuthFieldErrors } from '../utils/authFormErrors'
/**
* mode="signup": shown right after a valid email code. The account does not exist yet; accepting creates it,
@@ -15,25 +19,38 @@ export default function Consent({ mode = 'existing' }) {
const location = useLocation()
const navigate = useNavigate()
const consentToken = location.state?.consentToken
- const [agreed, setAgreed] = useState(false)
+ const dialogRef = useRef(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
+ const { register, handleSubmit, setError: setFieldError, control, formState: { errors, isSubmitting } } = useForm({
+ resolver: zodResolver(consentFormSchema),
+ defaultValues: { accepted: false },
+ })
+ const agreed = useWatch({ control, name: 'accepted' })
+
+ useEffect(() => {
+ if (mode !== 'signup') return
+
+ const dialog = dialogRef.current
+ if (!dialog) return
+ if (!dialog.open) dialog.showModal()
+
+ return () => {
+ if (dialog.open) dialog.close()
+ }
+ }, [mode])
if (mode === 'signup' && !consentToken) return
if (mode === 'existing' && (consentGiven(user) || user?.role === 'admin')) return
- const submit = async (event) => {
- event.preventDefault()
- if (!agreed) return
- setBusy(true)
+ const submit = async () => {
setError('')
try {
if (mode === 'signup') await completeSignup(consentToken, CONSENT_VERSION)
else await acceptConsent(CONSENT_VERSION)
navigate(paths.library, { replace: true })
} catch (cause) {
- setError(cause.message || 'We could not save your consent. Try again.')
- setBusy(false)
+ if (!applyAuthFieldErrors(cause, setFieldError)) setError(cause.message || 'We could not save your consent. Try again.')
}
}
@@ -53,12 +70,8 @@ export default function Consent({ mode = 'existing' }) {
}
}
- return
-
- {mode === 'signup' ? 'Decline and cancel sign-up' : 'Decline and log out'}
+ {mode === 'signup' ? 'Decline and cancel sign-up' : 'Decline and log out'}
+ >
+
+ if (mode === 'signup') {
+ return (
+
+ event.preventDefault()}
+ >
+ Consent notice
+ Before you contribute.
+
+ Your account is created once you accept how your recordings are used.
+
+ {consentForm}
+
+
+ )
+ }
+
+ return
+ {consentForm}
}
diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx
index ec4ed12..bfe8b48 100644
--- a/frontend/src/pages/ForgotPassword.jsx
+++ b/frontend/src/pages/ForgotPassword.jsx
@@ -1,78 +1,63 @@
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
import AuthShell from '../components/AuthShell'
import Field from '../components/Field'
import { Alert, Button } from '../components/ui'
import { paths } from '../routes/appRoutes'
import { api } from '../services/api'
-import { isEmail } from '../utils/validators'
+import { emailOnlySchema, resetPasswordSchema } from '../utils/authSchemas'
+import { applyAuthFieldErrors } from '../utils/authFormErrors'
export default function ForgotPassword() {
const [params, setParams] = useSearchParams()
const navigate = useNavigate()
- const [email, setEmail] = useState(params.get('email') || '')
- const [code, setCode] = useState('')
- const [password, setPassword] = useState('')
- const [confirmPassword, setConfirmPassword] = useState('')
- const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
+ const [resending, setResending] = useState(false)
const resetting = params.get('step') === 'reset'
+ const requestForm = useForm({
+ resolver: zodResolver(emailOnlySchema),
+ defaultValues: { email: params.get('email') || '' },
+ })
+ const resetForm = useForm({
+ resolver: zodResolver(resetPasswordSchema),
+ defaultValues: { email: params.get('email') || '', code: '', password: '', confirmPassword: '' },
+ })
- const requestCode = async (event) => {
- event.preventDefault()
+ const requestCode = async ({ email }) => {
setError('')
- if (!isEmail(email)) {
- setError('Enter a valid email address.')
- return
- }
- setBusy(true)
try {
const result = await api.auth.forgotPassword(email)
setNotice(result.message)
+ resetForm.setValue('email', email)
setParams({ email, step: 'reset' }, { replace: true })
} catch (cause) {
- setError(cause.message || 'A reset code could not be requested.')
- } finally {
- setBusy(false)
+ if (!applyAuthFieldErrors(cause, requestForm.setError)) setError(cause.message || 'A reset code could not be requested.')
}
}
- const reset = async (event) => {
- event.preventDefault()
+ const reset = async (values) => {
setError('')
- if (code.length !== 6) {
- setError('Enter the six-digit code from your email.')
- return
- }
- if (password.length < 8) {
- setError('Use at least 8 characters for your password.')
- return
- }
- if (password !== confirmPassword) {
- setError('Passwords do not match.')
- return
- }
- setBusy(true)
try {
- await api.auth.resetPassword({ email, code, password, confirmPassword })
+ await api.auth.resetPassword(values)
navigate(paths.login, { replace: true, state: { notice: 'Password reset. Log in with your new password.' } })
} catch (cause) {
- setError(cause.message || 'Your password could not be reset.')
- setBusy(false)
+ if (!applyAuthFieldErrors(cause, resetForm.setError)) setError(cause.message || 'Your password could not be reset.')
}
}
const resend = async () => {
setError('')
- setBusy(true)
+ setResending(true)
try {
- const result = await api.auth.forgotPassword(email)
+ const result = await api.auth.forgotPassword(resetForm.getValues('email'))
setNotice(result.message)
} catch (cause) {
- setError(cause.message || 'A reset code could not be requested.')
+ if (!applyAuthFieldErrors(cause, resetForm.setError)) setError(cause.message || 'A reset code could not be requested.')
} finally {
- setBusy(false)
+ setResending(false)
}
}
@@ -84,62 +69,57 @@ export default function ForgotPassword() {
footer={<>Remembered it? Log in>}
>
{!resetting ? (
-
) : (
-
)}
diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx
index dd2c1bd..1a8924c 100644
--- a/frontend/src/pages/Landing.jsx
+++ b/frontend/src/pages/Landing.jsx
@@ -230,7 +230,6 @@ export default function Landing() {
function ContactForm() {
const { user } = useAuth()
const [values, setValues] = useState(() => ({ name: user ? `${user.firstName} ${user.surname}` : '', email: user?.email || '', message: '' }))
- const [topic, setTopic] = useState(user ? 'general' : 'recording-deletion')
const [status, setStatus] = useState('idle') // idle | sending | sent | error
const [errorMessage, setErrorMessage] = useState('')
const change = (event) => setValues((current) => ({ ...current, [event.target.name]: event.target.value }))
@@ -239,14 +238,6 @@ function ContactForm() {
event.preventDefault()
setStatus('sending')
setErrorMessage('')
- try {
- await api.contact.send({ ...values, topic })
- setValues({ name: user ? `${user.firstName} ${user.surname}` : '', email: user?.email || '', message: '' })
- setStatus('sent')
- } catch (error) {
- setErrorMessage(error.message || 'Your message could not be sent. Try again.')
- setStatus('error')
- }
}
if (status === 'sent') {
@@ -264,18 +255,17 @@ function ContactForm() {
return (
{location.state?.notice && {location.state.notice} }
{formError && {formError} }
-
- {busy ? 'Logging in…' : 'Log in'}
+
+ {isSubmitting ? 'Logging in…' : 'Log in'}
diff --git a/frontend/src/pages/Player.jsx b/frontend/src/pages/Player.jsx
index f7e6298..5dec795 100644
--- a/frontend/src/pages/Player.jsx
+++ b/frontend/src/pages/Player.jsx
@@ -15,23 +15,20 @@ export default function Player() {
const [params] = useSearchParams()
const videoId = safeRouteId(params.get('v'))
- // Keyed so switching videos (Next video, another category) resets the player and the camera.
- return
+ return
}
/** Live "you can record again in Ns" countdown from a fixed past timestamp. Returns 0 once it has elapsed. */
function useCooldown(lastAt, cooldownMs) {
- const compute = () => (lastAt ? Math.max(0, cooldownMs - (Date.now() - lastAt)) : 0)
- const [remaining, setRemaining] = useState(compute)
+ const [now, setNow] = useState(null)
useEffect(() => {
if (!lastAt) return undefined
- const id = setInterval(() => setRemaining(compute()), 250)
+ const id = setInterval(() => setNow(Date.now()), 250)
return () => clearInterval(id)
- // eslint-disable-next-line react-hooks/exhaustive-deps -- `compute` is intentionally re-created each render; only lastAt/cooldownMs should restart the interval
}, [lastAt, cooldownMs])
- return remaining
+ return lastAt ? Math.max(0, cooldownMs - ((now ?? lastAt) - lastAt)) : 0
}
function PlayerScreen({ videoId }) {
@@ -60,27 +57,33 @@ function PlayerScreen({ videoId }) {
}, [cachedStatus, upNext])
// The page layout follows the video's shape: tall phone videos get a narrow window and more room for the recorder.
- const [measuredAspect, setMeasuredAspect] = useState(null)
- const [justSubmitted, setJustSubmitted] = useState(Boolean(location.state?.justSubmitted))
+ const [aspectMeasurement, setAspectMeasurement] = useState({ videoId, aspect: null })
+ const measuredAspect = aspectMeasurement.videoId === videoId ? aspectMeasurement.aspect : null
+ const [submissionNotice, setSubmissionNotice] = useState(() => ({ videoId, active: Boolean(location.state?.justSubmitted) }))
+ const justSubmitted = submissionNotice.videoId === videoId && submissionNotice.active
useEffect(() => {
if (!justSubmitted) return undefined
- const timer = setTimeout(() => setJustSubmitted(false), 5000)
+ const timer = setTimeout(() => setSubmissionNotice({ videoId, active: false }), 5000)
return () => clearTimeout(timer)
- }, [justSubmitted])
+ }, [justSubmitted, videoId])
const remaining = useCooldown(video ? lastSubmissionAt(video.id) : null, cooldownMs)
// The countdown only ever counts down, so a fresh 0 -> refresh library data to reflect the new submission in badges/counts.
- const wasCoolingDown = useRef(false)
+ const wasCoolingDown = useRef({ videoId, active: false })
useEffect(() => {
+ if (wasCoolingDown.current.videoId !== videoId) {
+ wasCoolingDown.current = { videoId, active: false }
+ return
+ }
if (remaining > 0) {
- wasCoolingDown.current = true
- } else if (wasCoolingDown.current) {
- wasCoolingDown.current = false
+ wasCoolingDown.current.active = true
+ } else if (wasCoolingDown.current.active) {
+ wasCoolingDown.current.active = false
refresh()
}
- }, [remaining, refresh])
+ }, [remaining, refresh, videoId])
if (!video) {
return (
@@ -111,7 +114,7 @@ function PlayerScreen({ videoId }) {
-
+
setAspectMeasurement({ videoId, aspect })} src={cached.src} loading={cached.status === 'loading'} progress={cached.progress} />
@@ -180,6 +183,7 @@ function PlayerScreen({ videoId }) {
) : (
setValues((current) => ({ ...current, [event.target.name]: event.target.value }))
+ const { register, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm({
+ resolver: zodResolver(signupFormSchema),
+ defaultValues: EMPTY,
+ })
- const submit = async (event) => {
- event.preventDefault()
- const found = validateSignup(values)
- setErrors(found)
- if (Object.keys(found).length) return
- setBusy(true)
+ const submit = async (values) => {
setFormError('')
try {
const result = await signup(values)
navigate(`${paths.verifyEmail}?email=${encodeURIComponent(result.email)}`, { replace: true, state: { emailSent: result.verificationEmailSent } })
} catch (error) {
- setFormError(error.message)
- setBusy(false)
+ if (!applyAuthFieldErrors(error, setError)) setFormError(error.message)
}
}
@@ -46,61 +42,51 @@ export default function Signup() {
>
}
>
-
+
{formError && {formError} }
-
- {busy ? 'Creating account…' : 'Create account'}
+
+ {isSubmitting ? 'Creating account…' : 'Create account'}
diff --git a/frontend/src/pages/VerifyEmail.jsx b/frontend/src/pages/VerifyEmail.jsx
index 21e93b5..8a6614e 100644
--- a/frontend/src/pages/VerifyEmail.jsx
+++ b/frontend/src/pages/VerifyEmail.jsx
@@ -1,48 +1,51 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
import AuthShell from '../components/AuthShell'
import Field from '../components/Field'
import { Alert, Button } from '../components/ui'
import { useAuth } from '../context/AuthContext'
import { paths } from '../routes/appRoutes'
import { api } from '../services/api'
+import { verifyEmailSchema } from '../utils/authSchemas'
+import { applyAuthFieldErrors } from '../utils/authFormErrors'
export default function VerifyEmail() {
const [params] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const { verifyEmail } = useAuth()
- const [email, setEmail] = useState(params.get('email') || '')
- const [code, setCode] = useState('')
- const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
+ const [resending, setResending] = useState(false)
const [notice, setNotice] = useState(location.state?.emailSent === false
? 'We could not send the code yet. Try sending a new code.'
: params.get('email') ? 'Enter the six-digit code sent to your email.' : '')
+ const { register, handleSubmit, getValues, setError: setFieldError, formState: { errors, isSubmitting } } = useForm({
+ resolver: zodResolver(verifyEmailSchema),
+ defaultValues: { email: params.get('email') || '', code: '' },
+ })
- const submit = async (event) => {
- event.preventDefault()
+ const submit = async ({ email, code }) => {
setError('')
- setBusy(true)
try {
const { consentToken } = await verifyEmail(email, code)
navigate(paths.signupConsent, { replace: true, state: { consentToken } })
} catch (cause) {
- setError(cause.message || 'We could not verify that code.')
- setBusy(false)
+ if (!applyAuthFieldErrors(cause, setFieldError)) setError(cause.message || 'We could not verify that code.')
}
}
const resend = async () => {
setError('')
- setBusy(true)
+ setResending(true)
try {
- const response = await api.auth.resendVerification(email)
+ const response = await api.auth.resendVerification(getValues('email'))
setNotice(response.message)
} catch (cause) {
- setError(cause.message || 'A new code could not be sent.')
+ if (!applyAuthFieldErrors(cause, setFieldError)) setError(cause.message || 'A new code could not be sent.')
} finally {
- setBusy(false)
+ setResending(false)
}
}
@@ -52,13 +55,19 @@ export default function VerifyEmail() {
subtitle="Verify your email address to activate your account."
footer={<>Already verified? Log in>}
>
-
- setEmail(event.target.value)} autoComplete="email" />
- setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} />
+
+
+ value.replace(/\D/g, '').slice(0, 6) })}
+ error={errors.code?.message}
+ />
{notice && {notice} }
{error && {error} }
- {busy ? 'Verifying…' : 'Verify email'}
+ {isSubmitting ? 'Verifying…' : 'Verify email'}
- Send a new code
+ {resending ? 'Sending…' : 'Send a new code'}
}
diff --git a/frontend/src/pages/admin/AdminVideos.jsx b/frontend/src/pages/admin/AdminVideos.jsx
index da5b95e..4ea81f3 100644
--- a/frontend/src/pages/admin/AdminVideos.jsx
+++ b/frontend/src/pages/admin/AdminVideos.jsx
@@ -81,6 +81,7 @@ function UploadPanel() {
const [prepared, setPrepared] = useState(null) // { file, durationSec, width, height, poster } once muted/trimmed
const [posterFile, setPosterFile] = useState(null)
const [useAutoFrame, setUseAutoFrame] = useState(false)
+ const [sourceType, setSourceType] = useState('upload')
const [form, setForm] = useState({ title: '', categoryId: '', status: 'published' })
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState(null)
@@ -133,15 +134,25 @@ function UploadPanel() {
const submit = async (event) => {
event.preventDefault()
- if (!file) { setMessage({ tone: 'error', text: 'Choose a video to upload first.' }); return }
- if (!prepared) { setMessage({ tone: 'error', text: 'Review the video and choose “Apply changes” first.' }); return }
- if (!posterFile && !useAutoFrame) { setMessage({ tone: 'error', text: 'Choose a poster image or select “Use an auto-generated frame”.' }); return }
- if (useAutoFrame && !prepared.poster) { setMessage({ tone: 'error', text: 'A frame could not be generated. Choose a poster image instead.' }); return }
+ const usingUrl = sourceType === 'url'
+ if (usingUrl && !(form.videoUrl || '').trim()) { setMessage({ tone: 'error', text: 'Enter a direct HTTPS video URL.' }); return }
+ if (!usingUrl && !file) { setMessage({ tone: 'error', text: 'Choose a video to upload first.' }); return }
+ if (!usingUrl && !prepared) { setMessage({ tone: 'error', text: 'Review the video and choose “Apply changes” first.' }); return }
+ if (!posterFile && (usingUrl || !useAutoFrame)) { setMessage({ tone: 'error', text: 'Choose a poster image before adding this video.' }); return }
+ if (!usingUrl && useAutoFrame && !prepared.poster) { setMessage({ tone: 'error', text: 'A frame could not be generated. Choose a poster image instead.' }); return }
if (!form.title.trim()) { setMessage({ tone: 'error', text: 'Give the video a title.' }); return }
setBusy(true)
try {
- await addVideo({ ...form, file: prepared.file, durationSec: prepared.durationSec, width: prepared.width, height: prepared.height, poster: posterFile || prepared.poster })
- setMessage({ tone: 'success', text: `“${form.title.trim()}” uploaded${form.status === 'published' && form.categoryId ? ' and live for contributors.' : '. Assign a category and publish it to make it visible.'}` })
+ await addVideo({
+ ...form,
+ videoUrl: usingUrl ? form.videoUrl.trim() : '',
+ file: usingUrl ? null : prepared.file,
+ durationSec: prepared?.durationSec || 0,
+ width: prepared?.width || 0,
+ height: prepared?.height || 0,
+ poster: posterFile || (!usingUrl ? prepared?.poster : null),
+ })
+ setMessage({ tone: 'success', text: `“${form.title.trim()}” added${form.status === 'published' && form.categoryId ? ' and live for contributors.' : '. Assign a category and publish it to make it visible.'}` })
clear()
setForm({ title: '', categoryId: '', status: 'published' })
} catch (error) {
@@ -150,10 +161,22 @@ function UploadPanel() {
}
return
-
+
+
{
+ const nextSource = event.target.value
+ setSourceType(nextSource)
+ setMessage(null)
+ if (nextSource === 'url') setUseAutoFrame(false)
+ }}>
+ Upload a video file
+ Use an external video URL
+
Unassigned {categories.map((item) => {item.label} )}
Published: contributors can see it Draft: only admins can see it
@@ -170,18 +202,20 @@ function UploadPanel() {
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={pickPoster}
- disabled={busy || useAutoFrame}
+ disabled={busy || (sourceType === 'upload' && useAutoFrame)}
/>
{posterFile &&
Selected poster: {posterFile.name}
}
-
- Use an auto-generated frame from this video as its poster
-
-
-
Choose a poster image above or check this box before uploading.
- {prepared && !prepared.poster &&
A frame could not be generated from this video; choose a poster image instead.
}
+ {sourceType === 'upload' && <>
+
+ Use an auto-generated frame from this video as its poster
+
+
+ {prepared && !prepared.poster &&
A frame could not be generated from this video; choose a poster image instead.
}
+ >}
+
{sourceType === 'url' ? 'Poster images are uploaded to the configured storage provider (Google Drive when STORAGE_DRIVER=gdrive).' : 'Choose a poster image above or use a frame from the uploaded video.'}
{message &&
{message.text} }
-
{busy ? 'Uploading…' : 'Upload video'}
- {!prepared &&
{file ? 'Remove sound, trim if needed, then choose “Apply changes” to enable the upload.' : 'Select a file to enable the upload action.'}
}
+
{busy ? 'Saving…' : sourceType === 'url' ? 'Add video URL' : 'Upload video'}
+ {sourceType === 'upload' && !prepared &&
{file ? 'Remove sound, trim if needed, then choose “Apply changes” to enable the upload.' : 'Select a file to enable the upload action.'}
}
@@ -224,7 +258,7 @@ function VideoRow({ video, videos, categories, selected, onToggleSelect }) {
setMode('delete')}>Delete
- {mode === 'preview' && (video.videoUrl ? : This video's file is missing from this browser. )}
+ {mode === 'preview' && (video.videoUrl ? : This video's file is missing from this browser. )}
{mode === 'edit' &&
Unassigned {categories.map((item) => {item.label} )}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 2dba320..4c8f0e6 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -1,4 +1,15 @@
import { apiRoutes } from './apiRoutes'
+import {
+ acceptConsentRequestSchema,
+ consentRequestSchema,
+ declineConsentRequestSchema,
+ emailOnlySchema,
+ loginFormSchema,
+ resetPasswordSchema,
+ signupRequestSchema,
+ verifyEmailSchema,
+} from '../utils/authSchemas'
+import { validateApiInput } from '../utils/validateApiInput'
const PREFIX = 'signpak:v1:'
@@ -174,17 +185,44 @@ export const api = {
auth: {
async session() { return normalize(await request(apiRoutes.auth.session())) },
- async register(values) { return normalize(await request(apiRoutes.auth.signup(), json('POST', values))) },
- async login(email, password) { return normalize(await request(apiRoutes.auth.login(), json('POST', { email, password }))) },
+ async register(values) {
+ const input = validateApiInput(signupRequestSchema, values)
+ return normalize(await request(apiRoutes.auth.signup(), json('POST', input)))
+ },
+ async login(email, password) {
+ const input = validateApiInput(loginFormSchema, { email, password })
+ return normalize(await request(apiRoutes.auth.login(), json('POST', input)))
+ },
// A valid code returns { consentToken }; the account is only created by completeSignup.
- async verifyEmail(email, code) { return request(apiRoutes.auth.verifyEmail(), json('POST', { email, code })) },
- async completeSignup(consentToken, version) { return normalize(await request(apiRoutes.auth.consent(), json('POST', { consentToken, accepted: true, version }))) },
- async declineSignup(consentToken) { await request(apiRoutes.auth.declineConsent(), json('POST', { consentToken })) },
- async resendVerification(email) { return request(apiRoutes.auth.resendVerification(), json('POST', { email })) },
- async forgotPassword(email) { return request(apiRoutes.auth.forgotPassword(), json('POST', { email })) },
- async resetPassword(values) { return request(apiRoutes.auth.resetPassword(), json('POST', values)) },
+ async verifyEmail(email, code) {
+ const input = validateApiInput(verifyEmailSchema, { email, code })
+ return request(apiRoutes.auth.verifyEmail(), json('POST', input))
+ },
+ async completeSignup(consentToken, version) {
+ const input = validateApiInput(consentRequestSchema, { consentToken, accepted: true, version })
+ return normalize(await request(apiRoutes.auth.consent(), json('POST', input)))
+ },
+ async declineSignup(consentToken) {
+ const input = validateApiInput(declineConsentRequestSchema, { consentToken })
+ await request(apiRoutes.auth.declineConsent(), json('POST', input))
+ },
+ async resendVerification(email) {
+ const input = validateApiInput(emailOnlySchema, { email })
+ return request(apiRoutes.auth.resendVerification(), json('POST', input))
+ },
+ async forgotPassword(email) {
+ const input = validateApiInput(emailOnlySchema, { email })
+ return request(apiRoutes.auth.forgotPassword(), json('POST', input))
+ },
+ async resetPassword(values) {
+ const input = validateApiInput(resetPasswordSchema, values)
+ return request(apiRoutes.auth.resetPassword(), json('POST', input))
+ },
async logout() { await request(apiRoutes.auth.logout(), { method: 'POST' }) },
- async acceptConsent(version) { return normalize(await request(apiRoutes.users.consent(), json('POST', { accepted: true, version }))) },
+ async acceptConsent(version) {
+ const input = validateApiInput(acceptConsentRequestSchema, { accepted: true, version })
+ return normalize(await request(apiRoutes.users.consent(), json('POST', input)))
+ },
async saveConnections(_userId, connections) { return normalize(await request(apiRoutes.users.me(), json('PATCH', { connections }))) },
},
@@ -210,9 +248,10 @@ export const api = {
videos: {
async list() { return normalizeList(await request(apiRoutes.videos.list())) },
- async create({ file, poster, title, categoryId, status, durationSec, width, height }) {
+ async create({ file, videoUrl, poster, title, categoryId, status, durationSec, width, height }) {
const body = new FormData()
- body.append('video', file)
+ if (file) body.append('video', file)
+ if (videoUrl) body.append('videoUrl', videoUrl)
const posterBlob = await toBlob(poster)
if (posterBlob) body.append('poster', posterBlob, poster?.name || 'poster.jpg')
body.append('title', title)
@@ -249,8 +288,10 @@ export const api = {
async fetchVideo(url, { onProgress, signal, maxBytes } = {}) {
const headers = new Headers()
const deviceId = getDeviceId()
- if (deviceId) headers.set('X-Device-ID', deviceId)
- const response = await fetch(url, { credentials: 'include', headers, signal })
+ let sameOrigin = true
+ try { sameOrigin = new URL(url, window.location.href).origin === window.location.origin } catch { /* the request below reports invalid URLs */ }
+ if (sameOrigin && deviceId) headers.set('X-Device-ID', deviceId)
+ const response = await fetch(url, { credentials: sameOrigin ? 'include' : 'omit', headers, signal })
if (!response.ok) throw new Error(`The video could not be loaded (${response.status}).`)
const total = Number(response.headers.get('Content-Length')) || 0
if (maxBytes && total > maxBytes) throw Object.assign(new Error('The video is too large to keep in the browser.'), { code: 'TOO_LARGE' })
diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css
index 6088ec5..780ced2 100644
--- a/frontend/src/styles/auth.css
+++ b/frontend/src/styles/auth.css
@@ -9,6 +9,7 @@
.auth-art-copy { @apply relative pb-2 md:pb-10; }
.auth-panel { @apply grid min-w-0 place-items-center bg-page px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16; }
.auth-card { @apply w-full max-w-110 rounded-2xl border border-line bg-surface p-5 shadow-soft sm:rounded-3xl sm:p-8 md:p-10; }
+ .auth-card > form + .btn { @apply mt-4; }
.auth-back { @apply mb-6 inline-block rounded-full text-sm font-semibold text-muted hover:text-accent-ink sm:mb-8; }
.auth-sub { @apply mt-4 text-base text-muted; }
.auth-submit { @apply mt-8; }
@@ -19,6 +20,18 @@
.consent-notice p { @apply mt-1; }
.consent-check { @apply my-5 flex cursor-pointer items-start gap-3 text-sm text-ink; }
.consent-check input { @apply mt-0.5 size-4 shrink-0 accent-[var(--color-accent)]; }
+ .consent-modal-page { min-height: calc(100svh - 72px); }
+ .consent-dialog {
+ @apply max-w-2xl rounded-3xl border border-line bg-surface p-6 text-ink shadow-lift sm:p-8;
+ width: min(42rem, calc(100vw - 2rem));
+ max-height: calc(100dvh - 2rem);
+ overflow-y: auto;
+ }
+ .consent-dialog::backdrop { background: rgb(0 0 0 / 0.64); }
+ .consent-dialog > .btn { @apply mt-4; }
+ .consent-dialog .consent-notice { max-height: min(36dvh, 18rem); }
+ .consent-dialog .display { @apply mt-2; }
+ .consent-dialog .auth-sub { @apply mb-2; }
.auth-dev { @apply mt-8 rounded-xl border border-dashed border-line-strong p-3 text-sm text-faint; }
@media (max-width: 767px) {
diff --git a/frontend/src/utils/authFormErrors.js b/frontend/src/utils/authFormErrors.js
new file mode 100644
index 0000000..43208fb
--- /dev/null
+++ b/frontend/src/utils/authFormErrors.js
@@ -0,0 +1,7 @@
+export function applyAuthFieldErrors(error, setError) {
+ const fields = Object.entries(error?.fields || {})
+ for (const [name, message] of fields) {
+ setError(name, { type: 'server', message })
+ }
+ return fields.length > 0
+}
diff --git a/frontend/src/utils/authSchemas.js b/frontend/src/utils/authSchemas.js
new file mode 100644
index 0000000..f114f41
--- /dev/null
+++ b/frontend/src/utils/authSchemas.js
@@ -0,0 +1,75 @@
+import { z } from 'zod'
+
+const email = z.string({ error: 'Enter your email address.' })
+ .trim()
+ .toLowerCase()
+ .pipe(z.email('Enter a valid email address, like you@example.com.'))
+
+const name = (label) => z.string({ error: `Enter your ${label}.` })
+ .trim()
+ .min(1, `Enter your ${label}.`)
+ .max(60, `Keep your ${label} under 60 characters.`)
+
+const password = z.string({ error: 'Create a password.' })
+ .min(8, 'Use at least 8 characters.')
+ .max(72, 'Use 72 characters or fewer.')
+
+const code = z.string().regex(/^\d{6}$/, 'Enter the six-digit code from your email.')
+
+const matchingPasswords = (values, context) => {
+ if (values.confirmPassword !== values.password) {
+ context.addIssue({ code: 'custom', path: ['confirmPassword'], message: 'Passwords do not match.' })
+ }
+}
+
+export const loginFormSchema = z.object({
+ email,
+ password: z.string({ error: 'Enter your password.' }).min(1, 'Enter your password.').max(200),
+})
+
+export const signupFormSchema = z.object({
+ firstName: name('first name'),
+ surname: name('surname'),
+ email,
+ password,
+ confirmPassword: z.string().min(1, 'Confirm your password.'),
+}).superRefine(matchingPasswords)
+
+export const signupRequestSchema = z.object({
+ firstName: name('first name'),
+ surname: name('surname'),
+ email,
+ password,
+ confirmPassword: z.string().optional(),
+}).superRefine((values, context) => {
+ if (values.confirmPassword !== undefined) matchingPasswords(values, context)
+})
+
+export const verifyEmailSchema = z.object({ email, code })
+export const emailOnlySchema = z.object({ email })
+
+export const resetPasswordSchema = z.object({
+ email,
+ code,
+ password,
+ confirmPassword: z.string().min(1, 'Confirm your password.'),
+}).superRefine(matchingPasswords)
+
+export const consentRequestSchema = z.object({
+ consentToken: z.string().min(10, 'This sign-up step has expired. Verify your email again.').max(2000),
+ accepted: z.literal(true, { error: 'You must accept the consent notice to create your account.' }),
+ version: z.string().min(1, 'Missing consent version.').max(40),
+})
+
+export const acceptConsentRequestSchema = z.object({
+ accepted: z.literal(true, { error: 'You must accept the consent notice to continue.' }),
+ version: z.string().min(1, 'Missing consent version.').max(40),
+})
+
+export const declineConsentRequestSchema = z.object({
+ consentToken: z.string().min(10, 'This sign-up step has expired.').max(2000),
+})
+
+export const consentFormSchema = z.object({
+ accepted: z.literal(true, { error: 'You must accept the consent notice to continue.' }),
+})
diff --git a/frontend/src/utils/validateApiInput.js b/frontend/src/utils/validateApiInput.js
new file mode 100644
index 0000000..53ec009
--- /dev/null
+++ b/frontend/src/utils/validateApiInput.js
@@ -0,0 +1,15 @@
+export function validateApiInput(schema, input) {
+ const result = schema.safeParse(input)
+ if (result.success) return result.data
+
+ const fields = {}
+ for (const issue of result.error.issues) {
+ const field = issue.path.join('.')
+ if (field && !fields[field]) fields[field] = issue.message
+ }
+
+ const error = new Error(result.error.issues[0]?.message || 'The request is invalid.')
+ error.fields = fields
+ error.status = 422
+ throw error
+}
diff --git a/frontend/src/utils/validators.js b/frontend/src/utils/validators.js
index 8a6d329..b7ae718 100644
--- a/frontend/src/utils/validators.js
+++ b/frontend/src/utils/validators.js
@@ -1,32 +1,9 @@
-const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
const GITHUB_USER = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i
const LINKEDIN_SLUG = /^[\w\-%]{3,100}$/
const ROUTE_ID = /^[A-Za-z0-9_-]{1,128}$/
-export const isEmail = (value) => EMAIL.test(value.trim())
export const safeRouteId = (value) => typeof value === 'string' && ROUTE_ID.test(value) ? value : null
-export function validateLogin({ email, password }) {
- const errors = {}
- if (!email.trim()) errors.email = 'Enter your email address.'
- else if (!isEmail(email)) errors.email = 'Enter a valid email address, like you@example.com.'
- if (!password) errors.password = 'Enter your password.'
- return errors
-}
-
-export function validateSignup({ firstName, surname, email, password, confirmPassword }) {
- const errors = {}
- if (!firstName.trim()) errors.firstName = 'Enter your first name.'
- if (!surname.trim()) errors.surname = 'Enter your surname.'
- if (!email.trim()) errors.email = 'Enter your email address.'
- else if (!isEmail(email)) errors.email = 'Enter a valid email address, like you@example.com.'
- if (!password) errors.password = 'Create a password.'
- else if (password.length < 8) errors.password = 'Use at least 8 characters.'
- if (!confirmPassword) errors.confirmPassword = 'Confirm your password.'
- else if (confirmPassword !== password) errors.confirmPassword = 'Passwords do not match.'
- return errors
-}
-
/** Accepts a username or a github.com URL. Returns { value, error }. Empty input means "not connected". */
export function normalizeGithub(input) {
const raw = input.trim()
diff --git a/frontend/test/AdminVideos.test.jsx b/frontend/test/AdminVideos.test.jsx
index 2c97031..18e7150 100644
--- a/frontend/test/AdminVideos.test.jsx
+++ b/frontend/test/AdminVideos.test.jsx
@@ -76,4 +76,28 @@ describe('AdminVideos poster selection', () => {
await waitFor(() => expect(addVideo).toHaveBeenCalledOnce())
expect(addVideo.mock.calls[0][0].poster).toBe(poster)
})
+
+ it('adds a direct HTTPS video URL with a required poster instead of uploading video bytes', async () => {
+ renderAdminVideos()
+ fireEvent.change(screen.getByLabelText('Video source'), { target: { value: 'url' } })
+ fireEvent.change(screen.getByLabelText('Direct HTTPS video URL'), {
+ target: { value: 'https://media.example.test/sign.mp4' },
+ })
+ fireEvent.change(screen.getByLabelText(/Poster image/), {
+ target: { files: [new File(['poster image'], 'poster.png', { type: 'image/png' })] },
+ })
+ fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'External sign' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add video URL' }))
+
+ await waitFor(() => expect(addVideo).toHaveBeenCalledOnce())
+ expect(addVideo.mock.calls[0][0]).toMatchObject({
+ title: 'External sign',
+ videoUrl: 'https://media.example.test/sign.mp4',
+ file: null,
+ durationSec: 0,
+ width: 0,
+ height: 0,
+ })
+ expect(addVideo.mock.calls[0][0].poster).toBeInstanceOf(File)
+ })
})
diff --git a/frontend/test/AuthValidation.test.jsx b/frontend/test/AuthValidation.test.jsx
new file mode 100644
index 0000000..94f7d1a
--- /dev/null
+++ b/frontend/test/AuthValidation.test.jsx
@@ -0,0 +1,63 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { MemoryRouter } from 'react-router-dom'
+import Login from '../src/pages/Login'
+import Signup from '../src/pages/Signup'
+import { api } from '../src/services/api'
+
+const { login, signup } = vi.hoisted(() => ({
+ login: vi.fn(),
+ signup: vi.fn(),
+}))
+
+vi.mock('../src/context/AuthContext', () => ({
+ useAuth: () => ({ login, signup }),
+}))
+
+describe('authentication form validation', () => {
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it('shows Zod field errors on signup and does not submit invalid values', async () => {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: /create account/i }))
+
+ expect(await screen.findByText('Enter your first name.')).toBeTruthy()
+ expect(screen.getByText('Enter your surname.')).toBeTruthy()
+ expect(screen.getByText('Enter a valid email address, like you@example.com.')).toBeTruthy()
+ expect(signup).not.toHaveBeenCalled()
+ })
+
+ it('validates auth API payloads before sending a request', async () => {
+ const fetch = vi.fn()
+ vi.stubGlobal('fetch', fetch)
+
+ await expect(api.auth.login('not-an-email', ''))
+ .rejects.toMatchObject({
+ status: 422,
+ fields: {
+ email: 'Enter a valid email address, like you@example.com.',
+ password: 'Enter your password.',
+ },
+ })
+
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it('maps API validation errors back to the corresponding login field', async () => {
+ login.mockRejectedValue(Object.assign(new Error('Invalid credentials.'), {
+ fields: { email: 'Enter a valid email address.' },
+ }))
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'person@example.com' } })
+ fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } })
+ fireEvent.click(screen.getByRole('button', { name: /log in/i }))
+
+ expect(await screen.findByText('Enter a valid email address.')).toBeTruthy()
+ await waitFor(() => expect(login).toHaveBeenCalledOnce())
+ })
+})