Skip to content

Commit 8ea4e15

Browse files
committed
Fix ExternalEndpoint client connection fix
1 parent 930cca8 commit 8ea4e15

File tree

3 files changed

+135
-47
lines changed

3 files changed

+135
-47
lines changed

src/decorators/file-upload.decorator.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,19 @@ export function FileUpload(fileFields: FileFieldConfig[]) {
99
const multerFields = fileFields.map((field) => ({
1010
name: field.name,
1111
maxCount: field.maxCount || 1,
12+
bucketName: field.bucketName,
1213
}));
1314

1415
// Create decorator that applies both interceptors and stores metadata
1516
return applyDecorators(
16-
// Apply Multer interceptor
17-
UseInterceptors(FileFieldsInterceptor(multerFields), MinioFileInterceptor),
1817
// Set metadata on the method
1918
(target: any, key: string, descriptor: PropertyDescriptor) => {
2019
// Store the file field configurations directly on the method
2120
Reflect.defineMetadata('fileField', fileFields, descriptor.value);
2221
return descriptor;
2322
},
23+
// Apply Multer interceptor
24+
UseInterceptors(FileFieldsInterceptor(multerFields), MinioFileInterceptor),
2425
// Swagger decorators for documentation
2526
ApiConsumes('multipart/form-data'),
2627
);

src/interceptors/file.interceptor.ts

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@ export class MinioFileInterceptor implements NestInterceptor {
2323
// Get file field config directly from the handler
2424
const fileFieldsConfig = Reflect.getMetadata('fileField', handler) || [];
2525

26-
console.log('Files received:', Object.keys(files));
27-
console.log('Handler file fields config:', fileFieldsConfig);
28-
2926
// Initialize a promise for processing files
3027
let fileProcessingPromise = Promise.resolve({});
3128

@@ -36,21 +33,20 @@ export class MinioFileInterceptor implements NestInterceptor {
3633

3734
if (fieldFiles && fieldFiles.length > 0) {
3835
const file = fieldFiles[0];
39-
const bucketName = fieldConfig.bucketName || 'media-files-bucket';
36+
const bucketName = fieldConfig.bucketName;
4037

41-
console.log(`Processing file field: ${fieldName}, bucket: ${bucketName}`);
38+
if (!bucketName) {
39+
throw new Error(`Bucket name is required for file field ${fieldName}`);
40+
}
4241

4342
// Chain promises for sequential processing
4443
fileProcessingPromise = fileProcessingPromise.then(async (processedData) => {
4544
try {
4645
this.validateFile(file, fieldConfig);
4746
const fileUrl = await this.minioService.uploadFile(file, bucketName);
48-
console.log(`File uploaded successfully: ${fileUrl}`);
4947
return { ...processedData, [fieldName]: fileUrl };
5048
} catch (error) {
51-
console.error(`Error uploading file for ${fieldName}:`, error);
5249
if (error instanceof BadRequestException) {
53-
console.log('BadRequestException');
5450
throw error;
5551
}
5652
if (fieldConfig.required) {
@@ -60,7 +56,7 @@ export class MinioFileInterceptor implements NestInterceptor {
6056
}
6157
});
6258
} else if (fieldConfig.required) {
63-
console.warn(`Required file ${fieldName} is missing`);
59+
throw new Error(`Required file ${fieldName} is missing`);
6460
}
6561
}
6662

@@ -118,14 +114,6 @@ export class MinioFileInterceptor implements NestInterceptor {
118114
}
119115

120116
private validateFile(file: any, config: any) {
121-
console.log('Validating file:', {
122-
filename: file.originalname,
123-
mimetype: file.mimetype,
124-
size: file.size,
125-
allowedTypes: config.allowedMimeTypes,
126-
maxSize: config.maxSize,
127-
});
128-
129117
// Get validation metadata from both decorators
130118
const validationConfig = {
131119
allowedMimeTypes: config.allowedMimeTypes || [],
@@ -148,13 +136,6 @@ export class MinioFileInterceptor implements NestInterceptor {
148136
type.toLowerCase(),
149137
);
150138

151-
console.log(
152-
'normalizedAllowedTypes',
153-
normalizedAllowedTypes,
154-
normalizedMimetype,
155-
!normalizedAllowedTypes.includes(normalizedMimetype),
156-
);
157-
158139
if (!normalizedAllowedTypes.includes(normalizedMimetype)) {
159140
throw new BadRequestException(
160141
`File ${file.originalname} has invalid type. Received: ${file.mimetype}, Allowed types: ${validationConfig.allowedMimeTypes.join(

src/minio.service.ts

Lines changed: 127 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
2+
import * as crypto from 'crypto';
23
import * as Minio from 'minio';
34
import { MINIO_CONFIG } from './constants';
45
import { IFileUpload } from './interfaces/file.interface';
@@ -24,6 +25,10 @@ export class MinioService implements OnModuleInit {
2425
await this.initializeBuckets();
2526
}
2627

28+
// =======================================================================
29+
// Bucket Initialization
30+
// =======================================================================
31+
2732
private async initializeBuckets() {
2833
if (this.bucketInitialized) return;
2934

@@ -76,6 +81,125 @@ export class MinioService implements OnModuleInit {
7681
this.bucketInitialized = true;
7782
}
7883

84+
// =======================================================================
85+
// AWS Signature V4 Helper Methods
86+
// =======================================================================
87+
88+
private sha256(data: string): string {
89+
return crypto.createHash('sha256').update(data).digest('hex');
90+
}
91+
92+
private hmacSha256(key: Buffer | string, data: string): Buffer {
93+
return crypto.createHmac('sha256', key).update(data).digest();
94+
}
95+
96+
private getSigningKey(
97+
secretKey: string,
98+
dateStamp: string,
99+
region: string,
100+
serviceName: string = 's3',
101+
): Buffer {
102+
const kSecret = 'AWS4' + secretKey;
103+
const kDate = this.hmacSha256(kSecret, dateStamp);
104+
const kRegion = this.hmacSha256(kDate, region);
105+
const kService = this.hmacSha256(kRegion, serviceName);
106+
const kSigning = this.hmacSha256(kService, 'aws4_request');
107+
return kSigning;
108+
}
109+
110+
// Get date in YYYYMMDDTHHMMSSZ format
111+
private getAmzDate(date: Date): string {
112+
return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
113+
}
114+
115+
// Get date in YYYYMMDD format
116+
private getDateStamp(date: Date): string {
117+
return date.toISOString().slice(0, 10).replace(/-/g, '');
118+
}
119+
120+
// =======================================================================
121+
// Manual Presigned URL Calculation
122+
// =======================================================================
123+
124+
private async calculatePresignedGetUrl(
125+
endPoint: string,
126+
bucketName: string,
127+
objectName: string,
128+
expirySeconds: number,
129+
): Promise<string> {
130+
const accessKey = this.config.accessKey;
131+
const secretKey = this.config.secretKey;
132+
const region = this.config.region || 'us-east-1';
133+
const externalHost = endPoint;
134+
const port = this.config.port;
135+
136+
// Use host without port for signing
137+
const signingHost = port ? `${externalHost}:${port}` : externalHost;
138+
139+
const currentDate = new Date();
140+
const amzDate = this.getAmzDate(currentDate);
141+
const dateStamp = this.getDateStamp(currentDate);
142+
const credentialScope = `${dateStamp}/${region}/s3/aws4_request`;
143+
144+
// Create canonical URI - don't encode forward slashes in object name
145+
const canonicalURI =
146+
'/' +
147+
encodeURIComponent(bucketName) +
148+
'/' +
149+
objectName
150+
.split('/')
151+
.map((segment) => encodeURIComponent(segment))
152+
.join('/');
153+
154+
// Create query parameters in specific order
155+
const params = {
156+
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
157+
'X-Amz-Credential': `${accessKey}/${credentialScope}`,
158+
'X-Amz-Date': amzDate,
159+
'X-Amz-Expires': expirySeconds.toString(),
160+
'X-Amz-SignedHeaders': 'host',
161+
};
162+
163+
// Build canonical query string maintaining strict ordering
164+
const canonicalQueryString = Object.keys(params)
165+
.sort()
166+
.map((key) => {
167+
return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`;
168+
})
169+
.join('&');
170+
171+
// Create canonical headers
172+
const canonicalHeaders = `host:${signingHost.toLowerCase()}\n`;
173+
174+
// Create canonical request
175+
const canonicalRequest = [
176+
'GET',
177+
canonicalURI,
178+
canonicalQueryString,
179+
canonicalHeaders,
180+
'host', // signed headers
181+
'UNSIGNED-PAYLOAD', // Changed from empty string hash
182+
].join('\n');
183+
184+
// Create string to sign
185+
const stringToSign = [
186+
'AWS4-HMAC-SHA256',
187+
amzDate,
188+
credentialScope,
189+
this.sha256(canonicalRequest),
190+
].join('\n');
191+
192+
// Calculate signature
193+
const signingKey = this.getSigningKey(secretKey, dateStamp, region);
194+
const signature = this.hmacSha256(signingKey, stringToSign).toString('hex');
195+
196+
// Construct final URL
197+
const protocol = (this.config.externalUseSSL ?? this.config.useSSL) ? 'https' : 'http';
198+
const finalUrl = `${protocol}://${signingHost}${canonicalURI}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
199+
200+
return finalUrl;
201+
}
202+
79203
async uploadFile(file: IFileUpload, bucketName: string, objectName?: string): Promise<string> {
80204
if (!this.bucketInitialized) {
81205
await this.initializeBuckets();
@@ -98,29 +222,11 @@ export class MinioService implements OnModuleInit {
98222
}/${bucketName}/${objectName}`;
99223
}
100224

101-
// For private buckets, if external endpoint is configured, create a new client with it
102-
if (this.config.externalEndPoint) {
103-
const externalClient = new Minio.Client({
104-
endPoint: this.config.externalEndPoint,
105-
port: this.config.port,
106-
useSSL: this.config.externalUseSSL ?? this.config.useSSL,
107-
accessKey: this.config.accessKey,
108-
secretKey: this.config.secretKey,
109-
region: this.config.region,
110-
});
111-
112-
return await externalClient.presignedGetObject(
113-
bucketName,
114-
objectName,
115-
this.config.urlExpiryHours * 60 * 60,
116-
);
117-
}
118-
119-
// If no external endpoint, use the default client
120-
return await this.minioClient.presignedGetObject(
225+
return await this.calculatePresignedGetUrl(
226+
this.config.externalEndPoint || this.config.endPoint,
121227
bucketName,
122228
objectName,
123-
this.config.urlExpiryHours * 60 * 60,
229+
(this.config.urlExpiryHours || 1) * 60 * 60,
124230
);
125231
}
126232

0 commit comments

Comments
 (0)