feat: setup the attachment to the passenger api

This commit is contained in:
Nathnael
2026-07-18 09:44:14 +00:00
parent 4f043ea4bf
commit bdcd5e957e
12 changed files with 781 additions and 116 deletions

View File

@@ -0,0 +1,20 @@
import { registerAs } from '@nestjs/config';
/**
* Mirrors the freight API's MinIO config so both apps read the same env vars and
* behave the same against the same object store. Kept as a copy rather than a
* shared package because the two APIs share no runtime code today, and a config
* package for six fields would be more coupling than it saves.
*/
export const minioConfig = registerAs('minio', () => ({
endPoint: process.env.MINIO_ENDPOINT || 'minio-dev.smart.aaca.gov.et',
port: parseInt(process.env.MINIO_PORT || '443', 10),
useSSL: process.env.MINIO_USE_SSL !== 'false',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || '',
bucket: process.env.MINIO_BUCKET || 'edr-dev',
// Preset the region so presignedGetObject signs URLs locally. Without it the
// minio client fires a live GetBucketLocation request on every sign, which
// blocks (no timeout) when MinIO is slow and would hang every thread load.
region: process.env.MINIO_REGION || 'us-east-1',
}));

View File

@@ -0,0 +1,90 @@
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<typeof minioConfig>,
) {
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<string> {
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<Readable> {
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<string> {
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;
}
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { minioConfig } from './minio.config';
import { MinioService } from './minio.service';
@Module({
imports: [ConfigModule.forFeature(minioConfig)],
providers: [MinioService],
exports: [MinioService],
})
export class StorageModule {}