uploader
This commit is contained in:
@@ -16,6 +16,7 @@ import { StylistsModule } from './stylists/stylists.module';
|
||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { PaymentsModule } from './payments/payments.module';
|
||||
import { UploaderModule } from './uploader/uploader.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -37,6 +38,7 @@ import { PaymentsModule } from './payments/payments.module';
|
||||
SentMessagesModule,
|
||||
SubscriptionsModule,
|
||||
PaymentsModule,
|
||||
UploaderModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
||||
@@ -31,5 +31,12 @@ export default () => {
|
||||
'http://localhost:4000/api/payments/callback',
|
||||
ZARINPAL_RETURN_URL:
|
||||
process.env.ZARINPAL_RETURN_URL ?? 'http://localhost:3000/payment/result',
|
||||
S3_ENDPOINT: process.env.S3_ENDPOINT ?? 'https://c274938.parspack.net',
|
||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY ?? '',
|
||||
S3_SECRET_KEY: process.env.S3_SECRET_KEY ?? '',
|
||||
S3_BUCKET: process.env.S3_BUCKET ?? 'c274938',
|
||||
S3_REGION: process.env.S3_REGION ?? 'us-east-1',
|
||||
S3_PUBLIC_BASE_URL:
|
||||
process.env.S3_PUBLIC_BASE_URL ?? process.env.S3_ENDPOINT ?? 'https://c274938.parspack.net',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user