13 KiB
13 KiB
| description |
|---|
| How to handle file uploads (videos, attachments) |
File Upload Workflow
Follow these steps to implement file upload functionality for videos and attachments using TypeScript and TSOA.
Prerequisites
- MinIO/S3 configured and running
- Dependencies installed:
npm install multer @aws-sdk/client-s3 @aws-sdk/lib-storage - TypeScript configured
Step 1: Configure S3/MinIO Client
Create src/config/s3.config.ts:
import { S3Client } from '@aws-sdk/client-s3';
export const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!
},
forcePathStyle: true // Required for MinIO
});
Step 2: Create Upload Service
Create src/services/upload.service.ts:
import { s3Client } from '../config/s3.config';
import { Upload } from '@aws-sdk/lib-storage';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
export interface UploadResult {
key: string;
url: string;
fileName: string;
fileSize: number;
mimeType: string;
}
type FolderType = 'videos' | 'documents' | 'images' | 'attachments';
class UploadService {
async uploadFile(file: Express.Multer.File, folder: FolderType): Promise<UploadResult> {
const fileExt = path.extname(file.originalname);
const fileName = `${Date.now()}-${uuidv4()}${fileExt}`;
const key = `${folder}/${fileName}`;
const upload = new Upload({
client: s3Client,
params: {
Bucket: this.getBucket(folder),
Key: key,
Body: file.buffer,
ContentType: file.mimetype
}
});
await upload.done();
return {
key,
url: `${process.env.S3_ENDPOINT}/${this.getBucket(folder)}/${key}`,
fileName: file.originalname,
fileSize: file.size,
mimeType: file.mimetype
};
}
async deleteFile(key: string, folder: FolderType): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.getBucket(folder),
Key: key
});
await s3Client.send(command);
}
private getBucket(folder: FolderType): string {
const bucketMap: Record<FolderType, string> = {
videos: process.env.S3_BUCKET_VIDEOS!,
documents: process.env.S3_BUCKET_DOCUMENTS!,
images: process.env.S3_BUCKET_IMAGES!,
attachments: process.env.S3_BUCKET_ATTACHMENTS!
};
return bucketMap[folder] || process.env.S3_BUCKET_COURSES!;
}
validateFileType(file: Express.Multer.File, allowedTypes: string[]): boolean {
return allowedTypes.includes(file.mimetype);
}
validateFileSize(file: Express.Multer.File, maxSize: number): boolean {
return file.size <= maxSize;
}
}
export const uploadService = new UploadService();
Step 3: Create Upload Middleware
Create src/middleware/upload.middleware.ts:
import multer from 'multer';
import { Request } from 'express';
// File type validators
export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
// File size limits
export const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
export const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
// Multer configuration
const storage = multer.memoryStorage();
const fileFilter = (allowedTypes: string[]) => (
req: Request,
file: Express.Multer.File,
cb: multer.FileFilterCallback
) => {
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
};
// Upload configurations
export const uploadVideo = multer({
storage,
limits: { fileSize: MAX_VIDEO_SIZE },
fileFilter: fileFilter(ALLOWED_VIDEO_TYPES)
}).single('video');
export const uploadAttachment = multer({
storage,
limits: { fileSize: MAX_ATTACHMENT_SIZE },
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
}).single('file');
export const uploadAttachments = multer({
storage,
limits: { fileSize: MAX_ATTACHMENT_SIZE },
fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES])
}).array('attachments', 10); // Max 10 files
export const uploadImage = multer({
storage,
limits: { fileSize: MAX_IMAGE_SIZE },
fileFilter: fileFilter(ALLOWED_IMAGE_TYPES)
}).single('image');
Step 4: Create Upload Controller with TSOA
Create src/controllers/upload.controller.ts:
import { Request } from 'express';
import { Controller, Post, Route, Tags, Security, UploadedFile, SuccessResponse } from 'tsoa';
import { uploadService } from '../services/upload.service';
interface UploadResponse {
video_url?: string;
file_size: number;
duration?: number | null;
file_name?: string;
file_path?: string;
mime_type?: string;
download_url?: string;
}
@Route('api/upload')
@Tags('File Upload')
export class UploadController extends Controller {
/**
* Upload video file
* @summary Upload video for lesson (Instructor/Admin only)
*/
@Post('video')
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
@SuccessResponse(200, 'Success')
public async uploadVideo(
@UploadedFile() file: Express.Multer.File
): Promise<UploadResponse> {
if (!file) {
this.setStatus(400);
throw new Error('No video file provided');
}
const result = await uploadService.uploadFile(file, 'videos');
return {
video_url: result.url,
file_size: result.fileSize,
duration: null // Will be processed later
};
}
/**
* Upload attachment file
* @summary Upload attachment for lesson (Instructor/Admin only)
*/
@Post('attachment')
@Security('jwt', ['INSTRUCTOR', 'ADMIN'])
@SuccessResponse(200, 'Success')
public async uploadAttachment(
@UploadedFile() file: Express.Multer.File
): Promise<UploadResponse> {
if (!file) {
this.setStatus(400);
throw new Error('No file provided');
}
const result = await uploadService.uploadFile(file, 'attachments');
return {
file_name: result.fileName,
file_path: result.key,
file_size: result.fileSize,
mime_type: result.mimeType,
download_url: result.url
};
}
}
Step 5: Generate TSOA Routes
// turbo After creating the upload controller, generate routes and API documentation:
npm run tsoa:gen
Step 5: Create Upload Routes
Create src/routes/upload.routes.js:
const express = require('express');
const router = express.Router();
const uploadController = require('../controllers/upload.controller');
const { authenticate, authorize } = require('../middleware/auth');
const { uploadVideo, uploadAttachment } = require('../middleware/upload.middleware');
// Video upload
router.post(
'/video',
authenticate,
authorize(['INSTRUCTOR', 'ADMIN']),
(req, res, next) => {
uploadVideo(req, res, (err) => {
if (err) {
if (err.message === 'Invalid file type') {
return res.status(422).json({
error: {
code: 'INVALID_FILE_TYPE',
message: 'Only MP4, WebM, and QuickTime videos are allowed'
}
});
}
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(422).json({
error: {
code: 'FILE_TOO_LARGE',
message: 'Video file size exceeds 500 MB limit'
}
});
}
return res.status(500).json({
error: {
code: 'UPLOAD_ERROR',
message: err.message
}
});
}
next();
});
},
uploadController.uploadVideo
);
// Attachment upload
router.post(
'/attachment',
authenticate,
authorize(['INSTRUCTOR', 'ADMIN']),
(req, res, next) => {
uploadAttachment(req, res, (err) => {
if (err) {
if (err.message === 'Invalid file type') {
return res.status(422).json({
error: {
code: 'INVALID_FILE_TYPE',
message: 'File type not allowed'
}
});
}
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(422).json({
error: {
code: 'FILE_TOO_LARGE',
message: 'File size exceeds 100 MB limit'
}
});
}
return res.status(500).json({
error: {
code: 'UPLOAD_ERROR',
message: err.message
}
});
}
next();
});
},
uploadController.uploadAttachment
);
module.exports = router;
Step 6: Integrate with Lesson Creation
Update lesson controller to handle file uploads:
async createLesson(req, res) {
try {
const { courseId, chapterId } = req.params;
// Create lesson
const lesson = await lessonService.create({
...req.body,
chapter_id: parseInt(chapterId)
});
// Handle video upload if present
if (req.files?.video) {
const videoResult = await uploadService.uploadFile(
req.files.video[0],
`courses/${courseId}/lessons/${lesson.id}`
);
await lessonService.update(lesson.id, {
video_url: videoResult.url
});
}
// Handle attachments if present
if (req.files?.attachments) {
const attachments = await Promise.all(
req.files.attachments.map(async (file, index) => {
const result = await uploadService.uploadFile(
file,
`lessons/${lesson.id}/attachments`
);
return {
lesson_id: lesson.id,
file_name: result.fileName,
file_path: result.key,
file_size: result.fileSize,
mime_type: result.mimeType,
description: req.body.descriptions?.[index] || {},
sort_order: index + 1
};
})
);
await prisma.attachment.createMany({
data: attachments
});
}
return res.status(201).json(lesson);
} catch (error) {
console.error('Create lesson error:', error);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to create lesson'
}
});
}
}
Step 7: Test File Upload
# Upload video
curl -X POST http://localhost:4000/api/upload/video \
-H "Authorization: Bearer <token>" \
-F "video=@/path/to/video.mp4"
# Upload attachment
curl -X POST http://localhost:4000/api/upload/attachment \
-H "Authorization: Bearer <token>" \
-F "file=@/path/to/document.pdf"
Error Handling
Common Errors
- File Too Large
{
"error": {
"code": "FILE_TOO_LARGE",
"message": "File size exceeds maximum limit"
}
}
- Invalid File Type
{
"error": {
"code": "INVALID_FILE_TYPE",
"message": "File type not allowed"
}
}
- S3 Upload Failed
{
"error": {
"code": "UPLOAD_FAILED",
"message": "Failed to upload file to storage"
}
}
Checklist
- S3/MinIO client configured
- Upload service created
- Upload middleware configured
- File type validation implemented
- File size validation implemented
- Upload routes created
- Error handling implemented
- File deletion implemented
- Tests written
- Manual testing done
Best Practices
- Validate Before Upload: Check file type and size before uploading
- Unique Filenames: Use UUID to prevent filename conflicts
- Organized Storage: Use folder structure (courses/1/lessons/5/video.mp4)
- Error Handling: Provide clear error messages
- Cleanup: Delete files from S3 when deleting records
- Security: Validate file content, not just extension
- Progress: Consider upload progress for large files
Advanced: Video Processing
For video processing (transcoding, thumbnails):
const ffmpeg = require('fluent-ffmpeg');
async function processVideo(videoPath) {
// Generate thumbnail
await new Promise((resolve, reject) => {
ffmpeg(videoPath)
.screenshots({
timestamps: ['00:00:01'],
filename: 'thumbnail.jpg',
folder: './thumbnails'
})
.on('end', resolve)
.on('error', reject);
});
// Get video duration
const metadata = await new Promise((resolve, reject) => {
ffmpeg.ffprobe(videoPath, (err, metadata) => {
if (err) reject(err);
else resolve(metadata);
});
});
return {
duration: metadata.format.duration,
thumbnail: './thumbnails/thumbnail.jpg'
};
}