feat: add Bunny Storage course cover uploads
This commit is contained in:
@@ -33,6 +33,7 @@ export function buildApp() {
|
||||
const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload);
|
||||
app.addContentTypeParser('application/octet-stream', rawUploadParser);
|
||||
app.addContentTypeParser(/^video\/.+$/, rawUploadParser);
|
||||
app.addContentTypeParser(/^image\/.+$/, { parseAs: 'buffer', bodyLimit: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
|
||||
|
||||
app.register(cors, {
|
||||
origin: config.FRONTEND_ORIGIN,
|
||||
|
||||
@@ -25,6 +25,11 @@ const environmentSchema = z.object({
|
||||
BUNNY_WEBHOOK_SECRET: optionalEnvironmentValue(z.string().min(20)),
|
||||
BUNNY_EMBED_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(86_400).default(600),
|
||||
BUNNY_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(5120).default(5120),
|
||||
BUNNY_STORAGE_ZONE: optionalEnvironmentValue(z.string().trim().min(1).max(120)),
|
||||
BUNNY_STORAGE_PASSWORD: optionalEnvironmentValue(z.string().min(1)),
|
||||
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
|
||||
BUNNY_STORAGE_CDN_HOST: optionalEnvironmentValue(z.string().url()),
|
||||
BUNNY_COVER_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(50).default(10),
|
||||
});
|
||||
|
||||
export const config = environmentSchema.parse(process.env);
|
||||
@@ -42,3 +47,14 @@ const bunnyConfigurationValues = [
|
||||
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) {
|
||||
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY, and BUNNY_EMBED_TOKEN_KEY must be configured together.');
|
||||
}
|
||||
|
||||
const bunnyStorageConfigurationValues = [
|
||||
config.BUNNY_STORAGE_ZONE,
|
||||
config.BUNNY_STORAGE_PASSWORD,
|
||||
config.BUNNY_STORAGE_ENDPOINT,
|
||||
config.BUNNY_STORAGE_CDN_HOST,
|
||||
];
|
||||
|
||||
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
|
||||
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.');
|
||||
}
|
||||
|
||||
54
server/src/providers/bunny-storage.ts
Normal file
54
server/src/providers/bunny-storage.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export class BunnyStorageConfigurationError extends Error {}
|
||||
export class BunnyStorageRequestError extends Error {}
|
||||
|
||||
type CoverImage = { body: Buffer; contentType: string };
|
||||
|
||||
function bunnyStorageConfiguration() {
|
||||
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
|
||||
throw new BunnyStorageConfigurationError('Bunny Storage is not configured. Ask an administrator to add the Storage Zone variables.');
|
||||
}
|
||||
return {
|
||||
zone: config.BUNNY_STORAGE_ZONE,
|
||||
password: config.BUNNY_STORAGE_PASSWORD,
|
||||
endpoint: config.BUNNY_STORAGE_ENDPOINT.replace(/\/$/, ''),
|
||||
cdnHost: config.BUNNY_STORAGE_CDN_HOST.replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function isBunnyStorageConfigured() {
|
||||
return Boolean(config.BUNNY_STORAGE_ZONE && config.BUNNY_STORAGE_PASSWORD && config.BUNNY_STORAGE_ENDPOINT && config.BUNNY_STORAGE_CDN_HOST);
|
||||
}
|
||||
|
||||
function imageExtension(image: CoverImage) {
|
||||
const { body, contentType } = image;
|
||||
const isPng = body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
const isJpeg = body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff;
|
||||
const isWebp = body.length >= 12 && body.subarray(0, 4).toString('ascii') === 'RIFF' && body.subarray(8, 12).toString('ascii') === 'WEBP';
|
||||
if (contentType === 'image/png' && isPng) return 'png';
|
||||
if (contentType === 'image/jpeg' && isJpeg) return 'jpg';
|
||||
if (contentType === 'image/webp' && isWebp) return 'webp';
|
||||
throw new BunnyStorageRequestError('Only valid JPG, PNG, and WebP images are accepted.');
|
||||
}
|
||||
|
||||
export async function uploadBunnyCover(image: CoverImage) {
|
||||
const { zone, password, endpoint, cdnHost } = bunnyStorageConfiguration();
|
||||
const extension = imageExtension(image);
|
||||
const key = `covers/${randomUUID()}.${extension}`;
|
||||
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
AccessKey: password,
|
||||
'Content-Type': image.contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
body: image.body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new BunnyStorageRequestError(`Bunny Storage upload failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
|
||||
}
|
||||
return { key, coverImageUrl: `${cdnHost}/${key}` };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
import { courseSelect } from './courses.js';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { recordAudit } from '../audit.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const mediaSchema = z.object({
|
||||
provider: z.string().trim().min(1).max(80),
|
||||
@@ -33,12 +34,17 @@ const lessonSchema = z.object({
|
||||
assets: z.array(assetSchema).default([]),
|
||||
});
|
||||
|
||||
const coverImageUrlSchema = z.string().url().refine((value) => {
|
||||
const protocol = new URL(value).protocol;
|
||||
return protocol === 'https:' || (config.APP_ENV !== 'production' && protocol === 'http:');
|
||||
}, { message: 'Cover image URL must use HTTPS.' });
|
||||
|
||||
const courseSchema = z.object({
|
||||
title: z.string().trim().min(1).max(255),
|
||||
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(),
|
||||
description: z.string().max(10000).default(''),
|
||||
category: z.string().trim().min(1).max(120),
|
||||
coverImageUrl: z.string().url().nullable().optional(),
|
||||
coverImageUrl: coverImageUrlSchema.nullable().optional(),
|
||||
status: z.enum(['draft', 'published']).default('draft'),
|
||||
lessons: z.array(lessonSchema).min(1),
|
||||
assets: z.array(assetSchema).default([]),
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
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() });
|
||||
@@ -21,6 +27,12 @@ function providerError(reply: { code: (status: number) => { send: (payload: obje
|
||||
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
|
||||
@@ -40,6 +52,30 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
}));
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user