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 { 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 { ApiBody, ApiConsumes, ApiOperation, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { GetPresignedUrlQueryDto } from '../DTO/presigned-url.dto';
|
||||||
import { UploadMultipleFileDto, UploadSingleFileDto } from '../DTO/upload-file.dto';
|
import { UploadMultipleFileDto, UploadSingleFileDto } from '../DTO/upload-file.dto';
|
||||||
import { UploaderService } from '../providers/uploader.service';
|
import { UploaderService } from '../providers/uploader.service';
|
||||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||||
@@ -54,4 +55,16 @@ export class UploaderController {
|
|||||||
uploadFilesAdmin(@UploadedFiles() files: File[]) {
|
uploadFilesAdmin(@UploadedFiles() files: File[]) {
|
||||||
return this.uploaderService.uploadMultiple(files);
|
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 {
|
export interface S3UploadResult {
|
||||||
key: string;
|
key: string;
|
||||||
url: string;
|
|
||||||
bucket: string;
|
bucket: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ export class S3Service {
|
|||||||
private readonly bucketName: string;
|
private readonly bucketName: string;
|
||||||
private readonly region: string;
|
private readonly region: string;
|
||||||
private readonly endpointUrl: string;
|
private readonly endpointUrl: string;
|
||||||
|
private readonly presignedUrlExpiresIn: number;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.bucketName = this.configService.getOrThrow<string>('BUCKET_NAME');
|
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.region = this.configService.getOrThrow<string>('BUCKET_REGION');
|
||||||
|
this.presignedUrlExpiresIn = this.configService.get<number>('S3_PRESIGNED_URL_EXPIRES_IN', 3600);
|
||||||
|
|
||||||
this.s3Client = new S3Client({
|
this.s3Client = new S3Client({
|
||||||
region: this.region,
|
region: this.region,
|
||||||
@@ -63,14 +65,10 @@ export class S3Service {
|
|||||||
|
|
||||||
await this.s3Client.send(command);
|
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}`);
|
this.logger.log(`File uploaded to S3: ${key}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
url,
|
|
||||||
bucket: this.bucketName,
|
bucket: this.bucketName,
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -107,7 +105,7 @@ export class S3Service {
|
|||||||
/**
|
/**
|
||||||
* Generate presigned URL for file download
|
* 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 {
|
try {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: this.bucketName,
|
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
|
* Delete file from S3
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -88,11 +88,27 @@ export class UploaderService {
|
|||||||
uploadedBy: 'admin',
|
uploadedBy: 'admin',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`File uploaded successfully: `);
|
this.logger.log(`File uploaded successfully: ${s3Result.key}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file: s3Result,
|
key: s3Result.key,
|
||||||
url: s3Result.url,
|
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