Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

File uploads

A picture for a post, an avatar, a document — the user sends all of these to the server as a file upload. For this a form uses a special format, multipart/form-data: the request body is split into parts, and each part is a separate field or file. Javalin parses such a request itself and gives you the file as an UploadedFile object. Let us add image uploads to the blog.

A form for a file

In HTML the form needs the enctype attribute, and the field needs the file type:

<!-- src/main/resources/public/upload.html -->
<form method="post" action="/uploads" enctype="multipart/form-data">
    <input type="file" name="image" accept="image/*">
    <button type="submit">Upload</button>
</form>

The page opens at http://localhost:8080/upload.html: files from public are served as static files.

The upload controller

// src/main/java/com/example/blog/UploadController.java
package com.example.blog;
 
import io.javalin.http.BadRequestResponse;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
import io.javalin.http.UploadedFile;
 
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
 
public class UploadController {
    private static final Set<String> ALLOWED = Set.of(".png", ".jpg", ".jpeg", ".gif", ".webp");
 
    private final Path dir;
 
    public UploadController(Path dir) {
        this.dir = dir;
    }
 
    public void upload(Context ctx) throws IOException {
        UploadedFile file = ctx.uploadedFile("image");
        if (file == null) {
            throw new BadRequestResponse("No file in the image field");
        }
        String name = save(file);
        ctx.status(HttpStatus.CREATED).json(Map.of("url", "/uploads/" + name, "size", file.size()));
    }
 
    private String save(UploadedFile file) throws IOException {
        String ext = file.extension().toLowerCase();
        if (!ALLOWED.contains(ext)) {
            throw new BadRequestResponse("Only images are allowed: " + ALLOWED);
        }
        String name = UUID.randomUUID() + ext;
        try (InputStream in = file.content()) {
            Files.copy(in, dir.resolve(name));
        }
        return name;
    }
}

ctx.uploadedFile("image") returns the file from the image field, or null. UploadedFile has filename(), extension() (including the dot), size(), contentType() and content() — a stream with the data.

Why not the client's file name

The client sends the file name, and you cannot trust it: it may contain ../../Main.java or the name of someone else's file. So the server checks the extension against a list of allowed ones and makes up its own name with UUID — a random unique identifier. This way a file cannot overwrite another one or escape the folder.

Wiring, the limit and serving

// src/main/java/com/example/blog/Main.java
Path uploadDir = Path.of("uploads");
Files.createDirectories(uploadDir);   // main now declares throws IOException
var uploads = new UploadController(uploadDir);
 
// inside Javalin.create(config -> { ... })
config.jetty.multipartConfig.maxFileSize(2, SizeUnit.MB);   // io.javalin.config.SizeUnit
config.staticFiles.add(files -> {
    files.hostedPath = "/uploads";
    files.directory = "uploads";
    files.location = Location.EXTERNAL;
});
 
// in the apiBuilder block
post("/uploads", uploads::upload);

By default the file size is not limited. maxFileSize rejects files larger than 2 MB, and maxTotalRequestSize limits the whole request. Javalin serves the uploads folder next to the project as static files from disk (Location.EXTERNAL) at /uploads/....

Trying it out

The -F option sends a multipart/form-data request, and @ before the name means "take the file from disk":

curl -i -F "image=@cat.png" http://localhost:8080/uploads
HTTP/1.1 201 Created
Content-Type: application/json

{"url":"/uploads/67e1d845-c9f1-408a-a4b4-ec15453d2692.png","size":70}

The file is available at the link from the response:

curl -I http://localhost:8080/uploads/67e1d845-c9f1-408a-a4b4-ec15453d2692.png
HTTP/1.1 200 OK
Content-Type: image/png
Cache-Control: max-age=0

A text file gets 400 and the text Only images are allowed: [.webp, .jpeg, .jpg, .gif, .png]. Several files from one field are read with ctx.uploadedFiles("images") — it returns a list that you go through in a loop.

Common mistake

If a file is larger than the limit, Javalin cannot parse the request. The client gets an error or a dropped connection (curl: (56) Recv failure: Connection was reset), and the server log shows:

WARN io.javalin.Javalin - Uncaught exception
jakarta.servlet.ServletException: org.eclipse.jetty.http.BadMessageException: 400: bad multipart
...
Caused by: java.lang.IllegalStateException: max file size exceeded: 2097152

The number is the limit in bytes: 2 MB. If there is no error but uploadedFile still returns null, check enctype="multipart/form-data" in the form: without it the browser sends only the file name, and the answer is No file in the image field.

Thanks for reading!

Donate to the site — we work hard for you.