feat: add Bunny Storage course cover uploads
This commit is contained in:
@@ -33,6 +33,14 @@ BUNNY_EMBED_TOKEN_TTL_SECONDS=600
|
||||
# Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf.
|
||||
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.
|
||||
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
||||
WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web
|
||||
|
||||
14
PORTAINER.md
14
PORTAINER.md
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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:
|
||||
|
||||
@@ -40,6 +40,11 @@ services:
|
||||
BUNNY_WEBHOOK_SECRET: ${BUNNY_WEBHOOK_SECRET:-}
|
||||
BUNNY_EMBED_TOKEN_TTL_SECONDS: ${BUNNY_EMBED_TOKEN_TTL_SECONDS:-600}
|
||||
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:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
||||
interval: 15s
|
||||
|
||||
@@ -44,8 +44,11 @@ const CourseModal: React.FC<{
|
||||
const [category, setCategory] = useState('');
|
||||
const [status, setStatus] = useState<Course['status']>('draft');
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
|
||||
const coverUrlRef = useRef<HTMLInputElement>(null);
|
||||
const coverFileRef = useRef<HTMLInputElement>(null);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
||||
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
||||
const [isCoverUploading, setIsCoverUploading] = useState(false);
|
||||
|
||||
// Lesson Management
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
@@ -99,9 +102,13 @@ const CourseModal: React.FC<{
|
||||
setNewLessonMedia(null);
|
||||
setBunnyUploadState('idle');
|
||||
setBunnyUploadMessage('');
|
||||
setCoverUploadMessage('');
|
||||
instructorApi.bunnyConfiguration()
|
||||
.then((response) => setBunnyConfigured(response.data.configured))
|
||||
.catch(() => setBunnyConfigured(false));
|
||||
instructorApi.coverConfiguration()
|
||||
.then((response) => setCoverConfigured(response.data.configured))
|
||||
.catch(() => setCoverConfigured(false));
|
||||
courseApi.categories()
|
||||
.then((response) => {
|
||||
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 lesson = lessons.find((item) => item.id === lessonId);
|
||||
const media = lesson?.media?.[0];
|
||||
@@ -345,12 +373,12 @@ const CourseModal: React.FC<{
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
|
||||
<div
|
||||
onClick={() => coverUrlRef.current?.focus()}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') coverUrlRef.current?.focus(); }}
|
||||
onClick={() => { if (!isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
|
||||
onKeyDown={(event) => { if ((event.key === 'Enter' || event.key === ' ') && !isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Informar URL da 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"
|
||||
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 transition-colors relative overflow-hidden group ${coverConfigured && !isCoverUploading ? 'cursor-pointer hover:border-orange-500/50' : 'cursor-not-allowed opacity-70'}`}
|
||||
>
|
||||
{thumbnailPreview ? (
|
||||
<>
|
||||
@@ -362,18 +390,19 @@ const CourseModal: React.FC<{
|
||||
) : (
|
||||
<div className="text-center p-6">
|
||||
<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>
|
||||
<input
|
||||
ref={coverUrlRef}
|
||||
type="url"
|
||||
value={thumbnailPreview}
|
||||
onChange={(event) => setThumbnailPreview(event.target.value)}
|
||||
placeholder="Cole a URL pública da imagem de capa"
|
||||
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"
|
||||
ref={coverFileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
disabled={!coverConfigured || isCoverUploading}
|
||||
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 className="space-y-4">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -84,6 +84,16 @@ async function uploadRequest<T>(path: string, file: File): Promise<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 = {
|
||||
async login(email: string, password: string) {
|
||||
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
|
||||
@@ -140,6 +150,8 @@ export const instructorApi = {
|
||||
async bunnyVideoStatus(videoId: string) {
|
||||
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 = {
|
||||
|
||||
@@ -123,7 +123,7 @@ const toCoursePayload = (course: Course) => ({
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
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',
|
||||
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
||||
lessons: course.lessons.map((lesson) => ({
|
||||
|
||||
Reference in New Issue
Block a user