From 4c4d27fee9af8b3b882770f28d423c528857d7b2 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Fri, 27 Jun 2025 09:59:20 +0000 Subject: [PATCH] Add file upload functionality to authentication service - Add multer, mime-types, and sharp packages for file handling - Create upload middleware with file validation and security - Implement file and avatar upload controllers - Add image processing with automatic avatar resizing to 200x200px - Create upload routes for multiple file types and avatars - Configure storage locations in /app/storage/uploads and /app/storage/avatars - Add file type validation (images, PDFs, documents) - Implement file size limits (10MB general, 5MB avatars) - Add protected and public endpoints for file management - Update README with comprehensive upload API documentation New endpoints: - POST /api/v1/upload/files - Upload multiple files (protected) - POST /api/v1/upload/avatar - Upload user avatar (protected) - GET /api/v1/upload/files - List files (protected) - GET /api/v1/upload/files/:filename - Download file (public) - GET /api/v1/upload/avatars/:filename - Get avatar (public) - DELETE /api/v1/upload/files/:filename - Delete file (protected) --- README.md | 56 +++++++++- package.json | 5 +- src/controllers/uploadController.js | 166 ++++++++++++++++++++++++++++ src/middleware/upload.js | 113 +++++++++++++++++++ src/routes/upload.js | 29 +++++ src/server.js | 2 + 6 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 src/controllers/uploadController.js create mode 100644 src/middleware/upload.js create mode 100644 src/routes/upload.js diff --git a/README.md b/README.md index c17397e..84558be 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A Node.js Express-based user authentication service with JWT token authenticatio - JWT token-based authentication - Password hashing with bcryptjs - SQLite database with Sequelize ORM +- File upload functionality with multer +- Image processing and avatar uploads - Input validation with express-validator - Rate limiting and security headers - CORS enabled for all origins @@ -64,6 +66,14 @@ npm start - `PUT /api/v1/users/profile` - Update user profile - `DELETE /api/v1/users/deactivate` - Deactivate user account +### File Upload Endpoints +- `POST /api/v1/upload/files` - Upload multiple files (protected) +- `POST /api/v1/upload/avatar` - Upload user avatar (protected) +- `GET /api/v1/upload/files` - List uploaded files (protected) +- `GET /api/v1/upload/files/:filename` - Download specific file (public) +- `GET /api/v1/upload/avatars/:filename` - Get avatar image (public) +- `DELETE /api/v1/upload/files/:filename` - Delete file (protected) + ## Usage Examples 1. Register a new user: @@ -94,6 +104,27 @@ curl -X PUT "http://localhost:3000/api/v1/users/profile" \ -d '{"email": "newemail@example.com"}' ``` +5. Upload files: +```bash +curl -X POST "http://localhost:3000/api/v1/upload/files" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -F "files=@/path/to/file1.pdf" \ + -F "files=@/path/to/file2.jpg" +``` + +6. Upload avatar: +```bash +curl -X POST "http://localhost:3000/api/v1/upload/avatar" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -F "avatar=@/path/to/avatar.jpg" +``` + +7. List uploaded files: +```bash +curl -X GET "http://localhost:3000/api/v1/upload/files" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + ## Development ### Available Scripts @@ -109,9 +140,32 @@ curl -X PUT "http://localhost:3000/api/v1/users/profile" \ src/ ├── config/ # Database configuration ├── controllers/ # Route controllers +│ ├── authController.js +│ ├── userController.js +│ └── uploadController.js ├── middleware/ # Custom middleware +│ ├── auth.js +│ └── upload.js ├── models/ # Sequelize models ├── routes/ # Express routes +│ ├── auth.js +│ ├── users.js +│ └── upload.js ├── utils/ # Utility functions └── server.js # Main server file -``` \ No newline at end of file +``` + +### File Upload Details + +**Supported File Types:** +- Images: JPEG, JPG, PNG, GIF, WebP +- Documents: PDF, TXT, DOC, DOCX + +**Upload Limits:** +- General files: 10MB per file, max 5 files +- Avatar images: 5MB per file, max 1 file +- Avatar images are automatically resized to 200x200px + +**Storage Locations:** +- General files: `/app/storage/uploads/` +- Avatar images: `/app/storage/avatars/` \ No newline at end of file diff --git a/package.json b/package.json index 859c0a9..12d4066 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,10 @@ "helmet": "^7.1.0", "express-validator": "^7.0.1", "express-rate-limit": "^7.1.5", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "multer": "^1.4.5-lts.1", + "mime-types": "^2.1.35", + "sharp": "^0.33.1" }, "devDependencies": { "nodemon": "^3.0.2", diff --git a/src/controllers/uploadController.js b/src/controllers/uploadController.js new file mode 100644 index 0000000..f29c2a8 --- /dev/null +++ b/src/controllers/uploadController.js @@ -0,0 +1,166 @@ +const fs = require('fs'); +const path = require('path'); +const sharp = require('sharp'); +const { uploadDir, avatarDir } = require('../middleware/upload'); + +const uploadFiles = async (req, res) => { + try { + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const uploadedFiles = req.files.map(file => ({ + filename: file.filename, + originalName: file.originalname, + mimetype: file.mimetype, + size: file.size, + path: `/uploads/${file.filename}`, + uploadedAt: new Date().toISOString() + })); + + res.json({ + message: 'Files uploaded successfully', + files: uploadedFiles, + count: uploadedFiles.length + }); + } catch (error) { + console.error('Upload error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +const uploadAvatar = async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No avatar file uploaded' }); + } + + const originalPath = req.file.path; + const filename = req.file.filename; + const resizedFilename = `resized-${filename}`; + const resizedPath = path.join(avatarDir, resizedFilename); + + await sharp(originalPath) + .resize(200, 200, { + fit: 'cover', + position: 'center' + }) + .jpeg({ quality: 90 }) + .toFile(resizedPath); + + fs.unlinkSync(originalPath); + + const avatarUrl = `/avatars/${resizedFilename}`; + + res.json({ + message: 'Avatar uploaded successfully', + avatar: { + filename: resizedFilename, + originalName: req.file.originalname, + size: req.file.size, + url: avatarUrl, + uploadedAt: new Date().toISOString() + } + }); + } catch (error) { + console.error('Avatar upload error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +const getFile = async (req, res) => { + try { + const { filename } = req.params; + const filePath = path.join(uploadDir, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'File not found' }); + } + + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + return res.status(404).json({ error: 'File not found' }); + } + + res.sendFile(filePath); + } catch (error) { + console.error('Get file error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +const getAvatar = async (req, res) => { + try { + const { filename } = req.params; + const filePath = path.join(avatarDir, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'Avatar not found' }); + } + + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + return res.status(404).json({ error: 'Avatar not found' }); + } + + res.sendFile(filePath); + } catch (error) { + console.error('Get avatar error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +const deleteFile = async (req, res) => { + try { + const { filename } = req.params; + const filePath = path.join(uploadDir, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'File not found' }); + } + + fs.unlinkSync(filePath); + + res.json({ + message: 'File deleted successfully', + filename: filename + }); + } catch (error) { + console.error('Delete file error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +const listFiles = async (req, res) => { + try { + const files = fs.readdirSync(uploadDir); + const fileList = files.map(filename => { + const filePath = path.join(uploadDir, filename); + const stat = fs.statSync(filePath); + + return { + filename: filename, + size: stat.size, + uploadedAt: stat.mtime.toISOString(), + url: `/uploads/${filename}` + }; + }); + + res.json({ + files: fileList, + count: fileList.length + }); + } catch (error) { + console.error('List files error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +module.exports = { + uploadFiles, + uploadAvatar, + getFile, + getAvatar, + deleteFile, + listFiles +}; \ No newline at end of file diff --git a/src/middleware/upload.js b/src/middleware/upload.js new file mode 100644 index 0000000..726fcd8 --- /dev/null +++ b/src/middleware/upload.js @@ -0,0 +1,113 @@ +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const mimeTypes = require('mime-types'); + +const uploadDir = '/app/storage/uploads'; +const avatarDir = '/app/storage/avatars'; + +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +if (!fs.existsSync(avatarDir)) { + fs.mkdirSync(avatarDir, { recursive: true }); +} + +const fileFilter = (req, file, cb) => { + const allowedTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ]; + + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only images, PDFs, and documents are allowed.'), false); + } +}; + +const avatarFilter = (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only image files are allowed for avatars.'), false); + } +}; + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const extension = mimeTypes.extension(file.mimetype) || 'bin'; + cb(null, `${file.fieldname}-${uniqueSuffix}.${extension}`); + } +}); + +const avatarStorage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, avatarDir); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const extension = mimeTypes.extension(file.mimetype) || 'jpg'; + cb(null, `avatar-${req.user.id}-${uniqueSuffix}.${extension}`); + } +}); + +const upload = multer({ + storage: storage, + fileFilter: fileFilter, + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + files: 5 // Maximum 5 files + } +}); + +const avatarUpload = multer({ + storage: avatarStorage, + fileFilter: avatarFilter, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit for avatars + files: 1 + } +}); + +const handleMulterError = (error, req, res, next) => { + if (error instanceof multer.MulterError) { + if (error.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: 'File size too large' }); + } + if (error.code === 'LIMIT_FILE_COUNT') { + return res.status(400).json({ error: 'Too many files' }); + } + if (error.code === 'LIMIT_UNEXPECTED_FILE') { + return res.status(400).json({ error: 'Unexpected file field' }); + } + } + + if (error.message.includes('Invalid file type')) { + return res.status(400).json({ error: error.message }); + } + + next(error); +}; + +module.exports = { + upload, + avatarUpload, + handleMulterError, + uploadDir, + avatarDir +}; \ No newline at end of file diff --git a/src/routes/upload.js b/src/routes/upload.js new file mode 100644 index 0000000..1b53455 --- /dev/null +++ b/src/routes/upload.js @@ -0,0 +1,29 @@ +const express = require('express'); +const { upload, avatarUpload, handleMulterError } = require('../middleware/upload'); +const { authenticateToken } = require('../middleware/auth'); +const { + uploadFiles, + uploadAvatar, + getFile, + getAvatar, + deleteFile, + listFiles +} = require('../controllers/uploadController'); + +const router = express.Router(); + +router.post('/files', authenticateToken, upload.array('files', 5), uploadFiles); + +router.post('/avatar', authenticateToken, avatarUpload.single('avatar'), uploadAvatar); + +router.get('/files', authenticateToken, listFiles); + +router.get('/files/:filename', getFile); + +router.get('/avatars/:filename', getAvatar); + +router.delete('/files/:filename', authenticateToken, deleteFile); + +router.use(handleMulterError); + +module.exports = router; \ No newline at end of file diff --git a/src/server.js b/src/server.js index caecf96..b8d978d 100644 --- a/src/server.js +++ b/src/server.js @@ -6,6 +6,7 @@ const rateLimit = require('express-rate-limit'); const authRoutes = require('./routes/auth'); const userRoutes = require('./routes/users'); +const uploadRoutes = require('./routes/upload'); const { sequelize } = require('./config/database'); const app = express(); @@ -42,6 +43,7 @@ app.get('/health', (req, res) => { app.use('/api/v1/auth', authRoutes); app.use('/api/v1/users', userRoutes); +app.use('/api/v1/upload', uploadRoutes); app.use('*', (req, res) => { res.status(404).json({ error: 'Endpoint not found' });