From f040c6ec8233f24b99a5781c5bbd6f063f36f592 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Sat, 24 Oct 2026 14:11:29 +0500 Subject: [PATCH 1/4] v1.0.8 and v1.0.5 --- backend/src/controllers/videoController.js | 2 +- backend/src/docs/openapi.js | 9 ++- .../middlewares/validators/videoSchemas.js | 7 ++ backend/src/models/Video.js | 15 +++- backend/src/services/storage/index.js | 6 +- backend/src/services/videoService.js | 74 ++++++++++++++++--- backend/tests/api-videos.test.js | 51 ++++++++++++- backend/tests/videoService.test.js | 33 ++++++++- backend/tests/videoServiceTestHelpers.js | 3 +- frontend/README.md | 6 +- frontend/docs/architecture.md | 14 +++- frontend/src/components/BasePlayer.jsx | 22 +++++- frontend/src/components/ResponsiveVideo.jsx | 7 +- frontend/src/pages/Consent.jsx | 50 +++++++++++-- frontend/src/pages/Landing.jsx | 16 +--- frontend/src/pages/Player.jsx | 38 +++++----- frontend/src/pages/admin/AdminVideos.jsx | 70 +++++++++++++----- frontend/src/services/api.js | 11 ++- frontend/src/styles/auth.css | 13 ++++ frontend/test/AdminVideos.test.jsx | 24 ++++++ 20 files changed, 377 insertions(+), 94 deletions(-) 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/README.md b/frontend/README.md index 03199d2..9d648e2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -62,8 +62,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 diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index e7359e4..4142160 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -117,7 +117,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 `