File Upload & Static Resource Handling

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 .php or .jsp file and execute it on the server (remote code execution)
  • Upload a file named ../../etc/passwd to overwrite system files (path traversal)
  • Upload a file with a .jpg extension 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

  1. Add upload size to PostSummary: Include the cover image URL in the PostSummary projection so list pages can show thumbnails.
  2. Implement file deletion: Create DELETE /api/posts/{id}/cover-image that removes the cover image from both disk and database.
  3. Add multiple image support: Create an images endpoint that allows uploading multiple images for a post (gallery-style). Store them in a post_images junction table.
  4. 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).
  5. Implement cloud storage: Create an S3FileStorageService that implements the same interface as FileStorageService but uploads to Amazon S3 (or MinIO for local testing). Use @Profile to switch between local and cloud storage.

Summary

This lecture added file management capabilities to your blog API:

  • Multipart uploads: Spring Boot handles multipart/form-data requests automatically. MultipartFile provides 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 Resource objects, return with appropriate Content-Type and Cache-Control headers.
  • 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 + Graphics2D resize images without external libraries. Generate thumbnails for list page performance.
  • Entity integration: @OneToOne relationships link UploadedFile to 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

Leave a Reply

Your email address will not be published. Required fields are marked *