Handling Multipart File Uploads
What is a Multipart Request?
When a browser or API client uploads a file, it sends the data as a multipart/form-data request. This is a special HTTP content type designed to transmit files alongside regular form fields in a single request.
A multipart request looks different from the JSON requests we have been working with:
POST /api/posts/1/image HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="spring-boot-cover.jpg"
Content-Type: image/jpeg
[binary file data]
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="caption"
Cover image for the Spring Boot article
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Each “part” of the request is separated by a boundary string. One part contains the file (with binary data), another contains a text field (“caption”). Spring Boot parses this automatically.
Spring Boot’s Built-in Support
Spring Boot handles multipart requests out of the box. You do not need any extra dependencies. The MultipartFile interface represents an uploaded file:
@RestController
@RequestMapping("/api/files")
public class FileController {
// Spring automatically parses the multipart request and injects
// the uploaded file as a MultipartFile parameter.
@PostMapping("/upload")
public Map<String, String> uploadFile(@RequestParam("file") MultipartFile file) {
return Map.of(
"filename", file.getOriginalFilename(),
"size", file.getSize() + " bytes",
"contentType", file.getContentType()
);
}
}
Testing with curl:
curl -X POST http://localhost:8080/api/files/upload \
-F "file=@/path/to/photo.jpg"
# Response:
# {"filename":"photo.jpg","size":"245760 bytes","contentType":"image/jpeg"}
The -F flag tells curl to send a multipart/form-data request. @/path/to/photo.jpg means “read this file from disk and include it.”
MultipartFile API and Configuration
The MultipartFile Interface
MultipartFile provides these methods:
public void handleUpload(MultipartFile file) {
// Original filename as sent by the client
String originalName = file.getOriginalFilename(); // "photo.jpg"
// MIME type
String contentType = file.getContentType(); // "image/jpeg"
// File size in bytes
long size = file.getSize(); // 245760
// Is the file empty?
boolean empty = file.isEmpty(); // false
// Get the raw bytes (loads entire file into memory — careful with large files)
byte[] bytes = file.getBytes();
// Get an InputStream (better for large files — does not load everything into memory)
InputStream inputStream = file.getInputStream();
// Save the file directly to a destination on disk
file.transferTo(new File("/uploads/photo.jpg"));
// Save to a Path (Java NIO — preferred over File)
file.transferTo(Path.of("/uploads/photo.jpg"));
}
Configuration
Add these settings to application.properties:
# ============================================
# File Upload Configuration
# ============================================
# Maximum size for individual files (default: 1MB)
spring.servlet.multipart.max-file-size=10MB
# Maximum size for the entire multipart request (default: 10MB)
# This includes all files + form fields in a single request
spring.servlet.multipart.max-request-size=20MB
# Enable multipart uploads (enabled by default)
spring.servlet.multipart.enabled=true
# Threshold after which files are written to disk instead of kept in memory
# Files smaller than this stay in memory; larger ones are written to a temp file
spring.servlet.multipart.file-size-threshold=2KB
# Custom property: where to store uploaded files
app.upload.dir=uploads
File Storage Strategies — Local Filesystem vs Cloud
Strategy 1: Local Filesystem (What We Implement)
Store files in a directory on the server’s filesystem:
project-root/
├── uploads/
│ ├── posts/
│ │ ├── post-1-cover-abc123.jpg
│ │ └── post-5-cover-def456.png
│ └── avatars/
│ ├── user-1-avatar-xyz789.jpg
│ └── user-3-avatar-uvw012.png
├── src/
└── pom.xml
Pros: Simple, no external dependencies, fast read/write, free.
Cons: Files are lost if the server is replaced. Not suitable for multiple servers (files are only on one machine). No CDN support.
Strategy 2: Cloud Storage (Production Recommendation)
Store files in a cloud service like Amazon S3, Google Cloud Storage, or Azure Blob Storage:
// Conceptual example — not implemented in this lecture
public String uploadToS3(MultipartFile file) {
String key = "posts/" + generateFilename(file);
s3Client.putObject(bucketName, key, file.getInputStream(), metadata);
return "https://my-bucket.s3.amazonaws.com/" + key;
}
Pros: Unlimited storage, built-in redundancy, CDN integration, works with multiple servers.
Cons: Costs money, requires cloud account, more complex setup.
Strategy 3: Database Storage (Avoid)
Storing files as BLOBs in the database is almost always a bad idea. It bloats the database, slows down queries, makes backups enormous, and provides no caching benefit.
Our Approach
For this series, we implement local filesystem storage. The architecture is designed so that switching to cloud storage later requires changing only the FileStorageService implementation — the rest of the application stays the same.
Storing File Metadata in MariaDB
We store the file itself on disk but store metadata (filename, path, type, size, upload date) in MariaDB. This lets us track files, search by type, associate them with posts/users, and serve them efficiently.
Flyway Migration
File: V12__create_uploaded_files_table.sql
-- V12: Create table to track uploaded files
CREATE TABLE uploaded_files (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
original_name VARCHAR(500) NOT NULL,
stored_name VARCHAR(500) NOT NULL UNIQUE,
file_path VARCHAR(1000) NOT NULL,
content_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
upload_type VARCHAR(50) NOT NULL,
uploaded_by BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_files_upload_type (upload_type),
INDEX idx_files_uploaded_by (uploaded_by),
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add cover_image_id to posts table
ALTER TABLE posts ADD COLUMN cover_image_id BIGINT NULL;
ALTER TABLE posts ADD CONSTRAINT fk_posts_cover_image
FOREIGN KEY (cover_image_id) REFERENCES uploaded_files(id) ON DELETE SET NULL;
-- Add avatar_id to users table
ALTER TABLE users ADD COLUMN avatar_id BIGINT NULL;
ALTER TABLE users ADD CONSTRAINT fk_users_avatar
FOREIGN KEY (avatar_id) REFERENCES uploaded_files(id) ON DELETE SET NULL;
The UploadedFile Entity
File: src/main/java/com/example/blogapi/model/UploadedFile.java
package com.example.blogapi.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "uploaded_files")
public class UploadedFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The original filename from the client (e.g., "my-photo.jpg")
@Column(name = "original_name", nullable = false, length = 500)
private String originalName;
// The name we store the file as (e.g., "post-1-cover-a1b2c3.jpg")
// Unique to prevent collisions
@Column(name = "stored_name", nullable = false, unique = true, length = 500)
private String storedName;
// Relative path on disk (e.g., "posts/post-1-cover-a1b2c3.jpg")
@Column(name = "file_path", nullable = false, length = 1000)
private String filePath;
// MIME type (e.g., "image/jpeg", "image/png")
@Column(name = "content_type", nullable = false, length = 100)
private String contentType;
// File size in bytes
@Column(name = "file_size", nullable = false)
private long fileSize;
// Purpose: "POST_COVER", "AVATAR", "ATTACHMENT"
@Column(name = "upload_type", nullable = false, length = 50)
private String uploadType;
// Who uploaded this file
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "uploaded_by", nullable = false)
private User uploadedBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
protected UploadedFile() { }
public UploadedFile(String originalName, String storedName, String filePath,
String contentType, long fileSize, String uploadType, User uploadedBy) {
this.originalName = originalName;
this.storedName = storedName;
this.filePath = filePath;
this.contentType = contentType;
this.fileSize = fileSize;
this.uploadType = uploadType;
this.uploadedBy = uploadedBy;
}
// --- Getters ---
public Long getId() { return id; }
public String getOriginalName() { return originalName; }
public String getStoredName() { return storedName; }
public String getFilePath() { return filePath; }
public String getContentType() { return contentType; }
public long getFileSize() { return fileSize; }
public String getUploadType() { return uploadType; }
public User getUploadedBy() { return uploadedBy; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
Serving Static Files and Images
The File Storage Service
File: src/main/java/com/example/blogapi/service/FileStorageService.java
package com.example.blogapi.service;
import com.example.blogapi.exception.BadRequestException;
import com.example.blogapi.exception.ResourceNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Set;
import java.util.UUID;
@Service
public class FileStorageService {
private static final Logger log = LoggerFactory.getLogger(FileStorageService.class);
// Allowed MIME types — only accept images
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/webp"
);
// Maximum file size: 5MB
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024;
private final Path uploadDir;
public FileStorageService(@Value("${app.upload.dir:uploads}") String uploadDir) {
this.uploadDir = Paths.get(uploadDir).toAbsolutePath().normalize();
}
// Create upload directories at startup
@PostConstruct
public void init() {
try {
Files.createDirectories(uploadDir.resolve("posts"));
Files.createDirectories(uploadDir.resolve("avatars"));
log.info("Upload directories created at: {}", uploadDir);
} catch (IOException e) {
throw new RuntimeException("Could not create upload directories", e);
}
}
// Store a file on disk and return the stored filename.
public StoredFileInfo storeFile(MultipartFile file, String subdirectory) {
// Validate the file
validateFile(file);
// Generate a unique filename to prevent collisions.
// "photo.jpg" → "a1b2c3d4-e5f6-7890.jpg"
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
String extension = getExtension(originalFilename);
String storedFilename = UUID.randomUUID().toString() + "." + extension;
// Resolve the full path: uploads/posts/a1b2c3d4-e5f6-7890.jpg
Path targetDir = uploadDir.resolve(subdirectory);
Path targetPath = targetDir.resolve(storedFilename);
// Security check: prevent path traversal attacks
// (e.g., filename = "../../etc/passwd")
if (!targetPath.getParent().equals(targetDir)) {
throw new BadRequestException("Invalid file path detected");
}
try {
// Copy the file to the target location, replacing if it exists
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
log.info("File stored: {} → {}", originalFilename, targetPath);
} catch (IOException e) {
throw new RuntimeException("Failed to store file: " + originalFilename, e);
}
// Return info about the stored file
return new StoredFileInfo(
originalFilename,
storedFilename,
subdirectory + "/" + storedFilename,
file.getContentType(),
file.getSize()
);
}
// Load a file as a Spring Resource (for serving via HTTP).
public Resource loadFileAsResource(String filePath) {
try {
Path path = uploadDir.resolve(filePath).normalize();
Resource resource = new UrlResource(path.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
} else {
throw new ResourceNotFoundException("File", "path", filePath);
}
} catch (MalformedURLException e) {
throw new ResourceNotFoundException("File", "path", filePath);
}
}
// Delete a file from disk.
public void deleteFile(String filePath) {
try {
Path path = uploadDir.resolve(filePath).normalize();
boolean deleted = Files.deleteIfExists(path);
if (deleted) {
log.info("File deleted: {}", filePath);
} else {
log.warn("File not found for deletion: {}", filePath);
}
} catch (IOException e) {
log.error("Failed to delete file: {}", filePath, e);
}
}
// Validate file before storage.
private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
throw new BadRequestException("File is empty");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new BadRequestException(
"File size exceeds maximum allowed size of " + (MAX_FILE_SIZE / 1024 / 1024) + "MB");
}
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new BadRequestException(
"File type not allowed. Accepted types: " + ALLOWED_CONTENT_TYPES);
}
String filename = file.getOriginalFilename();
if (filename != null && filename.contains("..")) {
throw new BadRequestException("Filename contains invalid characters");
}
}
// Extract file extension from filename.
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "bin";
}
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}
// Simple record to hold stored file information
public record StoredFileInfo(
String originalName,
String storedName,
String filePath,
String contentType,
long fileSize
) { }
}
The File Controller — Serving Files
File: src/main/java/com/example/blogapi/controller/FileController.java
package com.example.blogapi.controller;
import com.example.blogapi.service.FileStorageService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileStorageService fileStorageService;
public FileController(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
// GET /api/files/posts/a1b2c3d4.jpg — serve a file
// The ** wildcard captures the full path including subdirectories.
@GetMapping("/{*filePath}")
public ResponseEntity<Resource> serveFile(@PathVariable String filePath) {
Resource resource = fileStorageService.loadFileAsResource(filePath);
// Determine the content type from the file extension
String contentType = "application/octet-stream";
if (filePath.endsWith(".jpg") || filePath.endsWith(".jpeg")) {
contentType = "image/jpeg";
} else if (filePath.endsWith(".png")) {
contentType = "image/png";
} else if (filePath.endsWith(".gif")) {
contentType = "image/gif";
} else if (filePath.endsWith(".webp")) {
contentType = "image/webp";
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CACHE_CONTROL, "max-age=86400") // Cache for 24 hours
.body(resource);
}
}
The Cache-Control: max-age=86400 header tells the browser to cache the image for 24 hours. Subsequent requests for the same image are served from the browser cache, reducing server load.
Remember to make the file serving endpoint public in SecurityConfig:
.requestMatchers(HttpMethod.GET, "/api/files/**").permitAll()
File Validation — Size, Type, Extension
We already built validation into the FileStorageService, but let us discuss the security reasoning behind each check.
Why Validate Files?
Uploaded files are a major attack vector. Without validation, an attacker could:
- Upload a 10GB file to exhaust server disk space (denial of service)
- Upload a
.phpor.jspfile and execute it on the server (remote code execution) - Upload a file named
../../etc/passwdto overwrite system files (path traversal) - Upload a file with a
.jpgextension but containing malware
Our Validation Layers
Layer 1: Spring Boot configuration — Rejects oversized requests before they reach your code.
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB
Layer 2: MIME type check — Only accept known image types.
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/webp"
);
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
throw new BadRequestException("File type not allowed");
}
Layer 3: File size check — Enforce application-level limits.
if (file.getSize() > MAX_FILE_SIZE) {
throw new BadRequestException("File exceeds 5MB limit");
}
Layer 4: Filename sanitization — Prevent path traversal.
// Reject filenames with ".." (directory traversal)
if (filename.contains("..")) {
throw new BadRequestException("Invalid filename");
}
// Use UUID as stored filename — completely ignores the original name
String storedFilename = UUID.randomUUID() + "." + extension;
Layer 5: Path verification — Ensure the resolved path stays within the upload directory.
Path targetPath = targetDir.resolve(storedFilename);
if (!targetPath.getParent().equals(targetDir)) {
throw new BadRequestException("Invalid file path");
}
Content-Type Spoofing Warning
The Content-Type header is sent by the client and can be faked. An attacker can upload a .exe file with Content-Type: image/jpeg. For production systems, also verify the file content by reading the file’s magic bytes (the first few bytes that identify the file format):
// JPEG files start with bytes FF D8 FF
// PNG files start with bytes 89 50 4E 47
// This is more reliable than checking the Content-Type header
private boolean isActualImage(byte[] fileBytes) {
if (fileBytes.length < 4) return false;
// Check for JPEG magic bytes
if (fileBytes[0] == (byte) 0xFF && fileBytes[1] == (byte) 0xD8) return true;
// Check for PNG magic bytes
if (fileBytes[0] == (byte) 0x89 && fileBytes[1] == 0x50 &&
fileBytes[2] == 0x4E && fileBytes[3] == 0x47) return true;
return false;
}
For our blog application, MIME type checking is sufficient. Add magic byte verification if your application handles sensitive or untrusted uploads.
Image Thumbnail Generation (Bonus)
For blog cover images, you often want to generate a smaller thumbnail for list pages. Java’s built-in ImageIO can handle this without external libraries.
package com.example.blogapi.service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Path;
public class ThumbnailGenerator {
// Generate a thumbnail with a maximum width, preserving aspect ratio.
public static void generateThumbnail(Path sourcePath, Path targetPath,
int maxWidth) throws IOException {
// Read the original image
BufferedImage original = ImageIO.read(sourcePath.toFile());
if (original == null) {
throw new IOException("Could not read image: " + sourcePath);
}
// Calculate the new dimensions while preserving aspect ratio
int originalWidth = original.getWidth();
int originalHeight = original.getHeight();
// If the image is already smaller than maxWidth, skip resizing
if (originalWidth <= maxWidth) {
java.nio.file.Files.copy(sourcePath, targetPath);
return;
}
double ratio = (double) maxWidth / originalWidth;
int newWidth = maxWidth;
int newHeight = (int) (originalHeight * ratio);
// Create the thumbnail
BufferedImage thumbnail = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = thumbnail.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.drawImage(original, 0, 0, newWidth, newHeight, null);
g2d.dispose();
// Write the thumbnail to disk
String format = targetPath.toString().endsWith(".png") ? "png" : "jpg";
ImageIO.write(thumbnail, format, targetPath.toFile());
}
}
You can integrate this into the FileStorageService:
public StoredFileInfo storeFile(MultipartFile file, String subdirectory) {
// ... existing validation and storage logic ...
// Generate thumbnail for post cover images
if ("posts".equals(subdirectory)) {
Path thumbnailPath = targetDir.resolve("thumb-" + storedFilename);
try {
ThumbnailGenerator.generateThumbnail(targetPath, thumbnailPath, 400);
log.info("Thumbnail generated: {}", thumbnailPath);
} catch (IOException e) {
log.warn("Failed to generate thumbnail: {}", e.getMessage());
// Non-critical — continue without thumbnail
}
}
return new StoredFileInfo(...);
}
Profile Picture and Post Image Upload
Upload Service — Tying Files to Entities
File: src/main/java/com/example/blogapi/service/UploadService.java
package com.example.blogapi.service;
import com.example.blogapi.dto.FileUploadResponse;
import com.example.blogapi.exception.ResourceNotFoundException;
import com.example.blogapi.model.Post;
import com.example.blogapi.model.UploadedFile;
import com.example.blogapi.model.User;
import com.example.blogapi.repository.PostRepository;
import com.example.blogapi.repository.UploadedFileRepository;
import com.example.blogapi.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@Service
public class UploadService {
private static final Logger log = LoggerFactory.getLogger(UploadService.class);
private final FileStorageService fileStorageService;
private final UploadedFileRepository uploadedFileRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
public UploadService(FileStorageService fileStorageService,
UploadedFileRepository uploadedFileRepository,
PostRepository postRepository,
UserRepository userRepository) {
this.fileStorageService = fileStorageService;
this.uploadedFileRepository = uploadedFileRepository;
this.postRepository = postRepository;
this.userRepository = userRepository;
}
// Upload a cover image for a post
@Transactional
public FileUploadResponse uploadPostCover(Long postId, Long userId, MultipartFile file) {
log.info("Uploading cover image for post id={}", postId);
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", postId));
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", userId));
// Store the file on disk
FileStorageService.StoredFileInfo fileInfo = fileStorageService.storeFile(file, "posts");
// Save metadata to database
UploadedFile uploadedFile = new UploadedFile(
fileInfo.originalName(),
fileInfo.storedName(),
fileInfo.filePath(),
fileInfo.contentType(),
fileInfo.fileSize(),
"POST_COVER",
user
);
uploadedFile = uploadedFileRepository.save(uploadedFile);
// Delete old cover image if it exists
if (post.getCoverImage() != null) {
UploadedFile oldImage = post.getCoverImage();
fileStorageService.deleteFile(oldImage.getFilePath());
uploadedFileRepository.delete(oldImage);
log.info("Old cover image deleted: {}", oldImage.getFilePath());
}
// Associate the new image with the post
post.setCoverImage(uploadedFile);
log.info("Cover image uploaded: postId={}, fileId={}", postId, uploadedFile.getId());
return toResponse(uploadedFile);
}
// Upload an avatar for a user
@Transactional
public FileUploadResponse uploadAvatar(Long userId, MultipartFile file) {
log.info("Uploading avatar for user id={}", userId);
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", userId));
FileStorageService.StoredFileInfo fileInfo = fileStorageService.storeFile(file, "avatars");
UploadedFile uploadedFile = new UploadedFile(
fileInfo.originalName(),
fileInfo.storedName(),
fileInfo.filePath(),
fileInfo.contentType(),
fileInfo.fileSize(),
"AVATAR",
user
);
uploadedFile = uploadedFileRepository.save(uploadedFile);
// Delete old avatar if it exists
if (user.getAvatar() != null) {
UploadedFile oldAvatar = user.getAvatar();
fileStorageService.deleteFile(oldAvatar.getFilePath());
uploadedFileRepository.delete(oldAvatar);
}
user.setAvatar(uploadedFile);
log.info("Avatar uploaded: userId={}, fileId={}", userId, uploadedFile.getId());
return toResponse(uploadedFile);
}
private FileUploadResponse toResponse(UploadedFile file) {
return new FileUploadResponse(
file.getId(),
file.getOriginalName(),
"/api/files/" + file.getFilePath(),
file.getContentType(),
file.getFileSize()
);
}
}
The FileUploadResponse DTO
File: src/main/java/com/example/blogapi/dto/FileUploadResponse.java
package com.example.blogapi.dto;
public class FileUploadResponse {
private Long fileId;
private String originalName;
private String url;
private String contentType;
private long size;
public FileUploadResponse(Long fileId, String originalName, String url,
String contentType, long size) {
this.fileId = fileId;
this.originalName = originalName;
this.url = url;
this.contentType = contentType;
this.size = size;
}
public Long getFileId() { return fileId; }
public String getOriginalName() { return originalName; }
public String getUrl() { return url; }
public String getContentType() { return contentType; }
public long getSize() { return size; }
}
Update the Entity Relationships
Add the coverImage relationship to Post and avatar to User:
// In Post entity — add:
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cover_image_id")
private UploadedFile coverImage;
public UploadedFile getCoverImage() { return coverImage; }
public void setCoverImage(UploadedFile coverImage) { this.coverImage = coverImage; }
// In User entity — add:
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "avatar_id")
private UploadedFile avatar;
public UploadedFile getAvatar() { return avatar; }
public void setAvatar(UploadedFile avatar) { this.avatar = avatar; }
Configuring Max Upload Size
Spring Boot Configuration (Already Covered)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB
Handling the MaxUploadSizeExceededException
When a file exceeds the configured limit, Spring throws MaxUploadSizeExceededException before your controller code runs. Handle it in the GlobalExceptionHandler:
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ErrorResponse> handleMaxUploadSize(
MaxUploadSizeExceededException ex, HttpServletRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.PAYLOAD_TOO_LARGE.value(), // 413
"Payload Too Large",
"File size exceeds the maximum allowed size. Maximum: 10MB",
request.getRequestURI()
);
return new ResponseEntity<>(error, HttpStatus.PAYLOAD_TOO_LARGE);
}
Nginx Configuration (Production)
If your API runs behind Nginx (as we will configure in Lecture 17), you also need to increase Nginx’s upload limit:
# In nginx.conf or your server block
client_max_body_size 20M;
Without this, Nginx rejects large uploads with a 413 error before the request even reaches Spring Boot.
Hands-on: Add Image Upload to Blog Posts & User Profiles
Step 1: The Upload Controller Endpoints
Add to PostController.java:
// POST /api/posts/1/cover-image — upload a cover image
@PostMapping("/{id}/cover-image")
public ResponseEntity<FileUploadResponse> uploadCoverImage(
@PathVariable Long id,
@RequestParam("file") MultipartFile file,
Authentication authentication) {
String username = authentication.getName();
User user = userRepository.findByUsername(username).orElseThrow();
FileUploadResponse response = uploadService.uploadPostCover(id, user.getId(), file);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
Create UserController.java (or add to existing):
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UploadService uploadService;
private final UserRepository userRepository;
public UserController(UploadService uploadService, UserRepository userRepository) {
this.uploadService = uploadService;
this.userRepository = userRepository;
}
// POST /api/users/me/avatar — upload avatar for current user
@PostMapping("/me/avatar")
public ResponseEntity<FileUploadResponse> uploadAvatar(
@RequestParam("file") MultipartFile file,
Authentication authentication) {
String username = authentication.getName();
User user = userRepository.findByUsername(username).orElseThrow();
FileUploadResponse response = uploadService.uploadAvatar(user.getId(), file);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
Step 2: Update the PostResponse DTO
Add the image URL to the post response:
// In PostResponse — add:
private String coverImageUrl;
public String getCoverImageUrl() { return coverImageUrl; }
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
Update the PostMapper:
// In PostMapper.toResponse() — add:
if (post.getCoverImage() != null) {
dto.setCoverImageUrl("/api/files/" + post.getCoverImage().getFilePath());
}
Step 3: Test the Upload Flow
# Login first to get a JWT token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"securepass123"}' | jq -r '.accessToken')
# Upload a cover image for post 1
curl -X POST http://localhost:8080/api/posts/1/cover-image \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/spring-boot-cover.jpg"
# Response (201 Created):
# {
# "fileId": 1,
# "originalName": "spring-boot-cover.jpg",
# "url": "/api/files/posts/a1b2c3d4-e5f6-7890.jpg",
# "contentType": "image/jpeg",
# "size": 245760
# }
# View the image in a browser
# http://localhost:8080/api/files/posts/a1b2c3d4-e5f6-7890.jpg
# Upload an avatar
curl -X POST http://localhost:8080/api/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/avatar.png"
# Get a post — now includes coverImageUrl
curl http://localhost:8080/api/posts/1
# {
# "id": 1,
# "title": "...",
# "coverImageUrl": "/api/files/posts/a1b2c3d4-e5f6-7890.jpg",
# ...
# }
Step 4: Test Validation
# Upload an oversized file (exceeds 5MB application limit)
curl -X POST http://localhost:8080/api/posts/1/cover-image \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/huge-image.jpg"
# Expected 400: "File size exceeds maximum allowed size of 5MB"
# Upload a non-image file
curl -X POST http://localhost:8080/api/posts/1/cover-image \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/document.pdf"
# Expected 400: "File type not allowed. Accepted types: [image/jpeg, image/png, image/gif, image/webp]"
# Upload without authentication
curl -X POST http://localhost:8080/api/posts/1/cover-image \
-F "file=@/path/to/photo.jpg"
# Expected 401: "Authentication required"
Step 5: Exercises
- Add upload size to PostSummary: Include the cover image URL in the
PostSummaryprojection so list pages can show thumbnails. - Implement file deletion: Create
DELETE /api/posts/{id}/cover-imagethat removes the cover image from both disk and database. - Add multiple image support: Create an
imagesendpoint that allows uploading multiple images for a post (gallery-style). Store them in apost_imagesjunction table. - Add image dimension validation: Use
ImageIO.read()to verify that uploaded images meet minimum dimension requirements (e.g., cover images must be at least 800×400 pixels). - Implement cloud storage: Create an
S3FileStorageServicethat implements the same interface asFileStorageServicebut uploads to Amazon S3 (or MinIO for local testing). Use@Profileto switch between local and cloud storage.
Summary
This lecture added file management capabilities to your blog API:
- Multipart uploads: Spring Boot handles
multipart/form-datarequests automatically.MultipartFileprovides access to filename, content type, size, and data stream. - File storage strategies: Local filesystem for development, cloud storage (S3) for production, never database BLOBs. Design the service interface so storage backends are swappable.
- File metadata in MariaDB: Store the file on disk, store metadata (name, path, type, size, uploader) in the database. This enables searching, tracking, and association with entities.
- Serving files: Load files as Spring
Resourceobjects, return with appropriateContent-TypeandCache-Controlheaders. - Validation: Multi-layer defense — Spring Boot size limits, MIME type whitelist, application-level size check, filename sanitization, path traversal prevention.
- Thumbnail generation: Java’s
ImageIO+Graphics2Dresize images without external libraries. Generate thumbnails for list page performance. - Entity integration:
@OneToOnerelationships linkUploadedFileto Post (cover image) and User (avatar). Old files are cleaned up when replaced. - Security: Upload endpoints require JWT authentication. File serving endpoints are public (with cache headers).
What is Next
In Lecture 14, we will add API Documentation with Swagger/OpenAPI — making your API self-documenting so frontend developers, mobile developers, and other API consumers can understand and test your endpoints without reading the source code.
Quick Reference
| Concept | Description |
|---|---|
MultipartFile |
Spring’s interface for uploaded files |
@RequestParam("file") |
Binds an uploaded file to a controller parameter |
multipart/form-data |
HTTP content type for file uploads |
file.transferTo(path) |
Save uploaded file directly to disk |
file.getInputStream() |
Get file data as a stream (for large files) |
max-file-size |
Spring Boot limit per individual file |
max-request-size |
Spring Boot limit for entire multipart request |
| UUID filename | Prevents collisions and path traversal attacks |
Resource |
Spring interface for serving files via HTTP |
UrlResource |
Resource implementation for filesystem files |
Cache-Control |
HTTP header controlling browser caching |
| Path traversal | Attack using ../ in filenames to escape upload directory |
| Magic bytes | First bytes of a file that identify its true format |
ImageIO |
Java built-in library for reading/writing images |
@PostConstruct |
Method called after bean creation — create upload directories |
StoredFileInfo |
Record holding metadata about a stored file |
| 413 Payload Too Large | HTTP status for oversized uploads |
