From e97a54ac8dbfc0bf34ab51051b47954dc8f843d0 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Fri, 17 Apr 2026 10:45:00 +0100 Subject: [PATCH] fix(backend): use public MinIO origin for browser uploads --- Backend/.env.example | 4 ++ Backend/README.md | 3 + Backend/index.ts | 29 +--------- Backend/routes/admin.ts | 10 +++- Backend/routes/recordings.ts | 10 +++- Backend/routes/videos.ts | 7 ++- Backend/utils/minio.ts | 105 ++++++++++++++++++++++++++++++++--- 7 files changed, 127 insertions(+), 41 deletions(-) diff --git a/Backend/.env.example b/Backend/.env.example index d0f8962..b7fed3a 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -7,6 +7,10 @@ DEVICE_ONLINE_STALE_SECONDS=30 MINIO_ENDPOINT=localhost MINIO_PORT=9000 MINIO_USE_SSL=false +MINIO_PUBLIC_ORIGIN= +MINIO_PUBLIC_ENDPOINT= +MINIO_PUBLIC_PORT= +MINIO_PUBLIC_USE_SSL= MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=videos diff --git a/Backend/README.md b/Backend/README.md index 50cfd15..9e2c939 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -41,6 +41,8 @@ Required env vars: | `MEDIA_RECORDINGS_DIR` | Local output directory for server-side recording workers (planned in SFU mode) | | `MEDIA_MAX_PUBLISHERS` / `MEDIA_MAX_SUBSCRIBERS_PER_ROOM` | Soft concurrency limits for single-server media mode (planned) | | `MINIO_*` | Connection settings for the MinIO/S3 endpoint | +| `MINIO_PUBLIC_ORIGIN` | Optional browser-facing MinIO origin used for presigned URLs and CSP (for example `https://storage.example.com`) | +| `MINIO_PUBLIC_ENDPOINT` / `MINIO_PUBLIC_PORT` / `MINIO_PUBLIC_USE_SSL` | Optional browser-facing MinIO host settings if you prefer host/port flags instead of `MINIO_PUBLIC_ORIGIN` | | `MINIO_CA_CERT_PATH` | Optional path to a PEM CA bundle used to trust a private/self-managed MinIO certificate | | `MINIO_TLS_REJECT_UNAUTHORIZED` | TLS verification toggle for MinIO HTTPS requests (`true` by default) | | `MINIO_INSECURE_SKIP_TLS_VERIFY` | Dev-only escape hatch to skip MinIO TLS certificate verification | @@ -56,6 +58,7 @@ bun run dev ``` - Server boots after ensuring the configured MinIO bucket exists. +- If the backend reaches MinIO on an internal host (for example `minio:9000`) but browsers must upload/download through a different public host, set `MINIO_PUBLIC_ORIGIN` so presigned URLs target the browser-reachable origin instead of the internal one. - If MinIO uses a private or incomplete certificate chain, prefer setting `MINIO_CA_CERT_PATH` to a trusted PEM bundle. Only use `MINIO_INSECURE_SKIP_TLS_VERIFY=true` for local development or temporary debugging. ## Database (Drizzle ORM) diff --git a/Backend/index.ts b/Backend/index.ts index df86930..5262ebc 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -21,7 +21,7 @@ import opsRoutes from './routes/ops'; import { rateLimit } from './middleware/security'; import { requestContext } from './middleware/observability'; import { setupRealtimeGateway } from './realtime/gateway'; -import { ensureMinioBucket } from './utils/minio'; +import { ensureMinioBucket, minioPublicOrigin } from './utils/minio'; import { startRecordingsWorker } from './workers/recordings'; import { startPushWorker } from './services/push'; @@ -35,31 +35,8 @@ const corsMiddleware = cors({ credentials: true, }); -const buildMinioConnectOrigin = (): string | null => { - const endpoint = process.env.MINIO_ENDPOINT?.trim(); - if (!endpoint) { - return null; - } - - if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) { - try { - return new URL(endpoint).origin; - } catch { - return null; - } - } - - const useSSL = (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true'; - const port = Number(process.env.MINIO_PORT ?? (useSSL ? 443 : 80)); - const scheme = useSSL ? 'https' : 'http'; - const includePort = !(useSSL && port === 443) && !(!useSSL && port === 80); - - return `${scheme}://${endpoint}${includePort ? `:${port}` : ''}`; -}; - -const minioConnectOrigin = buildMinioConnectOrigin(); -const connectSrcDirectives = ["'self'", 'cdn.jsdelivr.net', ...(minioConnectOrigin ? [minioConnectOrigin] : [])]; -const mediaSrcDirectives = ["'self'", 'blob:', 'data:', ...(minioConnectOrigin ? [minioConnectOrigin] : [])]; +const connectSrcDirectives = ["'self'", 'cdn.jsdelivr.net', ...(minioPublicOrigin ? [minioPublicOrigin] : [])]; +const mediaSrcDirectives = ["'self'", 'blob:', 'data:', ...(minioPublicOrigin ? [minioPublicOrigin] : [])]; app.get('/', (_req, res) => { res.send('API is running'); diff --git a/Backend/routes/admin.ts b/Backend/routes/admin.ts index 1a6c97c..3f9f7dd 100644 --- a/Backend/routes/admin.ts +++ b/Backend/routes/admin.ts @@ -2,7 +2,13 @@ import type { NextFunction, Request, Response } from 'express'; import { Router } from 'express'; import { z } from 'zod'; -import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; +import { + ensureMinioBucket, + minioBucket, + minioClient, + minioPresignClient, + minioPresignedExpirySeconds, +} from '../utils/minio'; const adminUsername = process.env.ADMIN_USERNAME; const adminPassword = process.env.ADMIN_PASSWORD; @@ -325,7 +331,7 @@ router.post('/upload-url', async (req, res) => { await ensureMinioBucket(); const objectKey = buildObjectKey(parsed.data.fileName, parsed.data.prefix); - const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); + const uploadUrl = await minioPresignClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const expiresAt = new Date(Date.now() + minioPresignedExpirySeconds * 1000); res.status(201).json({ diff --git a/Backend/routes/recordings.ts b/Backend/routes/recordings.ts index 4f15119..ff0d0aa 100644 --- a/Backend/routes/recordings.ts +++ b/Backend/routes/recordings.ts @@ -6,7 +6,13 @@ import { db } from '../db/client'; import { recordings } from '../db/schema'; import { requireDeviceAuth } from '../middleware/device-auth'; import { writeAuditLog } from '../services/audit'; -import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; +import { + ensureMinioBucket, + minioBucket, + minioClient, + minioPresignClient, + minioPresignedExpirySeconds, +} from '../utils/minio'; const router = Router(); @@ -227,7 +233,7 @@ router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => throw error; } - const downloadUrl = await minioClient.presignedGetObject( + const downloadUrl = await minioPresignClient.presignedGetObject( recording.bucket, recording.objectKey, minioPresignedExpirySeconds, diff --git a/Backend/routes/videos.ts b/Backend/routes/videos.ts index 1755d02..662fe12 100644 --- a/Backend/routes/videos.ts +++ b/Backend/routes/videos.ts @@ -9,7 +9,9 @@ import { ensureMinioBucket, minioBucket, minioClient, + minioPresignClient, minioPresignedExpirySeconds, + minioPublicOrigin, } from '../utils/minio'; const router = Router(); @@ -83,7 +85,7 @@ router.post('/upload-url', async (req, res) => { } const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix); - const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); + const uploadUrl = await minioPresignClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const now = new Date(); const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000); @@ -98,6 +100,7 @@ router.post('/upload-url', async (req, res) => { minioEndpoint: process.env.MINIO_ENDPOINT ?? 'localhost', minioPort: Number(process.env.MINIO_PORT ?? 9000), minioUseSSL: (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true', + minioPublicOrigin, }); let persistedRecording; @@ -183,7 +186,7 @@ router.get('/download-url', async (req, res) => { await ensureMinioBucket(); - const downloadUrl = await minioClient.presignedGetObject( + const downloadUrl = await minioPresignClient.presignedGetObject( minioBucket, parsed.data.objectKey, minioPresignedExpirySeconds, diff --git a/Backend/utils/minio.ts b/Backend/utils/minio.ts index b3c0034..83ca721 100644 --- a/Backend/utils/minio.ts +++ b/Backend/utils/minio.ts @@ -2,14 +2,90 @@ import { readFileSync } from 'node:fs'; import { Agent as HttpsAgent } from 'node:https'; import { Client } from 'minio'; -const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost'; -const port = Number(process.env.MINIO_PORT ?? 9000); -const useSSL = (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true'; +type MinioTarget = { + endPoint: string; + port: number; + useSSL: boolean; + origin: string; +}; + +const parseBoolean = (value: string | undefined, fallback: boolean): boolean => { + if (value == null || value.trim() === '') { + return fallback; + } + + return value.toLowerCase() === 'true'; +}; + +const resolveMinioTarget = ({ + origin, + endpoint, + port, + useSSL, +}: { + origin?: string; + endpoint?: string; + port?: string | number; + useSSL: boolean; +}): MinioTarget => { + const rawOrigin = origin?.trim(); + + if (rawOrigin) { + const url = new URL(rawOrigin); + const targetUseSSL = url.protocol === 'https:'; + const targetPort = Number(url.port || (targetUseSSL ? 443 : 80)); + + return { + endPoint: url.hostname, + port: targetPort, + useSSL: targetUseSSL, + origin: url.origin, + }; + } + + const rawEndpoint = endpoint?.trim() || 'localhost'; + + if (rawEndpoint.startsWith('http://') || rawEndpoint.startsWith('https://')) { + const url = new URL(rawEndpoint); + const targetUseSSL = url.protocol === 'https:'; + const targetPort = Number(url.port || (targetUseSSL ? 443 : 80)); + + return { + endPoint: url.hostname, + port: targetPort, + useSSL: targetUseSSL, + origin: url.origin, + }; + } + + const targetPort = Number(port ?? (useSSL ? 443 : 80)); + const includePort = !(useSSL && targetPort === 443) && !(!useSSL && targetPort === 80); + + return { + endPoint: rawEndpoint, + port: targetPort, + useSSL, + origin: `${useSSL ? 'https' : 'http'}://${rawEndpoint}${includePort ? `:${targetPort}` : ''}`, + }; +}; + const accessKey = process.env.MINIO_ACCESS_KEY; const secretKey = process.env.MINIO_SECRET_KEY; -const insecureSkipTlsVerify = (process.env.MINIO_INSECURE_SKIP_TLS_VERIFY ?? 'false').toLowerCase() === 'true'; -const tlsRejectUnauthorized = (process.env.MINIO_TLS_REJECT_UNAUTHORIZED ?? 'true').toLowerCase() !== 'false'; +const insecureSkipTlsVerify = parseBoolean(process.env.MINIO_INSECURE_SKIP_TLS_VERIFY, false); +const tlsRejectUnauthorized = parseBoolean(process.env.MINIO_TLS_REJECT_UNAUTHORIZED, true); const minioCaCertPath = process.env.MINIO_CA_CERT_PATH?.trim(); +const internalUseSSL = parseBoolean(process.env.MINIO_USE_SSL, false); +const internalTarget = resolveMinioTarget({ + endpoint: process.env.MINIO_ENDPOINT ?? 'localhost', + port: process.env.MINIO_PORT ?? 9000, + useSSL: internalUseSSL, +}); +const publicTarget = resolveMinioTarget({ + origin: process.env.MINIO_PUBLIC_ORIGIN, + endpoint: process.env.MINIO_PUBLIC_ENDPOINT ?? internalTarget.endPoint, + port: process.env.MINIO_PUBLIC_PORT ?? internalTarget.port, + useSSL: parseBoolean(process.env.MINIO_PUBLIC_USE_SSL, internalTarget.useSSL), +}); if (!accessKey || !secretKey) { throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY must be set'); @@ -17,8 +93,9 @@ if (!accessKey || !secretKey) { export const minioBucket = process.env.MINIO_BUCKET ?? 'videos'; export const minioPresignedExpirySeconds = Number(process.env.MINIO_PRESIGNED_EXPIRY_SECONDS ?? 60 * 10); +export const minioPublicOrigin = publicTarget.origin; const customCa = minioCaCertPath ? readFileSync(minioCaCertPath) : undefined; -const transportAgent = useSSL +const transportAgent = internalTarget.useSSL ? new HttpsAgent({ keepAlive: true, ca: customCa, @@ -27,14 +104,24 @@ const transportAgent = useSSL : undefined; export const minioClient = new Client({ - endPoint: endpoint, - port, - useSSL, + endPoint: internalTarget.endPoint, + port: internalTarget.port, + useSSL: internalTarget.useSSL, accessKey, secretKey, + region: process.env.MINIO_REGION ?? 'us-east-1', transportAgent, }); +export const minioPresignClient = new Client({ + endPoint: publicTarget.endPoint, + port: publicTarget.port, + useSSL: publicTarget.useSSL, + accessKey, + secretKey, + region: process.env.MINIO_REGION ?? 'us-east-1', +}); + let ensureBucketPromise: Promise | null = null; export const ensureMinioBucket = async (): Promise => {