diff --git a/server/migrations/014_bunny_video_ownership.sql b/server/migrations/014_bunny_video_ownership.sql new file mode 100644 index 0000000..62ba47c --- /dev/null +++ b/server/migrations/014_bunny_video_ownership.sql @@ -0,0 +1,16 @@ +create table if not exists bunny_video_uploads ( + video_id text primary key, + owner_id uuid not null references users(id) on delete cascade, + created_at timestamptz not null default now() +); + +create index if not exists bunny_video_uploads_owner_index on bunny_video_uploads (owner_id); + +-- Preserve access to Bunny videos created before ownership was tracked. +insert into bunny_video_uploads (video_id, owner_id) +select distinct on (lm.external_id) lm.external_id, c.instructor_id +from lesson_media lm +join lessons l on l.id = lm.lesson_id +join courses c on c.id = l.course_id +where lm.provider = 'bunny' +on conflict (video_id) do nothing; diff --git a/server/src/routes/media.ts b/server/src/routes/media.ts index 630e65e..73350d0 100644 --- a/server/src/routes/media.ts +++ b/server/src/routes/media.ts @@ -12,6 +12,7 @@ import { uploadBunnyVideo, } from '../providers/bunny.js'; import { config } from '../config.js'; +import { VideoUploadValidationError, validateVideoUpload } from '../uploads/video.js'; import { BunnyStorageConfigurationError, BunnyStorageRequestError, @@ -23,6 +24,7 @@ const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) }) const videoParamsSchema = z.object({ videoId: z.string().uuid() }); function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) { + if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message }); if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message }); if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' }); throw error; @@ -51,6 +53,23 @@ async function persistBunnyStatus(video: { id: string; status: string; durationS } } +async function assertVideoAccess(videoId: string, user: { id: string; role: string }) { + if (user.role === 'admin') return; + const result = await pool.query<{ owner_id: string }>('select owner_id from bunny_video_uploads where video_id = $1', [videoId]); + if (result.rows[0]?.owner_id === user.id) return; + const legacy = await pool.query<{ instructor_id: string }>( + `select c.instructor_id + from lesson_media lm + join lessons l on l.id = lm.lesson_id + join courses c on c.id = l.course_id + where lm.provider = 'bunny' and lm.external_id = $1 + limit 1`, + [videoId], + ); + if (legacy.rows[0]?.instructor_id === user.id) return; + throw new VideoUploadValidationError('You do not have access to this video.', 403); +} + export const mediaRoutes: FastifyPluginAsync = async (app) => { const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) }; @@ -89,6 +108,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { const input = createVideoSchema.parse(request.body); try { const video = await createBunnyVideo(input.title); + await pool.query('insert into bunny_video_uploads (video_id, owner_id) values ($1, $2)', [video.id, request.user.id]); await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip }); return reply.code(201).send({ data: video }); } catch (error) { @@ -98,6 +118,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => { const { videoId } = videoParamsSchema.parse(request.params); + try { + await assertVideoAccess(videoId, request.user); + } catch (error) { + return providerError(reply, error); + } const contentLength = Number(request.headers['content-length'] || 0); const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024; if (contentLength > maxBytes) { @@ -105,7 +130,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { } try { - const video = await uploadBunnyVideo(videoId, request.body as ReadableStream, request.headers['content-type']); + const body = validateVideoUpload(request.body as import('node:stream').Readable, request.headers['content-type'], maxBytes); + const video = await uploadBunnyVideo(videoId, body as unknown as ReadableStream, request.headers['content-type']); await persistBunnyStatus(video); await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip }); return { data: video }; @@ -117,6 +143,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => { const { videoId } = videoParamsSchema.parse(request.params); try { + await assertVideoAccess(videoId, request.user); const video = await getBunnyVideo(videoId); await persistBunnyStatus(video); return { data: video }; @@ -128,7 +155,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => { const { videoId } = videoParamsSchema.parse(request.params); try { + await assertVideoAccess(videoId, request.user); await deleteBunnyVideo(videoId); + await pool.query('delete from bunny_video_uploads where video_id = $1', [videoId]); await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip }); return reply.code(204).send(); } catch (error) { diff --git a/server/src/uploads/video.ts b/server/src/uploads/video.ts new file mode 100644 index 0000000..82b349b --- /dev/null +++ b/server/src/uploads/video.ts @@ -0,0 +1,62 @@ +import { Transform, type Readable } from 'node:stream'; + +const allowedContentTypes = new Set(['video/mp4', 'video/webm', 'video/quicktime']); + +export class VideoUploadValidationError extends Error { + constructor(message: string, public readonly statusCode: 403 | 413 | 415) { + super(message); + } +} + +const isRecognizedVideo = (header: Buffer) => { + // ISO Base Media (MP4/MOV): bytes 4–7 identify the file type box. + const isMp4Family = header.length >= 12 && header.subarray(4, 8).toString('ascii') === 'ftyp'; + // WebM/Matroska EBML signature. + const isWebm = header.length >= 4 && header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3])); + return isMp4Family || isWebm; +}; + +class ValidatedVideoStream extends Transform { + private totalBytes = 0; + private header = Buffer.alloc(0); + private validated = false; + + constructor(private readonly maxBytes: number) { + super(); + } + + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) { + this.totalBytes += chunk.length; + if (this.totalBytes > this.maxBytes) { + callback(new VideoUploadValidationError('Video is larger than the configured upload limit.', 413)); + return; + } + if (!this.validated) { + this.header = Buffer.concat([this.header, chunk]).subarray(0, 32); + if (this.header.length >= 12) { + if (!isRecognizedVideo(this.header)) { + callback(new VideoUploadValidationError('Only valid MP4, WebM, and MOV video files are accepted.', 415)); + return; + } + this.validated = true; + } + } + callback(null, chunk); + } + + override _flush(callback: (error?: Error | null) => void) { + if (!this.validated) { + callback(new VideoUploadValidationError('The uploaded file is not a valid video.', 415)); + return; + } + callback(); + } +} + +export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) { + const normalizedType = contentType?.split(';')[0]?.toLowerCase(); + if (!normalizedType || !allowedContentTypes.has(normalizedType)) { + throw new VideoUploadValidationError('Only MP4, WebM, and MOV video uploads are accepted.', 415); + } + return input.pipe(new ValidatedVideoStream(maxBytes)); +}