diff --git a/.env.docker.example b/.env.docker.example index cab4e31..349e68b 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -70,5 +70,7 @@ STRIPE_WEBHOOK_SECRET="" BUNNY_STREAM_API_KEY="" BUNNY_STREAM_LIBRARY_ID="" BUNNY_API_KEY="" +# Playback host for Bunny versions. Set BUNNY_CDN_URL: the app reads it at request +# time, while NEXT_PUBLIC_BUNNY_CDN_URL only reaches the browser in a source build. BUNNY_CDN_URL="" NEXT_PUBLIC_BUNNY_CDN_URL="" diff --git a/README.md b/README.md index eb6c6b7..f6a4bd6 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ OPENFRAME_REQUIRE_INVITE_CODE=false Behavior when disabled: - `OPENFRAME_ENABLE_STRIPE=false` disables Stripe checkout and customer portal flows and removes billing-based workspace restrictions. -- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available. +- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available. When enabling it, set `BUNNY_CDN_URL` (not only `NEXT_PUBLIC_BUNNY_CDN_URL`): it is read at request time, so a published image picks up the playback host without a rebuild. - `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`. - `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact. - `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`, or as JSON on `/api/admin/growth` by a script sending `Authorization: Bearer $OPENFRAME_ADMIN_API_TOKEN` (at least 32 characters, unset by default, in which case an admin session is the only way in). Off by default, and nothing leaves the instance either way. diff --git a/app/layout.tsx b/app/layout.tsx index c16359f..4f311c6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,10 @@ import type { Metadata } from 'next'; import { Geist_Mono, JetBrains_Mono } from 'next/font/google'; import { Toaster } from 'sonner'; import { ThemeProvider } from '@/components/theme-provider'; +import { + buildRuntimePublicConfig, + RUNTIME_PUBLIC_CONFIG_ELEMENT_ID, +} from '@/lib/runtime-public-config'; import { seoConfig } from '@/lib/seo'; import './globals.css'; @@ -120,6 +124,16 @@ export default function RootLayout({ suppressHydrationWarning >
+ {/* Not executed, only parsed by readRuntimePublicConfig(). It carries the + public settings the browser cannot get from NEXT_PUBLIC_* variables, + which are frozen into the bundle when the image is built. */} + {/* One script per object: single-object payloads with a top-level @context survive naive JSON-LD consumers that choke on arrays. */} {structuredData.map((data) => ( diff --git a/components/video-page/hooks/use-download-actions.ts b/components/video-page/hooks/use-download-actions.ts index 21fdd68..fa24364 100644 --- a/components/video-page/hooks/use-download-actions.ts +++ b/components/video-page/hooks/use-download-actions.ts @@ -10,6 +10,7 @@ import type { VideoData, } from '@/components/video-page/types'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; +import { resolvePublicDirectDownloadAllowedHosts } from '@/lib/runtime-public-config'; import { downloadNamedFile, downloadProgressLabel, @@ -32,11 +33,9 @@ function sanitizeDownloadFileName(value: string): string { function getAllowedHosts() { const bunnyCdnHostname = resolvePublicBunnyCdnHostname(); return [ - ...(bunnyCdnHostname ? [bunnyCdnHostname] : []), - ...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','), - ] - .map((host) => host.trim().toLowerCase()) - .filter(Boolean); + ...(bunnyCdnHostname ? [bunnyCdnHostname.trim().toLowerCase()] : []), + ...resolvePublicDirectDownloadAllowedHosts(), + ].filter(Boolean); } function getSafeDirectDownloadUrl(rawUrl: string): string | null { diff --git a/lib/bunny-cdn.ts b/lib/bunny-cdn.ts index 62a1683..89c35d8 100644 --- a/lib/bunny-cdn.ts +++ b/lib/bunny-cdn.ts @@ -1,3 +1,5 @@ +import { readRuntimePublicConfig } from '@/lib/runtime-public-config'; + function normalizeBunnyCdnHostname(raw: string | null | undefined): string | null { if (!raw) return null; const trimmed = raw.trim(); @@ -17,6 +19,17 @@ export function resolveServerBunnyCdnHostname(): string | null { ); } +/** + * Browser-side hostname. Prefers the config the server injects at request time, + * because `NEXT_PUBLIC_BUNNY_CDN_URL` is inlined when the bundle is built and the + * published Docker image is built without it. Falls back to the build-time variable, + * which is what a source build sets, and to the server-only variable during the SSR + * pass of a client component, where it is still readable and has to produce the same + * hostname the browser will read or hydration diverges. + */ export function resolvePublicBunnyCdnHostname(): string | null { - return normalizeBunnyCdnHostname(process.env.NEXT_PUBLIC_BUNNY_CDN_URL); + return ( + normalizeBunnyCdnHostname(readRuntimePublicConfig()?.bunnyCdnUrl) ?? + resolveServerBunnyCdnHostname() + ); } diff --git a/lib/runtime-public-config.ts b/lib/runtime-public-config.ts new file mode 100644 index 0000000..94fc93c --- /dev/null +++ b/lib/runtime-public-config.ts @@ -0,0 +1,74 @@ +/** + * Public configuration the browser needs but cannot read from the environment. + * + * Next inlines `NEXT_PUBLIC_*` into the client bundle at build time. The published + * Docker image is built by CI with none of these set, so every browser-side reader + * gets an empty string no matter what the operator puts in `.env.docker`, and the + * Bunny player ends up with no CDN host to build a playlist URL from. The server + * knows the real values on every request, so it serialises them into a JSON script + * tag in the root layout and the browser reads them back from the DOM. Same reason + * the CSP is built per request in lib/content-security-policy.ts. + * + * Caveat: a prerendered route bakes the values it had at build time into its HTML, + * and a client-side navigation away from one keeps that copy of the root layout. The + * prerendered routes are the legal and marketing pages, none of which play video or + * download media, so nothing reads a stale copy today. A new prerendered route that + * needs either has to opt into per-request rendering. + */ + +export const RUNTIME_PUBLIC_CONFIG_ELEMENT_ID = 'openframe-runtime-public-config'; + +export interface RuntimePublicConfig { + /** Raw value, not a hostname: lib/bunny-cdn.ts owns the normalisation. */ + bunnyCdnUrl: string; + /** Comma-separated hostnames, as the environment variable spells them. */ + directDownloadAllowedHosts: string; +} + +/** Server-side: the values as configured for this deployment, read at request time. */ +export function buildRuntimePublicConfig(): RuntimePublicConfig { + return { + bunnyCdnUrl: process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL || '', + directDownloadAllowedHosts: process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS || '', + }; +} + +/** + * Browser-side: the injected values, or null when there is no document (the SSR pass + * of a client component) or no script tag (a page rendered before this existed). + * Callers fall back to the build-time environment in both cases. + */ +export function readRuntimePublicConfig(): RuntimePublicConfig | null { + if (typeof document === 'undefined') return null; + + const element = document.getElementById(RUNTIME_PUBLIC_CONFIG_ELEMENT_ID); + if (!element?.textContent) return null; + + try { + const parsed: unknown = JSON.parse(element.textContent); + if (!parsed || typeof parsed !== 'object') return null; + const { bunnyCdnUrl, directDownloadAllowedHosts } = parsed as Record