Creare un'applicazione in stile WeTransfer con Java e Spring Boot 4
WeTransfer ha reso popolare un'idea molto semplice: caricare uno o più file, ottenere un link, condividerlo e lasciare che tutto scompaia dopo qualche giorno. Dietro questa semplicità apparente si nascondono però diversi problemi tecnici interessanti: gestione di upload di grandi dimensioni senza saturare la memoria, generazione di link non indovinabili, streaming del download, creazione di archivi ZIP al volo, scadenza automatica dei contenuti e pulizia dello storage.
In questo articolo costruiremo, passo dopo passo, un servizio di questo tipo con Java e Spring Boot 4, partendo dal modello dati fino al deployment dietro Nginx. Il risultato sarà un'applicazione monolitica, volutamente semplice da mettere in produzione su un singolo VPS, ma progettata con astrazioni che permettono di passare a uno storage a oggetti (S3, MinIO) senza riscrivere la logica applicativa.
Requisiti funzionali
Prima di scrivere codice conviene fissare i confini del progetto. Il servizio dovrà:
- accettare l'upload di uno o più file in un'unica operazione, raggruppati in un transfer;
- restituire un link pubblico contenente un token opaco e non indovinabile;
- permettere il download di un singolo file oppure dell'intero transfer come archivio ZIP generato in streaming;
- far scadere il transfer dopo un numero configurabile di giorni;
- cancellare fisicamente i file scaduti tramite un job pianificato;
- inviare una email al mittente con il link e, opzionalmente, ai destinatari;
- proteggere l'endpoint di upload con un rate limiting elementare;
- consentire la cancellazione anticipata tramite un secondo token privato.
Restano fuori dallo scopo, ma li citeremo dove rilevante, l'autenticazione degli utenti, la scansione antivirus e la cifratura a riposo.
Architettura generale
L'applicazione segue una struttura a strati piuttosto convenzionale, con un'unica astrazione importante: lo strato di storage è separato dal resto e nascosto dietro un'interfaccia. Il flusso di un upload è il seguente:
- il client invia una richiesta
multipart/form-dataall'endpoint di upload; - il container Servlet scrive le parti su file temporanei su disco, non in memoria;
- il servizio applicativo crea un'entità
Transfere, per ogni parte, delega alloStorageServicela persistenza del contenuto, calcolando nel frattempo la dimensione e un checksum; - vengono generati i due token (download e delete) e salvati i metadati su PostgreSQL;
- viene inviata la notifica via email e restituita la risposta JSON con i link.
I byte dei file non passano mai per la memoria heap in blocco: dal file temporaneo del container vengono copiati verso lo storage con un InputStream. È la scelta che rende sostenibile il caricamento di archivi da diversi gigabyte su una macchina con poca RAM.
Impostazione del progetto
Spring Boot 4 richiede come baseline Java 17, ma conviene partire da una versione recente della piattaforma per sfruttare i pattern matching e i record senza compromessi. Nell'esempio useremo Java 25 e Maven.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
<relativePath/>
</parent>
<groupId>dev.example</groupId>
<artifactId>filedrop</artifactId>
<version>1.0.0</version>
<name>filedrop</name>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- In Spring Boot 4 Flyway va aggiunto tramite lo starter dedicato -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Chi arriva da Spring Boot 3 troverà qui il primo cambiamento rilevante: la gestione delle migrazioni non viene più attivata dalla semplice presenza di flyway-core sul classpath, ma passa da uno starter esplicito. Vedremo più avanti un secondo cambiamento, relativo alla riorganizzazione dei package di auto-configurazione, che si nota soprattutto nei test.
Configurazione
Il file application.yaml raccoglie sia la configurazione dell'infrastruttura sia le proprietà specifiche dell'applicazione, che raggrupperemo sotto il prefisso filedrop.
spring:
application:
name: filedrop
datasource:
url: jdbc:postgresql://localhost:5432/filedrop
username: filedrop
password: filedrop
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
flyway:
enabled: true
servlet:
multipart:
enabled: true
max-file-size: 5GB
max-request-size: 10GB
# Soglia a zero: ogni parte finisce subito su disco, mai in memoria
file-size-threshold: 0
location: /var/tmp/filedrop-uploads
mail:
host: smtp.example.com
port: 587
username: no-reply@example.com
password: ${SMTP_PASSWORD}
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
server:
# Nessun limite lato Tomcat: la dimensione la governa la configurazione multipart
tomcat:
max-swallow-size: -1
max-http-form-post-size: -1
filedrop:
base-url: https://filedrop.example.com
storage:
root: /srv/filedrop/data
limits:
max-files-per-transfer: 50
max-transfer-size: 10GB
retention: 7d
rate-limit:
uploads-per-hour: 10
mail:
from: no-reply@example.com
Le proprietà personalizzate vengono mappate su un record immutabile, la soluzione più naturale a partire da Spring Boot 3 e ormai lo standard di fatto.
package dev.example.filedrop.config;
import java.nio.file.Path;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DataSizeUnit;
import org.springframework.util.unit.DataSize;
import org.springframework.util.unit.DataUnit;
@ConfigurationProperties(prefix = "filedrop")
public record FiledropProperties(
String baseUrl,
Storage storage,
Limits limits,
RateLimit rateLimit,
Mail mail) {
public record Storage(Path root) {
}
public record Limits(
int maxFilesPerTransfer,
@DataSizeUnit(DataUnit.BYTES) DataSize maxTransferSize,
Duration retention) {
}
public record RateLimit(int uploadsPerHour) {
}
public record Mail(String from) {
}
}
La classe principale abilita il binding delle proprietà, la pianificazione dei job e l'esecuzione asincrona per l'invio delle email.
package dev.example.filedrop;
import dev.example.filedrop.config.FiledropProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableConfigurationProperties(FiledropProperties.class)
@EnableScheduling
@EnableAsync
public class FiledropApplication {
public static void main(String[] args) {
SpringApplication.run(FiledropApplication.class, args);
}
}
Il modello dati
Due sole entità sono sufficienti. Un Transfer rappresenta la spedizione nel suo insieme e possiede i token, i metadati del mittente e la data di scadenza. Uno StoredFile rappresenta il singolo file caricato e conserva il nome originale, la chiave di storage, il tipo MIME, la dimensione e il checksum.
La separazione fra nome originale e chiave di storage è essenziale per la sicurezza: il nome fornito dall'utente non deve mai essere usato per costruire un percorso sul filesystem, perché aprirebbe la porta al path traversal. La chiave viene invece generata dal server.
package dev.example.filedrop.domain;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "transfers")
public class Transfer {
@Id
private UUID id;
@Column(nullable = false, unique = true, length = 64)
private String downloadToken;
@Column(nullable = false, unique = true, length = 64)
private String deleteToken;
@Column(length = 200)
private String title;
@Column(length = 2000)
private String message;
@Column(nullable = false, length = 254)
private String senderEmail;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "transfer_recipients", joinColumns = @JoinColumn(name = "transfer_id"))
@Column(name = "email", nullable = false, length = 254)
private List<String> recipients = new ArrayList<>();
@Column(nullable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant expiresAt;
@Column(nullable = false)
private long totalSize;
@Column(nullable = false)
private int downloadCount;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TransferStatus status = TransferStatus.ACTIVE;
@OneToMany(mappedBy = "transfer", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC")
private List<StoredFile> files = new ArrayList<>();
protected Transfer() {
// Richiesto da JPA
}
public Transfer(String senderEmail, Instant createdAt, Instant expiresAt,
String downloadToken, String deleteToken) {
this.id = UUID.randomUUID();
this.senderEmail = senderEmail;
this.createdAt = createdAt;
this.expiresAt = expiresAt;
this.downloadToken = downloadToken;
this.deleteToken = deleteToken;
}
public void addFile(StoredFile file) {
file.attachTo(this, this.files.size());
this.files.add(file);
this.totalSize += file.getSize();
}
public boolean isExpired(Instant now) {
return now.isAfter(this.expiresAt);
}
public boolean isDownloadable(Instant now) {
return this.status == TransferStatus.ACTIVE && !isExpired(now);
}
public void registerDownload() {
this.downloadCount++;
}
public void markDeleted() {
this.status = TransferStatus.DELETED;
}
// Omessi per brevità i restanti getter
public UUID getId() {
return id;
}
public List<StoredFile> getFiles() {
return files;
}
public String getDownloadToken() {
return downloadToken;
}
public String getDeleteToken() {
return deleteToken;
}
public long getTotalSize() {
return totalSize;
}
public Instant getExpiresAt() {
return expiresAt;
}
public String getSenderEmail() {
return senderEmail;
}
public List<String> getRecipients() {
return recipients;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
package dev.example.filedrop.domain;
public enum TransferStatus {
ACTIVE,
EXPIRED,
DELETED
}
package dev.example.filedrop.domain;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "stored_files")
public class StoredFile {
@Id
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "transfer_id", nullable = false)
private Transfer transfer;
@Column(nullable = false, length = 255)
private String originalName;
@Column(nullable = false, length = 300)
private String storageKey;
@Column(nullable = false, length = 150)
private String contentType;
@Column(nullable = false)
private long size;
@Column(nullable = false, length = 64)
private String checksum;
@Column(nullable = false)
private int position;
protected StoredFile() {
// Richiesto da JPA
}
public StoredFile(String originalName, String storageKey, String contentType,
long size, String checksum) {
this.id = UUID.randomUUID();
this.originalName = originalName;
this.storageKey = storageKey;
this.contentType = contentType;
this.size = size;
this.checksum = checksum;
}
void attachTo(Transfer transfer, int position) {
this.transfer = transfer;
this.position = position;
}
public UUID getId() {
return id;
}
public String getOriginalName() {
return originalName;
}
public String getStorageKey() {
return storageKey;
}
public String getContentType() {
return contentType;
}
public long getSize() {
return size;
}
public String getChecksum() {
return checksum;
}
}
La migrazione Flyway
Con ddl-auto: validate lo schema è responsabilità esclusiva di Flyway. Il file src/main/resources/db/migration/V1__initial_schema.sql contiene la definizione iniziale.
CREATE TABLE transfers (
id UUID PRIMARY KEY,
download_token VARCHAR(64) NOT NULL UNIQUE,
delete_token VARCHAR(64) NOT NULL UNIQUE,
title VARCHAR(200),
message VARCHAR(2000),
sender_email VARCHAR(254) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
total_size BIGINT NOT NULL DEFAULT 0,
download_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL
);
CREATE INDEX idx_transfers_expires_at ON transfers (expires_at);
CREATE INDEX idx_transfers_status ON transfers (status);
CREATE TABLE transfer_recipients (
transfer_id UUID NOT NULL REFERENCES transfers (id) ON DELETE CASCADE,
email VARCHAR(254) NOT NULL
);
CREATE INDEX idx_transfer_recipients_transfer ON transfer_recipients (transfer_id);
CREATE TABLE stored_files (
id UUID PRIMARY KEY,
transfer_id UUID NOT NULL REFERENCES transfers (id) ON DELETE CASCADE,
original_name VARCHAR(255) NOT NULL,
storage_key VARCHAR(300) NOT NULL,
content_type VARCHAR(150) NOT NULL,
size BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
position INTEGER NOT NULL
);
CREATE INDEX idx_stored_files_transfer ON stored_files (transfer_id);
I repository
package dev.example.filedrop.domain;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TransferRepository extends JpaRepository<Transfer, UUID> {
@EntityGraph(attributePaths = "files")
Optional<Transfer> findByDownloadToken(String downloadToken);
Optional<Transfer> findByDeleteToken(String deleteToken);
// Recupera un lotto limitato di transfer scaduti da ripulire
@EntityGraph(attributePaths = "files")
List<Transfer> findByStatusAndExpiresAtBefore(TransferStatus status, Instant threshold, Limit limit);
}
Lo strato di storage
L'astrazione dello storage è il punto in cui si decide se l'applicazione resterà legata al filesystem locale o potrà migrare verso un object store. L'interfaccia deve quindi ragionare per chiavi opache e stream, non per percorsi.
package dev.example.filedrop.storage;
import java.io.InputStream;
import org.springframework.core.io.Resource;
public interface StorageService {
/**
* Scrive il contenuto dello stream sotto la chiave indicata e restituisce
* i metadati calcolati durante la scrittura (dimensione e checksum).
*/
StoredObject store(String key, InputStream content);
Resource load(String key);
void delete(String key);
boolean exists(String key);
}
package dev.example.filedrop.storage;
public record StoredObject(String key, long size, String checksum) {
}
L'implementazione su filesystem distribuisce i file in sottodirectory basate sui primi caratteri della chiave. È un accorgimento che evita di ritrovarsi con centinaia di migliaia di inode nella stessa directory, condizione che degrada sensibilmente le prestazioni su ext4.
package dev.example.filedrop.storage;
import dev.example.filedrop.config.FiledropProperties;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
@Component
public class FileSystemStorageService implements StorageService {
private final Path root;
public FileSystemStorageService(FiledropProperties properties) {
this.root = properties.storage().root().toAbsolutePath().normalize();
try {
Files.createDirectories(this.root);
} catch (IOException ex) {
throw new StorageException("Impossibile creare la directory radice dello storage", ex);
}
}
@Override
public StoredObject store(String key, InputStream content) {
Path target = resolve(key);
try {
Files.createDirectories(target.getParent());
// Scrittura su file temporaneo e spostamento atomico:
// evita di lasciare file parziali in caso di errore
Path temp = Files.createTempFile(target.getParent(), "upload-", ".part");
MessageDigest digest = MessageDigest.getInstance("SHA-256");
long size;
try (OutputStream out = Files.newOutputStream(temp);
DigestOutputStream digestOut = new DigestOutputStream(out, digest)) {
size = content.transferTo(digestOut);
}
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
String checksum = HexFormat.of().formatHex(digest.digest());
return new StoredObject(key, size, checksum);
} catch (IOException | NoSuchAlgorithmException ex) {
throw new StorageException("Scrittura non riuscita per la chiave " + key, ex);
}
}
@Override
public Resource load(String key) {
Path target = resolve(key);
if (!Files.isReadable(target)) {
throw new StorageException("Oggetto non trovato: " + key);
}
return new FileSystemResource(target);
}
@Override
public void delete(String key) {
try {
Files.deleteIfExists(resolve(key));
} catch (IOException ex) {
throw new StorageException("Cancellazione non riuscita per la chiave " + key, ex);
}
}
@Override
public boolean exists(String key) {
return Files.exists(resolve(key));
}
/**
* Traduce una chiave logica in un percorso, verificando che il risultato
* resti confinato dentro la directory radice.
*/
private Path resolve(String key) {
Path candidate = root.resolve(key).normalize();
if (!candidate.startsWith(root)) {
throw new StorageException("Chiave non valida: " + key);
}
return candidate;
}
}
package dev.example.filedrop.storage;
public class StorageException extends RuntimeException {
public StorageException(String message) {
super(message);
}
public StorageException(String message, Throwable cause) {
super(message, cause);
}
}
Il controllo candidate.startsWith(root) dopo la normalizzazione è la difesa contro il path traversal. Anche se le chiavi sono generate dal server, il controllo resta: la sicurezza va posta il più vicino possibile all'operazione pericolosa, non solo a monte.
Generazione dei token
Il link di download è l'unico meccanismo di protezione del contenuto, quindi il token deve avere entropia sufficiente da rendere il bruteforce impraticabile. Un token da 32 byte casuali codificato in Base64 URL-safe fornisce 256 bit di entropia e produce una stringa di 43 caratteri, adatta a comparire in un URL.
package dev.example.filedrop.service;
import java.security.SecureRandom;
import java.util.Base64;
import org.springframework.stereotype.Component;
@Component
public class TokenGenerator {
private static final int TOKEN_BYTES = 32;
private final SecureRandom random = new SecureRandom();
private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
public String newToken() {
byte[] buffer = new byte[TOKEN_BYTES];
random.nextBytes(buffer);
return encoder.encodeToString(buffer);
}
/**
* Genera la chiave di storage di un file: due livelli di sharding
* ricavati dall'identificativo, seguiti dall'identificativo stesso.
*/
public String newStorageKey(String id) {
return "%s/%s/%s".formatted(id.substring(0, 2), id.substring(2, 4), id);
}
}
Un dettaglio importante riguarda il confronto dei token in fase di cancellazione: usare equals su una stringa segreta espone, in teoria, a un timing attack. Nella pratica il rumore di rete lo rende difficilmente sfruttabile, ma il costo di usare MessageDigest.isEqual è nullo e conviene adottarlo.
L'endpoint di upload
Il controller riceve le parti multipart e i campi del form. Vale la pena sottolineare che i MultipartFile non contengono i byte in memoria: con file-size-threshold: 0 ogni parte è già un file temporaneo su disco, e getInputStream() restituisce uno stream su quel file.
package dev.example.filedrop.web;
import dev.example.filedrop.service.TransferService;
import dev.example.filedrop.web.dto.CreateTransferRequest;
import dev.example.filedrop.web.dto.TransferCreatedResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/transfers")
public class TransferUploadController {
private final TransferService transferService;
public TransferUploadController(TransferService transferService) {
this.transferService = transferService;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public TransferCreatedResponse upload(
@RequestPart("metadata") @Valid CreateTransferRequest metadata,
@RequestPart("files") List<MultipartFile> files) {
return transferService.create(metadata, files);
}
}
package dev.example.filedrop.web.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record CreateTransferRequest(
@NotBlank @Email @Size(max = 254) String senderEmail,
@Size(max = 200) String title,
@Size(max = 2000) String message,
List<@Email @Size(max = 254) String> recipients) {
public List<String> safeRecipients() {
return recipients == null ? List.of() : recipients;
}
}
package dev.example.filedrop.web.dto;
import java.time.Instant;
import java.util.UUID;
public record TransferCreatedResponse(
UUID id,
String downloadUrl,
String deleteUrl,
Instant expiresAt,
long totalSize,
int fileCount) {
}
Il servizio applicativo
Qui si concentra la logica: validazione dei limiti, sanificazione dei nomi, scrittura sullo storage, persistenza dei metadati e compensazione in caso di errore.
package dev.example.filedrop.service;
import dev.example.filedrop.config.FiledropProperties;
import dev.example.filedrop.domain.*;
import dev.example.filedrop.storage.StorageService;
import dev.example.filedrop.storage.StoredObject;
import dev.example.filedrop.web.dto.CreateTransferRequest;
import dev.example.filedrop.web.dto.TransferCreatedResponse;
import java.io.IOException;
import java.io.InputStream;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@Service
public class TransferService {
private static final Logger log = LoggerFactory.getLogger(TransferService.class);
private final TransferRepository transferRepository;
private final StorageService storageService;
private final TokenGenerator tokenGenerator;
private final NotificationService notificationService;
private final FiledropProperties properties;
private final Clock clock;
public TransferService(TransferRepository transferRepository,
StorageService storageService,
TokenGenerator tokenGenerator,
NotificationService notificationService,
FiledropProperties properties,
Clock clock) {
this.transferRepository = transferRepository;
this.storageService = storageService;
this.tokenGenerator = tokenGenerator;
this.notificationService = notificationService;
this.properties = properties;
this.clock = clock;
}
@Transactional
public TransferCreatedResponse create(CreateTransferRequest request, List<MultipartFile> files) {
validate(files);
Instant now = Instant.now(clock);
Transfer transfer = new Transfer(
request.senderEmail(),
now,
now.plus(properties.limits().retention()),
tokenGenerator.newToken(),
tokenGenerator.newToken());
transfer.setTitle(request.title());
transfer.setMessage(request.message());
transfer.getRecipients().addAll(request.safeRecipients());
// Teniamo traccia delle chiavi scritte: se qualcosa fallisce a metà
// dobbiamo rimuovere i byte già finiti sullo storage
List<String> writtenKeys = new ArrayList<>();
try {
for (MultipartFile file : files) {
String key = tokenGenerator.newStorageKey(UUID.randomUUID().toString().replace("-", ""));
StoredObject stored;
try (InputStream in = file.getInputStream()) {
stored = storageService.store(key, in);
}
writtenKeys.add(key);
transfer.addFile(new StoredFile(
sanitizeFileName(file.getOriginalFilename()),
stored.key(),
resolveContentType(file),
stored.size(),
stored.checksum()));
}
enforceTotalSize(transfer);
transferRepository.save(transfer);
} catch (IOException ex) {
rollbackStorage(writtenKeys);
throw new TransferException("Errore durante la lettura di un file caricato", ex);
} catch (RuntimeException ex) {
rollbackStorage(writtenKeys);
throw ex;
}
notificationService.notifyTransferCreated(transfer);
return new TransferCreatedResponse(
transfer.getId(),
downloadUrl(transfer),
deleteUrl(transfer),
transfer.getExpiresAt(),
transfer.getTotalSize(),
transfer.getFiles().size());
}
private void validate(List<MultipartFile> files) {
if (files == null || files.isEmpty()) {
throw new TransferException("Nessun file caricato");
}
if (files.size() > properties.limits().maxFilesPerTransfer()) {
throw new TransferException("Numero di file superiore al limite consentito");
}
long declaredSize = files.stream().mapToLong(MultipartFile::getSize).sum();
if (declaredSize > properties.limits().maxTransferSize().toBytes()) {
throw new TransferException("Dimensione complessiva superiore al limite consentito");
}
}
private void enforceTotalSize(Transfer transfer) {
// Ricontrollo sulla dimensione reale scritta, non su quella dichiarata dal client
if (transfer.getTotalSize() > properties.limits().maxTransferSize().toBytes()) {
throw new TransferException("Dimensione complessiva superiore al limite consentito");
}
}
private void rollbackStorage(List<String> keys) {
for (String key : keys) {
try {
storageService.delete(key);
} catch (RuntimeException ex) {
log.warn("Impossibile rimuovere l'oggetto orfano {}", key, ex);
}
}
}
/**
* Riduce il nome fornito dal client al solo nome file, senza componenti
* di percorso, e sostituisce i caratteri problematici.
*/
private String sanitizeFileName(String original) {
String name = StringUtils.getFilename(original);
if (!StringUtils.hasText(name)) {
return "file";
}
name = name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_");
return name.length() > 255 ? name.substring(name.length() - 255) : name;
}
private String resolveContentType(MultipartFile file) {
String contentType = file.getContentType();
return StringUtils.hasText(contentType) ? contentType : "application/octet-stream";
}
private String downloadUrl(Transfer transfer) {
return "%s/d/%s".formatted(properties.baseUrl(), transfer.getDownloadToken());
}
private String deleteUrl(Transfer transfer) {
return "%s/api/transfers/%s".formatted(properties.baseUrl(), transfer.getDeleteToken());
}
}
Due punti meritano attenzione. Il primo è la doppia validazione della dimensione: il valore restituito da MultipartFile.getSize() dipende da ciò che il container ha effettivamente ricevuto ed è affidabile, ma ricontrollare la somma dei byte realmente scritti costa nulla e chiude il cerchio. Il secondo è la compensazione manuale: la transazione JPA protegge le righe su PostgreSQL, non i byte sul filesystem. Se il salvataggio fallisce, i file già scritti restano orfani a meno di rimuoverli esplicitamente.
Il Clock iniettato non è pedanteria: rende i test sulla scadenza deterministici. Va dichiarato come bean.
package dev.example.filedrop.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ClockConfiguration {
@Bean
public Clock clock() {
return Clock.systemUTC();
}
}
Il download
Il download ha due modalità: file singolo e archivio completo. Nel primo caso possiamo restituire direttamente una Resource e lasciare che Spring gestisca lo streaming, incluso il supporto per le richieste con Range, indispensabile per riprendere un download interrotto.
package dev.example.filedrop.web;
import dev.example.filedrop.service.DownloadService;
import dev.example.filedrop.service.FileDownload;
import dev.example.filedrop.web.dto.TransferSummary;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@RestController
public class TransferDownloadController {
private final DownloadService downloadService;
public TransferDownloadController(DownloadService downloadService) {
this.downloadService = downloadService;
}
// Anteprima dei metadati: alimenta la pagina pubblica del transfer
@GetMapping("/api/d/{token}")
public TransferSummary summary(@PathVariable String token) {
return downloadService.summary(token);
}
@GetMapping("/api/d/{token}/files/{fileId}")
public ResponseEntity<Resource> downloadSingle(@PathVariable String token,
@PathVariable UUID fileId) {
FileDownload download = downloadService.singleFile(token, fileId);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(download.contentType()))
.contentLength(download.size())
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(download.fileName()))
.header(HttpHeaders.ETAG, "\"" + download.checksum() + "\"")
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.body(download.resource());
}
@GetMapping("/api/d/{token}/archive")
public ResponseEntity<StreamingResponseBody> downloadArchive(@PathVariable String token) {
var archive = downloadService.archive(token);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(archive.fileName()))
.body(archive.body());
}
/**
* Costruisce l'header Content-Disposition con codifica RFC 5987,
* necessaria per i nomi file non ASCII.
*/
private String contentDisposition(String fileName) {
return ContentDisposition.attachment()
.filename(fileName, java.nio.charset.StandardCharsets.UTF_8)
.build()
.toString();
}
}
package dev.example.filedrop.service;
import org.springframework.core.io.Resource;
public record FileDownload(String fileName, String contentType, long size,
String checksum, Resource resource) {
}
package dev.example.filedrop.service;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
public record ArchiveDownload(String fileName, StreamingResponseBody body) {
}
ZIP generato in streaming
Costruire l'archivio su disco prima di inviarlo significherebbe raddoppiare lo spazio occupato e far attendere l'utente per minuti. La soluzione è scrivere le entry direttamente sull'OutputStream della risposta, mano a mano che i file vengono letti.
Poiché la dimensione finale non è nota in anticipo, non possiamo impostare Content-Length: la risposta userà il chunked transfer encoding. È anche il motivo per cui si sceglie ZipEntry.STORED. Comprimere file già compressi (JPEG, MP4, PDF) consuma CPU senza guadagno; per contenuti comprimibili si può passare a DEFLATED con un livello basso, ma la scelta va valutata sul tipo di traffico reale.
package dev.example.filedrop.service;
import dev.example.filedrop.domain.*;
import dev.example.filedrop.storage.StorageService;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.time.Clock;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@Service
public class DownloadService {
private static final Logger log = LoggerFactory.getLogger(DownloadService.class);
private final TransferRepository transferRepository;
private final StorageService storageService;
private final Clock clock;
public DownloadService(TransferRepository transferRepository,
StorageService storageService,
Clock clock) {
this.transferRepository = transferRepository;
this.storageService = storageService;
this.clock = clock;
}
@Transactional(readOnly = true)
public FileDownload singleFile(String token, UUID fileId) {
Transfer transfer = requireDownloadable(token);
StoredFile file = transfer.getFiles().stream()
.filter(candidate -> candidate.getId().equals(fileId))
.findFirst()
.orElseThrow(() -> new TransferNotFoundException("File non presente nel transfer"));
return new FileDownload(
file.getOriginalName(),
file.getContentType(),
file.getSize(),
file.getChecksum(),
storageService.load(file.getStorageKey()));
}
@Transactional
public ArchiveDownload archive(String token) {
Transfer transfer = requireDownloadable(token);
transfer.registerDownload();
// La lambda viene eseguita dopo il commit, quando la risposta è già in scrittura:
// per questo estraiamo qui tutti i dati che servono, senza toccare più le entità
var entries = transfer.getFiles().stream()
.map(file -> new ArchiveEntry(file.getOriginalName(), file.getStorageKey(), file.getSize()))
.toList();
StreamingResponseBody body = outputStream -> writeArchive(entries, outputStream);
return new ArchiveDownload(archiveName(transfer), body);
}
private void writeArchive(java.util.List<ArchiveEntry> entries, OutputStream outputStream)
throws IOException {
Set<String> usedNames = new HashSet<>();
try (ZipOutputStream zip = new ZipOutputStream(outputStream)) {
// Metodo STORED: nessuna compressione, il tempo di CPU va risparmiato
zip.setMethod(ZipOutputStream.STORED);
for (ArchiveEntry entry : entries) {
ZipEntry zipEntry = new ZipEntry(uniqueName(usedNames, entry.name()));
zip.putNextEntry(zipEntry);
try (InputStream in = storageService.load(entry.storageKey()).getInputStream()) {
in.transferTo(zip);
}
zip.closeEntry();
}
} catch (IOException ex) {
// Un client che chiude la connessione a metà download è normale:
// registriamo l'evento senza trattarlo come errore applicativo
log.debug("Streaming dell'archivio interrotto", ex);
}
}
/**
* Evita collisioni quando due file caricati hanno lo stesso nome:
* il secondo diventa "documento (1).pdf".
*/
private String uniqueName(Set<String> used, String name) {
if (used.add(name)) {
return name;
}
int dot = name.lastIndexOf('.');
String base = dot > 0 ? name.substring(0, dot) : name;
String extension = dot > 0 ? name.substring(dot) : "";
int counter = 1;
String candidate;
do {
candidate = "%s (%d)%s".formatted(base, counter++, extension);
} while (!used.add(candidate));
return candidate;
}
private String archiveName(Transfer transfer) {
String title = transfer.getTitle();
String base = (title == null || title.isBlank()) ? "transfer" : title;
return base.replaceAll("[^\\p{L}\\p{N}\\-_ ]", "_") + ".zip";
}
private Transfer requireDownloadable(String token) {
Transfer transfer = transferRepository.findByDownloadToken(token)
.orElseThrow(() -> new TransferNotFoundException("Transfer inesistente"));
if (!transfer.isDownloadable(Instant.now(clock))) {
throw new TransferGoneException("Transfer scaduto o rimosso");
}
return transfer;
}
private record ArchiveEntry(String name, String storageKey, long size) {
}
}
Attenzione a un dettaglio non ovvio: con ZipOutputStream.STORED l'implementazione standard richiede che dimensione e CRC siano impostati prima di scrivere l'entry. Se si vuole realmente evitare la compressione senza precalcolare il CRC di ogni file, la strada praticabile è memorizzare il CRC32 insieme al checksum al momento dell'upload, oppure accettare DEFLATED con setLevel(Deflater.NO_COMPRESSION), che produce un archivio non compresso senza vincoli sui metadati anticipati. Quest'ultima è la scelta più semplice in un primo rilascio.
// Variante senza vincoli sui metadati: struttura DEFLATED, livello zero
zip.setMethod(ZipOutputStream.DEFLATED);
zip.setLevel(java.util.zip.Deflater.NO_COMPRESSION);
Cancellazione e scadenza
Il transfer può sparire in due modi: perché il mittente lo elimina con il token privato, oppure perché è scaduto. In entrambi i casi la logica di rimozione fisica è la stessa e conviene concentrarla in un unico servizio.
package dev.example.filedrop.service;
import dev.example.filedrop.domain.*;
import dev.example.filedrop.storage.StorageService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TransferLifecycleService {
private static final Logger log = LoggerFactory.getLogger(TransferLifecycleService.class);
private static final int CLEANUP_BATCH_SIZE = 100;
private final TransferRepository transferRepository;
private final StorageService storageService;
private final Clock clock;
public TransferLifecycleService(TransferRepository transferRepository,
StorageService storageService,
Clock clock) {
this.transferRepository = transferRepository;
this.storageService = storageService;
this.clock = clock;
}
@Transactional
public void deleteByToken(String deleteToken) {
Transfer transfer = transferRepository.findByDeleteToken(deleteToken)
.orElseThrow(() -> new TransferNotFoundException("Transfer inesistente"));
// Confronto a tempo costante sul segreto
boolean matches = MessageDigest.isEqual(
transfer.getDeleteToken().getBytes(StandardCharsets.UTF_8),
deleteToken.getBytes(StandardCharsets.UTF_8));
if (!matches) {
throw new TransferNotFoundException("Transfer inesistente");
}
purge(transfer);
}
@Transactional
public int cleanupExpired() {
Instant now = Instant.now(clock);
List<Transfer> expired = transferRepository.findByStatusAndExpiresAtBefore(
TransferStatus.ACTIVE, now, Limit.of(CLEANUP_BATCH_SIZE));
for (Transfer transfer : expired) {
purge(transfer);
}
return expired.size();
}
private void purge(Transfer transfer) {
for (StoredFile file : transfer.getFiles()) {
try {
storageService.delete(file.getStorageKey());
} catch (RuntimeException ex) {
// Non blocchiamo la cancellazione dei metadati: un oggetto rimasto
// sullo storage verrà intercettato dal controllo di consistenza
log.warn("Impossibile cancellare l'oggetto {}", file.getStorageKey(), ex);
}
}
transfer.markDeleted();
transferRepository.delete(transfer);
}
}
Il job pianificato si limita a invocare il servizio. Lavorare a lotti evita che una singola esecuzione tenga aperta una transazione per ore quando l'arretrato è grande.
package dev.example.filedrop.scheduling;
import dev.example.filedrop.service.TransferLifecycleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class ExpirationJob {
private static final Logger log = LoggerFactory.getLogger(ExpirationJob.class);
private final TransferLifecycleService lifecycleService;
public ExpirationJob(TransferLifecycleService lifecycleService) {
this.lifecycleService = lifecycleService;
}
// Ogni quindici minuti, con un ritardo iniziale che evita il picco all'avvio
@Scheduled(fixedDelayString = "PT15M", initialDelayString = "PT1M")
public void run() {
int removed = lifecycleService.cleanupExpired();
if (removed > 0) {
log.info("Rimossi {} transfer scaduti", removed);
}
}
}
Se in futuro l'applicazione girerà su più istanze, questo job va reso esclusivo: senza un lock distribuito ogni nodo tenterebbe la stessa cancellazione. La soluzione più leggera è ShedLock; in alternativa, un SELECT ... FOR UPDATE SKIP LOCKED nella query di selezione dei lotti risolve il problema restando dentro PostgreSQL.
Notifiche via email
L'invio della email non deve rallentare la risposta all'upload né farla fallire. La rendiamo asincrona e tolleriamo l'errore.
package dev.example.filedrop.service;
import dev.example.filedrop.config.FiledropProperties;
import dev.example.filedrop.domain.Transfer;
import jakarta.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
@Service
public class NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine;
private final FiledropProperties properties;
public NotificationService(JavaMailSender mailSender,
TemplateEngine templateEngine,
FiledropProperties properties) {
this.mailSender = mailSender;
this.templateEngine = templateEngine;
this.properties = properties;
}
public void notifyTransferCreated(Transfer transfer) {
String downloadUrl = "%s/d/%s".formatted(properties.baseUrl(), transfer.getDownloadToken());
String deleteUrl = "%s/api/transfers/%s".formatted(properties.baseUrl(), transfer.getDeleteToken());
send(List.of(transfer.getSenderEmail()), "Il tuo transfer è pronto",
render(transfer, downloadUrl, deleteUrl, true));
if (!transfer.getRecipients().isEmpty()) {
// Ai destinatari non va mai inviato il token di cancellazione
send(transfer.getRecipients(), "Hai ricevuto dei file",
render(transfer, downloadUrl, null, false));
}
}
private String render(Transfer transfer, String downloadUrl, String deleteUrl, boolean forSender) {
Context context = new Context();
context.setVariable("transfer", transfer);
context.setVariable("downloadUrl", downloadUrl);
context.setVariable("deleteUrl", deleteUrl);
context.setVariable("forSender", forSender);
return templateEngine.process("mail/transfer-created", context);
}
@Async
protected void send(List<String> recipients, String subject, String html) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setFrom(properties.mail().from());
// Destinatari multipli in Bcc: evita di esporre gli indirizzi altrui
helper.setTo(properties.mail().from());
helper.setBcc(recipients.toArray(String[]::new));
helper.setSubject(subject);
helper.setText(html, true);
mailSender.send(message);
} catch (Exception ex) {
log.error("Invio della notifica non riuscito", ex);
}
}
}
Un'alternativa più pulita consiste nel pubblicare un evento di dominio e ascoltarlo con @TransactionalEventListener(phase = AFTER_COMMIT): in questo modo la email parte solo se la transazione è andata a buon fine, evitando di annunciare un transfer che poi non esiste. È un miglioramento che consiglio non appena il progetto esce dal prototipo.
Rate limiting dell'upload
Senza alcun limite, l'endpoint di upload è un invito a riempire il disco. Un filtro con una cache Caffeine per indirizzo IP è sufficiente per un servizio a singola istanza.
package dev.example.filedrop.web;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import dev.example.filedrop.config.FiledropProperties;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@Order(1)
public class UploadRateLimitFilter extends OncePerRequestFilter {
private final LoadingCache<String, AtomicInteger> counters;
private final int maxUploadsPerHour;
public UploadRateLimitFilter(FiledropProperties properties) {
this.maxUploadsPerHour = properties.rateLimit().uploadsPerHour();
this.counters = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofHours(1))
.maximumSize(100_000)
.build(key -> new AtomicInteger());
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !("POST".equals(request.getMethod()) && "/api/transfers".equals(request.getRequestURI()));
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String client = clientAddress(request);
int attempts = counters.get(client).incrementAndGet();
if (attempts > maxUploadsPerHour) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "3600");
response.getWriter().write("{\"error\":\"Troppi upload, riprova più tardi\"}");
return;
}
chain.doFilter(request, response);
}
/**
* Dietro un reverse proxy l'IP reale arriva in X-Forwarded-For.
* Il valore è affidabile solo se il proxy lo imposta e non lo accoda.
*/
private String clientAddress(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
Il commento sull'header non è un dettaglio: se Nginx è configurato per accodare il valore ricevuto invece di sovrascriverlo, un client può iniettare un IP falso e aggirare il limite. Nella configurazione più avanti useremo proxy_set_header X-Forwarded-For $remote_addr, che sovrascrive.
Gestione degli errori
Un gestore centralizzato traduce le eccezioni di dominio in risposte HTTP coerenti. Il formato ProblemDetail definito da RFC 9457 è ormai il default naturale in Spring.
package dev.example.filedrop.web;
import dev.example.filedrop.service.TransferException;
import dev.example.filedrop.service.TransferGoneException;
import dev.example.filedrop.service.TransferNotFoundException;
import dev.example.filedrop.storage.StorageException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class ApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(TransferNotFoundException.class)
public ProblemDetail handleNotFound(TransferNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(TransferGoneException.class)
public ProblemDetail handleGone(TransferGoneException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.GONE, ex.getMessage());
}
@ExceptionHandler(TransferException.class)
public ProblemDetail handleBadRequest(TransferException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ProblemDetail handleTooLarge(MaxUploadSizeExceededException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE,
"Il caricamento supera la dimensione massima consentita");
}
@ExceptionHandler(StorageException.class)
public ProblemDetail handleStorage(StorageException ex) {
// Il messaggio interno non deve trapelare verso il client
log.error("Errore dello storage", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"Errore interno durante l'elaborazione del file");
}
}
Test
Il test di integrazione dell'upload usa Testcontainers per PostgreSQL e uno storage temporaneo. Qui emerge il secondo cambiamento significativo di Spring Boot 4: le classi di auto-configurazione dei test sono state redistribuite in package modulari, quindi @AutoConfigureMockMvc non si importa più da org.springframework.boot.test.autoconfigure.web.servlet ma dal package del modulo Web MVC. L'IDE lo segnala subito, ma su una migrazione da Spring Boot 3 è la prima cosa che si rompe.
package dev.example.filedrop;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.example.filedrop.domain.TransferRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.webmvc.autoconfigure.test.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class TransferUploadIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
@DynamicPropertySource
static void storageRoot(DynamicPropertyRegistry registry) {
// Storage isolato per l'esecuzione dei test
registry.add("filedrop.storage.root", () -> System.getProperty("java.io.tmpdir") + "/filedrop-test");
}
@Autowired
private MockMvc mockMvc;
@Autowired
private TransferRepository transferRepository;
@Test
void createsTransferAndReturnsDownloadUrl() throws Exception {
var metadata = new MockMultipartFile("metadata", "", MediaType.APPLICATION_JSON_VALUE,
"""
{"senderEmail":"sender@example.com","title":"Foto vacanze"}
""".getBytes());
var first = new MockMultipartFile("files", "foto.jpg", "image/jpeg", "contenuto-1".getBytes());
var second = new MockMultipartFile("files", "note.txt", "text/plain", "contenuto-2".getBytes());
mockMvc.perform(multipart("/api/transfers").file(metadata).file(first).file(second))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.fileCount").value(2))
.andExpect(jsonPath("$.downloadUrl").exists());
assertThat(transferRepository.count()).isEqualTo(1);
}
}
Per la scadenza il test è puramente unitario e sfrutta il Clock fisso: si crea un transfer con un orologio impostato al passato, si esegue cleanupExpired con l'orologio corrente e si verifica che i metadati siano spariti e che StorageService.exists restituisca false per ogni chiave.
Deployment
Il Dockerfile usa una build multi-stage e un'immagine JRE snella.
# Fase di build
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
# Fase di runtime
FROM eclipse-temurin:25-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /workspace/target/filedrop-1.0.0.jar app.jar
# La directory dei file temporanei deve essere scrivibile dall'utente non privilegiato
RUN mkdir -p /var/tmp/filedrop-uploads /srv/filedrop/data && \
chown -R app:app /var/tmp/filedrop-uploads /srv/filedrop/data
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: filedrop
POSTGRES_USER: filedrop
POSTGRES_PASSWORD: filedrop
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U filedrop"]
interval: 10s
retries: 5
app:
build: .
depends_on:
db:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/filedrop
SMTP_PASSWORD: ${SMTP_PASSWORD}
volumes:
- storage-data:/srv/filedrop/data
- upload-tmp:/var/tmp/filedrop-uploads
ports:
- "127.0.0.1:8080:8080"
volumes:
db-data:
storage-data:
upload-tmp:
Il volume per i file temporanei non è opzionale: senza di esso ogni upload di grandi dimensioni scrive nel layer scrivibile del container, con prestazioni peggiori e rischio di riempire lo spazio riservato a Docker.
Nginx
La configurazione del reverse proxy è la parte in cui più spesso si sbaglia. Il valore predefinito di client_max_body_size è un megabyte, e il buffering delle richieste farebbe scrivere a Nginx l'intero upload su disco prima di inoltrarlo, raddoppiando I/O e latenza percepita.
server {
listen 443 ssl http2;
server_name filedrop.example.com;
ssl_certificate /etc/letsencrypt/live/filedrop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/filedrop.example.com/privkey.pem;
# Deve essere almeno pari al limite applicativo, altrimenti Nginx
# respinge la richiesta prima che Spring possa gestirla
client_max_body_size 10G;
client_body_timeout 600s;
send_timeout 600s;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Sovrascriviamo l'header invece di accodare: un client non deve
# poter iniettare indirizzi falsi e aggirare il rate limiting
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Inoltro in streaming, senza bufferizzare il corpo della richiesta
proxy_request_buffering off;
# Nessun buffering della risposta: serve per lo ZIP generato al volo
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_connect_timeout 30s;
}
}
Perché lo scaricamento dell'archivio funzioni correttamente serve anche proxy_buffering off: con il buffering attivo Nginx accumula la risposta e l'utente non vede alcun progresso finché il server non ha finito di comprimere tutto.
Considerazioni sulla sicurezza
Un servizio di file sharing pubblico è un bersaglio evidente. Alcuni punti da affrontare prima di aprirlo al mondo:
- Content type dei download. Restituire il tipo MIME dichiarato dal client espone a XSS: un file HTML caricato e servito come
text/htmlgirerebbe sul dominio del servizio. La contromisura minima è l'headerX-Content-Type-Options: nosniffinsieme aContent-Disposition: attachment; quella robusta è servire i download da un dominio separato, senza cookie di sessione. - Scansione antivirus. Un'integrazione con ClamAV via
clamdè semplice da aggiungere nelloStorageService, facendo passare lo stream attraverso lo scanner durante la scrittura. - Enumerazione dei token. Con 256 bit di entropia il bruteforce è escluso, ma è comunque prudente aggiungere un rate limiting anche sull'endpoint di download e restituire sempre
404per token inesistenti e scaduti, senza distinguere i due casi. - Quote sullo spazio. Il limite per transfer non impedisce che molti transfer legittimi saturino il disco. Serve un monitoraggio dello spazio residuo e una politica di rifiuto sopra una certa soglia.
- Cifratura a riposo. Se il contenuto è sensibile, la cifratura lato server con chiave derivata dal token di download offre una proprietà interessante: senza il link, nemmeno chi ha accesso al filesystem può leggere i file.
Dove andare da qui
L'applicazione che abbiamo costruito è completa nelle funzioni essenziali e sufficiente per un uso interno o per un servizio a basso volume. Tre direzioni di evoluzione meritano attenzione.
La prima è il passaggio a uno storage a oggetti. Grazie all'interfaccia StorageService basta una seconda implementazione basata sull'SDK di S3 o su MinIO, attivata con un @ConditionalOnProperty. Da lì si apre la possibilità degli upload presigned, in cui il client carica direttamente sull'object store e il backend riceve solo i metadati: è il modo per togliere completamente il traffico dei byte dall'applicazione.
La seconda è l'upload resumable. Il caricamento in un'unica richiesta multipart è fragile su connessioni instabili: un'interruzione all'ottantesimo percento costringe a ricominciare. Il protocollo tus, o una implementazione a chunk con un endpoint di stato, risolve il problema al prezzo di una gestione più complessa dei caricamenti parziali e della loro scadenza.
La terza è l'osservabilità. Actuator con Micrometer fornisce già le metriche di base; conviene aggiungere contatori personalizzati su byte caricati, byte scaricati, transfer attivi e spazio occupato, perché sono le grandezze che dicono se il servizio sta per avere problemi molto prima che il disco si riempia.
Il valore di questo esercizio, al di là del risultato, sta nell'aver isolato correttamente le due decisioni che pesano di più: dove finiscono i byte e chi può leggerli. Tutto il resto è codice che si sostituisce senza dolore.