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,14 @@ BUNNY_EMBED_TOKEN_TTL_SECONDS=600
# Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf. # Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf.
BUNNY_MAX_UPLOAD_MB=5120 BUNNY_MAX_UPLOAD_MB=5120
# Bunny Storage course banners. Create a Storage Zone and linked Pull Zone;
# copy the Storage endpoint shown in Bunny's FTP & API Access panel.
BUNNY_STORAGE_ZONE=
BUNNY_STORAGE_PASSWORD=
BUNNY_STORAGE_ENDPOINT=
BUNNY_STORAGE_CDN_HOST=
BUNNY_COVER_MAX_UPLOAD_MB=10
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images. # Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web

View File

@@ -28,6 +28,20 @@ After deploying the new images:
The API key and embed-token key never reach the browser. The instructor browser uploads to the authenticated Academy API, which sends the file to Bunny; learners receive only a signed iframe URL. The API key and embed-token key never reach the browser. The instructor browser uploads to the authenticated Academy API, which sends the file to Bunny; learners receive only a signed iframe URL.
## Bunny Storage course banners
Course banners use Bunny Storage, not the Stream library. Create one Standard Storage Zone in São Paulo and link one Standard Pull Zone to it. The Pull Zone hostname is the public CDN host for uploaded banners. Add these server-only variables to Portainer:
```env
BUNNY_STORAGE_ZONE=compor-academy-storage
BUNNY_STORAGE_PASSWORD=the-storage-zone-password
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
BUNNY_COVER_MAX_UPLOAD_MB=10
```
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload only JPG, PNG, or WebP cover images; the API verifies the image bytes and uploads them under a generated `covers/` filename. The CDN URL is then saved on the course.
### Bunny processing webhooks ### Bunny processing webhooks
The editor can poll Bunny while a video encodes, but production should also configure Bunny's webhook so the Academy records the result even when no instructor page is open. In the library webhook settings, use: The editor can poll Bunny while a video encodes, but production should also configure Bunny's webhook so the Academy records the result even when no instructor page is open. In the library webhook settings, use:

View File

@@ -40,6 +40,11 @@ services:
BUNNY_WEBHOOK_SECRET: ${BUNNY_WEBHOOK_SECRET:-} BUNNY_WEBHOOK_SECRET: ${BUNNY_WEBHOOK_SECRET:-}
BUNNY_EMBED_TOKEN_TTL_SECONDS: ${BUNNY_EMBED_TOKEN_TTL_SECONDS:-600} BUNNY_EMBED_TOKEN_TTL_SECONDS: ${BUNNY_EMBED_TOKEN_TTL_SECONDS:-600}
BUNNY_MAX_UPLOAD_MB: ${BUNNY_MAX_UPLOAD_MB:-5120} BUNNY_MAX_UPLOAD_MB: ${BUNNY_MAX_UPLOAD_MB:-5120}
BUNNY_STORAGE_ZONE: ${BUNNY_STORAGE_ZONE:-}
BUNNY_STORAGE_PASSWORD: ${BUNNY_STORAGE_PASSWORD:-}
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"] test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
interval: 15s interval: 15s

View File

