Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ JWT_EXPIRES_IN=7d

# Development Settings
NODE_ENV=development

# File Upload Configuration
FILE_UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ JWT_EXPIRES_IN=7d

# Development Settings
NODE_ENV=development

# File Upload Configuration
FILE_UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^8.1.0",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"knex": "^3.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
Expand All @@ -44,6 +44,7 @@
"@nestjs/testing": "^10.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/multer": "^2.1.0",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.0",
Expand Down
5 changes: 4 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ import { MonitoringModule } from './modules/monitoring/monitoring.module';
import { DatabaseModule } from './modules/database/database.module';
import { LoggingModule } from './modules/logging/logging.module';
import { AuthModule } from './modules/auth/auth.module';
import { FileUploadModule } from './modules/file-upload/file-upload.module';
import { ConditionalAuthGuard } from './modules/auth/guards/conditional-auth.guard';
import { SwaggerModule } from './modules/swagger/swagger.module';
import { authConfig } from './config/auth.config';
import { fileUploadConfig } from './config/file-upload.config';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [authConfig],
load: [authConfig, fileUploadConfig],
}),
LoggingModule,
DatabaseModule,
MonitoringModule,
AuthModule,
FileUploadModule,
SwaggerModule,
],
providers: [
Expand Down
21 changes: 21 additions & 0 deletions backend/src/config/file-upload.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { registerAs } from '@nestjs/config';
import { join } from 'path';

export const fileUploadConfig = registerAs('fileUpload', () => ({
uploadDir: process.env.FILE_UPLOAD_DIR || join(process.cwd(), 'uploads'),
maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 10 * 1024 * 1024,
allowedMimeTypes: process.env.ALLOWED_MIME_TYPES
? process.env.ALLOWED_MIME_TYPES.split(',')
: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
}));
19 changes: 19 additions & 0 deletions backend/src/migrations/20240320000000_create_files_table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Knex } from 'knex';

exports.up = async function(knex: Knex): Promise<void> {
await knex.schema.createTable('files', (table) => {
table.string('id').primary();
table.string('user_id').notNullable().index();
table.string('original_name').notNullable();
table.string('file_name').notNullable();
table.string('file_path').notNullable();
table.bigInteger('file_size').notNullable();
table.string('mime_type').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
};

exports.down = async function(knex: Knex): Promise<void> {
await knex.schema.dropTable('files');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
Controller,
Post,
Get,
Delete,
Param,
UseInterceptors,
UploadedFile,
Request,
Res,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { FileUploadService } from '../services/file-upload.service';
import { FileResponseDto } from '../dto/file-response.dto';

const DEFAULT_USER_ID = 'anonymous-user-id';

@Controller('api/files')
export class FileUploadController {
constructor(
private readonly fileUploadService: FileUploadService,
private readonly configService: ConfigService,
) {}

private getUserId(req: any): string {
const authEnabled = this.configService.get<boolean>('auth.enabled');
const userId = req.user?.id;

if (authEnabled && !userId) {
throw new BadRequestException('User not authenticated');
}

return userId || DEFAULT_USER_ID;
}

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@UploadedFile() file: Express.Multer.File,
@Request() req: any,
): Promise<FileResponseDto> {
if (!file) {
throw new BadRequestException('No file uploaded');
}

const userId = this.getUserId(req);

return this.fileUploadService.uploadFile(
userId,
file.originalname,
file.mimetype,
file.buffer,
);
}

@Get()
async getFiles(@Request() req: any): Promise<FileResponseDto[]> {
const userId = this.getUserId(req);
return this.fileUploadService.getFilesByUser(userId);
}

@Get(':id')
async downloadFile(
@Param('id') id: string,
@Request() req: any,
@Res() res: Response,
): Promise<void> {
const userId = this.getUserId(req);
const { file, stream } = await this.fileUploadService.getFileById(id, userId);

res.set({
'Content-Type': file.mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(file.originalName)}"`,
'Content-Length': file.fileSize,
});

stream.pipe(res);
}

@Delete(':id')
async deleteFile(
@Param('id') id: string,
@Request() req: any,
): Promise<{ message: string }> {
const userId = this.getUserId(req);
await this.fileUploadService.deleteFile(id, userId);
return { message: 'File deleted successfully' };
}
}
10 changes: 10 additions & 0 deletions backend/src/modules/file-upload/dto/file-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export class FileResponseDto {
id: string;
userId: string;
originalName: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: Date;
updatedAt: Date;
}
11 changes: 11 additions & 0 deletions backend/src/modules/file-upload/entities/file.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class File {
id: string;
userId: string;
originalName: string;
fileName: string;
filePath: string;
fileSize: number;
mimeType: string;
createdAt: Date;
updatedAt: Date;
}
21 changes: 21 additions & 0 deletions backend/src/modules/file-upload/file-upload.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MulterModule } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { FileUploadController } from './controllers/file-upload.controller';
import { FileUploadService } from './services/file-upload.service';
import { FileRepository } from './repositories/file.repository';
import { fileUploadConfig } from '../../config/file-upload.config';

@Module({
imports: [
ConfigModule.forFeature(fileUploadConfig),
MulterModule.register({
storage: memoryStorage(),
}),
],
controllers: [FileUploadController],
providers: [FileUploadService, FileRepository],
exports: [FileUploadService],
})
export class FileUploadModule {}
72 changes: 72 additions & 0 deletions backend/src/modules/file-upload/repositories/file.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { ulid } from 'ulid';
import { DatabaseService } from '../../database/database.service';
import { File } from '../entities/file.entity';

@Injectable()
export class FileRepository {
constructor(private readonly databaseService: DatabaseService) {}

private get knex() {
return this.databaseService.knex;
}

async create(fileData: Omit<File, 'id' | 'createdAt' | 'updatedAt'>): Promise<File> {
const id = ulid();
const now = new Date();

const [createdFile] = await this.knex('files')
.insert({
id,
user_id: fileData.userId,
original_name: fileData.originalName,
file_name: fileData.fileName,
file_path: fileData.filePath,
file_size: fileData.fileSize,
mime_type: fileData.mimeType,
created_at: now,
updated_at: now,
})
.returning('*');

return this.mapToEntity(createdFile);
}

async findById(id: string): Promise<File | null> {
const file = await this.knex('files')
.where('id', id)
.first();

return file ? this.mapToEntity(file) : null;
}

async findByUserId(userId: string): Promise<File[]> {
const files = await this.knex('files')
.where('user_id', userId)
.orderBy('created_at', 'desc');

return files.map(this.mapToEntity);
}

async delete(id: string): Promise<boolean> {
const deletedCount = await this.knex('files')
.where('id', id)
.delete();

return deletedCount > 0;
}

private mapToEntity(row: any): File {
return {
id: row.id,
userId: row.user_id,
originalName: row.original_name,
fileName: row.file_name,
filePath: row.file_path,
fileSize: Number(row.file_size),
mimeType: row.mime_type,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}
Loading