Creare un'applicazione in stile WeTransfer con Java e Spring Boot 4

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:

  1. il client invia una richiesta multipart/form-data all'endpoint di upload;
  2. il container Servlet scrive le parti su file temporanei su disco, non in memoria;
  3. il servizio applicativo crea un'entità Transfer e, per ogni parte, delega allo StorageService la persistenza del contenuto, calcolando nel frattempo la dimensione e un checksum;
  4. vengono generati i due token (download e delete) e salvati i metadati su PostgreSQL;
  5. 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/html girerebbe sul dominio del servizio. La contromisura minima è l'header X-Content-Type-Options: nosniff insieme a Content-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 nello StorageService, 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 404 per 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.