From 2c137529bc6d7fdc84999caadd38819a15643e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 2 Sep 2026 10:09:36 -0300 Subject: [PATCH] feat: integrate Bunny Stream course videos --- .env.example | 8 ++ PORTAINER.md | 24 ++++ components/CoursePlayerModal.tsx | 69 +++++++++--- docker-compose.yml | 4 + docker/nginx.conf | 7 ++ pages/ManageCourses.tsx | 166 ++++++++++++++++++++++++++-- server/src/app.ts | 8 ++ server/src/config.ts | 14 +++ server/src/providers/bunny.ts | 115 +++++++++++++++++++ server/src/routes/courses.ts | 48 +++++++- server/src/routes/manage-courses.ts | 2 +- server/src/routes/media.ts | 82 ++++++++++++++ services/api.ts | 28 +++++ services/db.ts | 59 +++++++--- types.ts | 13 +++ 15 files changed, 607 insertions(+), 40 deletions(-) create mode 100644 server/src/providers/bunny.ts create mode 100644 server/src/routes/media.ts diff --git a/.env.example b/.env.example index dcc344b..aedf73c 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,14 @@ AUTH_RATE_LIMIT_MAX=10 AUTH_RATE_LIMIT_WINDOW_SECONDS=900 SUPERADMIN_NAME=Compor HUB Superadmin +# Bunny Stream. Set all three in Portainer to enable instructor uploads and +# signed embedded playback. Keep every value server-side; none are VITE_ vars. +BUNNY_STREAM_LIBRARY_ID= +BUNNY_STREAM_API_KEY= +BUNNY_EMBED_TOKEN_KEY= +# Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf. +BUNNY_MAX_UPLOAD_MB=5120 + # 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 47c378b..a039d19 100644 --- a/PORTAINER.md +++ b/PORTAINER.md @@ -2,6 +2,30 @@ Use `docker-compose.yml` as a Portainer Stack from this repository. It deploys the frontend, API, and PostgreSQL as one internal Docker network. Only the web container exposes a port; it proxies `/api` to the API container. +## Bunny Stream video hosting + +To let instructors upload protected course videos, configure these API environment variables in the Portainer Stack. They are server secrets: never add a `VITE_` version or place them in the frontend container. + +```env +BUNNY_STREAM_LIBRARY_ID=123456 +BUNNY_STREAM_API_KEY=your-bunny-library-api-key +BUNNY_EMBED_TOKEN_KEY=your-bunny-embed-view-token-key +BUNNY_MAX_UPLOAD_MB=5120 +``` + +In Bunny Stream, create one Video Library using the Volume tier. In that library's security settings, enable MediaCage Basic DRM and Embed View Token Authentication, then copy its token key into `BUNNY_EMBED_TOKEN_KEY`. Disable Direct Play and MP4 fallback if they are not needed, and add the Academy production hostname to Bunny's allowed referrers. This keeps playback inside Bunny's protected iframe and makes the Academy API issue a short-lived embed token only after a logged-in user requests a lesson. + +The deployed Nginx proxy permits uploads up to **5 GB**. Keep `BUNNY_MAX_UPLOAD_MB` at or below `5120`; reduce it if you want a smaller application-level limit. Large uploads stream through the API rather than being held in memory. + +After deploying the new images: + +1. Sign in as an instructor and open **Gerenciar Cursos**. +2. Enter the lesson title first, then choose **Selecionar vídeo** in the Bunny Stream section. +3. Wait for the upload message, add the lesson, and save the course. Bunny encoding continues asynchronously. +4. The editor refreshes Bunny processing status automatically; it also has an **Atualizar** action for a pending lesson. + +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. + This is a Docker Swarm stack: Portainer pulls prebuilt API and web images from the Gitea Container Registry. It never builds Dockerfiles itself. ## Gitea Actions registry secrets diff --git a/components/CoursePlayerModal.tsx b/components/CoursePlayerModal.tsx index f40eb89..208c9c9 100644 --- a/components/CoursePlayerModal.tsx +++ b/components/CoursePlayerModal.tsx @@ -22,6 +22,7 @@ import { useAuth } from '../context/AuthContext'; import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db'; import { LoginModal } from './LoginModal'; import { MaterialCard } from './MaterialCard'; +import { courseApi, LessonPlayback } from '../services/api'; interface CoursePlayerModalProps { course: Course | null; @@ -38,6 +39,8 @@ export const CoursePlayerModal: React.FC = ({ course, on const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist'); const [copiedLink, setCopiedLink] = useState(false); + const [playback, setPlayback] = useState(null); + const [playbackError, setPlaybackError] = useState(''); const videoRef = useRef(null); const lastProgressSave = useRef>({}); @@ -70,6 +73,27 @@ export const CoursePlayerModal: React.FC = ({ course, on } }, [course, user]); + // The API verifies the session before returning a time-limited Bunny embed + // URL. A video provider key is never sent to the browser. + useEffect(() => { + if (!course || !user || !currentLesson) { + setPlayback(null); + setPlaybackError(''); + return; + } + let cancelled = false; + setPlayback(null); + setPlaybackError(''); + courseApi.playback(course.id, currentLesson.id) + .then((response) => { + if (!cancelled) setPlayback(response.data); + }) + .catch((error) => { + if (!cancelled) setPlaybackError(error instanceof Error ? error.message : 'Não foi possível carregar este vídeo.'); + }); + return () => { cancelled = true; }; + }, [course, user, currentLesson]); + // Persist completed lessons and recalculate progress const updateCompletedLessons = (newCompleted: string[]) => { setCompletedLessonIds(newCompleted); @@ -239,19 +263,38 @@ export const CoursePlayerModal: React.FC = ({ course, on {/* Video Player Section */}
{canWatch ? ( - + playback ? ( + playback.kind === 'embed' && playback.embedUrl ? ( +