fix(backend): use public MinIO origin for browser uploads
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
|
||||
export const ensureMinioBucket = async (): Promise<void> => {
|
||||
|
||||
Reference in New Issue
Block a user