Merge pull request #66 from yusufipk/fix/bunny-cdn-runtime-config

fix(bunny): read the CDN host from runtime config so Docker images can play video
This commit is contained in:
Yusuf İpek
2026-08-20 16:19:15 +03:00
committed by GitHub
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_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=""
+1 -1
View File
@@ -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.
+14
View File
@@ -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
>
<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
@context survive naive JSON-LD consumers that choke on arrays. */}
{structuredData.map((data) => (
@@ -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 {
+14 -1
View File
@@ -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()
);
}
+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(() => {
// 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_DIRECT_DOWNLOAD_ALLOWED_HOSTS', ALLOWED_DIRECT_HOST);
clicked = [];
@@ -173,6 +173,9 @@ function makeFile(name = 'my clip.mp4') {
beforeEach(() => {
tusUploads.length = 0;
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');
fetchMock = vi.fn((url: string) => {
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();
});
it('reads only the public variable, ignoring the server-only one', () => {
// This runs in the browser bundle, where BUNNY_CDN_URL is never inlined.
it('falls back to the server variable when no config has been injected', () => {
// 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');
expect(resolvePublicBunnyCdnHostname()).toBeNull();
expect(resolvePublicBunnyCdnHostname()).toBe('server.b-cdn.net');
});
it('reduces the configured public url to its hostname', () => {