feat: add Bunny Storage course cover uploads
All checks were successful
CI / Validate frontend and API (push) Successful in 44s
CI / Build and publish Docker images (push) Successful in 22s

This commit is contained in:
Cauê Faleiros
2026-09-04 13:39:30 -03:00
parent 3498d47182
commit e15a44e733
11 changed files with 195 additions and 14 deletions

View File

@@ -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,

View File

@@ -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.');
}

View 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}` };
}

View File

@@ -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([]),

View File

@@ -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 {