diff --git a/.env.docker.example b/.env.docker.example index 8a4331b..0474610 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -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" diff --git a/Dockerfile b/Dockerfile index 846fbe8..391f3dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 7965869..9206f12 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/content-security-policy.ts b/lib/content-security-policy.ts new file mode 100644 index 0000000..aa26022 --- /dev/null +++ b/lib/content-security-policy.ts @@ -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(); + + 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('; '); +} diff --git a/next.config.ts b/next.config.ts index 4f5cf82..74b4694 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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(); - - 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 diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..52d5ac4 --- /dev/null +++ b/proxy.ts @@ -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).*)', + ], +};