From 9b6fea4b81ab8eff0aa20955d1360420a377eab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 4 Sep 2026 10:07:07 -0300 Subject: [PATCH] feat: complete platform operations roadmap --- .env.example | 7 ++ PORTAINER.md | 20 ++++ components/CoursePlayerModal.tsx | 53 ++++++++++ docker-compose.yml | 8 ++ pages/ManageCourses.tsx | 65 +++++++----- pages/SuperAdmin.tsx | 92 ++++++++++++++++- public/course-placeholder.svg | 8 ++ .../006_platform_content_and_security.sql | 47 +++++++++ server/src/app.ts | 15 +++ server/src/audit.ts | 4 +- server/src/config.ts | 6 ++ server/src/providers/bunny.ts | 20 +++- server/src/routes/admin.ts | 99 ++++++++++++++++++- server/src/routes/auth.ts | 50 ++++++---- server/src/routes/bunny-webhooks.ts | 40 ++++++++ server/src/routes/courses.ts | 24 ++++- server/src/routes/manage-courses.ts | 85 +++++++++++++++- services/api.ts | 6 ++ services/db.ts | 6 +- 19 files changed, 589 insertions(+), 66 deletions(-) create mode 100644 public/course-placeholder.svg create mode 100644 server/migrations/006_platform_content_and_security.sql create mode 100644 server/src/routes/bunny-webhooks.ts diff --git a/.env.example b/.env.example index aedf73c..5780892 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ SUPERADMIN_EMAIL=admin@example.com SUPERADMIN_PASSWORD=change-this-password AUTH_RATE_LIMIT_MAX=10 AUTH_RATE_LIMIT_WINDOW_SECONDS=900 +JWT_SESSION_TTL=7d +INVITATION_TTL_HOURS=168 +PASSWORD_RESET_TTL_HOURS=24 +AUDIT_LOG_PAGE_SIZE=50 SUPERADMIN_NAME=Compor HUB Superadmin # Bunny Stream. Set all three in Portainer to enable instructor uploads and @@ -23,6 +27,9 @@ SUPERADMIN_NAME=Compor HUB Superadmin BUNNY_STREAM_LIBRARY_ID= BUNNY_STREAM_API_KEY= BUNNY_EMBED_TOKEN_KEY= +# Bunny Read-Only API key. Required only if Bunny webhooks are enabled. +BUNNY_WEBHOOK_SECRET= +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 diff --git a/PORTAINER.md b/PORTAINER.md index a039d19..d2ecd0f 100644 --- a/PORTAINER.md +++ b/PORTAINER.md @@ -10,6 +10,8 @@ To let instructors upload protected course videos, configure these API environme 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_WEBHOOK_SECRET=your-bunny-read-only-api-key +BUNNY_EMBED_TOKEN_TTL_SECONDS=600 BUNNY_MAX_UPLOAD_MB=5120 ``` @@ -26,6 +28,16 @@ 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 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: + +```text +https://YOUR-DOMAIN/api/v1/webhooks/bunny +``` + +Set `BUNNY_WEBHOOK_SECRET` to Bunny's **Read-Only API key**. The Academy checks Bunny's HMAC signature against the unmodified request body and rejects unsigned requests. Do not use the normal library API key for this setting. + 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 @@ -45,6 +57,7 @@ The built-in Actions job token can be disabled or lack registry scope on self-ho - `SUPERADMIN_EMAIL`: email address for the initial platform administrator. - `SUPERADMIN_PASSWORD`: password for that administrator (at least 12 characters). - `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` are optional login and public-auth throttling controls (defaults: 10 attempts per 900 seconds per source IP). +- `JWT_SESSION_TTL`, `INVITATION_TTL_HOURS`, `PASSWORD_RESET_TTL_HOURS`, `BUNNY_EMBED_TOKEN_TTL_SECONDS`, and `AUDIT_LOG_PAGE_SIZE` tune operating policy without changing code. Optional variables: @@ -63,4 +76,11 @@ Optional variables: 5. Place the web service behind HTTPS, normally through your existing reverse proxy (Traefik, Nginx Proxy Manager, or Cloudflare Tunnel), and set `FRONTEND_ORIGIN` to that HTTPS address. 6. Back up the `compor_postgres_data` volume before updates. +## Reliability checklist + +- Keep the API at one replica until PostgreSQL capacity and upload traffic justify scaling. Auth throttling is database-backed, so it will remain consistent if you later add replicas. +- Point an external monitor at `https://YOUR-DOMAIN/api/v1/ready`; alert when it returns anything other than HTTP 200. +- Test a PostgreSQL backup restoration into a separate temporary database at least once per quarter. A backup is only proven when it restores. +- Create a separate Portainer stack and database for staging. Use a different `FRONTEND_ORIGIN`, `JWT_SECRET`, Bunny library, and `WEB_PORT`; never point staging at production PostgreSQL or video credentials. + Do not expose port 5432 or port 3001 publicly. diff --git a/components/CoursePlayerModal.tsx b/components/CoursePlayerModal.tsx index 208c9c9..5c668cd 100644 --- a/components/CoursePlayerModal.tsx +++ b/components/CoursePlayerModal.tsx @@ -24,6 +24,32 @@ import { LoginModal } from './LoginModal'; import { MaterialCard } from './MaterialCard'; import { courseApi, LessonPlayback } from '../services/api'; +declare global { + interface Window { + playerjs?: { + Player: new (element: HTMLIFrameElement) => { + on: (event: string, listener: (data?: { seconds?: number; duration?: number }) => void) => void; + }; + }; + } +} + +let bunnyPlayerJsPromise: Promise | null = null; + +const loadBunnyPlayerJs = () => { + if (window.playerjs) return Promise.resolve(); + if (bunnyPlayerJsPromise) return bunnyPlayerJsPromise; + bunnyPlayerJsPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://assets.mediadelivery.net/playerjs/player-0.1.0.min.js'; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Bunny player events could not be loaded')); + document.head.appendChild(script); + }); + return bunnyPlayerJsPromise; +}; + interface CoursePlayerModalProps { course: Course | null; onClose: () => void; @@ -43,6 +69,7 @@ export const CoursePlayerModal: React.FC = ({ course, on const [playbackError, setPlaybackError] = useState(''); const videoRef = useRef(null); + const bunnyFrameRef = useRef(null); const lastProgressSave = useRef>({}); // Load course data and this learner's saved state from PostgreSQL. @@ -162,6 +189,31 @@ export const CoursePlayerModal: React.FC = ({ course, on } }; + // Bunny's iframe does not expose an HTMLVideoElement. Player.js receives + // Bunny's trusted playback events and sends the same progress updates used + // for external video providers. + useEffect(() => { + if (!playback || playback.kind !== 'embed' || !currentLesson || !user) return; + let disposed = false; + const lessonId = currentLesson.id; + void loadBunnyPlayerJs().then(() => { + if (disposed || !bunnyFrameRef.current || !window.playerjs) return; + const player = new window.playerjs.Player(bunnyFrameRef.current); + player.on('timeupdate', (data) => { + const watchedSeconds = Math.floor(data?.seconds || 0); + if (disposed || watchedSeconds <= 0 || watchedSeconds - (lastProgressSave.current[lessonId] || 0) < 30) return; + lastProgressSave.current[lessonId] = watchedSeconds; + setWatchedSecondsByLesson((current) => ({ ...current, [lessonId]: watchedSeconds })); + saveLessonProgress(lessonId, watchedSeconds).catch(() => undefined); + }); + player.on('ended', (data) => { + if (disposed) return; + markLessonCompleted(lessonId, Math.floor(data?.duration || data?.seconds || 0)); + }); + }).catch(() => undefined); + return () => { disposed = true; }; + }, [playback, currentLesson, user, completedLessonIds]); + const restoreWatchPosition = () => { if (!currentLesson || !videoRef.current) return; const watchedSeconds = watchedSecondsByLesson[currentLesson.id] || 0; @@ -267,6 +319,7 @@ export const CoursePlayerModal: React.FC = ({ course, on playback.kind === 'embed' && playback.embedUrl ? (