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)
This commit is contained in:
Automated Action 2025-06-27 09:59:20 +00:00
parent 84cb69bf10
commit 4c4d27fee9
6 changed files with 369 additions and 2 deletions

View File

@ -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
```
```
### 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/`

View File

@ -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",

View File

@ -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
};

113
src/middleware/upload.js Normal file
View File

@ -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
};

29
src/routes/upload.js Normal file
View File

@ -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;

View File

@ -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' });