init
This commit is contained in:
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import type { File } from '@nest-lab/fastify-multer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UploadSingleFileDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary', nullable: false })
|
||||
file: File;
|
||||
}
|
||||
|
||||
export class UploadMultipleFileDto {
|
||||
@ApiProperty({
|
||||
required: true,
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
},
|
||||
})
|
||||
files: File[];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { File } from "@nest-lab/fastify-multer";
|
||||
|
||||
export interface FileUploadResult {
|
||||
file: File;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FileStreamResult {
|
||||
stream: NodeJS.ReadableStream;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface FileMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
bitrate?: number;
|
||||
codec?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export type FileType = "video" | "audio" | "image" | "document" | "text" | "unknown";
|
||||
|
||||
export interface FileValidationOptions {
|
||||
maxFileSize: number;
|
||||
allowedMimeTypes: string[];
|
||||
}
|
||||
|
||||
export interface FileUploadOptions {
|
||||
roomId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// File processing status
|
||||
export type FileProcessingStatus = "pending" | "processing" | "completed" | "failed";
|
||||
|
||||
export interface FileProcessingResult {
|
||||
status: FileProcessingStatus;
|
||||
metadata?: FileMetadata;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface S3UploadResult {
|
||||
key: string;
|
||||
url: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface S3UploadOptions {
|
||||
contentType: string;
|
||||
metadata?: Record<string, string>;
|
||||
acl?: "private" | "public-read" | "public-read-write";
|
||||
}
|
||||
|
||||
export interface S3DownloadOptions {
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}
|
||||
|
||||
export interface S3FileInfo {
|
||||
key: string;
|
||||
bucket: string;
|
||||
size: number;
|
||||
lastModified: Date;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface S3DeleteResult {
|
||||
deleted: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface S3ListResult {
|
||||
files: S3FileInfo[];
|
||||
continuationToken?: string;
|
||||
isTruncated: boolean;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FileType } from '../interfaces/file.interface';
|
||||
import { S3UploadResult } from '../interfaces/s3.interface';
|
||||
|
||||
@Injectable()
|
||||
export class S3Service {
|
||||
private readonly logger = new Logger(S3Service.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly region: string; // Region is often 'us-east-1' or similar for Liara, but depends on your config
|
||||
private readonly endpointUrl: string; // Renamed 'url' to 'endpointUrl' for clarity
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// --- Configuration for Liara S3 ---
|
||||
this.bucketName = this.configService.getOrThrow<string>('BUCKET_NAME');
|
||||
// Liara S3 usually requires a specific endpoint URL
|
||||
this.endpointUrl = this.configService.getOrThrow<string>('LIARA_S3_ENDPOINT');
|
||||
// The region for Liara S3 is often 'us-east-1' or just a placeholder,
|
||||
// but we'll keep it configurable for consistency.
|
||||
this.region = this.configService.getOrThrow<string>('BUCKET_REGION');
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region: this.region, // Use the configured region
|
||||
endpoint: this.endpointUrl, // This is the crucial change for Liara S3
|
||||
forcePathStyle: true, // Often required when using custom S3 endpoints like Liara
|
||||
credentials: {
|
||||
accessKeyId: this.configService.getOrThrow<string>('BUCKET_ACCESS_KEY'),
|
||||
secretAccessKey: this.configService.getOrThrow<string>('BUCKET_SECRET_KEY'),
|
||||
},
|
||||
});
|
||||
// ------------------------------------
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to S3
|
||||
*/
|
||||
async uploadFile(
|
||||
buffer: Buffer,
|
||||
key: string,
|
||||
contentType: string,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<S3UploadResult> {
|
||||
try {
|
||||
const sanitizedMetadata = metadata
|
||||
? Object.entries(metadata).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Encode all values to be ASCII-safe
|
||||
acc[key] = encodeURIComponent(value);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
// NOTE: 'ACL: public-read' might not be supported or necessary for Liara S3,
|
||||
// depending on your bucket configuration on Liara.
|
||||
// If uploads fail, try removing this line.
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
// ACL: 'public-read',
|
||||
ContentType: contentType,
|
||||
Metadata: sanitizedMetadata,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
|
||||
// --- URL for Liara S3 ---
|
||||
// The public URL for Liara S3 files is typically: ${endpoint}/${bucketName}/${key}
|
||||
const url = `${this.endpointUrl}/${this.bucketName}/${key}`;
|
||||
|
||||
this.logger.log(`File uploaded to S3: ${key}`);
|
||||
|
||||
return {
|
||||
key,
|
||||
url,
|
||||
bucket: this.bucketName,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to upload file to S3: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other methods remain the same as they use the S3Client which is now configured for Liara ---
|
||||
|
||||
/**
|
||||
* Get file stream from S3
|
||||
*/
|
||||
|
||||
async getFileStream(key: string): Promise<Readable> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await this.s3Client.send(command);
|
||||
|
||||
// ✅ VALIDATION
|
||||
if (response.Body instanceof Readable) {
|
||||
return response.Body;
|
||||
}
|
||||
|
||||
// If it's not a Readable stream, throw a specific error
|
||||
throw new Error(`File body is not a readable stream for key: ${key}`);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to get file from S3: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate presigned URL for file download
|
||||
*/
|
||||
async getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to generate presigned URL: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from S3
|
||||
*/
|
||||
async deleteFile(key: string): Promise<void> {
|
||||
try {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
this.logger.log(`File deleted from S3: ${key}`);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to delete file from S3: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate S3 key for file
|
||||
*/
|
||||
generateFileKey(originalName: string, fileType: FileType): string {
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = randomInt(1000000, 9999999);
|
||||
const extension = originalName.split('.').pop();
|
||||
|
||||
const basePathMap: Record<FileType, string> = {
|
||||
image: 'images',
|
||||
video: 'videos',
|
||||
audio: 'audio',
|
||||
document: 'documents',
|
||||
text: 'texts',
|
||||
unknown: 'unknowns',
|
||||
};
|
||||
|
||||
const basePath = basePathMap[fileType];
|
||||
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { File } from '@nest-lab/fastify-multer';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { S3Service } from './s3.service';
|
||||
import { UploaderMessage } from '../../../common/enums/message.enum';
|
||||
import { FileType } from '../interfaces/file.interface';
|
||||
|
||||
@Injectable()
|
||||
export class UploaderService {
|
||||
private readonly logger = new Logger(UploaderService.name);
|
||||
private readonly maxFileSize: number;
|
||||
private readonly allowedMimeTypes: string[];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly s3Service: S3Service,
|
||||
) {
|
||||
this.maxFileSize = this.configService.get('MAX_FILE_SIZE', 1024 * 1024 * 100); // 100MB
|
||||
this.allowedMimeTypes = [
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
'video/avi',
|
||||
'video/mov',
|
||||
'video/mkv',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/m4a',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/m4a',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/tiff',
|
||||
'image/bmp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
|
||||
'application/vnd.ms-excel.template.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.template.macroEnabled.12',
|
||||
'application/vnd.ms-excel.addin.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.addin.macroEnabled.12',
|
||||
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.presentation.binary.macroEnabled.12',
|
||||
'application/vnd.ms-excel.template.binary.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.template.binary.macroEnabled.12',
|
||||
'application/vnd.ms-excel.addin.binary.macroEnabled.12',
|
||||
];
|
||||
}
|
||||
|
||||
async uploadMultiple(files: File[]) {
|
||||
return await Promise.all(files.map(file => this.uploadFile(file)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file upload
|
||||
*/
|
||||
async uploadFile(file: File) {
|
||||
if (!file || !file.buffer || !file.size) {
|
||||
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
|
||||
}
|
||||
this.logger.log(`Uploading file: ${file.originalname}`);
|
||||
|
||||
this.validateFile(file);
|
||||
|
||||
const fileType = await this.determineFileType(file.mimetype);
|
||||
|
||||
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType);
|
||||
|
||||
// Upload to S3
|
||||
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
|
||||
originalName: file.originalname,
|
||||
uploadedBy: 'admin',
|
||||
});
|
||||
|
||||
this.logger.log(`File uploaded successfully: `);
|
||||
|
||||
return {
|
||||
file: s3Result,
|
||||
url: s3Result.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate uploaded file
|
||||
*/
|
||||
private validateFile(file: File): void {
|
||||
if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
|
||||
|
||||
if (file.size && file.size > this.maxFileSize)
|
||||
throw new BadRequestException(
|
||||
UploaderMessage.UPLOAD_FILE_TOO_LARGE.replace('[MAX_FILE_SIZE]', this.maxFileSize.toString()),
|
||||
);
|
||||
|
||||
if (!this.allowedMimeTypes.includes(file.mimetype))
|
||||
throw new BadRequestException(
|
||||
UploaderMessage.UPLOAD_FILE_TYPE_NOT_SUPPORTED.replace('[MIME_TYPES]', file.mimetype),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file type based on mimetype
|
||||
*/
|
||||
private async determineFileType(mimetype: string): Promise<FileType> {
|
||||
if (mimetype.startsWith('video/')) {
|
||||
return 'video';
|
||||
} else if (mimetype.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
} else if (mimetype.startsWith('image/')) {
|
||||
return 'image';
|
||||
} else if (mimetype.startsWith('text/')) {
|
||||
return 'text';
|
||||
} else if (mimetype.startsWith('application/')) {
|
||||
return 'document';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type File, FileInterceptor, FilesInterceptor } from '@nest-lab/fastify-multer';
|
||||
import { Controller, Post, UploadedFile, UploadedFiles, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
|
||||
import { UploadMultipleFileDto, UploadSingleFileDto } from './DTO/upload-file.dto';
|
||||
import { UploaderService } from './providers/uploader.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
|
||||
@Controller('uploader')
|
||||
export class UploaderController {
|
||||
constructor(private readonly uploaderService: UploaderService) {}
|
||||
|
||||
@ApiOperation({ summary: 'Upload a file (admin)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiBody({ type: UploadSingleFileDto })
|
||||
@Post('single-file')
|
||||
@UseGuards(AuthGuard)
|
||||
uploadFile(@UploadedFile() file: File) {
|
||||
return this.uploaderService.uploadFile(file);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Uploads multiple files' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FilesInterceptor('files'))
|
||||
@ApiBearerAuth()
|
||||
@ApiBody({ type: UploadMultipleFileDto })
|
||||
@Post('multi-file')
|
||||
@UseGuards(AuthGuard)
|
||||
@UseGuards(AuthGuard)
|
||||
uploadFiles(@UploadedFiles() files: File[]) {
|
||||
return this.uploaderService.uploadMultiple(files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { S3Service } from './providers/s3.service';
|
||||
import { UploaderService } from './providers/uploader.service';
|
||||
import { UploaderController } from './uploader.controller';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [UploaderController],
|
||||
providers: [UploaderService, S3Service],
|
||||
exports: [UploaderService, S3Service],
|
||||
imports: [JwtModule],
|
||||
})
|
||||
export class UploaderModule {}
|
||||
Reference in New Issue
Block a user