fix(backend): use public MinIO origin for browser uploads

This commit is contained in:
2026-04-17 10:45:00 +01:00
parent 14509aa7e4
commit e97a54ac8d
7 changed files with 127 additions and 41 deletions

View File

@@ -7,6 +7,10 @@ DEVICE_ONLINE_STALE_SECONDS=30
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost
MINIO_PORT=9000 MINIO_PORT=9000
MINIO_USE_SSL=false MINIO_USE_SSL=false
MINIO_PUBLIC_ORIGIN=
MINIO_PUBLIC_ENDPOINT=
MINIO_PUBLIC_PORT=
MINIO_PUBLIC_USE_SSL=
MINIO_ACCESS_KEY=minioadmin MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=videos MINIO_BUCKET=videos

View File

@@ -41,6 +41,8 @@ Required env vars:
| `MEDIA_RECORDINGS_DIR` | Local output directory for server-side recording workers (planned in SFU mode) | | `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) | | `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_*` | 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_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_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 | | `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. - 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. - 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) ## Database (Drizzle ORM)

View File

@@ -21,7 +21,7 @@ import opsRoutes from './routes/ops';
import { rateLimit } from './middleware/security'; import { rateLimit } from './middleware/security';
import { requestContext } from './middleware/observability'; import { requestContext } from './middleware/observability';
import { setupRealtimeGateway } from './realtime/gateway'; import { setupRealtimeGateway } from './realtime/gateway';
import { ensureMinioBucket } from './utils/minio'; import { ensureMinioBucket, minioPublicOrigin } from './utils/minio';
import { startRecordingsWorker } from './workers/recordings'; import { startRecordingsWorker } from './workers/recordings';
import { startPushWorker } from './services/push'; import { startPushWorker } from './services/push';
@@ -35,31 +35,8 @@ const corsMiddleware = cors({
credentials: true, credentials: true,
}); });
const buildMinioConnectOrigin = (): string | null => { const connectSrcDirectives = ["'self'", 'cdn.jsdelivr.net', ...(minioPublicOrigin ? [minioPublicOrigin] : [])];
const endpoint = process.env.MINIO_ENDPOINT?.trim(); const mediaSrcDirectives = ["'self'", 'blob:', 'data:', ...(minioPublicOrigin ? [minioPublicOrigin] : [])];
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] : [])];
app.get('/', (_req, res) => { app.get('/', (_req, res) => {
res.send('API is running'); res.send('API is running');

View File

@@ -2,7 +2,13 @@ import type { NextFunction, Request, Response } from 'express';
import { Router } from 'express'; import { Router } from 'express';
import { z } from 'zod'; 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 adminUsername = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
@@ -325,7 +331,7 @@ router.post('/upload-url', async (req, res) => {
await ensureMinioBucket(); await ensureMinioBucket();
const objectKey = buildObjectKey(parsed.data.fileName, parsed.data.prefix); 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); const expiresAt = new Date(Date.now() + minioPresignedExpirySeconds * 1000);
res.status(201).json({ res.status(201).json({

View File

@@ -6,7 +6,13 @@ import { db } from '../db/client';
import { recordings } from '../db/schema'; import { recordings } from '../db/schema';
import { requireDeviceAuth } from '../middleware/device-auth'; import { requireDeviceAuth } from '../middleware/device-auth';
import { writeAuditLog } from '../services/audit'; 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(); const router = Router();
@@ -227,7 +233,7 @@ router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) =>
throw error; throw error;
} }
const downloadUrl = await minioClient.presignedGetObject( const downloadUrl = await minioPresignClient.presignedGetObject(
recording.bucket, recording.bucket,
recording.objectKey, recording.objectKey,
minioPresignedExpirySeconds, minioPresignedExpirySeconds,

View File

@@ -9,7 +9,9 @@ import {
ensureMinioBucket, ensureMinioBucket,
minioBucket, minioBucket,
minioClient, minioClient,
minioPresignClient,
minioPresignedExpirySeconds, minioPresignedExpirySeconds,
minioPublicOrigin,
} from '../utils/minio'; } from '../utils/minio';
const router = Router(); 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 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 now = new Date();
const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000); 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', minioEndpoint: process.env.MINIO_ENDPOINT ?? 'localhost',
minioPort: Number(process.env.MINIO_PORT ?? 9000), minioPort: Number(process.env.MINIO_PORT ?? 9000),
minioUseSSL: (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true', minioUseSSL: (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true',
minioPublicOrigin,
}); });
let persistedRecording; let persistedRecording;
@@ -183,7 +186,7 @@ router.get('/download-url', async (req, res) => {
await ensureMinioBucket(); await ensureMinioBucket();
const downloadUrl = await minioClient.presignedGetObject( const downloadUrl = await minioPresignClient.presignedGetObject(
minioBucket, minioBucket,
parsed.data.objectKey, parsed.data.objectKey,
minioPresignedExpirySeconds, minioPresignedExpirySeconds,

View File

@@ -2,14 +2,90 @@ import { readFileSync } from 'node:fs';
import { Agent as HttpsAgent } from 'node:https'; import { Agent as HttpsAgent } from 'node:https';
import { Client } from 'minio'; import { Client } from 'minio';
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost'; type MinioTarget = {
const port = Number(process.env.MINIO_PORT ?? 9000); endPoint: string;
const useSSL = (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true'; 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 accessKey = process.env.MINIO_ACCESS_KEY;
const secretKey = process.env.MINIO_SECRET_KEY; const secretKey = process.env.MINIO_SECRET_KEY;
const insecureSkipTlsVerify = (process.env.MINIO_INSECURE_SKIP_TLS_VERIFY ?? 'false').toLowerCase() === 'true'; const insecureSkipTlsVerify = parseBoolean(process.env.MINIO_INSECURE_SKIP_TLS_VERIFY, false);
const tlsRejectUnauthorized = (process.env.MINIO_TLS_REJECT_UNAUTHORIZED ?? 'true').toLowerCase() !== 'false'; const tlsRejectUnauthorized = parseBoolean(process.env.MINIO_TLS_REJECT_UNAUTHORIZED, true);
const minioCaCertPath = process.env.MINIO_CA_CERT_PATH?.trim(); 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) { if (!accessKey || !secretKey) {
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY must be set'); 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 minioBucket = process.env.MINIO_BUCKET ?? 'videos';
export const minioPresignedExpirySeconds = Number(process.env.MINIO_PRESIGNED_EXPIRY_SECONDS ?? 60 * 10); export const minioPresignedExpirySeconds = Number(process.env.MINIO_PRESIGNED_EXPIRY_SECONDS ?? 60 * 10);
export const minioPublicOrigin = publicTarget.origin;
const customCa = minioCaCertPath ? readFileSync(minioCaCertPath) : undefined; const customCa = minioCaCertPath ? readFileSync(minioCaCertPath) : undefined;
const transportAgent = useSSL const transportAgent = internalTarget.useSSL
? new HttpsAgent({ ? new HttpsAgent({
keepAlive: true, keepAlive: true,
ca: customCa, ca: customCa,
@@ -27,14 +104,24 @@ const transportAgent = useSSL
: undefined; : undefined;
export const minioClient = new Client({ export const minioClient = new Client({
endPoint: endpoint, endPoint: internalTarget.endPoint,
port, port: internalTarget.port,
useSSL, useSSL: internalTarget.useSSL,
accessKey, accessKey,
secretKey, secretKey,
region: process.env.MINIO_REGION ?? 'us-east-1',
transportAgent, 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<void> | null = null; let ensureBucketPromise: Promise<void> | null = null;
export const ensureMinioBucket = async (): Promise<void> => { export const ensureMinioBucket = async (): Promise<void> => {