mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
+2
-1
@@ -34,7 +34,8 @@ TRUSTED_PROXY_MODE="nginx"
|
|||||||
MINIO_ROOT_USER="replace-with-minio-root-user"
|
MINIO_ROOT_USER="replace-with-minio-root-user"
|
||||||
MINIO_ROOT_PASSWORD="replace-with-strong-minio-password"
|
MINIO_ROOT_PASSWORD="replace-with-strong-minio-password"
|
||||||
R2_ENDPOINT="http://minio:9000"
|
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_PRESIGN_ENDPOINT="http://localhost:9000"
|
||||||
R2_PUBLIC_BASE_URL="http://localhost:9000/openframe"
|
R2_PUBLIC_BASE_URL="http://localhost:9000/openframe"
|
||||||
R2_ACCESS_KEY_ID="replace-with-minio-root-user"
|
R2_ACCESS_KEY_ID="replace-with-minio-root-user"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ COPY scripts ./scripts
|
|||||||
COPY types ./types
|
COPY types ./types
|
||||||
COPY components.json ./components.json
|
COPY components.json ./components.json
|
||||||
COPY next.config.ts ./next.config.ts
|
COPY next.config.ts ./next.config.ts
|
||||||
|
COPY proxy.ts ./proxy.ts
|
||||||
COPY postcss.config.mjs ./postcss.config.mjs
|
COPY postcss.config.mjs ./postcss.config.mjs
|
||||||
COPY prisma.config.ts ./prisma.config.ts
|
COPY prisma.config.ts ./prisma.config.ts
|
||||||
COPY tsconfig.json ./tsconfig.json
|
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/package.json ./package.json
|
||||||
COPY --from=build /app/bun.lock ./bun.lock
|
COPY --from=build /app/bun.lock ./bun.lock
|
||||||
COPY --from=build /app/next.config.ts ./next.config.ts
|
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/public ./public
|
||||||
COPY --from=build /app/prisma ./prisma
|
COPY --from=build /app/prisma ./prisma
|
||||||
COPY --from=build /app/scripts ./scripts
|
COPY --from=build /app/scripts ./scripts
|
||||||
|
|||||||
@@ -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_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.
|
||||||
- `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.
|
- `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:
|
These integrations remain optional for self-hosted deployments and can be enabled later by setting the related environment variables:
|
||||||
|
|
||||||
- Stripe billing
|
- Stripe billing
|
||||||
|
|||||||
@@ -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
@@ -13,90 +13,10 @@ function resolveBunnyCdnHostname(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bunnyCdnHostname = resolveBunnyCdnHostname();
|
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 = [
|
const securityHeaders = [
|
||||||
{ key: 'Content-Security-Policy', value: contentSecurityPolicy },
|
|
||||||
// Prevent the app from being embedded in foreign iframes (clickjacking)
|
// Prevent the app from being embedded in foreign iframes (clickjacking)
|
||||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
// Prevent MIME-type sniffing on all responses
|
// Prevent MIME-type sniffing on all responses
|
||||||
|
|||||||
@@ -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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user