2026-02-14 15:26:42 +01:00
|
|
|
import * as Minio from 'minio'
|
|
|
|
|
|
2026-03-05 13:06:17 +01:00
|
|
|
// MinIO client singletons (lazy-initialized to avoid build-time errors)
|
2026-02-14 15:26:42 +01:00
|
|
|
const globalForMinio = globalThis as unknown as {
|
|
|
|
|
minio: Minio.Client | undefined
|
2026-03-05 13:06:17 +01:00
|
|
|
minioPublic: Minio.Client | undefined
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
2026-03-05 13:06:17 +01:00
|
|
|
function createClientFromUrl(endpoint: string): Minio.Client {
|
|
|
|
|
const url = new URL(endpoint)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-05 13:06:17 +01:00
|
|
|
* Get the internal MinIO client (for server-side operations: bucket ops, delete, etc.)
|
2026-02-14 15:26:42 +01:00
|
|
|
*/
|
|
|
|
|
export function getMinioClient(): Minio.Client {
|
|
|
|
|
if (!globalForMinio.minio) {
|
2026-03-05 13:06:17 +01:00
|
|
|
globalForMinio.minio = createClientFromUrl(MINIO_ENDPOINT)
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
return globalForMinio.minio
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 13:06:17 +01:00
|
|
|
/**
|
|
|
|
|
* 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)
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-05 13:06:17 +01:00
|
|
|
* 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.
|
2026-02-14 15:26:42 +01:00
|
|
|
*/
|
|
|
|
|
export async function getPresignedUrl(
|
|
|
|
|
bucket: string,
|
|
|
|
|
objectKey: string,
|
|
|
|
|
method: 'GET' | 'PUT' = 'GET',
|
2026-02-18 14:56:09 +01:00
|
|
|
expirySeconds: number = 900, // 15 minutes default
|
|
|
|
|
options?: { downloadFileName?: string }
|
2026-02-14 15:26:42 +01:00
|
|
|
): Promise<string> {
|
2026-03-05 13:06:17 +01:00
|
|
|
const publicClient = getPublicMinioClient()
|
2026-02-14 15:26:42 +01:00
|
|
|
if (method === 'GET') {
|
2026-02-18 14:56:09 +01:00
|
|
|
const respHeaders = options?.downloadFileName
|
|
|
|
|
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
|
|
|
|
|
: undefined
|
2026-03-05 13:06:17 +01:00
|
|
|
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
|
2026-02-14 15:26:42 +01:00
|
|
|
} else {
|
2026-03-05 13:06:17 +01:00
|
|
|
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if a bucket exists, create if not
|
|
|
|
|
*/
|
|
|
|
|
export async function ensureBucket(bucket: string): Promise<void> {
|
|
|
|
|
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<void> {
|
|
|
|
|
await minio.removeObject(bucket, objectKey)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-16 09:20:02 +01:00
|
|
|
* 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.
|
2026-02-14 15:26:42 +01:00
|
|
|
*/
|
|
|
|
|
export function generateObjectKey(
|
2026-02-16 09:20:02 +01:00
|
|
|
projectName: string,
|
|
|
|
|
fileName: string,
|
|
|
|
|
roundName?: string
|
2026-02-14 15:26:42 +01:00
|
|
|
): string {
|
|
|
|
|
const timestamp = Date.now()
|
2026-02-16 09:20:02 +01:00
|
|
|
const sanitizedProject = sanitizePath(projectName)
|
|
|
|
|
const sanitizedRound = roundName ? sanitizePath(roundName) : 'general'
|
|
|
|
|
const sanitizedFile = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
|
|
|
|
|
return `${sanitizedProject}/${sanitizedRound}/${timestamp}-${sanitizedFile}`
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|