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:
parent
84cb69bf10
commit
4c4d27fee9
54
README.md
54
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
|
||||
```
|
||||
|
||||
### 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/`
|
@ -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",
|
||||
|
166
src/controllers/uploadController.js
Normal file
166
src/controllers/uploadController.js
Normal 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
113
src/middleware/upload.js
Normal 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
29
src/routes/upload.js
Normal 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;
|
@ -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' });
|
||||
|
Loading…
x
Reference in New Issue
Block a user