fix(media): replace hardcoded MinIO creds and raw fetch with S3 SDK

- Remove `minioadmin` fallback credentials — app now throws on missing
  MINIO_ACCESS_KEY / MINIO_SECRET_KEY env vars
- Replace raw fetch() PUT/DELETE with @aws-sdk/client-s3 (PutObject,
  DeleteObject) using AWS Signature V4 auth
- Add OnModuleInit bucket existence check + auto-creation
- Use forcePathStyle for MinIO S3 compatibility

Closes TEC-1452

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 04:02:45 +07:00
parent f55c8a8788
commit fcdb3cac9c
3 changed files with 1236 additions and 32 deletions

View File

@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1026.0",
"@goodgo/mcp-servers": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",

View File

@@ -1,5 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
HeadBucketCommand,
CreateBucketCommand,
} from '@aws-sdk/client-s3';
import * as crypto from 'crypto';
import * as path from 'path';
@@ -10,22 +17,57 @@ export interface IMediaStorageService {
delete(fileUrl: string): Promise<void>;
}
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
@Injectable()
export class MinioMediaStorageService implements IMediaStorageService {
export class MinioMediaStorageService implements IMediaStorageService, OnModuleInit {
private readonly s3: S3Client;
private readonly endpoint: string;
private readonly port: number;
private readonly accessKey: string;
private readonly secretKey: string;
private readonly bucket: string;
private readonly useSSL: boolean;
constructor(private readonly logger: LoggerService) {
const accessKey = requireEnv('MINIO_ACCESS_KEY');
const secretKey = requireEnv('MINIO_SECRET_KEY');
this.endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
this.port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
this.accessKey = process.env['MINIO_ACCESS_KEY'] || 'minioadmin';
this.secretKey = process.env['MINIO_SECRET_KEY'] || 'minioadmin';
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
this.useSSL = process.env['MINIO_USE_SSL'] === 'true';
const protocol = this.useSSL ? 'https' : 'http';
this.s3 = new S3Client({
endpoint: `${protocol}://${this.endpoint}:${this.port}`,
region: 'us-east-1',
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey,
},
forcePathStyle: true,
});
}
async onModuleInit(): Promise<void> {
try {
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
this.logger.log(`Bucket "${this.bucket}" exists`, 'MinioMediaStorageService');
} catch (error: unknown) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
if (statusCode === 404) {
this.logger.log(`Creating bucket "${this.bucket}"...`, 'MinioMediaStorageService');
await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
this.logger.log(`Bucket "${this.bucket}" created`, 'MinioMediaStorageService');
} else {
throw error;
}
}
}
async upload(buffer: Buffer, originalName: string, mimeType: string, folder: string): Promise<string> {
@@ -33,24 +75,18 @@ export class MinioMediaStorageService implements IMediaStorageService {
const hash = crypto.createHash('md5').update(buffer).digest('hex').slice(0, 12);
const objectName = `${folder}/${Date.now()}-${hash}${ext}`;
const protocol = this.useSSL ? 'https' : 'http';
const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectName}`;
try {
// PUT object via MinIO S3-compatible API
const putUrl = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectName}`;
const response = await fetch(putUrl, {
method: 'PUT',
headers: {
'Content-Type': mimeType,
'Content-Length': buffer.length.toString(),
},
body: buffer,
});
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: objectName,
Body: buffer,
ContentType: mimeType,
}),
);
if (!response.ok) {
throw new Error(`MinIO upload failed: ${response.status} ${response.statusText}`);
}
const protocol = this.useSSL ? 'https' : 'http';
const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectName}`;
this.logger.log(`Media uploaded: ${objectName}`, 'MinioMediaStorageService');
return url;
@@ -63,12 +99,16 @@ export class MinioMediaStorageService implements IMediaStorageService {
async delete(fileUrl: string): Promise<void> {
try {
const urlObj = new URL(fileUrl);
const objectPath = urlObj.pathname.replace(`/${this.bucket}/`, '');
const protocol = this.useSSL ? 'https' : 'http';
const deleteUrl = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectPath}`;
const objectKey = urlObj.pathname.replace(`/${this.bucket}/`, '');
await fetch(deleteUrl, { method: 'DELETE' });
this.logger.log(`Media deleted: ${objectPath}`, 'MinioMediaStorageService');
await this.s3.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: objectKey,
}),
);
this.logger.log(`Media deleted: ${objectKey}`, 'MinioMediaStorageService');
} catch (error) {
this.logger.error(`Media delete failed: ${fileUrl}`, String(error), 'MinioMediaStorageService');
throw error;