diff --git a/.env.example b/.env.example index 5780892..29c820d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/PORTAINER.md b/PORTAINER.md index d2ecd0f..c36186e 100644 --- a/PORTAINER.md +++ b/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: diff --git a/docker-compose.yml b/docker-compose.yml index 3f7e124..caa09fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx index ba9076c..7267934 100644 --- a/pages/ManageCourses.tsx +++ b/pages/ManageCourses.tsx @@ -44,8 +44,11 @@ const CourseModal: React.FC<{ const [category, setCategory] = useState(''); const [status, setStatus] = useState('draft'); const [thumbnailPreview, setThumbnailPreview] = useState(''); - const coverUrlRef = useRef(null); + const coverFileRef = useRef(null); const [categories, setCategories] = useState([]); + const [coverConfigured, setCoverConfigured] = useState(null); + const [coverUploadMessage, setCoverUploadMessage] = useState(''); + const [isCoverUploading, setIsCoverUploading] = useState(false); // Lesson Management const [lessons, setLessons] = useState([]); @@ -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) => { + 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<{
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<{ ) : (
-

Clique para informar a URL da imagem de capa

+

{isCoverUploading ? 'Enviando imagem...' : 'Clique para enviar uma imagem de capa'}

)}
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} /> +

{coverUploadMessage || (coverConfigured ? 'JPG, PNG ou WebP. Tamanho máximo: 10 MB.' : 'O upload de capas será habilitado após configurar Bunny Storage no servidor.')}

diff --git a/server/src/app.ts b/server/src/app.ts index 24f3098..60fbcad 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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, diff --git a/server/src/config.ts b/server/src/config.ts index 881d996..9dca014 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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.'); +} diff --git a/server/src/providers/bunny-storage.ts b/server/src/providers/bunny-storage.ts new file mode 100644 index 0000000..053231d --- /dev/null +++ b/server/src/providers/bunny-storage.ts @@ -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}` }; +} diff --git a/server/src/routes/manage-courses.ts b/server/src/routes/manage-courses.ts index 07a7c78..11ee4c9 100644 --- a/server/src/routes/manage-courses.ts +++ b/server/src/routes/manage-courses.ts @@ -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([]), diff --git a/server/src/routes/media.ts b/server/src/routes/media.ts index b25e0e4..644ea61 100644 --- a/server/src/routes/media.ts +++ b/server/src/routes/media.ts @@ -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 { diff --git a/services/api.ts b/services/api.ts index 5c9e3ab..973011c 100644 --- a/services/api.ts +++ b/services/api.ts @@ -84,6 +84,16 @@ async function uploadRequest(path: string, file: File): Promise { return body as T; } +async function uploadImageRequest(path: string, file: File): Promise { + 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('/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 = { diff --git a/services/db.ts b/services/db.ts index 1c06a56..a8d0ea9 100644 --- a/services/db.ts +++ b/services/db.ts @@ -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) => ({