import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { Client } from "minio"; import { Readable } from "stream"; import { minioConfig } from "./minio.config"; @Injectable() export class MinioService { private readonly client: Client; private readonly logger = new Logger(MinioService.name); private readonly bucket: string; constructor( @Inject(minioConfig.KEY) private readonly config: ConfigType, ) { console.log('[MinioService] Configuration loaded:', { endPoint: config.endPoint, port: config.port, useSSL: config.useSSL, accessKey: config.accessKey, secretKey: config.secretKey ? '***HIDDEN***' : 'EMPTY', bucket: config.bucket, }); this.bucket = config.bucket; this.client = new Client({ endPoint: config.endPoint, port: config.port, useSSL: config.useSSL, accessKey: config.accessKey, secretKey: config.secretKey, // Presetting the region keeps presignedGetObject fully local — no live // GetBucketLocation round-trip to the endpoint on each signed URL. region: config.region, }); } async uploadFile( objectName: string, buffer: Buffer, contentType: string, ): Promise { try { await this.client.putObject(this.bucket, objectName, buffer, buffer.length, { "Content-Type": contentType, }); this.logger.log(`File uploaded successfully: ${objectName}`); return this.getPublicUrl(objectName); } catch (error) { this.logger.error(`Failed to upload file ${objectName}:`, error); throw error; } } getPublicUrl(objectName: string): string { const protocol = this.config.useSSL ? "https" : "http"; return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; } getObjectNameFromUrl(value: string): string { const trimmed = value.trim(); if (!trimmed) { throw new NotFoundException("File object path is empty"); } if (!/^https?:\/\//i.test(trimmed)) { return trimmed.replace(/^\/+/, ""); } const url = new URL(trimmed); // `url.pathname` percent-encodes the object key (e.g. a space becomes // "%20"), but MinIO stores the key with its literal characters. Decode each // segment so the recovered key matches what was uploaded — otherwise a file // whose name had spaces/unicode 404s with "specified key does not exist". const parts = url.pathname .split("/") .filter(Boolean) .map((segment) => decodeURIComponent(segment)); if (parts[0] === this.bucket) { parts.shift(); } const objectName = parts.join("/"); if (!objectName) { throw new NotFoundException("File object path is empty"); } return objectName; } async deleteFile(objectName: string): Promise { try { await this.client.removeObject(this.bucket, objectName); this.logger.log(`File deleted successfully: ${objectName}`); } catch (error) { this.logger.error(`Failed to delete file ${objectName}:`, error); throw error; } } async getFileStream(objectName: string): Promise { try { return this.client.getObject(this.bucket, objectName); } catch (error) { this.logger.error(`Failed to get file ${objectName}:`, error); throw error; } } async getSignedUrl(objectName: string, expirySeconds: number = 300): Promise { try { return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds); } catch (error) { // Signing a file URL must never break a booking/transition response — the // caller only needs SOMETHING to link to. Degrade to the public object URL // and log, rather than throwing (which would 500 an otherwise-good load). this.logger.error(`Failed to generate signed URL for ${objectName}:`, error); return this.getPublicUrl(objectName); } } }