presigned url
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-10 20:08:17 +03:30
parent ea43307983
commit e110825783
5 changed files with 94 additions and 11 deletions
@@ -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;
} }
+39 -6
View File
@@ -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,
}; };
} }