import * as Minio from 'minio' // MinIO client singleton (lazy-initialized to avoid build-time errors) const globalForMinio = globalThis as unknown as { minio: Minio.Client | undefined } // Internal endpoint for server-to-server communication const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://localhost:9000' // Public endpoint for browser-accessible URLs (pre-signed URLs) // If not set, falls back to internal endpoint export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_ENDPOINT function createMinioClient(): Minio.Client { const url = new URL(MINIO_ENDPOINT) const accessKey = process.env.MINIO_ACCESS_KEY const secretKey = process.env.MINIO_SECRET_KEY if (process.env.NODE_ENV === 'production' && (!accessKey || !secretKey)) { throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables are required in production') } return new Minio.Client({ endPoint: url.hostname, port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80), useSSL: url.protocol === 'https:', accessKey: accessKey || 'minioadmin', secretKey: secretKey || 'minioadmin', }) } /** * Get the MinIO client instance (lazy-initialized). * The client is only created on first access, not at module import time. * This prevents build-time errors when env vars are not available. */ export function getMinioClient(): Minio.Client { if (!globalForMinio.minio) { globalForMinio.minio = createMinioClient() } return globalForMinio.minio } // Backward-compatible export — lazy getter via Proxy export const minio: Minio.Client = new Proxy({} as Minio.Client, { get(_target, prop, receiver) { return Reflect.get(getMinioClient(), prop, receiver) }, }) // Default bucket name export const BUCKET_NAME = process.env.MINIO_BUCKET || 'mopc-files' /** * Replace internal endpoint with public endpoint in a URL */ function replaceEndpoint(url: string): string { if (MINIO_PUBLIC_ENDPOINT === MINIO_ENDPOINT) { return url } try { const internalUrl = new URL(MINIO_ENDPOINT) const publicUrl = new URL(MINIO_PUBLIC_ENDPOINT) return url.replace( `${internalUrl.protocol}//${internalUrl.host}`, `${publicUrl.protocol}//${publicUrl.host}` ) } catch { return url } } // ============================================================================= // Helper Functions // ============================================================================= /** * Generate a pre-signed URL for file download or upload * Uses MINIO_PUBLIC_ENDPOINT for browser-accessible URLs */ export async function getPresignedUrl( bucket: string, objectKey: string, method: 'GET' | 'PUT' = 'GET', expirySeconds: number = 900 // 15 minutes default ): Promise { let url: string if (method === 'GET') { url = await minio.presignedGetObject(bucket, objectKey, expirySeconds) } else { url = await minio.presignedPutObject(bucket, objectKey, expirySeconds) } // Replace internal endpoint with public endpoint for browser access return replaceEndpoint(url) } /** * Check if a bucket exists, create if not */ export async function ensureBucket(bucket: string): Promise { const exists = await minio.bucketExists(bucket) if (!exists) { await minio.makeBucket(bucket) console.log(`Created MinIO bucket: ${bucket}`) } } /** * Delete an object from MinIO */ export async function deleteObject( bucket: string, objectKey: string ): Promise { await minio.removeObject(bucket, objectKey) } /** * Sanitize a name for use as a MinIO path segment. * Removes special characters, replaces spaces with underscores, limits length. */ function sanitizePath(name: string): string { return ( name .trim() .replace(/[^a-zA-Z0-9\-_ ]/g, '') .replace(/\s+/g, '_') .substring(0, 100) || 'unnamed' ) } /** * Generate a unique object key for a project file. * * Structure: {ProjectName}/{RoundName}/{timestamp}-{fileName} * - projectName: human-readable project title (sanitized) * - roundName: round name for submission context (sanitized), defaults to "general" * - fileName: original file name (sanitized) * * Existing files with old-style keys (projects/{id}/...) are unaffected * because retrieval uses the objectKey stored in the database. */ export function generateObjectKey( projectName: string, fileName: string, roundName?: string ): string { const timestamp = Date.now() const sanitizedProject = sanitizePath(projectName) const sanitizedRound = roundName ? sanitizePath(roundName) : 'general' const sanitizedFile = fileName.replace(/[^a-zA-Z0-9.-]/g, '_') return `${sanitizedProject}/${sanitizedRound}/${timestamp}-${sanitizedFile}` }