119 lines
4.9 KiB
TypeScript
119 lines
4.9 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { recordAudit } from '../audit.js';
|
|
import { pool } from '../db/pool.js';
|
|
import {
|
|
BunnyConfigurationError,
|
|
BunnyRequestError,
|
|
createBunnyVideo,
|
|
getBunnyVideo,
|
|
isBunnyConfigured,
|
|
uploadBunnyVideo,
|
|
} from '../providers/bunny.js';
|
|
import { config } from '../config.js';
|
|
import {
|
|
BunnyStorageConfigurationError,
|
|
BunnyStorageRequestError,
|
|
isBunnyStorageConfigured,
|
|
uploadBunnyCover,
|
|
} from '../providers/bunny-storage.js';
|
|
|
|
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 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;
|
|
}
|
|
|
|
function storageError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
|
if (error instanceof BunnyStorageConfigurationError) return reply.code(503).send({ error: error.message });
|
|
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
|
|
throw error;
|
|
}
|
|
|
|
async function persistBunnyStatus(video: { id: string; status: string; durationSeconds: number | null }) {
|
|
await pool.query(
|
|
`update lesson_media
|
|
set status = $2::media_status, duration_seconds = coalesce($3, duration_seconds)
|
|
where provider = 'bunny' and external_id = $1`,
|
|
[video.id, video.status, video.durationSeconds],
|
|
);
|
|
}
|
|
|
|
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
|
|
|
|
app.get('/bunny/config', manageAccess, async () => ({
|
|
data: {
|
|
configured: isBunnyConfigured(),
|
|
maxUploadBytes: config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024,
|
|
},
|
|
}));
|
|
|
|
app.get('/covers/config', manageAccess, async () => ({
|
|
data: {
|
|
configured: isBunnyStorageConfigured(),
|
|
maxUploadBytes: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024,
|
|
},
|
|
}));
|
|
|
|
app.post('/covers', manageAccess, async (request, reply) => {
|
|
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
|
const body = request.body;
|
|
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Send an image file as the request body.' });
|
|
if (body.length === 0) return reply.code(400).send({ error: 'Choose an image to upload.' });
|
|
if (body.length > config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024) {
|
|
return reply.code(413).send({ error: `Cover image is larger than the ${config.BUNNY_COVER_MAX_UPLOAD_MB} MB upload limit.` });
|
|
}
|
|
try {
|
|
const cover = await uploadBunnyCover({ body, contentType });
|
|
await recordAudit({ actorId: request.user.id, action: 'media.cover.uploaded', subjectType: 'cover', metadata: { key: cover.key }, ipAddress: request.ip });
|
|
return reply.code(201).send({ data: cover });
|
|
} catch (error) {
|
|
return storageError(reply, error);
|
|
}
|
|
});
|
|
|
|
app.post('/bunny/videos', manageAccess, async (request, reply) => {
|
|
const input = createVideoSchema.parse(request.body);
|
|
try {
|
|
const video = await createBunnyVideo(input.title);
|
|
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) {
|
|
return providerError(reply, error);
|
|
}
|
|
});
|
|
|
|
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
|
|
const { videoId } = videoParamsSchema.parse(request.params);
|
|
const contentLength = Number(request.headers['content-length'] || 0);
|
|
const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
|
|
if (contentLength > maxBytes) {
|
|
return reply.code(413).send({ error: `Video is larger than the ${config.BUNNY_MAX_UPLOAD_MB} MB upload limit.` });
|
|
}
|
|
|
|
try {
|
|
const video = await uploadBunnyVideo(videoId, request.body 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 };
|
|
} catch (error) {
|
|
return providerError(reply, error);
|
|
}
|
|
});
|
|
|
|
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
|
const { videoId } = videoParamsSchema.parse(request.params);
|
|
try {
|
|
const video = await getBunnyVideo(videoId);
|
|
await persistBunnyStatus(video);
|
|
return { data: video };
|
|
} catch (error) {
|
|
return providerError(reply, error);
|
|
}
|
|
});
|
|
};
|