fix: generate CSP from runtime storage env for self-hosted MinIO

Move Content-Security-Policy generation to proxy.ts so R2_PRESIGN_ENDPOINT
is included at request time instead of being frozen at image build time.
Document reverse-proxy layouts for Docker self-hosting and copy proxy.ts
into the Docker image.

Closes #17
This commit is contained in:
yusufipk
2026-06-12 21:21:31 +02:00
parent 4bf6e821af
commit 52ace1a1a8
6 changed files with 130 additions and 84 deletions
+2 -1
View File
@@ -34,7 +34,8 @@ TRUSTED_PROXY_MODE="nginx"
MINIO_ROOT_USER="replace-with-minio-root-user"
MINIO_ROOT_PASSWORD="replace-with-strong-minio-password"
R2_ENDPOINT="http://minio:9000"
# Browser-facing endpoint used for presigned upload URLs (must be reachable from the browser).
# Browser-facing MinIO origin for presigned upload URLs (scheme + host, no path).
# Use your public MinIO domain when behind a reverse proxy, e.g. https://minio.example.com
R2_PRESIGN_ENDPOINT="http://localhost:9000"
R2_PUBLIC_BASE_URL="http://localhost:9000/openframe"
R2_ACCESS_KEY_ID="replace-with-minio-root-user"
+2
View File
@@ -16,6 +16,7 @@ COPY scripts ./scripts
COPY types ./types
COPY components.json ./components.json
COPY next.config.ts ./next.config.ts
COPY proxy.ts ./proxy.ts
COPY postcss.config.mjs ./postcss.config.mjs
COPY prisma.config.ts ./prisma.config.ts
COPY tsconfig.json ./tsconfig.json
@@ -36,6 +37,7 @@ RUN apt-get update \
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/bun.lock ./bun.lock
COPY --from=build /app/next.config.ts ./next.config.ts
COPY --from=build /app/proxy.ts ./proxy.ts
COPY --from=build /app/public ./public
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/scripts ./scripts
+8 -1
View File
@@ -178,9 +178,16 @@ 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_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=http://localhost:9000` (browser-reachable host).
- `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.
For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:
- Separate storage host: route `https://minio.example.com` to MinIO and set `R2_PRESIGN_ENDPOINT=https://minio.example.com` plus `R2_PUBLIC_BASE_URL=https://minio.example.com/openframe`.
- Same app host: route the bucket path, for example `https://openframe.example.com/openframe/*`, to MinIO and keep all other paths routed to the OpenFrame app. Set `R2_PRESIGN_ENDPOINT=https://openframe.example.com` and `R2_PUBLIC_BASE_URL=https://openframe.example.com/openframe`.
Do not add an extra path prefix such as `/s3` in front of the bucket unless your proxy rewrites it away before MinIO sees the request. S3 path-style presigned URLs expect the first path segment to be the bucket name, so `/openframe/videos/...` is valid while `/s3/openframe/videos/...` makes MinIO treat `s3` as the bucket.
These integrations remain optional for self-hosted deployments and can be enabled later by setting the related environment variables:
- Stripe billing
+96
View File
@@ -0,0 +1,96 @@
function resolveBunnyCdnHostname(): string | null {
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!raw) return null;
try {
const parsed = new URL(raw);
return parsed.hostname || null;
} catch {
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null;
}
}
function resolveR2ConnectOrigins(): string[] {
const origins = new Set<string>();
for (const raw of [
process.env.R2_ENDPOINT?.trim(),
process.env.R2_PRESIGN_ENDPOINT?.trim(),
process.env.R2_PUBLIC_BASE_URL?.trim(),
]) {
if (!raw) continue;
try {
origins.add(new URL(raw).origin);
} catch {
// Ignore invalid custom endpoints in CSP generation.
}
}
const accountId = process.env.R2_ACCOUNT_ID?.trim();
const bucket = process.env.R2_BUCKET_NAME?.trim();
if (accountId) {
origins.add(`https://${accountId}.r2.cloudflarestorage.com`);
if (bucket) {
origins.add(`https://${bucket}.${accountId}.r2.cloudflarestorage.com`);
}
origins.add('https://*.r2.cloudflarestorage.com');
}
// Docker/MinIO self-hosted defaults for local development.
origins.add('http://localhost:9000');
origins.add('http://127.0.0.1:9000');
return [...origins];
}
/**
* Build Content-Security-Policy from runtime environment variables.
* Called per request so Docker/self-hosted storage endpoints are available
* without rebuilding the image.
*/
export function buildContentSecurityPolicy(): string {
const isDev = process.env.NODE_ENV === 'development';
const bunnyCdnHostname = resolveBunnyCdnHostname();
const cdnOrigin = bunnyCdnHostname ? `https://${bunnyCdnHostname}` : '';
const connectSrcParts = [
"'self'",
'https://video.bunnycdn.com',
'https://www.youtube.com',
cdnOrigin,
...resolveR2ConnectOrigins(),
// Allow Next.js HMR websocket in development
...(isDev ? ['ws://localhost:* wss://localhost:*'] : []),
].filter(Boolean);
const imgSrcParts = [
"'self'",
'data:',
'blob:',
'https://img.youtube.com',
'https://i.ytimg.com',
'https://images.unsplash.com',
'https://vz-thumbnail.b-cdn.net',
cdnOrigin,
].filter(Boolean);
const mediaSrcParts = ["'self'", 'blob:', cdnOrigin].filter(Boolean);
return [
"default-src 'self'",
// 'unsafe-inline' is required by Next.js App Router (hydration scripts, inline styles)
// https://www.youtube.com is required for the dynamically-injected YouTube IFrame API script
"script-src 'self' 'unsafe-inline' https://www.youtube.com",
"style-src 'self' 'unsafe-inline'",
`img-src ${imgSrcParts.join(' ')}`,
`media-src ${mediaSrcParts.join(' ')}`,
"frame-src 'self' https://www.youtube.com https://iframe.mediadelivery.net",
`connect-src ${connectSrcParts.join(' ')}`,
// next/font self-hosts Google Fonts at build time — no external font origin needed
"font-src 'self'",
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ');
}
+2 -82
View File
@@ -13,90 +13,10 @@ function resolveBunnyCdnHostname(): string | null {
}
const bunnyCdnHostname = resolveBunnyCdnHostname();
const isDev = process.env.NODE_ENV === 'development';
function resolveR2ConnectOrigins(): string[] {
const origins = new Set<string>();
for (const raw of [
process.env.R2_ENDPOINT?.trim(),
process.env.R2_PRESIGN_ENDPOINT?.trim(),
process.env.R2_PUBLIC_BASE_URL?.trim(),
]) {
if (!raw) continue;
try {
origins.add(new URL(raw).origin);
} catch {
// Ignore invalid custom endpoints in CSP generation.
}
}
const accountId = process.env.R2_ACCOUNT_ID?.trim();
const bucket = process.env.R2_BUCKET_NAME?.trim();
if (accountId) {
origins.add(`https://${accountId}.r2.cloudflarestorage.com`);
if (bucket) {
origins.add(`https://${bucket}.${accountId}.r2.cloudflarestorage.com`);
}
origins.add('https://*.r2.cloudflarestorage.com');
}
// Docker/MinIO self-hosted defaults. Keep unconditional because CSP is
// compiled at build time and build env may not include runtime self-hosted
// variables.
origins.add('http://localhost:9000');
origins.add('http://127.0.0.1:9000');
return [...origins];
}
// Build Content-Security-Policy from resolved config
const cdnOrigin = bunnyCdnHostname ? `https://${bunnyCdnHostname}` : '';
const connectSrcParts = [
"'self'",
'https://video.bunnycdn.com',
'https://www.youtube.com',
cdnOrigin,
...resolveR2ConnectOrigins(),
// Allow Next.js HMR websocket in development
...(isDev ? ['ws://localhost:* wss://localhost:*'] : []),
].filter(Boolean);
const imgSrcParts = [
"'self'",
'data:',
'blob:',
'https://img.youtube.com',
'https://i.ytimg.com',
'https://images.unsplash.com',
'https://vz-thumbnail.b-cdn.net',
cdnOrigin,
].filter(Boolean);
const mediaSrcParts = ["'self'", 'blob:', cdnOrigin].filter(Boolean);
const contentSecurityPolicy = [
"default-src 'self'",
// 'unsafe-inline' is required by Next.js App Router (hydration scripts, inline styles)
// https://www.youtube.com is required for the dynamically-injected YouTube IFrame API script
"script-src 'self' 'unsafe-inline' https://www.youtube.com",
"style-src 'self' 'unsafe-inline'",
`img-src ${imgSrcParts.join(' ')}`,
`media-src ${mediaSrcParts.join(' ')}`,
"frame-src 'self' https://www.youtube.com https://iframe.mediadelivery.net",
`connect-src ${connectSrcParts.join(' ')}`,
// next/font self-hosts Google Fonts at build time — no external font origin needed
"font-src 'self'",
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ');
// Content-Security-Policy is set at request time in proxy.ts so runtime storage
// endpoints (R2_PRESIGN_ENDPOINT, etc.) are available in Docker deployments.
const securityHeaders = [
{ key: 'Content-Security-Policy', value: contentSecurityPolicy },
// Prevent the app from being embedded in foreign iframes (clickjacking)
{ key: 'X-Frame-Options', value: 'DENY' },
// Prevent MIME-type sniffing on all responses
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { buildContentSecurityPolicy } from '@/lib/content-security-policy';
export function proxy() {
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', buildContentSecurityPolicy());
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};