546 lines
13 KiB
Markdown
546 lines
13 KiB
Markdown
---
|
|
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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```bash
|
|
npm run tsoa:gen
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Create Upload Routes
|
|
|
|
Create `src/routes/upload.routes.js`:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
1. **File Too Large**
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "FILE_TOO_LARGE",
|
|
"message": "File size exceeds maximum limit"
|
|
}
|
|
}
|
|
```
|
|
|
|
2. **Invalid File Type**
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "INVALID_FILE_TYPE",
|
|
"message": "File type not allowed"
|
|
}
|
|
}
|
|
```
|
|
|
|
3. **S3 Upload Failed**
|
|
```json
|
|
{
|
|
"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
|
|
|
|
1. **Validate Before Upload**: Check file type and size before uploading
|
|
2. **Unique Filenames**: Use UUID to prevent filename conflicts
|
|
3. **Organized Storage**: Use folder structure (courses/1/lessons/5/video.mp4)
|
|
4. **Error Handling**: Provide clear error messages
|
|
5. **Cleanup**: Delete files from S3 when deleting records
|
|
6. **Security**: Validate file content, not just extension
|
|
7. **Progress**: Consider upload progress for large files
|
|
|
|
---
|
|
|
|
## Advanced: Video Processing
|
|
|
|
For video processing (transcoding, thumbnails):
|
|
|
|
```javascript
|
|
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'
|
|
};
|
|
}
|
|
```
|