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'; /** * Minimal object-storage client for the passenger API. * * A deliberate subset of the freight MinioService — only what chat attachments * need (put, sign, stream, key-from-url). Freight's extra surface (delete, * public URLs for unauthenticated links) is omitted rather than copied * speculatively. */ @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, ) { this.bucket = config.bucket; this.client = new Client({ endPoint: config.endPoint, port: config.port, useSSL: config.useSSL, accessKey: config.accessKey, secretKey: config.secretKey, region: config.region, }); } async uploadFile(objectName: string, buffer: Buffer, contentType: string): Promise { await this.client.putObject(this.bucket, objectName, buffer, buffer.length, { 'Content-Type': contentType, }); return this.getObjectUrl(objectName); } /** Unsigned object URL — what gets persisted. Not browser-fetchable. */ getObjectUrl(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); // pathname percent-encodes the key (a space becomes "%20") but MinIO stores // the literal characters, so decode each segment or a file whose name had // spaces 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 getFileStream(objectName: string): Promise { return this.client.getObject(this.bucket, objectName); } /** * Short-lived signed URL for inline preview. * * Unlike the freight twin this does NOT degrade to an unsigned public URL when * signing fails: a chat attachment is another passenger's file, and quietly * handing back a URL that only works if the bucket is world-readable trades a * visible error for a silent access-control surprise. Fail loudly instead. */ async getSignedUrl(objectName: string, expirySeconds: number): Promise { try { return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds); } catch (error) { this.logger.error(`Failed to sign URL for ${objectName}: ${(error as Error).message}`); throw error; } } }