This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class GetPresignedUrlQueryDto {
|
||||
@ApiProperty({
|
||||
description: 'S3 object key (e.g. images/123.jpg) or a legacy direct bucket URL',
|
||||
example: 'images/1783695302514-7430487.jpg',
|
||||
})
|
||||
@IsString()
|
||||
key: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'URL lifetime in seconds (default from S3_PRESIGNED_URL_EXPIRES_IN, usually 3600)',
|
||||
minimum: 60,
|
||||
maximum: 604800,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(60)
|
||||
@Max(604800)
|
||||
expiresIn?: number;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type File, FileInterceptor, FilesInterceptor } from '@nest-lab/fastify-multer';
|
||||
import { Controller, Post, UploadedFile, UploadedFiles, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Query, UploadedFile, UploadedFiles, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { GetPresignedUrlQueryDto } from '../DTO/presigned-url.dto';
|
||||
import { UploadMultipleFileDto, UploadSingleFileDto } from '../DTO/upload-file.dto';
|
||||
import { UploaderService } from '../providers/uploader.service';
|
||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||
@@ -54,4 +55,16 @@ export class UploaderController {
|
||||
uploadFilesAdmin(@UploadedFiles() files: File[]) {
|
||||
return this.uploaderService.uploadMultiple(files);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get a presigned download URL for an uploaded file' })
|
||||
@Get('public/presigned-url')
|
||||
getPresignedUrl(@Query() query: GetPresignedUrlQueryDto) {
|
||||
return this.uploaderService.getPresignedUrl(query.key, query.expiresIn);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get a presigned download URL for an uploaded file (admin)' })
|
||||
@Get('admin/presigned-url')
|
||||
getPresignedUrlAdmin(@Query() query: GetPresignedUrlQueryDto) {
|
||||
return this.uploaderService.getPresignedUrl(query.key, query.expiresIn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export interface S3UploadResult {
|
||||
key: string;
|
||||
url: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ export class S3Service {
|
||||
private readonly bucketName: string;
|
||||
private readonly region: string;
|
||||
private readonly endpointUrl: string;
|
||||
private readonly presignedUrlExpiresIn: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.bucketName = this.configService.getOrThrow<string>('BUCKET_NAME');
|
||||
this.endpointUrl = this.configService.getOrThrow<string>('PARSPACK_S3_ENDPOINT');
|
||||
this.endpointUrl = this.configService.getOrThrow<string>('PARSPACK_S3_ENDPOINT').replace(/\/$/, '');
|
||||
this.region = this.configService.getOrThrow<string>('BUCKET_REGION');
|
||||
this.presignedUrlExpiresIn = this.configService.get<number>('S3_PRESIGNED_URL_EXPIRES_IN', 3600);
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region: this.region,
|
||||
@@ -63,14 +65,10 @@ export class S3Service {
|
||||
|
||||
await this.s3Client.send(command);
|
||||
|
||||
const encodedKey = key.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
const url = `${this.endpointUrl}/${this.bucketName}/${encodedKey}`;
|
||||
|
||||
this.logger.log(`File uploaded to S3: ${key}`);
|
||||
|
||||
return {
|
||||
key,
|
||||
url,
|
||||
bucket: this.bucketName,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
@@ -107,7 +105,7 @@ export class S3Service {
|
||||
/**
|
||||
* Generate presigned URL for file download
|
||||
*/
|
||||
async getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
|
||||
async getPresignedUrl(key: string, expiresIn: number = this.presignedUrlExpiresIn): Promise<string> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
@@ -121,6 +119,41 @@ export class S3Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored object key (or legacy direct URL) to a presigned download URL.
|
||||
*/
|
||||
async resolvePresignedUrl(keyOrUrl: string, expiresIn?: number): Promise<string> {
|
||||
const key = this.extractKeyFromUrl(keyOrUrl);
|
||||
if (!key) {
|
||||
return keyOrUrl;
|
||||
}
|
||||
|
||||
return this.getPresignedUrl(key, expiresIn);
|
||||
}
|
||||
|
||||
extractKeyFromUrl(keyOrUrl: string): string | null {
|
||||
if (!keyOrUrl.startsWith('http://') && !keyOrUrl.startsWith('https://')) {
|
||||
return keyOrUrl;
|
||||
}
|
||||
|
||||
const bucketPrefix = `${this.endpointUrl}/${this.bucketName}/`;
|
||||
if (keyOrUrl.startsWith(bucketPrefix)) {
|
||||
return decodeURIComponent(keyOrUrl.slice(bucketPrefix.length).split('?')[0]);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(keyOrUrl);
|
||||
const pathPrefix = `/${this.bucketName}/`;
|
||||
if (parsedUrl.pathname.startsWith(pathPrefix)) {
|
||||
return decodeURIComponent(parsedUrl.pathname.slice(pathPrefix.length));
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from S3
|
||||
*/
|
||||
|
||||
@@ -88,11 +88,27 @@ export class UploaderService {
|
||||
uploadedBy: 'admin',
|
||||
});
|
||||
|
||||
this.logger.log(`File uploaded successfully: `);
|
||||
this.logger.log(`File uploaded successfully: ${s3Result.key}`);
|
||||
|
||||
return {
|
||||
file: s3Result,
|
||||
url: s3Result.url,
|
||||
key: s3Result.key,
|
||||
bucket: s3Result.bucket,
|
||||
};
|
||||
}
|
||||
|
||||
async getPresignedUrl(keyOrUrl: string, expiresIn?: number) {
|
||||
const key = this.s3Service.extractKeyFromUrl(keyOrUrl);
|
||||
if (!key) {
|
||||
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
|
||||
}
|
||||
|
||||
const resolvedExpiresIn = expiresIn ?? this.configService.get<number>('S3_PRESIGNED_URL_EXPIRES_IN', 3600);
|
||||
const url = await this.s3Service.getPresignedUrl(key, resolvedExpiresIn);
|
||||
|
||||
return {
|
||||
key,
|
||||
url,
|
||||
expiresIn: resolvedExpiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user