fix(bunny): read the CDN host from runtime config so Docker images can play video

NEXT_PUBLIC_BUNNY_CDN_URL is inlined into the client bundle at build time, and
the published image is built by CI without it, so the browser had no host to
build a playlist URL from no matter what the operator set in .env.docker. The
player read the empty URL as a stream that had not finished encoding and sat on
'Video Is Processing', retrying forever.

The server knows the value on every request, so the root layout now serialises
the public settings into a JSON script tag and the browser reads them from
there, falling back to the build-time variable for source builds. The
direct-download allow list came through the same broken path and moves with it.

Closes #60
This commit is contained in:
2026-08-20 15:40:21 +03:00
parent 1f2294c7ac
commit 0ff8b42b4a
10 changed files with 254 additions and 10 deletions
+2
View File
@@ -70,5 +70,7 @@ STRIPE_WEBHOOK_SECRET=""
BUNNY_STREAM_API_KEY="" BUNNY_STREAM_API_KEY=""
BUNNY_STREAM_LIBRARY_ID="" BUNNY_STREAM_LIBRARY_ID=""
BUNNY_API_KEY="" 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="" BUNNY_CDN_URL=""
NEXT_PUBLIC_BUNNY_CDN_URL="" NEXT_PUBLIC_BUNNY_CDN_URL=""
+1 -1
View File
@@ -177,7 +177,7 @@ OPENFRAME_REQUIRE_INVITE_CODE=false
Behavior when disabled: Behavior when disabled:
- `OPENFRAME_ENABLE_STRIPE=false` disables Stripe checkout and customer portal flows and removes billing-based workspace restrictions. - `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_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_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. - `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.
+14
View File
@@ -2,6 +2,10 @@ import type { Metadata } from 'next';
import { Geist_Mono, JetBrains_Mono } from 'next/font/google'; import { Geist_Mono, JetBrains_Mono } from 'next/font/google';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { ThemeProvider } from '@/components/theme-provider'; import { ThemeProvider } from '@/components/theme-provider';
import {
buildRuntimePublicConfig,
RUNTIME_PUBLIC_CONFIG_ELEMENT_ID,
} from '@/lib/runtime-public-config';
import { seoConfig } from '@/lib/seo'; import { seoConfig } from '@/lib/seo';
import './globals.css'; import './globals.css';
@@ -120,6 +124,16 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<body className="antialiased min-h-screen bg-background font-sans"> <body className="antialiased min-h-screen bg-background font-sans">
{/* 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. */}
<script
id={RUNTIME_PUBLIC_CONFIG_ELEMENT_ID}
type="application/json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(buildRuntimePublicConfig()).replace(/</g, '\\u003c'),
}}
/>
{/* One script per object: single-object payloads with a top-level {/* One script per object: single-object payloads with a top-level
@context survive naive JSON-LD consumers that choke on arrays. */} @context survive naive JSON-LD consumers that choke on arrays. */}
{structuredData.map((data) => ( {structuredData.map((data) => (
@@ -10,6 +10,7 @@ import type {
VideoData, VideoData,
} from '@/components/video-page/types'; } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { resolvePublicDirectDownloadAllowedHosts } from '@/lib/runtime-public-config';
import { import {
downloadNamedFile, downloadNamedFile,
downloadProgressLabel, downloadProgressLabel,
@@ -32,11 +33,9 @@ function sanitizeDownloadFileName(value: string): string {
function getAllowedHosts() { function getAllowedHosts() {
const bunnyCdnHostname = resolvePublicBunnyCdnHostname(); const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
return [ return [
...(bunnyCdnHostname ? [bunnyCdnHostname] : []), ...(bunnyCdnHostname ? [bunnyCdnHostname.trim().toLowerCase()] : []),
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','), ...resolvePublicDirectDownloadAllowedHosts(),
] ].filter(Boolean);
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
} }
function getSafeDirectDownloadUrl(rawUrl: string): string | null { function getSafeDirectDownloadUrl(rawUrl: string): string | null {
+14 -1
View File
@@ -1,3 +1,5 @@
import { readRuntimePublicConfig } from '@/lib/runtime-public-config';
function normalizeBunnyCdnHostname(raw: string | null | undefined): string | null { function normalizeBunnyCdnHostname(raw: string | null | undefined): string | null {
if (!raw) return null; if (!raw) return null;
const trimmed = raw.trim(); 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 { export function resolvePublicBunnyCdnHostname(): string | null {
return normalizeBunnyCdnHostname(process.env.NEXT_PUBLIC_BUNNY_CDN_URL); return (
normalizeBunnyCdnHostname(readRuntimePublicConfig()?.bunnyCdnUrl) ??
resolveServerBunnyCdnHostname()
);
} }
+74
View File
@@ -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<string, unknown>;
return {
bunnyCdnUrl: typeof bunnyCdnUrl === 'string' ? bunnyCdnUrl : '',
directDownloadAllowedHosts:
typeof directDownloadAllowedHosts === 'string' ? directDownloadAllowedHosts : '',
};
} catch {
return null;
}
}
export function resolvePublicDirectDownloadAllowedHosts(): string[] {
// An injected value that is empty means the same as an absent one: nothing was
// configured here, so keep whatever the bundle was built with rather than
// narrowing a deployment that already worked.
const configured =
readRuntimePublicConfig()?.directDownloadAllowedHosts ||
process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ||
'';
return configured
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
}
@@ -125,6 +125,9 @@ function urlsFetched(): string[] {
} }
beforeEach(() => { beforeEach(() => {
// The runtime resolver reads BUNNY_CDN_URL first, so pin it rather than
// inheriting the value a developer has in .env.
vi.stubEnv('BUNNY_CDN_URL', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', `https://${BUNNY_HOST}`); vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', `https://${BUNNY_HOST}`);
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', ALLOWED_DIRECT_HOST); vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', ALLOWED_DIRECT_HOST);
clicked = []; clicked = [];
@@ -173,6 +173,9 @@ function makeFile(name = 'my clip.mp4') {
beforeEach(() => { beforeEach(() => {
tusUploads.length = 0; tusUploads.length = 0;
tusFailure = null; tusFailure = null;
// The runtime resolver reads BUNNY_CDN_URL first, so pin it rather than
// inheriting the value a developer has in .env.
vi.stubEnv('BUNNY_CDN_URL', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://cdn.example.test'); vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://cdn.example.test');
fetchMock = vi.fn((url: string) => { fetchMock = vi.fn((url: string) => {
if (url === BUNNY_INIT_URL) { if (url === BUNNY_INIT_URL) {
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import {
buildRuntimePublicConfig,
readRuntimePublicConfig,
resolvePublicDirectDownloadAllowedHosts,
RUNTIME_PUBLIC_CONFIG_ELEMENT_ID,
} from '@/lib/runtime-public-config';
/**
* These run in jsdom because that is the only place the bug shows: on the server
* the environment is readable at request time, in the browser it is whatever was
* inlined when the bundle was built, which for the published Docker image is
* nothing.
*/
function injectConfig(payload: string): void {
const element = document.createElement('script');
element.id = RUNTIME_PUBLIC_CONFIG_ELEMENT_ID;
element.type = 'application/json';
element.textContent = payload;
document.body.appendChild(element);
}
beforeEach(() => {
vi.stubEnv('BUNNY_CDN_URL', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', undefined);
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', undefined);
});
afterEach(() => {
vi.unstubAllEnvs();
document.getElementById(RUNTIME_PUBLIC_CONFIG_ELEMENT_ID)?.remove();
});
describe('buildRuntimePublicConfig', () => {
it('prefers the server variable over the public one', () => {
vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net');
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
expect(buildRuntimePublicConfig().bunnyCdnUrl).toBe('https://server.b-cdn.net');
});
it('emits empty strings rather than undefined when nothing is configured', () => {
expect(buildRuntimePublicConfig()).toEqual({
bunnyCdnUrl: '',
directDownloadAllowedHosts: '',
});
});
});
describe('readRuntimePublicConfig', () => {
it('returns null when the page carries no config', () => {
expect(readRuntimePublicConfig()).toBeNull();
});
it('returns null for a payload that is not valid JSON', () => {
injectConfig('{ not json');
expect(readRuntimePublicConfig()).toBeNull();
});
it('coerces missing or non-string fields to empty strings', () => {
injectConfig(JSON.stringify({ bunnyCdnUrl: 42 }));
expect(readRuntimePublicConfig()).toEqual({
bunnyCdnUrl: '',
directDownloadAllowedHosts: '',
});
});
});
describe('resolvePublicBunnyCdnHostname in the browser', () => {
it('uses the injected hostname when the build-time variable is empty', () => {
// The published Docker image, where the operator configured BUNNY_CDN_URL at
// runtime and the bundle was built without it.
injectConfig(JSON.stringify({ bunnyCdnUrl: 'https://vz-runtime.b-cdn.net' }));
expect(resolvePublicBunnyCdnHostname()).toBe('vz-runtime.b-cdn.net');
});
it('prefers the injected hostname over the one inlined at build time', () => {
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://vz-build.b-cdn.net');
injectConfig(JSON.stringify({ bunnyCdnUrl: 'https://vz-runtime.b-cdn.net' }));
expect(resolvePublicBunnyCdnHostname()).toBe('vz-runtime.b-cdn.net');
});
it('falls back to the build-time variable when the injected value is empty', () => {
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://vz-build.b-cdn.net');
injectConfig(JSON.stringify({ bunnyCdnUrl: '' }));
expect(resolvePublicBunnyCdnHostname()).toBe('vz-build.b-cdn.net');
});
it('returns null when neither source is configured', () => {
injectConfig(JSON.stringify({ bunnyCdnUrl: '' }));
expect(resolvePublicBunnyCdnHostname()).toBeNull();
});
});
describe('resolvePublicDirectDownloadAllowedHosts', () => {
it('splits, trims and lowercases the injected list', () => {
injectConfig(
JSON.stringify({ directDownloadAllowedHosts: ' Files.Example.com , cdn.example.com ,, ' })
);
expect(resolvePublicDirectDownloadAllowedHosts()).toEqual([
'files.example.com',
'cdn.example.com',
]);
});
it('falls back to the build-time variable when no config is injected', () => {
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', 'files.example.com');
expect(resolvePublicDirectDownloadAllowedHosts()).toEqual(['files.example.com']);
});
it('keeps the build-time list when the injected one is empty', () => {
// An empty injected value means nothing was configured on this deployment, so
// it must not narrow a source build that already had a list.
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', 'files.example.com');
injectConfig(JSON.stringify({ directDownloadAllowedHosts: '' }));
expect(resolvePublicDirectDownloadAllowedHosts()).toEqual(['files.example.com']);
});
it('returns an empty list when neither source is configured', () => {
injectConfig(JSON.stringify({ directDownloadAllowedHosts: '' }));
expect(resolvePublicDirectDownloadAllowedHosts()).toEqual([]);
});
});
+5 -3
View File
@@ -103,11 +103,13 @@ describe('resolvePublicBunnyCdnHostname', () => {
expect(resolvePublicBunnyCdnHostname()).toBeNull(); expect(resolvePublicBunnyCdnHostname()).toBeNull();
}); });
it('reads only the public variable, ignoring the server-only one', () => { it('falls back to the server variable when no config has been injected', () => {
// This runs in the browser bundle, where BUNNY_CDN_URL is never inlined. // No document here, which is the SSR pass of a client component: the
// server-only variable is readable and has to yield the same hostname the
// browser will read out of the injected config, or hydration diverges.
vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net'); vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net');
expect(resolvePublicBunnyCdnHostname()).toBeNull(); expect(resolvePublicBunnyCdnHostname()).toBe('server.b-cdn.net');
}); });
it('reduces the configured public url to its hostname', () => { it('reduces the configured public url to its hostname', () => {