Lab video portal. Admins upload videos, lab members sign in with Google to
watch them, and the site records where each viewer stopped and how long they
actually watched. Only emails on the allow list can sign in, and videos can be
locked to tags so that only the right people see them. Videos are stored on the lab Nextcloud over WebDAV and
streamed through the app with HTTP Range support, so nothing is reachable
without a signed-in session except /api/health. Uploads are transcribed by
transcribe.winlab.tw and the transcript is
shown next to the player, clickable to seek.
| Layer | Choice |
|---|---|
| Framework | Next.js (App Router) + TypeScript |
| UI | shadcn/ui + Tailwind CSS |
| Auth | Auth.js (NextAuth v5): password, passkey (WebAuthn) or Google OAuth, JWT sessions |
| Database | SQLite via Drizzle ORM (better-sqlite3) |
| Video storage | Nextcloud WebDAV (video-svc service account) |
| Thumbnails, duration | ffmpeg / ffprobe on the host, cached under THUMB_DIR |
| Transcripts | transcribe.winlab.tw HTTP API, polled in-process |
- The
userstable is the allow list. Every sign-in method goes through it: an email that is not listed is rejected with "This email is not on the allow list, ask an admin" on/login. Removing someone ends their session on their next request. - Admins are
ADMIN_EMAILSplus every user withrole = 'admin'.ADMIN_EMAILSis the bootstrap source and cannot be demoted from the UI. - The first migration seeds the allow list from everyone already known to the
database (viewers in
watch_progress, uploaders invideos) plusADMIN_EMAILS, so switching the allow list on locks nobody out. It runs on any start that finds the table empty, so clearing the allow list entirely brings it back; removing individual users does not. - Tags (
/admin/tags) are assigned to users (/admin/users) and to videos (/admin/videos/<id>). A video with no tag is visible to everyone signed in; a video locked to tags is visible to admins and to users carrying one of those tags. The rule is enforced on the home list, the watch page and the stream, thumbnail, transcript and progress APIs, not just in the UI. - Sign-in methods: password, passkey and Google. All three end in the same allow list check, so adding a method never widens access.
- Passwords are hashed with
scrypt(N=2^15, r=8, p=1, 16-byte salt) from Node core, stored asscrypt$N$r$p$salt$hashinusers.password_hash. They must be 8 to 128 characters and may not be the email address itself. Ten failed attempts pause an email for 15 minutes (login_attempts). - The mailed 6-digit code is not a sign-in method. It only proves that
someone owns an address so they can set or reset a password:
POST /api/auth/pin/requestmails it (sha256-hashed inlogin_codes, valid 10 minutes, one mail per minute per address, 10 per hour, 5 guesses per code) andPOST /api/auth/password/resetexchanges code plus new password. - Passkeys are discoverable WebAuthn credentials (
@simplewebauthn). Register them on/account, sign in with the passkey button or through the browser's autofill on/login. The relying party id is the hostname fromAUTH_URL, so a passkey registered onvideo.winlab.twdoes not work on a preview URL or on localhost, and each host needs its own registration. A verified assertion hands out a single-use ticket that thepasskeycredentials provider exchanges for a session, and the allow list is checked again at that moment, so removing a user kills their passkeys too. /accountis where a signed-in user changes their password and adds, renames or removes passkeys./.well-known/change-passwordredirects there for password managers. The forms are plain HTML forms with the standardautocompletetokens, so 1Password, iCloud Keychain and Chrome fill and update them.
proxy.ts(the Next.js 16 name for middleware) requires a session on every page and API route except/login, the auth callbacks and/api/health./adminadditionally requires an admin. Every API route re-checks the session itself.- Upload (
POST /api/videos, admin only, 2 GB cap) streams the file to Nextcloud atfiles/video-svc/videos/<id>-<name>, records metadata in SQLite, then in the background probes the duration with ffprobe, renders a thumbnail, and submits the file to transcribe.winlab.tw. SetTRANSCRIBE_TOKENto submit as a service caller (larger caps, three concurrent jobs); otherwise submissions are anonymous and limited to one job at a time per IP. - Thumbnails (
GET /api/thumb/:id) are generated on first request with ffmpeg reading straight from WebDAV and cached as JPEG underTHUMB_DIR. A failed render is remembered for 24 h so a broken file cannot spawn ffmpeg on every page view. - Transcripts:
instrumentation.tsstarts a poller that, every 5 minutes, asks transcribe for everypendingvideo and imports finished segments in one SQLite transaction. The admin pages and the watch page's 15 s poll go through the same guarded path, so an import never runs twice at once. - Admin (
/admin) lists videos with live transcript status and who they are visible to,/admin/usersmanages the allow list, roles and user tags, and/admin/tagsmanages tags. Each video page offers rename, tag locks, delete (removes the file, thumbnail, transcript and everyone's progress), resubmit, and a link to the transcribe job. GET /api/health(public) reportsdb,transcriptSync,thumbnails,nextcloudandtranscribechecks. It returns 503 only when the database or the poller is broken; dependency outages show asdegradedwith 200.- Playback (
GET /api/stream/:id) proxies WebDAV and forwards theRangeheader, so seeking works without downloading the whole file. - The player posts a heartbeat to
POST /api/progressevery 10 seconds and on pause/leave: current position plus seconds actually watched (seeks are not counted). The server clamps position to the probed duration and watched time to the wall-clock gap since the previous heartbeat, so a forged client cannot inflate the stats. The admin page shows per-viewer position, watch time, and last activity.
Production runs on PVE VM 114 as the video systemd service. Merging to
main does not deploy; run:
ssh video 'cd /opt/video/app && git pull && ~/.bun/bin/bun install && ~/.bun/bin/bun run build && sudo systemctl restart video'Password set and reset mails need SMTP_USER and SMTP_PASS (and optionally
SMTP_HOST, SMTP_PORT, MAIL_FROM) in /opt/video/app/.env, and AUTH_URL
must be the public URL because passkeys are bound to its hostname. The deploy
must run bun install for nodemailer and @simplewebauthn/*. Without SMTP
credentials the app refuses to hand out codes in production.
bun install
cp .env.example .env.local # fill in values
bun devGoogle OAuth needs http://localhost:3000/api/auth/callback/google (and the
production URL) registered as an authorized redirect URI.
- Videos should be H.264 MP4 with the moov atom up front for instant
progressive playback:
ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4. - Upload buffers through the app process, so the cap is 2 GB
(
lib/limits.tsandproxyClientMaxBodySizeinnext.config.ts). For larger files upload directly to the Nextcloud folder and insert the metadata row manually. - The host needs
ffmpegandffprobe(7.0 or newer, for-/headers). - SQLite lives at
DATABASE_PATH(default./data/app.db); the schema is created automatically on first run.