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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user