@@ -44,8 +44,11 @@ const CourseModal: React.FC<{
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [status, setStatus] = useState<Course['status']>('draft'); const [status, setStatus] = useState<Course['status']>('draft');
const [thumbnailPreview, setThumbnailPreview] = useState<string>(''); const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
const coverUrlRef = useRef<HTMLInputElement>(null); const coverFileRef = useRef<HTMLInputElement>(null);
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
const [coverUploadMessage, setCoverUploadMessage] = useState('');
const [isCoverUploading, setIsCoverUploading] = useState(false);
// Lesson Management // Lesson Management
const [lessons, setLessons] = useState<Lesson[]>([]); const [lessons, setLessons] = useState<Lesson[]>([]);
@@ -99,9 +102,13 @@ const CourseModal: React.FC<{
setNewLessonMedia(null); setNewLessonMedia(null);
setBunnyUploadState('idle'); setBunnyUploadState('idle');
setBunnyUploadMessage(''); setBunnyUploadMessage('');
setCoverUploadMessage('');
instructorApi.bunnyConfiguration() instructorApi.bunnyConfiguration()
.then((response) => setBunnyConfigured(response.data.configured)) .then((response) => setBunnyConfigured(response.data.configured))
.catch(() => setBunnyConfigured(false)); .catch(() => setBunnyConfigured(false));
instructorApi.coverConfiguration()
.then((response) => setCoverConfigured(response.data.configured))
.catch(() => setCoverConfigured(false));
courseApi.categories() courseApi.categories()
.then((response) => { .then((response) => {
setCategories(response.data); setCategories(response.data);
@@ -152,6 +159,27 @@ const CourseModal: React.FC<{
} }
}; };
const handleCoverUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (!coverConfigured) {
setCoverUploadMessage('O armazenamento de imagens ainda não foi configurado pelo administrador.');
return;
}
setIsCoverUploading(true);
setCoverUploadMessage(`Enviando ${file.name}...`);
try {
const response = await instructorApi.uploadCover(file);
setThumbnailPreview(response.data.coverImageUrl);
setCoverUploadMessage('Imagem de capa enviada com sucesso.');
} catch (error) {
setCoverUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar a imagem de capa.');
} finally {
setIsCoverUploading(false);
}
};
const refreshLessonMedia = async (lessonId: string) => { const refreshLessonMedia = async (lessonId: string) => {
const lesson = lessons.find((item) => item.id === lessonId); const lesson = lessons.find((item) => item.id === lessonId);
const media = lesson?.media?.[0]; const media = lesson?.media?.[0];
@@ -345,12 +373,12 @@ const CourseModal: React.FC<{
<div> <div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label> <label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
<div <div
onClick={() => coverUrlRef.current?.focus()} onClick={() => { if (!isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') coverUrlRef.current?.focus(); }} onKeyDown={(event) => { if ((event.key === 'Enter' || event.key === ' ') && !isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label="Informar URL da imagem de capa" aria-label="Enviar imagem de capa"
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group" className={`w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center transition-colors relative overflow-hidden group ${coverConfigured && !isCoverUploading ? 'cursor-pointer hover:border-orange-500/50' : 'cursor-not-allowed opacity-70'}`}
> >
{thumbnailPreview ? ( {thumbnailPreview ? (
<> <>
@@ -362,18 +390,19 @@ const CourseModal: React.FC<{
) : ( ) : (
<div className="text-center p-6"> <div className="text-center p-6">
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" /> <ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
<p className="text-gray-300 text-xs font-medium">Clique para informar a URL da imagem de capa</p> <p className="text-gray-300 text-xs font-medium">{isCoverUploading ? 'Enviando imagem...' : 'Clique para enviar uma imagem de capa'}</p>
</div> </div>
)} )}
</div> </div>
<input <input
ref={coverUrlRef} ref={coverFileRef}
type="url" type="file"
value={thumbnailPreview} accept="image/jpeg,image/png,image/webp"
onChange={(event) => setThumbnailPreview(event.target.value)} className="hidden"
placeholder="Cole a URL pública da imagem de capa" disabled={!coverConfigured || isCoverUploading}
className="w-full mt-2 bg-black/50 text-white text-xs border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50" onChange={handleCoverUpload}
/> />
<p className={`mt-2 text-[11px] ${coverUploadMessage.includes('sucesso') ? 'text-emerald-300' : coverUploadMessage ? 'text-amber-300' : 'text-gray-500'}`}>{coverUploadMessage || (coverConfigured ? 'JPG, PNG ou WebP. Tamanho máximo: 10 MB.' : 'O upload de capas será habilitado após configurar Bunny Storage no servidor.')}</p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">

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); const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload);
app.addContentTypeParser('application/octet-stream', rawUploadParser); app.addContentTypeParser('application/octet-stream', rawUploadParser);
app.addContentTypeParser(/^video\/.+$/, 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, { app.register(cors, {
origin: config.FRONTEND_ORIGIN, origin: config.FRONTEND_ORIGIN,

View File

@@ -25,6 +25,11 @@ const environmentSchema = z.object({
BUNNY_WEBHOOK_SECRET: optionalEnvironmentValue(z.string().min(20)), 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_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_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); export const config = environmentSchema.parse(process.env);
@@ -42,3 +47,14 @@ const bunnyConfigurationValues = [
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) { 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.'); 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 { courseSelect } from './courses.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js'; import { recordAudit } from '../audit.js';
import { config } from '../config.js';
const mediaSchema = z.object({ const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80), provider: z.string().trim().min(1).max(80),
@@ -33,12 +34,17 @@ const lessonSchema = z.object({
assets: z.array(assetSchema).default([]), 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({ const courseSchema = z.object({
title: z.string().trim().min(1).max(255), title: z.string().trim().min(1).max(255),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(), slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(),
description: z.string().max(10000).default(''), description: z.string().max(10000).default(''),
category: z.string().trim().min(1).max(120), 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'), status: z.enum(['draft', 'published']).default('draft'),
lessons: z.array(lessonSchema).min(1), lessons: z.array(lessonSchema).min(1),
assets: z.array(assetSchema).default([]), assets: z.array(assetSchema).default([]),

View File

@@ -11,6 +11,12 @@ import {
uploadBunnyVideo, uploadBunnyVideo,
} from '../providers/bunny.js'; } from '../providers/bunny.js';
import { config } from '../config.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 createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
const videoParamsSchema = z.object({ videoId: z.string().uuid() }); const videoParamsSchema = z.object({ videoId: z.string().uuid() });
@@ -21,6 +27,12 @@ function providerError(reply: { code: (status: number) => { send: (payload: obje
throw error; 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 }) { async function persistBunnyStatus(video: { id: string; status: string; durationSeconds: number | null }) {
await pool.query( await pool.query(
`update lesson_media `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) => { app.post('/bunny/videos', manageAccess, async (request, reply) => {
const input = createVideoSchema.parse(request.body); const input = createVideoSchema.parse(request.body);
try { try {

View File

@@ -84,6 +84,16 @@ async function uploadRequest<T>(path: string, file: File): Promise<T> {
return body as T; return body as T;
} }
async function uploadImageRequest<T>(path: string, file: File): Promise<T> {
const session = getSession();
const headers = new Headers({ 'Content-Type': file.type || 'application/octet-stream' });
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'POST', headers, body: file });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'The cover image could not be uploaded', response.status);
return body as T;
}
export const authApi = { export const authApi = {
async login(email: string, password: string) { async login(email: string, password: string) {
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }); return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
@@ -140,6 +150,8 @@ export const instructorApi = {
async bunnyVideoStatus(videoId: string) { async bunnyVideoStatus(videoId: string) {
return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`); return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`);
}, },
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
}; };
export const courseApi = { export const courseApi = {

View File

@@ -123,7 +123,7 @@ const toCoursePayload = (course: Course) => ({
title: course.title, title: course.title,
description: course.description, description: course.description,
category: course.category, category: course.category,
coverImageUrl: course.thumbnail && !course.thumbnail.startsWith('blob:') ? course.thumbnail : null, coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null,
status: course.status || 'published', status: course.status || 'published',
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload), assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
lessons: course.lessons.map((lesson) => ({ lessons: course.lessons.map((lesson) => ({