import * as Minio from 'minio' // MinIO client singletons (lazy-initialized to avoid build-time errors) const globalForMinio = globalThis as unknown as { minio: Minio.Client | undefined minioPublic: 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 createClientFromUrl(endpoint: string): Minio.Client { const url = new URL(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 internal MinIO client (for server-side operations: bucket ops, delete, etc.) */ export function getMinioClient(): Minio.Client { if (!globalForMinio.minio) { globalForMinio.minio = createClientFromUrl(MINIO_ENDPOINT) } return globalForMinio.minio } /** * Get the public MinIO client (for generating presigned URLs). * Uses MINIO_PUBLIC_ENDPOINT so the AWS V4 signature matches the Host * header the browser will send. Without this, presigned URLs generated * against the internal hostname fail with SignatureDoesNotMatch. */ function getPublicMinioClient(): Minio.Client { if (!globalForMinio.minioPublic) { globalForMinio.minioPublic = createClientFromUrl(MINIO_PUBLIC_ENDPOINT) } return globalForMinio.minioPublic } // Backward-compatible export — lazy getter via Proxy (internal client) 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' // ============================================================================= // Helper Functions // ============================================================================= /** * Generate a pre-signed URL for file download or upload. * Uses the public MinIO client so the AWS V4 signature is computed against * the public hostname — the browser's Host header will match the signature. */ export async function getPresignedUrl( bucket: string, objectKey: string, method: 'GET' | 'PUT' = 'GET', expirySeconds: number = 900, // 15 minutes default options?: { downloadFileName?: string } ): Promise { const publicClient = getPublicMinioClient() if (method === 'GET') { const respHeaders = options?.downloadFileName ? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` } : undefined return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders) } else { return publicClient.presignedPutObject(bucket, objectKey, expirySeconds) } } /** * 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}` }