File Uploads
File upload system with local storage support and future extensibility for cloud storage (S3, Azure, etc.).
Overview
- Local file storage implementation
- Upload tracking in database
- Multiple upload types (avatars, service banners, documents, chat files)
- Tag-based organization
- Automatic file serving
- Future cloud storage support
Storage Interface
export abstract class AbstractStorage {
abstract upload(
file: File,
objectType: UploadObjectType,
objectId: string,
tag: UploadTag,
order: number
): Promise<{ id: string; path: string }>;
abstract delete(uploadId: string): Promise<void>;
abstract getUrl(path: string): string;
}
Upload Types
export enum UploadObjectType {
USER = 'user',
SERVICE = 'service',
ORDER = 'order',
IDENTITY = 'identity',
CHAT = 'chat'
}
export enum UploadTag {
AVATAR = 'avatar',
SERVICE_BANNER = 'service_banner',
SERVICE_GALLERY = 'service_gallery',
ID_DOCUMENT = 'id_document',
BUSINESS_DOCUMENT = 'business_document',
ORDER_DELIVERY = 'order_delivery',
CHAT_ATTACHMENT = 'chat_attachment'
}
Local Storage Implementation
export class LocalStorage extends AbstractStorage {
private basePath: string = './storage/uploads';
async upload(
file: File,
objectType: UploadObjectType,
objectId: string,
tag: UploadTag,
order: number = 0
): Promise<{ id: string; path: string }> {
// Generate unique filename
const ext = path.extname(file.filename);
const filename = `${generateId()}${ext}`;
// Organize by type and object
const relativePath = `${objectType}/${objectId}/${tag}/${filename}`;
const fullPath = path.join(this.basePath, relativePath);
// Create directory if not exists
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
// Save file
await fs.promises.writeFile(fullPath, file.buffer);
// Create upload record
const upload = await this.uploadRepository.create({
id: generateId(),
objectType,
objectId,
tag,
path: relativePath,
filename: file.filename,
mimetype: file.mimetype,
size: file.size,
order
});
return { id: upload.id, path: relativePath };
}
async delete(uploadId: string): Promise<void> {
const upload = await this.uploadRepository.get(uploadId);
const fullPath = path.join(this.basePath, upload.path);
await fs.promises.unlink(fullPath);
await this.uploadRepository.delete(uploadId);
}
getUrl(path: string): string {
return `${process.env.API_URL}/uploads/${path}`;
}
}
Storage Service
Convenience wrapper:
export class StorageService {
constructor(
private storage: AbstractStorage,
private uploadRepository: UploadRepository
) {}
async upload(
file: File,
objectType: UploadObjectType,
objectId: string,
tag: UploadTag,
order: number = 0
) {
return this.storage.upload(file, objectType, objectId, tag, order);
}
async attach(
uploadId: string,
objectType: UploadObjectType,
objectId: string,
tag: UploadTag,
order: number = 0
) {
// Move uploaded file to proper location
const upload = await this.uploadRepository.get(uploadId);
await this.uploadRepository.update(uploadId, {
objectType,
objectId,
tag,
order
});
return upload;
}
async attachOrReplace(
uploadId: string,
objectType: UploadObjectType,
objectId: string,
tag: UploadTag
) {
// Delete existing upload with same tag
const existing = await this.uploadRepository.findOne({
objectType,
objectId,
tag
});
if (existing) {
await this.storage.delete(existing.id);
}
// Attach new upload
return this.attach(uploadId, objectType, objectId, tag, 0);
}
async getUrl(uploadId: string): Promise<string> {
const upload = await this.uploadRepository.get(uploadId);
return this.storage.getUrl(upload.path);
}
}
Upload Flow
1. Upload File
POST /uploads
Authorization: Bearer <access_token>
Content-Type: multipart/form-data
file: [binary data]
Response:
2. Attach to Object
When creating/updating an entity:
POST /users/me
Authorization: Bearer <access_token>
Content-Type: application/json
{
"fullName": "John Doe",
"avatar": "upload-123"
}
The service will call:
await storageService.attachOrReplace(
data.avatar,
UploadObjectType.USER,
user.id,
UploadTag.AVATAR
);
3. Serve Files
Static file serving configured in NestJS:
File Validation
export class FileUploadValidator {
static validate(file: File, options: {
maxSize?: number;
allowedMimeTypes?: string[];
}) {
// Check size
if (options.maxSize && file.size > options.maxSize) {
throw new ValidationError({
file: [`File too large. Maximum ${options.maxSize} bytes`]
});
}
// Check mimetype
if (options.allowedMimeTypes &&
!options.allowedMimeTypes.includes(file.mimetype)) {
throw new ValidationError({
file: [`Invalid file type. Allowed: ${options.allowedMimeTypes.join(', ')}`]
});
}
}
}
Upload Constraints
- Avatars: Max 5MB, images only (jpg, png)
- Service banners: Max 10MB, images only
- ID documents: Max 10MB, images/PDFs
- Chat attachments: Max 10MB, all types
Best Practices
✅ Do
- Validate file size and type
- Generate unique filenames
- Organize files by type and object
- Track uploads in database
- Serve files through web server
- Implement file cleanup for orphaned uploads
❌ Don't
- Don't trust user-provided filenames
- Don't skip virus scanning (in production)
- Don't expose full file paths
- Don't allow unlimited upload sizes
- Don't store sensitive files without encryption
Cloud Storage Migration
To migrate to S3/Azure/Google Cloud:
export class S3Storage extends AbstractStorage {
private s3Client: S3Client;
async upload(...) {
// Upload to S3
await this.s3Client.putObject({
Bucket: process.env.S3_BUCKET,
Key: relativePath,
Body: file.buffer,
ContentType: file.mimetype
});
// Save upload record
return { id: upload.id, path: relativePath };
}
getUrl(path: string): string {
// Return S3 URL or CloudFront URL
return `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${path}`;
}
}
Update dependency injection:
providers: [
{
provide: AbstractStorage,
useClass: process.env.STORAGE_TYPE === 's3' ? S3Storage : LocalStorage
}
]
Summary
The file upload system provides: - Local storage implementation - Upload tracking in database - Type-based organization - Static file serving - Easy cloud migration path - File validation built-in