diff --git a/src/modules/uploader/DTO/presigned-url.dto.ts b/src/modules/uploader/DTO/presigned-url.dto.ts new file mode 100644 index 0000000..7d0e908 --- /dev/null +++ b/src/modules/uploader/DTO/presigned-url.dto.ts @@ -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; +} diff --git a/src/modules/uploader/controllers/uploader.controller.ts b/src/modules/uploader/controllers/uploader.controller.ts index 87cddcd..cf997b9 100644 --- a/src/modules/uploader/controllers/uploader.controller.ts +++ b/src/modules/uploader/controllers/uploader.controller.ts @@ -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); + } } diff --git a/src/modules/uploader/interfaces/s3.interface.ts b/src/modules/uploader/interfaces/s3.interface.ts index 2791d05..c970dc0 100644 --- a/src/modules/uploader/interfaces/s3.interface.ts +++ b/src/modules/uploader/interfaces/s3.interface.ts @@ -1,6 +1,5 @@ export interface S3UploadResult { key: string; - url: string; bucket: string; } diff --git a/src/modules/uploader/providers/s3.service.ts b/src/modules/uploader/providers/s3.service.ts index 9bedb3b..0c67aa7 100644 --- a/src/modules/uploader/providers/s3.service.ts +++ b/src/modules/uploader/providers/s3.service.ts @@ -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('BUCKET_NAME'); - this.endpointUrl = this.configService.getOrThrow('PARSPACK_S3_ENDPOINT'); + this.endpointUrl = this.configService.getOrThrow('PARSPACK_S3_ENDPOINT').replace(/\/$/, ''); this.region = this.configService.getOrThrow('BUCKET_REGION'); + this.presignedUrlExpiresIn = this.configService.get('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 { + async getPresignedUrl(key: string, expiresIn: number = this.presignedUrlExpiresIn): Promise { 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 { + 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 */ diff --git a/src/modules/uploader/providers/uploader.service.ts b/src/modules/uploader/providers/uploader.service.ts index 9bda649..dd34d86 100644 --- a/src/modules/uploader/providers/uploader.service.ts +++ b/src/modules/uploader/providers/uploader.service.ts @@ -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('S3_PRESIGNED_URL_EXPIRES_IN', 3600); + const url = await this.s3Service.getPresignedUrl(key, resolvedExpiresIn); + + return { + key, + url, + expiresIn: resolvedExpiresIn, }; }