This commit is contained in:
2026-07-06 14:17:20 +03:30
parent bfc077df1c
commit 7f9358e637
9 changed files with 756 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
export class UploadFileResponseDto {
@ApiProperty({ example: 'uploads/2026/07/uuid-photo.jpg' })
key: string;
@ApiProperty({ example: 'https://c274938.parspack.net/uploads/2026/07/uuid-photo.jpg' })
url: string;
@ApiProperty({ example: 'photo.jpg' })
originalName: string;
@ApiProperty({ example: 'image/jpeg' })
mimeType: string;
@ApiProperty({ example: 102400 })
size: number;
}
export class MultiUploadResponseDto {
@ApiProperty({ type: [UploadFileResponseDto] })
files: UploadFileResponseDto[];
}
+100
View File
@@ -0,0 +1,100 @@
import {
Controller,
Post,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiTags,
} from '@nestjs/swagger';
import { memoryStorage } from 'multer';
import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PermissionsGuard } from '../auth/guards/permissions.guard';
import { UserRole } from '../users/enums/user-role.enum';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import {
ApiStandardController,
ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator';
import {
MultiUploadResponseDto,
UploadFileResponseDto,
} from './dto/upload-response.dto';
import { UploaderService } from './uploader.service';
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
@ApiTags('Uploader')
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@ApiStandardController()
@Controller('uploader')
@UseGuards(RolesGuard, PermissionsGuard)
export class UploaderController {
constructor(private readonly uploaderService: UploaderService) {}
@Roles(UserRole.ADMIN, UserRole.SALON)
@AllowWithoutSubscription()
@Post('single-upload')
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: {
file: {
type: 'string',
format: 'binary',
},
},
},
})
@ApiStandardOkResponse(UploadFileResponseDto)
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE_BYTES },
}),
)
singleUpload(@UploadedFile() file: Express.Multer.File) {
return this.uploaderService.uploadSingle(file);
}
@Roles(UserRole.ADMIN, UserRole.SALON)
@AllowWithoutSubscription()
@Post('multi-upload')
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['files'],
properties: {
files: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
},
},
},
})
@ApiStandardOkResponse(MultiUploadResponseDto)
@UseInterceptors(
FilesInterceptor('files', 10, {
storage: memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE_BYTES },
}),
)
async multiUpload(@UploadedFiles() files: Express.Multer.File[]) {
const uploadedFiles = await this.uploaderService.uploadMultiple(files);
return { files: uploadedFiles };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthGuardsModule } from '../auth/auth-guards.module';
import { UploaderController } from './uploader.controller';
import { UploaderService } from './uploader.service';
@Module({
imports: [AuthGuardsModule],
controllers: [UploaderController],
providers: [UploaderService],
exports: [UploaderService],
})
export class UploaderModule {}
+131
View File
@@ -0,0 +1,131 @@
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { extname } from 'path';
import { UploadFileResponseDto } from './dto/upload-response.dto';
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/svg+xml',
'application/pdf',
]);
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
@Injectable()
export class UploaderService {
private readonly s3Client: S3Client;
private readonly bucket: string;
private readonly publicBaseUrl: string;
constructor(private readonly configService: ConfigService) {
const endpoint = this.configService.getOrThrow<string>('S3_ENDPOINT');
const accessKeyId = this.configService.getOrThrow<string>('S3_ACCESS_KEY');
const secretAccessKey =
this.configService.getOrThrow<string>('S3_SECRET_KEY');
this.bucket = this.configService.getOrThrow<string>('S3_BUCKET');
this.publicBaseUrl = (
this.configService.get<string>('S3_PUBLIC_BASE_URL') ?? endpoint
).replace(/\/$/, '');
this.s3Client = new S3Client({
endpoint,
region: this.configService.get<string>('S3_REGION') ?? 'us-east-1',
credentials: { accessKeyId, secretAccessKey },
forcePathStyle: true,
});
}
async uploadSingle(file: Express.Multer.File): Promise<UploadFileResponseDto> {
this.validateFile(file);
return this.uploadFile(file);
}
async uploadMultiple(
files: Express.Multer.File[],
): Promise<UploadFileResponseDto[]> {
if (!files?.length) {
throw new BadRequestException('At least one file is required');
}
for (const file of files) {
this.validateFile(file);
}
return Promise.all(files.map((file) => this.uploadFile(file)));
}
private validateFile(file: Express.Multer.File): void {
if (!file) {
throw new BadRequestException('File is required');
}
if (file.size > MAX_FILE_SIZE_BYTES) {
throw new BadRequestException(
`File size must not exceed ${MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB`,
);
}
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
throw new BadRequestException(
`Unsupported file type: ${file.mimetype}. Allowed: ${[...ALLOWED_MIME_TYPES].join(', ')}`,
);
}
}
private async uploadFile(
file: Express.Multer.File,
): Promise<UploadFileResponseDto> {
const key = this.buildObjectKey(file.originalname);
try {
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
ACL: 'public-read',
}),
);
} catch (error) {
throw new InternalServerErrorException(
`Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
return {
key,
url: this.buildPublicUrl(key),
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
};
}
private buildPublicUrl(key: string): string {
return `${this.publicBaseUrl}/${this.bucket}/${key}`;
}
private buildObjectKey(originalName: string): string {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const extension = extname(originalName).toLowerCase();
const safeName = originalName
.replace(extname(originalName), '')
.replace(/[^a-zA-Z0-9._-]/g, '-')
.slice(0, 80);
return `uploads/${year}/${month}/${randomUUID()}-${safeName || 'file'}${extension}`;
}
}