Build an image upload system from scratch! Learn file uploads, multipart form data, image processing, and filesystem operations.
- File Uploads - Handle multipart form data with multer
- Image Processing - Generate thumbnails with sharp library
- Filesystem Operations - Read, write, and delete files in Node.js
- Metadata Storage - Separate binary files from database records
- Resource Cleanup - Coordinate database and filesystem deletions
- File Validation - Type checking, size limits, and sanitization
- Security - Path traversal prevention and file type whitelisting
npm install# Start MongoDB container
docker-compose up -d
# Check if MongoDB is running
docker-compose ps
# View MongoDB logs
docker-compose logs mongodbcp .env.example .envThe default .env values work out of the box.
# Run all tests
npm test
# Run specific test
npm test -- 01-health
npm test -- 02-connect
npm test -- 03-upload
npm test -- 04-list
npm test -- 05-download
npm test -- 06-deletenpm run devimage-upload-api/
├── src/
│ ├── app.js # Express app setup (TODO)
│ ├── server.js # Server startup (minimal TODO)
│ ├── db/
│ │ └── connect.js # MongoDB connection (TODO)
│ ├── models/
│ │ └── image.model.js # Image schema (TODO)
│ ├── controllers/
│ │ └── image.controller.js # Upload, list, download, delete (TODO)
│ ├── routes/
│ │ └── image.routes.js # Route definitions (TODO)
│ ├── middlewares/
│ │ ├── upload.middleware.js # Multer config (TODO - challenging!)
│ │ ├── validateObjectId.middleware.js # ID validation (TODO)
│ │ ├── error.middleware.js # Error handler (TODO)
│ │ └── notFound.middleware.js # 404 handler (TODO)
│ └── utils/
│ └── thumbnail.js # Thumbnail utilities (TODO - sharp library)
└── tests/
├── __helpers__/
│ └── setupTestDb.js # Test utilities (COMPLETE)
└── visible/
├── 01-health.spec.js # Health check tests
├── 02-connect.spec.js # Database tests
├── 03-upload.spec.js # Upload tests
├── 04-list.spec.js # List tests
├── 05-download.spec.js # Download tests
└── 06-delete.spec.js # Delete tests
File: src/server.js
Read environment variables:
PORTfromprocess.env.PORT(default: 3000)MONGO_URIfromprocess.env.MONGO_URI(default: mongodb://localhost:27017/image_upload_api)
File: src/app.js
Create Express app:
- Add
express.json()middleware - Create uploads directories (uploads/ and uploads/thumbnails/)
- Add
GET /healthroute returning{ ok: true } - Mount routes and error handlers
Test: npm test -- 01-health
File: src/db/connect.js
Implement connectDB(uri):
- Validate URI is provided
- Connect using
mongoose.connect(uri) - Return connection
Test: npm test -- 02-connect
File: src/utils/thumbnail.js
Implement image processing utilities:
generateThumbnail(filename):
- Construct input path:
uploads/{filename} - Create thumbnail name:
thumb-{filename}.jpg(always JPEG) - Construct output path:
uploads/thumbnails/{thumbnailName} - Use sharp to resize: 200x200 max, fit: 'inside', withoutEnlargement: true
- Convert to JPEG with quality 80
- Save to output path with
.toFile() - Return thumbnail filename
getImageDimensions(filepath):
- Use
sharp(filepath).metadata()to read image metadata - Extract and return
{ width, height }from metadata
File: src/models/image.model.js
Define Image schema:
- originalName (String, required, trim, max 255)
- filename (String, required, unique)
- mimetype (String, required, enum: ['image/jpeg', 'image/png', 'image/gif'])
- size (Number, required, min 1, max 5MB)
- width, height (Number, required, min 1)
- thumbnailFilename (String, required)
- description (String, optional, max 500)
- tags (Array of Strings, max 10)
- uploadDate (Date, default: Date.now)
- timestamps enabled
File: src/middlewares/upload.middleware.js
Configure multer:
- Set up diskStorage with unique filenames:
{timestamp}-{random}.{ext} - Add fileFilter to allow only jpeg, png, gif
- Set 5MB file size limit
- Export upload middleware
File: src/controllers/image.controller.js
Implement uploadImage:
- Check if file uploaded (req.file)
- Get image dimensions using
getImageDimensions() - Generate thumbnail using
generateThumbnail() - Parse tags from req.body (comma-separated)
- Save metadata to database
- Return 201 with metadata
File: src/routes/image.routes.js
Add POST / route with upload.single('image') middleware
Test: npm test -- 03-upload
File: src/controllers/image.controller.js
Implement listImages:
-
Extract query parameters:
- page (default 1)
- limit (default 10, max 50)
- search (text search in originalName and description)
- mimetype (filter by type)
- sortBy (default: uploadDate)
- sortOrder (asc/desc, default: desc)
-
Build MongoDB query with filters
-
Calculate pagination:
- skip = (page - 1) * limit
- total = count matching documents
- pages = Math.ceil(total / limit)
- totalSize = sum of all image sizes
-
Fetch images with sorting and pagination
-
Return
{ data: [...], meta: { total, page, limit, pages, totalSize } }
File: src/routes/image.routes.js
Add GET / route
Test: npm test -- 04-list
File: src/controllers/image.controller.js
Implement three functions:
getImage: Return metadata by ID (404 if not found)
downloadImage:
- Find image by ID
- Check file exists on disk
- Set headers (Content-Type, Content-Disposition)
- Send file with
res.sendFile()
downloadThumbnail:
- Find image by ID
- Check thumbnail exists
- Set Content-Type to
image/jpeg - Send thumbnail
File: src/routes/image.routes.js
Add routes:
- GET
/:id→ getImage - GET
/:id/download→ downloadImage - GET
/:id/thumbnail→ downloadThumbnail
All ID routes need validateObjectId middleware
Test: npm test -- 05-download
File: src/controllers/image.controller.js
Implement deleteImage:
- Find image by ID (404 if not found)
- Delete original file (use try-catch, ignore if missing)
- Delete thumbnail (use try-catch, ignore if missing)
- Delete database record
- Return 204 (no content)
File: src/routes/image.routes.js
Add DELETE /:id route
Important: Gracefully handle missing files - delete metadata even if files are gone
Test: npm test -- 06-delete
Request:
- Content-Type:
multipart/form-data - Field:
image(file) - Optional:
description(string),tags(comma-separated)
Response (201):
{
"_id": "...",
"originalName": "vacation.jpg",
"filename": "1704067200000-abc123.jpg",
"mimetype": "image/jpeg",
"size": 245680,
"width": 1920,
"height": 1080,
"thumbnailFilename": "thumb-1704067200000-abc123.jpg",
"description": "Beach photo",
"tags": ["vacation", "beach"],
"uploadDate": "2024-01-01T00:00:00.000Z"
}Query Parameters:
page(number, default: 1)limit(number, default: 10, max: 50)search(string) - Search in originalName and descriptionmimetype(string) - Filter by mimetypesortBy(string) - Sort field (default: uploadDate)sortOrder(string) - asc or desc (default: desc)
Response (200):
{
"data": [
{
"_id": "...",
"originalName": "photo.jpg",
"mimetype": "image/jpeg",
"size": 123456,
"width": 1920,
"height": 1080,
"uploadDate": "..."
}
],
"meta": {
"total": 42,
"page": 1,
"limit": 10,
"pages": 5,
"totalSize": 12345678
}
}Returns image metadata (same format as upload response).
Downloads the full-resolution original image with proper Content-Type and Content-Disposition headers.
Downloads the 200x200 thumbnail (always JPEG format).
Deletes image metadata and files. Returns 204 (no content).
All errors must use this format:
{
"error": {
"message": "Descriptive error message"
}
}This is validated in every test!
uploads/
├── 1704067200000-abc123.jpg # Original image
├── 1704067200001-def456.png
└── thumbnails/
├── thumb-1704067200000-abc123.jpg # Thumbnail (always JPEG)
├── thumb-1704067200001-def456.jpg
└── ...
Filename Convention:
- Original:
{timestamp}-{random}.{ext} - Thumbnail:
thumb-{original-name}.jpg
All tests use an in-memory MongoDB database for isolation. Tests are transparent - you can see exactly what's expected.
Run tests progressively:
npm test -- 01-health # 5 points
npm test -- 02-connect # 10 points
npm test -- 03-upload # 25 points
npm test -- 04-list # 20 points
npm test -- 05-download # 20 points
npm test -- 06-delete # 20 pointsTotal: 100 points
Problem: Multer fails if uploads/ doesn't exist
Solution: Create directories in app.js:
const uploadsDir = path.join(__dirname, '../uploads');
const thumbnailsDir = path.join(uploadsDir, 'thumbnails');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
if (!fs.existsSync(thumbnailsDir)) {
fs.mkdirSync(thumbnailsDir, { recursive: true });
}Problem: Frontend sends file, backend expects image
Solution: Use upload.single('image') in routes (field name is 'image')
Problem: Import fails, tests crash
Solution: Run npm install (sharp is in package.json)
Problem: Storing full path like /Users/john/project/uploads/file.jpg
Solution: Store only filename, reconstruct path when needed:
// Store
filename: '1704067200000-abc123.jpg'
// Reconstruct
const filepath = path.join(__dirname, '../../uploads', image.filename);Problem: File size/type errors crash server
Solution: Error middleware handles multer errors:
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: { message: 'File size exceeds 5MB limit' }
});
}Problem: Thumbnail same size as original
Solution: Already implemented in utils/thumbnail.js:
await sharp(inputPath)
.resize(200, 200, { fit: 'inside' })
.jpeg({ quality: 80 })
.toFile(outputPath);Problem: Files remain on disk forever
Solution: Delete files THEN database:
// Delete original
await fs.promises.unlink(filepath);
// Delete thumbnail
await fs.promises.unlink(thumbnailPath);
// Delete metadata
await Image.findByIdAndDelete(id);Problem: Crash when file doesn't exist
Solution: Use try-catch, ignore ENOENT:
try {
await fs.promises.unlink(filepath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}Problem: Width/height not saved
Solution: Use helper function:
const { width, height } = await getImageDimensions(filepath);Problem: { message: "..." } instead of { error: { message: "..." } }
Solution: Consistent format everywhere:
res.status(400).json({ error: { message: 'Error text' } });Never trust client: Validate mimetype in multer fileFilter
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedMimes.includes(file.mimetype)) {
cb(new Error('Invalid file type...'), false);
}Prevent DoS: Limit to 5MB
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
}Prevent path traversal: Generate new filename, ignore original
// BAD: Using original filename
filename: file.originalname // Could be "../../etc/passwd"
// GOOD: Generate unique name
filename: `${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`Security: Store outside public directory
- Files in
uploads/(not served directly) - Download through API only
- No direct URL access
Copy .env.example to .env:
# Database
MONGO_URI=mongodb://localhost:27017/image_upload_api
# Server
PORT=3000
NODE_ENV=development# Start MongoDB
docker-compose up -d
# Stop MongoDB
docker-compose down
# View logs
docker-compose logs -f mongodb
# Remove all data
docker-compose down -v- Start MongoDB:
docker-compose up -d - Check MongoDB is running:
docker-compose ps
- Install dependencies:
npm install - sharp requires native compilation (automatic during install)
- Create uploads directory (app.js should do this)
- Check file paths are constructed correctly
- Check sharp is installed
- Verify thumbnail directory exists
- Check for errors in upload controller
- Check file size is under 5MB
- Error should be caught by error middleware
# Install dependencies
npm install
# Start MongoDB
docker-compose up -d
# Run tests (watch for failures)
npm test
# Start development server (auto-restart on changes)
npm run dev
# Test upload with curl
curl -X POST http://localhost:3000/api/images \
-F "image=@test.jpg" \
-F "description=Test image"
# List images
curl http://localhost:3000/api/images
# Download image
curl http://localhost:3000/api/images/{id}/download -o downloaded.jpg- Complete all TODO comments in source files
- Run
npm test- ensure all tests pass - Commit your code to GitHub
- GitHub Classroom will automatically run tests and calculate your grade
- Express Documentation
- Mongoose Documentation
- Multer Documentation
- Sharp Documentation
- Node.js fs/promises
MIT