Skip to content

Large file uploads

A @FileUpload field reads the picked file into a data URI held in the field’s value. That is fine for a photo or a small CSV, and unusable for a 20 MB PDF: the bytes would travel back and forth in the component state on every action.

For real files, Mateu uploads them — with a progress bar, a Cancel, server-side size and type limits — to a storage port your application implements. Mateu ships the UI of uploading and the endpoint that receives the file; it ships no storage (no disk, S3 or Azure adapter): you write the few lines that keep the file wherever your files live, exactly as you write a listing exporter.

@Component // Spring; a CDI / Micronaut @Singleton works the same
class DiskFileStorage implements FileStorage {
private final Path root = Path.of("uploads");
@Override
public StoredFile store(IncomingFile file, HttpRequest httpRequest) throws Exception {
var id = UUID.randomUUID().toString();
Files.createDirectories(root);
long size = Files.copy(file.content(), root.resolve(id)); // streamed, never in memory
return StoredFile.builder()
.id(id).name(file.name()).contentType(file.contentType()).size(size)
.url("/files/" + id) // optional: where the UI links the file
.build();
}
}

file.content() is the request body as it arrives, wrapped in a stream that throws an IOException past the field’s size limit — let it propagate (and drop the partial file). A throw is answered as an unexpected error, with a reference in the log.

As soon as a FileStorage bean is registered, every editable @FileUpload field of the app uploads through it.

@FileUpload(accept = ".pdf,application/pdf", maxBytes = 50L * 1024 * 1024)
String contract; // one file: a reference
@FileUpload(maxBytes = 200L * 1024 * 1024)
List<String> attachments; // several files: a list of references
AttributeMeaning
acceptComma list for the file input — .pdf, image/*, application/pdf. Enforced by the server too.
maxBytesThe largest ONE file may weigh. 0 = the server’s default, mateu.uploads.max-bytes (100 MB). Enforced while the file streams (413 past it).
multipleSeveral files (the value is a list). Inferred for a List<String> / Set<String> / array field.
storageThe FileStorage.name() to use when the app registers several; blank = the default store (the one named "", or the only one). FileUpload.INLINE keeps the field a data URI even with a store — for code that reads the bytes from the value, like the import wizard’s CSV.

Fluent and YAML fields carry the same options in their generic attributes (stereotype: fileUpload, attributes: {accept: .pdf, maxBytes: "52428800", multiple: "true"}).

The value is a reference, never the bytes:

mateu-file:<id>?name=contract.pdf&type=application%2Fpdf&size=1048576&url=%2Ffiles%2F<id>
@Toolbar
Message save() {
var stored = StoredFile.parse(contract); // null for a data URI or a plain URL
repository.attach(order, stored.id());
return Message.success("Saved " + stored.name());
}

Direct uploads to the store (pre-signed targets)

Section titled “Direct uploads to the store (pre-signed targets)”

A store that can issue pre-signed URLs (S3, Azure Blob, GCS…) can have the browser send the bytes straight to it, so they never cross your server:

@Override public boolean directUploads() { return true; }
@Override
public UploadTarget uploadTarget(UploadRequest upload, HttpRequest httpRequest) {
var key = UUID.randomUUID().toString();
return UploadTarget.builder()
.url(presignedPutUrl(key, upload.contentType())) // your SDK
.method("PUT")
.headers(Map.of("Content-Type", upload.contentType()))
.file(StoredFile.builder().id(key).name(upload.name())
.contentType(upload.contentType()).size(upload.size()).build())
.build();
}

Mateu checks the declared size and type against the field before asking you. Return null for a file you prefer to receive through store instead. The browser sends no Mateu credentials to the pre-signed URL — only the headers you return. Your store must allow that origin (CORS on the bucket).

No FileStorage registered (or storage = FileUpload.INLINE): the field keeps today’s behaviour — the file is read in the browser into a data URI stored in the value — and the client refuses a file larger than maxBytes, or 10 MB when undeclared, with “This file is too large (max 10 MB).”.

  • The endpoint is POST /mateu/v3/upload (and /mateu/v3/upload/target), at the root path, on every adapter (Spring MVC, WebFlux, Micronaut, Quarkus, Helidon MP) and on the .NET and Python ports. Its body is the raw file, not multipart.
  • Each editable upload field the caller is SHOWN carries a short-lived, signed ticket naming the store, the field’s limits and a hash of the caller’s identity. No ticket, a forged or expired one, or another caller’s → 403. A read-only field, or one hidden by @EyesOnly / an access key, never gets a ticket.
  • A ticket sends a bounded number of files: 10 for a single-file field (the file and its replacements before the form is saved), mateu.uploads.max-files-per-ticket (100) for a multiple one; past it → 429 “Too many uploads”, and a reload of the page issues a new ticket.
  • The ticket travels in the X-Mateu-Upload-Ticket header: a cross-site page cannot send it without a CORS preflight, which is what makes the endpoint CSRF-safe without a JSON body (Spring Security’s CSRF exemption covers it). CORS follows the usual mateu.cors.allowed-origins.
  • Tickets are signed with mateu.uploads.secret / MATEU_UPLOADS_SECRET; without it each process draws a random secret — fine for one instance, but a deployment of several instances must set it, or a ticket issued by one is refused by the others.
builder.Services.AddSingleton<IFileStorage, DiskFileStorage>();
class DiskFileStorage : IFileStorage
{
public async Task<StoredFile> StoreAsync(IncomingFile file, IncomingRequest request)
{
var id = Guid.NewGuid().ToString();
await using var target = File.Create(Path.Combine("uploads", id));
await file.Content.CopyToAsync(target);
return new StoredFile(id, file.Name, file.ContentType, target.Length);
}
}
[FileUpload(".pdf", MaxBytes = 50 * 1024 * 1024)] public string? Contract { get; set; }
import os
import uuid
from pathlib import Path
from fastapi import FastAPI
import views # your module of @ui classes
class DiskFileStorage(FileStorage):
async def store(self, file: IncomingFile, request) -> StoredFile:
id = uuid.uuid4().hex # the store picks the name: never the client's
size = 0
with open(Path("uploads") / id, "wb") as target:
async for chunk in file.content:
target.write(chunk)
size += len(chunk)
return StoredFile(id=id, name=file.name, content_type=file.content_type, size=size)
app = FastAPI()
add_mateu(app, views, file_storages=[DiskFileStorage()], uploads_secret=os.environ["SECRET"])
# on a view:
contract: Annotated[str, FileUpload(accept=".pdf", max_bytes=50 * 1024 * 1024)] = ""

Options: .NET MateuOptions.UploadsSecret; Python add_mateu(uploads_secret=…); both read MATEU_UPLOADS_SECRET otherwise.

RendererUpload mode
Web (Vaadin)✅ progress, Cancel, errors, several files, direct uploads
React Native✅ progress, Cancel, errors, several files, direct uploads
IntelliJ / desktop✅ progress, Cancel, errors, several files, direct uploads
Redwood🟡 shows a stored reference (name · size); picking a file still stores a data URI — an app serving Redwood must accept both values

Demo: demo/demo-admin-panel — /file-uploads-demo over a disk-backed DiskFileStorage.