Creare un'applicazione in stile WeTransfer con Go

Creare un'applicazione in stile WeTransfer con Go

WeTransfer risolve un problema apparentemente banale: spedire file troppo grandi per la posta elettronica. Dietro l'interfaccia minimale, però, si nasconde un insieme di requisiti tecnici tutt'altro che triviali: caricamenti di dimensioni arbitrarie che non devono saturare la RAM, link segreti a scadenza, download multipli impacchettati al volo in un archivio ZIP, pulizia automatica dello storage e protezione contro gli abusi.

Go è particolarmente adatto a questo tipo di servizio: la libreria standard offre tutto il necessario (net/http, mime/multipart, archive/zip, io), il modello di concorrenza rende naturale gestire migliaia di connessioni lente e il binario statico si distribuisce in un container da pochi megabyte.

In questo articolo costruiremo un'applicazione completa e funzionante, partendo dall'architettura fino al deployment, con particolare attenzione allo streaming: in nessun punto del codice un file verrà caricato interamente in memoria.

Cosa costruiremo

Il servizio, che chiameremo gotransfer, avrà queste funzionalità:

  • caricamento di uno o più file tramite una pagina web, con barra di avanzamento;
  • generazione di un capability URL segreto e non indovinabile da condividere con il destinatario;
  • pagina di riepilogo del trasferimento con elenco dei file, dimensioni e data di scadenza;
  • download del singolo file oppure di tutti i file in un unico ZIP generato in streaming;
  • scadenza temporale e limite opzionale al numero di download;
  • garbage collector in background che elimina i trasferimenti scaduti dal database e dallo storage;
  • limiti su dimensione totale, numero di file e frequenza delle richieste;
  • astrazione dello storage, così da poter passare dal filesystem locale a S3/MinIO senza toccare gli handler.

Architettura

L'applicazione segue un'architettura a livelli molto classica, senza framework esterni:

  1. Transport (HTTP): gli handler leggono e scrivono, non contengono logica di persistenza.
  2. Repository: unico punto di accesso al database (SQLite, sostituibile con PostgreSQL).
  3. Storage: interfaccia per i byte veri e propri (disco locale, S3, MinIO).
  4. Janitor: goroutine periodica che rimuove i dati scaduti.

La separazione tra metadati (database) e contenuto (storage a oggetti) è la scelta architetturale più importante. Il database resta piccolo e veloce, mentre i byte vivono in un sistema pensato per contenere terabyte. La stessa struttura la si ritrova in tutti i servizi di file sharing reali.

Struttura del progetto

gotransfer/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── handler/
│   │   ├── handler.go
│   │   ├── upload.go
│   │   ├── download.go
│   │   └── middleware.go
│   ├── janitor/
│   │   └── janitor.go
│   ├── model/
│   │   └── model.go
│   ├── repo/
│   │   ├── migrations.sql
│   │   └── sqlite.go
│   ├── storage/
│   │   ├── storage.go
│   │   └── local.go
│   └── token/
│       └── token.go
├── web/
│   ├── static/
│   │   └── app.js
│   └── templates/
│       ├── index.html
│       └── transfer.html
├── Dockerfile
├── docker-compose.yml
└── go.mod

Inizializzazione

mkdir gotransfer && cd gotransfer
go mod init github.com/example/gotransfer
go get modernc.org/sqlite
go get golang.org/x/time/rate

Usiamo modernc.org/sqlite perché è una traduzione in Go puro di SQLite: niente cgo, quindi cross-compilazione libera e immagini Docker scratch. Le uniche altre dipendenze sono golang.org/x/time/rate per il rate limiting; tutto il resto viene dalla libreria standard.

Configurazione

La configurazione arriva esclusivamente da variabili d'ambiente, secondo i principi della dodici-fattori. Nessun file YAML, nessuna libreria.

// internal/config/config.go
package config

import (
	"os"
	"strconv"
	"time"
)

// Config raccoglie tutti i parametri di runtime del servizio.
type Config struct {
	Addr            string        // indirizzo di ascolto, es. ":8080"
	BaseURL         string        // URL pubblico usato per costruire i link condivisibili
	StorageDir      string        // radice dello storage su disco
	DatabaseDSN     string        // DSN SQLite
	MaxTransferSize int64         // dimensione massima complessiva di un trasferimento
	MaxFiles        int           // numero massimo di file per trasferimento
	DefaultTTL      time.Duration // durata di validità di un trasferimento
	CleanupInterval time.Duration // frequenza del garbage collector
}

// Load legge la configurazione dall'ambiente applicando valori di default sensati.
func Load() Config {
	return Config{
		Addr:            getString("GOTRANSFER_ADDR", ":8080"),
		BaseURL:         getString("GOTRANSFER_BASE_URL", "http://localhost:8080"),
		StorageDir:      getString("GOTRANSFER_STORAGE_DIR", "./data/objects"),
		DatabaseDSN:     getString("GOTRANSFER_DSN", "file:./data/gotransfer.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"),
		MaxTransferSize: getInt64("GOTRANSFER_MAX_SIZE", 2<<30), // 2 GiB
		MaxFiles:        int(getInt64("GOTRANSFER_MAX_FILES", 20)),
		DefaultTTL:      getDuration("GOTRANSFER_TTL", 7*24*time.Hour),
		CleanupInterval: getDuration("GOTRANSFER_CLEANUP_INTERVAL", 10*time.Minute),
	}
}

func getString(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

func getInt64(key string, def int64) int64 {
	v := os.Getenv(key)
	if v == "" {
		return def
	}
	n, err := strconv.ParseInt(v, 10, 64)
	if err != nil {
		// Una configurazione errata non deve passare inosservata: meglio il default esplicito.
		return def
	}
	return n
}

func getDuration(key string, def time.Duration) time.Duration {
	v := os.Getenv(key)
	if v == "" {
		return def
	}
	d, err := time.ParseDuration(v)
	if err != nil {
		return def
	}
	return d
}

Generazione dei token

L'identificatore del trasferimento è la credenziale: chi possiede il link può scaricare. Un URL di questo tipo si chiama capability URL e impone due regole non negoziabili: deve essere generato con un generatore crittograficamente sicuro e deve avere entropia sufficiente (almeno 128 bit) per rendere impraticabile qualsiasi enumerazione.

// internal/token/token.go
package token

import (
	"crypto/rand"
	"encoding/base64"
)

// Size è il numero di byte casuali per token: 16 byte = 128 bit di entropia.
const Size = 16

// New restituisce un token URL-safe non indovinabile.
// crypto/rand.Read non fallisce mai su sistemi supportati: in caso contrario
// il processo non è in grado di operare in sicurezza e deve terminare.
func New() string {
	b := make([]byte, Size)
	if _, err := rand.Read(b); err != nil {
		panic("token: sorgente di entropia non disponibile: " + err.Error())
	}
	return base64.RawURLEncoding.EncodeToString(b)
}

Il risultato è una stringa di 22 caratteri come 0J9x2QcW7bJm1kPq3fTgAA. Non usiamo mai math/rand: sarebbe l'equivalente di dare a tutti la stessa chiave di casa.

Il modello dati

// internal/model/model.go
package model

import "time"

// Transfer rappresenta un invio: un insieme di file con metadati e scadenza.
type Transfer struct {
	ID            string
	Title         string
	Message       string
	SenderEmail   string
	TotalSize     int64
	DownloadCount int64
	MaxDownloads  int64 // 0 significa nessun limite
	CreatedAt     time.Time
	ExpiresAt     time.Time
	Files         []File
}

// File è un singolo allegato appartenente a un Transfer.
type File struct {
	ID          string
	TransferID  string
	Name        string
	Size        int64
	ContentType string
	ObjectKey   string // percorso logico dell'oggetto nello storage
}

// IsExpired indica se il trasferimento non è più scaricabile per tempo o per quota.
func (t *Transfer) IsExpired(now time.Time) bool {
	if now.After(t.ExpiresAt) {
		return true
	}
	if t.MaxDownloads > 0 && t.DownloadCount >= t.MaxDownloads {
		return true
	}
	return false
}

Lo schema SQL

-- internal/repo/migrations.sql
CREATE TABLE IF NOT EXISTS transfers (
    id             TEXT PRIMARY KEY,
    title          TEXT NOT NULL DEFAULT '',
    message        TEXT NOT NULL DEFAULT '',
    sender_email   TEXT NOT NULL DEFAULT '',
    total_size     INTEGER NOT NULL DEFAULT 0,
    download_count INTEGER NOT NULL DEFAULT 0,
    max_downloads  INTEGER NOT NULL DEFAULT 0,
    created_at     INTEGER NOT NULL,
    expires_at     INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_transfers_expires_at ON transfers (expires_at);

CREATE TABLE IF NOT EXISTS files (
    id           TEXT PRIMARY KEY,
    transfer_id  TEXT NOT NULL REFERENCES transfers (id) ON DELETE CASCADE,
    name         TEXT NOT NULL,
    size         INTEGER NOT NULL,
    content_type TEXT NOT NULL,
    object_key   TEXT NOT NULL,
    position     INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS idx_files_transfer_id ON files (transfer_id);

Le date sono salvate come timestamp Unix interi: confronti banali, nessuna ambiguità di fuso orario, ordinamento naturale sull'indice.

Il repository

// internal/repo/sqlite.go
package repo

import (
	"context"
	"database/sql"
	_ "embed"
	"errors"
	"fmt"
	"time"

	"github.com/example/gotransfer/internal/model"

	_ "modernc.org/sqlite"
)

//go:embed migrations.sql
var migrations string

// ErrNotFound viene restituito quando un trasferimento non esiste.
var ErrNotFound = errors.New("repo: trasferimento non trovato")

type Repo struct {
	db *sql.DB
}

func Open(dsn string) (*Repo, error) {
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, fmt.Errorf("apertura database: %w", err)
	}
	// SQLite gestisce un solo writer alla volta: limitare le connessioni
	// evita errori "database is locked" sotto carico.
	db.SetMaxOpenConns(1)
	db.SetConnMaxLifetime(time.Hour)

	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("ping database: %w", err)
	}
	return &Repo{db: db}, nil
}

func (r *Repo) Close() error { return r.db.Close() }

// Migrate applica lo schema. È idempotente grazie alle clausole IF NOT EXISTS.
func (r *Repo) Migrate(ctx context.Context) error {
	_, err := r.db.ExecContext(ctx, migrations)
	return err
}

// CreateTransfer inserisce trasferimento e file in un'unica transazione:
// o esiste tutto, o non esiste niente.
func (r *Repo) CreateTransfer(ctx context.Context, t *model.Transfer) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback() // no-op se il Commit è andato a buon fine

	_, err = tx.ExecContext(ctx, `
		INSERT INTO transfers (id, title, message, sender_email, total_size,
		                       download_count, max_downloads, created_at, expires_at)
		VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
		t.ID, t.Title, t.Message, t.SenderEmail, t.TotalSize,
		t.MaxDownloads, t.CreatedAt.Unix(), t.ExpiresAt.Unix())
	if err != nil {
		return fmt.Errorf("inserimento transfer: %w", err)
	}

	stmt, err := tx.PrepareContext(ctx, `
		INSERT INTO files (id, transfer_id, name, size, content_type, object_key, position)
		VALUES (?, ?, ?, ?, ?, ?, ?)`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	for i, f := range t.Files {
		if _, err := stmt.ExecContext(ctx, f.ID, t.ID, f.Name, f.Size, f.ContentType, f.ObjectKey, i); err != nil {
			return fmt.Errorf("inserimento file %q: %w", f.Name, err)
		}
	}
	return tx.Commit()
}

// GetTransfer carica un trasferimento con i relativi file.
func (r *Repo) GetTransfer(ctx context.Context, id string) (*model.Transfer, error) {
	var t model.Transfer
	var createdAt, expiresAt int64

	err := r.db.QueryRowContext(ctx, `
		SELECT id, title, message, sender_email, total_size,
		       download_count, max_downloads, created_at, expires_at
		FROM transfers WHERE id = ?`, id).
		Scan(&t.ID, &t.Title, &t.Message, &t.SenderEmail, &t.TotalSize,
			&t.DownloadCount, &t.MaxDownloads, &createdAt, &expiresAt)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	t.CreatedAt = time.Unix(createdAt, 0).UTC()
	t.ExpiresAt = time.Unix(expiresAt, 0).UTC()

	rows, err := r.db.QueryContext(ctx, `
		SELECT id, transfer_id, name, size, content_type, object_key
		FROM files WHERE transfer_id = ? ORDER BY position`, id)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	for rows.Next() {
		var f model.File
		if err := rows.Scan(&f.ID, &f.TransferID, &f.Name, &f.Size, &f.ContentType, &f.ObjectKey); err != nil {
			return nil, err
		}
		t.Files = append(t.Files, f)
	}
	return &t, rows.Err()
}

// IncrementDownloads aggiorna il contatore in modo atomico lato database.
func (r *Repo) IncrementDownloads(ctx context.Context, id string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE transfers SET download_count = download_count + 1 WHERE id = ?`, id)
	return err
}

// ListExpired restituisce i trasferimenti da eliminare, a blocchi.
func (r *Repo) ListExpired(ctx context.Context, now time.Time, limit int) ([]*model.Transfer, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT id FROM transfers WHERE expires_at <= ? LIMIT ?`, now.Unix(), limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var ids []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		ids = append(ids, id)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	out := make([]*model.Transfer, 0, len(ids))
	for _, id := range ids {
		t, err := r.GetTransfer(ctx, id)
		if err != nil {
			continue // già rimosso da un altro giro: non è un errore
		}
		out = append(out, t)
	}
	return out, nil
}

// DeleteTransfer rimuove il trasferimento; i file cadono per ON DELETE CASCADE.
func (r *Repo) DeleteTransfer(ctx context.Context, id string) error {
	_, err := r.db.ExecContext(ctx, `DELETE FROM transfers WHERE id = ?`, id)
	return err
}

L'astrazione dello storage

Definiamo un'interfaccia minima: quattro metodi bastano per l'intero servizio, e mantenerla piccola è ciò che rende banale sostituire il disco locale con S3.

// internal/storage/storage.go
package storage

import (
	"context"
	"errors"
	"io"
)

// ErrInvalidKey segnala una chiave che tenta di uscire dalla radice dello storage.
var ErrInvalidKey = errors.New("storage: chiave non valida")

// ErrNotFound segnala un oggetto inesistente.
var ErrNotFound = errors.New("storage: oggetto non trovato")

// Storage astrae il livello di persistenza dei byte.
type Storage interface {
	// Put scrive il contenuto di r sotto la chiave key e restituisce i byte scritti.
	Put(ctx context.Context, key string, r io.Reader) (int64, error)
	// Open apre un oggetto in lettura; il chiamante deve chiudere il reader.
	Open(ctx context.Context, key string) (io.ReadSeekCloser, error)
	// Delete rimuove un oggetto; non è un errore se non esiste.
	Delete(ctx context.Context, key string) error
	// DeletePrefix rimuove tutti gli oggetti sotto un prefisso logico.
	DeletePrefix(ctx context.Context, prefix string) error
}

L'implementazione su filesystem locale nasconde due insidie classiche: il path traversal e le scritture parziali.

// internal/storage/local.go
package storage

import (
	"context"
	"errors"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

type Local struct {
	root string
}

func NewLocal(root string) (*Local, error) {
	abs, err := filepath.Abs(root)
	if err != nil {
		return nil, err
	}
	if err := os.MkdirAll(abs, 0o750); err != nil {
		return nil, err
	}
	return &Local{root: abs}, nil
}

// resolve converte una chiave logica in un percorso assoluto verificando
// che non esca dalla radice: è la difesa contro il path traversal.
func (l *Local) resolve(key string) (string, error) {
	if key == "" || strings.Contains(key, "\x00") {
		return "", ErrInvalidKey
	}
	full := filepath.Clean(filepath.Join(l.root, filepath.FromSlash(key)))
	if full != l.root && !strings.HasPrefix(full, l.root+string(os.PathSeparator)) {
		return "", ErrInvalidKey
	}
	return full, nil
}

// Put scrive prima su un file temporaneo e poi rinomina: la rename è atomica
// sullo stesso filesystem, quindi un oggetto o è completo o non esiste.
func (l *Local) Put(ctx context.Context, key string, r io.Reader) (int64, error) {
	full, err := l.resolve(key)
	if err != nil {
		return 0, err
	}
	dir := filepath.Dir(full)
	if err := os.MkdirAll(dir, 0o750); err != nil {
		return 0, err
	}

	tmp, err := os.CreateTemp(dir, ".upload-*")
	if err != nil {
		return 0, err
	}
	tmpName := tmp.Name()
	defer func() {
		tmp.Close()
		os.Remove(tmpName) // no-op se la rename è già avvenuta
	}()

	// io.Copy usa un buffer di 32 KiB: la memoria occupata è costante
	// indipendentemente dalla dimensione del file.
	n, err := io.Copy(tmp, r)
	if err != nil {
		return n, err
	}
	if err := tmp.Sync(); err != nil {
		return n, err
	}
	if err := tmp.Close(); err != nil {
		return n, err
	}
	if err := os.Rename(tmpName, full); err != nil {
		return n, err
	}
	return n, nil
}

func (l *Local) Open(ctx context.Context, key string) (io.ReadSeekCloser, error) {
	full, err := l.resolve(key)
	if err != nil {
		return nil, err
	}
	f, err := os.Open(full)
	if errors.Is(err, fs.ErrNotExist) {
		return nil, ErrNotFound
	}
	return f, err
}

func (l *Local) Delete(ctx context.Context, key string) error {
	full, err := l.resolve(key)
	if err != nil {
		return err
	}
	err = os.Remove(full)
	if errors.Is(err, fs.ErrNotExist) {
		return nil
	}
	return err
}

func (l *Local) DeletePrefix(ctx context.Context, prefix string) error {
	full, err := l.resolve(prefix)
	if err != nil {
		return err
	}
	return os.RemoveAll(full)
}

Il pattern write-to-temp-then-rename è ciò che garantisce la durabilità: se il processo viene ucciso a metà di un upload da 3 GB, sul disco resta solo un file temporaneo nascosto, mai un oggetto corrotto raggiungibile da una chiave valida.

L'upload: streaming, non buffering

Questo è il cuore dell'applicazione ed è il punto in cui la maggior parte delle implementazioni sbaglia. La tentazione è scrivere:

// SBAGLIATO: bufferizza in memoria (e su disco temporaneo) l'intera richiesta
r.ParseMultipartForm(32 << 20)

ParseMultipartForm legge tutta la richiesta prima di restituire il controllo, tenendo in RAM fino al limite indicato e spostando il resto in file temporanei. Con dieci utenti che caricano 2 GB ciascuno, il server muore.

La soluzione corretta è r.MultipartReader(), che restituisce uno stream di part da consumare una alla volta.

// internal/handler/upload.go
package handler

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"mime"
	"mime/multipart"
	"net/http"
	"path"
	"path/filepath"
	"strings"
	"time"
	"unicode"

	"github.com/example/gotransfer/internal/model"
	"github.com/example/gotransfer/internal/token"
)

// maxFieldSize limita i campi testuali (titolo, messaggio, email).
const maxFieldSize = 4 << 10 // 4 KiB

type createResponse struct {
	ID        string    `json:"id"`
	URL       string    `json:"url"`
	ExpiresAt time.Time `json:"expires_at"`
	TotalSize int64     `json:"total_size"`
}

// CreateTransfer gestisce POST /api/transfers.
func (h *Handler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	// Prima linea di difesa: tronca la richiesta a livello di trasporto.
	// Il margine extra copre header, boundary e campi testuali del multipart.
	r.Body = http.MaxBytesReader(w, r.Body, h.cfg.MaxTransferSize+(1<<20))

	mr, err := r.MultipartReader()
	if err != nil {
		writeError(w, http.StatusBadRequest, "richiesta multipart non valida")
		return
	}

	now := time.Now().UTC()
	transfer := &model.Transfer{
		ID:        token.New(),
		CreatedAt: now,
		ExpiresAt: now.Add(h.cfg.DefaultTTL),
	}

	// Se qualcosa va storto a metà, gli oggetti già scritti vanno rimossi:
	// altrimenti lo storage si riempirebbe di orfani invisibili al database.
	committed := false
	defer func() {
		if !committed {
			// Il contesto della richiesta è già annullato quando si arriva qui:
			// WithoutCancel consente comunque di completare la pulizia.
			cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
			defer cancel()
			if err := h.storage.DeletePrefix(cleanupCtx, transfer.ID); err != nil {
				h.log.Error("pulizia oggetti orfani fallita", "transfer_id", transfer.ID, "err", err)
			}
		}
	}()

	for {
		part, err := mr.NextPart()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			// MaxBytesError arriva qui quando il client supera il limite globale.
			var maxErr *http.MaxBytesError
			if errors.As(err, &maxErr) {
				writeError(w, http.StatusRequestEntityTooLarge, "trasferimento troppo grande")
				return
			}
			writeError(w, http.StatusBadRequest, "flusso multipart interrotto")
			return
		}

		switch part.FormName() {
		case "title":
			transfer.Title = readField(part)
		case "message":
			transfer.Message = readField(part)
		case "sender_email":
			transfer.SenderEmail = readField(part)
		case "max_downloads":
			transfer.MaxDownloads = parseInt64(readField(part))
		case "files":
			if part.FileName() == "" {
				part.Close()
				continue // campo vuoto inviato dal browser: si ignora
			}
			if len(transfer.Files) >= h.cfg.MaxFiles {
				part.Close()
				writeError(w, http.StatusBadRequest, "troppi file nel trasferimento")
				return
			}
			file, n, err := h.storeFilePart(ctx, transfer, part)
			part.Close()
			if err != nil {
				if errors.Is(err, errTooLarge) {
					writeError(w, http.StatusRequestEntityTooLarge, "trasferimento troppo grande")
					return
				}
				h.log.Error("scrittura file fallita", "transfer_id", transfer.ID, "err", err)
				writeError(w, http.StatusInternalServerError, "impossibile salvare il file")
				return
			}
			transfer.TotalSize += n
			transfer.Files = append(transfer.Files, file)
		default:
			part.Close()
		}
	}

	if len(transfer.Files) == 0 {
		writeError(w, http.StatusBadRequest, "nessun file caricato")
		return
	}

	if err := h.repo.CreateTransfer(ctx, transfer); err != nil {
		h.log.Error("creazione transfer fallita", "err", err)
		writeError(w, http.StatusInternalServerError, "errore interno")
		return
	}
	committed = true

	h.log.Info("trasferimento creato",
		"transfer_id", transfer.ID,
		"files", len(transfer.Files),
		"size", transfer.TotalSize)

	writeJSON(w, http.StatusCreated, createResponse{
		ID:        transfer.ID,
		URL:       h.cfg.BaseURL + "/t/" + transfer.ID,
		ExpiresAt: transfer.ExpiresAt,
		TotalSize: transfer.TotalSize,
	})
}

Scrittura del singolo file con controllo della quota

http.MaxBytesReader protegge il server, ma vogliamo anche un messaggio d'errore pulito quando la somma dei file supera il limite. La tecnica è leggere al massimo un byte in più del consentito: se lo otteniamo, sappiamo di aver sforato.

var errTooLarge = errors.New("handler: quota superata")

// storeFilePart scrive una singola part sullo storage rispettando la quota residua.
func (h *Handler) storeFilePart(ctx context.Context, t *model.Transfer, part *multipart.Part) (model.File, int64, error) {
	remaining := h.cfg.MaxTransferSize - t.TotalSize
	if remaining <= 0 {
		return model.File{}, 0, errTooLarge
	}

	f := model.File{
		ID:          token.New(),
		TransferID:  t.ID,
		Name:        sanitizeFileName(part.FileName()),
		ContentType: detectContentType(part),
	}
	// La chiave sullo storage non contiene mai il nome fornito dall'utente.
	f.ObjectKey = path.Join(t.ID, f.ID)

	// Leggendo remaining+1 byte distinguiamo "esattamente al limite" da "oltre il limite".
	limited := io.LimitReader(part, remaining+1)
	n, err := h.storage.Put(ctx, f.ObjectKey, limited)
	if err != nil {
		return model.File{}, n, err
	}
	if n > remaining {
		_ = h.storage.Delete(ctx, f.ObjectKey)
		return model.File{}, n, errTooLarge
	}
	f.Size = n
	return f, n, nil
}

// sanitizeFileName riduce un nome arbitrario a un nome sicuro da mostrare
// e da inserire in uno ZIP. Non viene mai usato come percorso su disco.
func sanitizeFileName(name string) string {
	// I browser possono inviare percorsi completi: teniamo solo l'ultimo segmento.
	name = filepath.Base(filepath.FromSlash(name))
	name = strings.Map(func(r rune) rune {
		if r < 32 || r == 127 || r == '/' || r == '\\' || !unicode.IsPrint(r) {
			return -1
		}
		return r
	}, name)
	name = strings.TrimSpace(name)
	if name == "" || name == "." || name == ".." {
		return "file"
	}
	if len(name) > 200 {
		name = name[:200]
	}
	return name
}

// detectContentType si fida del client solo per un valore dichiarativo,
// mai per decidere come servire il file (vedi la sezione sui download).
func detectContentType(part *multipart.Part) string {
	ct := part.Header.Get("Content-Type")
	if ct == "" {
		return "application/octet-stream"
	}
	mediaType, _, err := mime.ParseMediaType(ct)
	if err != nil {
		return "application/octet-stream"
	}
	return mediaType
}

// readField legge un campo testuale troncandolo a maxFieldSize.
func readField(part *multipart.Part) string {
	b, err := io.ReadAll(io.LimitReader(part, maxFieldSize))
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(b))
}

Da notare: il nome originale del file finisce solo nel database e nell'archivio ZIP, mai nel filesystem. La chiave dell'oggetto è <transfer_id>/<file_id>, composta esclusivamente da token generati da noi. È la difesa più solida contro qualunque tentativo di traversal, perché l'input dell'utente non tocca mai un percorso.

Il download

Il download ha due modalità: file singolo servito direttamente e archivio ZIP generato al volo.

// internal/handler/download.go
package handler

import (
	"archive/zip"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/example/gotransfer/internal/model"
	"github.com/example/gotransfer/internal/repo"
	"github.com/example/gotransfer/internal/storage"
)

// DownloadAll gestisce GET /t/{id}/download.
func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	t, ok := h.loadActiveTransfer(w, r)
	if !ok {
		return
	}

	if err := h.repo.IncrementDownloads(ctx, t.ID); err != nil {
		h.log.Warn("aggiornamento contatore fallito", "transfer_id", t.ID, "err", err)
	}

	if len(t.Files) == 1 {
		h.serveSingle(w, r, t.Files[0])
		return
	}
	h.serveZip(w, r, t)
}

// DownloadFile gestisce GET /t/{id}/files/{fileID}.
func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
	t, ok := h.loadActiveTransfer(w, r)
	if !ok {
		return
	}
	fileID := r.PathValue("fileID")
	for _, f := range t.Files {
		if f.ID == fileID {
			_ = h.repo.IncrementDownloads(r.Context(), t.ID)
			h.serveSingle(w, r, f)
			return
		}
	}
	http.NotFound(w, r)
}

// loadActiveTransfer recupera il trasferimento gestendo i casi di errore comuni.
func (h *Handler) loadActiveTransfer(w http.ResponseWriter, r *http.Request) (*model.Transfer, bool) {
	id := r.PathValue("id")
	t, err := h.repo.GetTransfer(r.Context(), id)
	if errors.Is(err, repo.ErrNotFound) {
		http.NotFound(w, r)
		return nil, false
	}
	if err != nil {
		h.log.Error("lettura transfer fallita", "transfer_id", id, "err", err)
		http.Error(w, "errore interno", http.StatusInternalServerError)
		return nil, false
	}
	if t.IsExpired(time.Now().UTC()) {
		// 410 Gone comunica che la risorsa è esistita ma non esiste più.
		http.Error(w, "questo trasferimento è scaduto", http.StatusGone)
		return nil, false
	}
	return t, true
}

// serveSingle usa http.ServeContent per ottenere gratis Range, ETag e If-Modified-Since.
func (h *Handler) serveSingle(w http.ResponseWriter, r *http.Request, f model.File) {
	rc, err := h.storage.Open(r.Context(), f.ObjectKey)
	if err != nil {
		if errors.Is(err, storage.ErrNotFound) {
			http.NotFound(w, r)
			return
		}
		h.log.Error("apertura oggetto fallita", "object_key", f.ObjectKey, "err", err)
		http.Error(w, "errore interno", http.StatusInternalServerError)
		return
	}
	defer rc.Close()

	// Non serviamo mai il Content-Type dichiarato dall'utente: un HTML caricato
	// da un terzo verrebbe eseguito nel nostro dominio (XSS memorizzata).
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Header().Set("Content-Disposition", contentDisposition(f.Name))

	// Il tempo passato a ServeContent è quello di modifica logico: usare
	// time.Time{} disattiva la gestione condizionale.
	http.ServeContent(w, r, f.Name, time.Time{}, rc)
}

// serveZip impacchetta tutti i file in streaming, senza materializzare l'archivio.
func (h *Handler) serveZip(w http.ResponseWriter, r *http.Request, t *model.Transfer) {
	name := zipName(t)

	w.Header().Set("Content-Type", "application/zip")
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Header().Set("Content-Disposition", contentDisposition(name))
	// Non conosciamo in anticipo la dimensione dell'archivio: niente Content-Length.
	// La risposta userà chunked transfer encoding.

	zw := zip.NewWriter(w)
	defer zw.Close()

	used := make(map[string]int, len(t.Files))
	for _, f := range t.Files {
		entryName := uniqueName(used, f.Name)

		hdr := &zip.FileHeader{
			Name:     entryName,
			Method:   zip.Store, // i file caricati sono già compressi: comprimerli costa CPU e non serve
			Modified: t.CreatedAt,
		}
		hdr.SetMode(0o644)
		// Il flag UTF-8 evita nomi corrotti con caratteri accentati su Windows.
		hdr.Flags |= 0x800

		entry, err := zw.CreateHeader(hdr)
		if err != nil {
			h.log.Error("creazione voce zip fallita", "err", err)
			return
		}

		rc, err := h.storage.Open(r.Context(), f.ObjectKey)
		if err != nil {
			h.log.Error("apertura oggetto per zip fallita", "object_key", f.ObjectKey, "err", err)
			return
		}
		_, err = copyTo(entry, rc)
		rc.Close()
		if err != nil {
			// A questo punto lo status è già stato inviato: possiamo solo
			// interrompere il flusso, il client vedrà un archivio troncato.
			h.log.Warn("streaming zip interrotto", "transfer_id", t.ID, "err", err)
			return
		}
	}
}

// uniqueName evita collisioni quando due file hanno lo stesso nome.
func uniqueName(used map[string]int, name string) string {
	n, seen := used[name]
	used[name] = n + 1
	if !seen {
		return name
	}
	ext := ""
	if i := strings.LastIndex(name, "."); i > 0 {
		ext = name[i:]
		name = name[:i]
	}
	return fmt.Sprintf("%s (%d)%s", name, n, ext)
}

func zipName(t *model.Transfer) string {
	if t.Title != "" {
		return sanitizeFileName(t.Title) + ".zip"
	}
	return "gotransfer-" + t.ID + ".zip"
}

// contentDisposition costruisce l'header secondo la RFC 6266/5987:
// il parametro ASCII garantisce i client vecchi, filename* quelli moderni.
func contentDisposition(name string) string {
	ascii := strings.Map(func(r rune) rune {
		if r > 126 || r < 32 || r == '"' || r == '\\' {
			return '_'
		}
		return r
	}, name)
	return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", ascii, url.PathEscape(name))
}

La funzione copyTo merita una nota: usa io.CopyBuffer con un buffer riciclato da un sync.Pool, così con centinaia di download concorrenti non si allocano continuamente buffer da 32 KiB.

// internal/handler/handler.go (estratto)

// bufPool ricicla i buffer di copia tra le richieste per ridurre la pressione sul GC.
var bufPool = sync.Pool{
	New: func() any {
		b := make([]byte, 256<<10) // 256 KiB: buon compromesso per lo streaming di file
		return &b
	},
}

func copyTo(dst io.Writer, src io.Reader) (int64, error) {
	bp := bufPool.Get().(*[]byte)
	defer bufPool.Put(bp)
	return io.CopyBuffer(dst, src, *bp)
}

Perché zip.Store e non zip.Deflate

Il metodo di default di archive/zip è Deflate. Per un servizio di file transfer è quasi sempre la scelta sbagliata: i contenuti tipici (JPEG, MP4, PDF, ZIP) sono già compressi, quindi Deflate consuma CPU per un guadagno prossimo allo zero, rallentando il download e limitando la concorrenza. Con zip.Store il costo è quello di una copia di byte e il throughput è limitato solo dalla rete.

Un compromesso ragionevole è scegliere il metodo per estensione: Deflate per .txt, .csv, .json, .log; Store per tutto il resto.

Handler, routing e middleware

// internal/handler/handler.go
package handler

import (
	"embed"
	"encoding/json"
	"html/template"
	"io"
	"io/fs"
	"log/slog"
	"net/http"
	"strconv"
	"sync"

	"github.com/example/gotransfer/internal/config"
	"github.com/example/gotransfer/internal/repo"
	"github.com/example/gotransfer/internal/storage"
)

//go:embed all:../../web
var webFS embed.FS

type Handler struct {
	cfg     config.Config
	repo    *repo.Repo
	storage storage.Storage
	log     *slog.Logger
	tpl     *template.Template
}

func New(cfg config.Config, r *repo.Repo, s storage.Storage, log *slog.Logger) (*Handler, error) {
	tpl, err := template.New("").Funcs(template.FuncMap{
		"humanSize": humanSize,
	}).ParseFS(webFS, "../../web/templates/*.html")
	if err != nil {
		return nil, err
	}
	return &Handler{cfg: cfg, repo: r, storage: s, log: log, tpl: tpl}, nil
}

// Routes usa i pattern con metodo e wildcard introdotti in Go 1.22:
// non serve alcun router di terze parti.
func (h *Handler) Routes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /{$}", h.Index)
	mux.HandleFunc("POST /api/transfers", h.CreateTransfer)
	mux.HandleFunc("GET /t/{id}", h.TransferPage)
	mux.HandleFunc("GET /t/{id}/download", h.DownloadAll)
	mux.HandleFunc("GET /t/{id}/files/{fileID}", h.DownloadFile)
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		io.WriteString(w, "ok")
	})

	static, _ := fs.Sub(webFS, "web/static")
	mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))

	// L'ordine conta: il primo middleware è il più esterno.
	return recoverMW(h.log)(
		logMW(h.log)(
			securityHeadersMW(
				rateLimitMW(mux))))
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

func parseInt64(s string) int64 {
	n, err := strconv.ParseInt(s, 10, 64)
	if err != nil || n < 0 {
		return 0
	}
	return n
}

// humanSize formatta i byte in unità leggibili per i template.
func humanSize(n int64) string {
	const unit = 1024
	if n < unit {
		return strconv.FormatInt(n, 10) + " B"
	}
	div, exp := int64(unit), 0
	for m := n / unit; m >= unit; m /= unit {
		div *= unit
		exp++
	}
	return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + []string{"KB", "MB", "GB", "TB"}[exp]
}

I middleware

// internal/handler/middleware.go
package handler

import (
	"log/slog"
	"net"
	"net/http"
	"sync"
	"time"

	"golang.org/x/time/rate"
)

type middleware func(http.Handler) http.Handler

// statusRecorder cattura lo status per il log senza toccare il corpo.
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int64
}

func (s *statusRecorder) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(b []byte) (int, error) {
	if s.status == 0 {
		s.status = http.StatusOK
	}
	n, err := s.ResponseWriter.Write(b)
	s.bytes += int64(n)
	return n, err
}

// Unwrap permette a http.ResponseController di raggiungere il writer originale.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

func logMW(log *slog.Logger) middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &statusRecorder{ResponseWriter: w}
			next.ServeHTTP(rec, r)
			log.Info("richiesta",
				"method", r.Method,
				"path", r.URL.Path,
				"status", rec.status,
				"bytes", rec.bytes,
				"duration_ms", time.Since(start).Milliseconds(),
			)
		})
	}
}

func recoverMW(log *slog.Logger) middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if rec := recover(); rec != nil && rec != http.ErrAbortHandler {
					log.Error("panic nell'handler", "err", rec, "path", r.URL.Path)
					http.Error(w, "errore interno", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

func securityHeadersMW(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("Referrer-Policy", "no-referrer")
		// Referrer-Policy è essenziale: senza, il link segreto finirebbe
		// nell'header Referer di ogni risorsa esterna caricata dalla pagina.
		next.ServeHTTP(w, r)
	})
}

// visitor tiene traccia del rate limit per indirizzo IP.
type visitor struct {
	limiter  *rate.Limiter
	lastSeen time.Time
}

type limiterStore struct {
	mu       sync.Mutex
	visitors map[string]*visitor
}

var store = &limiterStore{visitors: make(map[string]*visitor)}

func init() {
	// Goroutine di pulizia: senza, la mappa crescerebbe indefinitamente.
	go func() {
		for range time.Tick(time.Minute) {
			store.mu.Lock()
			for ip, v := range store.visitors {
				if time.Since(v.lastSeen) > 10*time.Minute {
					delete(store.visitors, ip)
				}
			}
			store.mu.Unlock()
		}
	}()
}

func (s *limiterStore) get(ip string) *rate.Limiter {
	s.mu.Lock()
	defer s.mu.Unlock()
	v, ok := s.visitors[ip]
	if !ok {
		// 1 upload al secondo di media, con raffiche fino a 5.
		v = &visitor{limiter: rate.NewLimiter(1, 5)}
		s.visitors[ip] = v
	}
	v.lastSeen = time.Now()
	return v.limiter
}

func rateLimitMW(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Limitiamo solo le scritture: i download devono restare liberi.
		if r.Method != http.MethodPost {
			next.ServeHTTP(w, r)
			return
		}
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}
		if !store.get(ip).Allow() {
			w.Header().Set("Retry-After", "5")
			http.Error(w, "troppe richieste", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Attenzione a Referrer-Policy: è una protezione concreta, non un dettaglio. Se la pagina del trasferimento caricasse un font o un pixel di analytics da un dominio terzo, il browser invierebbe l'URL segreto nell'header Referer, regalando il link a terzi.

Il garbage collector

Un servizio del genere accumula dati morti al ritmo con cui li riceve. La pulizia deve essere automatica e resiliente: se fallisce l'eliminazione dei byte, non dobbiamo cancellare la riga nel database, altrimenti l'oggetto diventa un orfano invisibile.

// internal/janitor/janitor.go
package janitor

import (
	"context"
	"log/slog"
	"time"

	"github.com/example/gotransfer/internal/repo"
	"github.com/example/gotransfer/internal/storage"
)

// batchSize limita il lavoro di ogni passata per non bloccare il database.
const batchSize = 100

type Janitor struct {
	repo     *repo.Repo
	storage  storage.Storage
	log      *slog.Logger
	interval time.Duration
}

func New(r *repo.Repo, s storage.Storage, log *slog.Logger, interval time.Duration) *Janitor {
	return &Janitor{repo: r, storage: s, log: log, interval: interval}
}

// Run esegue la pulizia finché il contesto non viene annullato.
func (j *Janitor) Run(ctx context.Context) {
	ticker := time.NewTicker(j.interval)
	defer ticker.Stop()

	j.sweep(ctx) // una passata subito all'avvio

	for {
		select {
		case <-ctx.Done():
			j.log.Info("janitor terminato")
			return
		case <-ticker.C:
			j.sweep(ctx)
		}
	}
}

func (j *Janitor) sweep(ctx context.Context) {
	now := time.Now().UTC()
	expired, err := j.repo.ListExpired(ctx, now, batchSize)
	if err != nil {
		j.log.Error("elenco scaduti fallito", "err", err)
		return
	}
	if len(expired) == 0 {
		return
	}

	var freed int64
	for _, t := range expired {
		// Prima i byte, poi i metadati: se l'ordine fosse invertito e la
		// cancellazione dei byte fallisse, avremmo file orfani per sempre.
		if err := j.storage.DeletePrefix(ctx, t.ID); err != nil {
			j.log.Error("rimozione oggetti fallita", "transfer_id", t.ID, "err", err)
			continue
		}
		if err := j.repo.DeleteTransfer(ctx, t.ID); err != nil {
			j.log.Error("rimozione transfer fallita", "transfer_id", t.ID, "err", err)
			continue
		}
		freed += t.TotalSize
	}
	j.log.Info("pulizia completata", "rimossi", len(expired), "byte_liberati", freed)
}

Le pagine web

Il frontend è volutamente minimo: due template e un file JavaScript. Nessun CSS in questo esempio, nessun framework.

<!-- web/templates/index.html -->
<!DOCTYPE html>
<html lang="it">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>GoTransfer</title>
</head>
<body>
  <h1>Invia i tuoi file</h1>

  <form id="upload-form">
    <p>
      <label for="files">File</label>
      <input type="file" id="files" name="files" multiple required>
    </p>
    <p>
      <label for="title">Titolo</label>
      <input type="text" id="title" name="title" maxlength="120">
    </p>
    <p>
      <label for="message">Messaggio</label>
      <textarea id="message" name="message" maxlength="1000" rows="4"></textarea>
    </p>
    <p>
      <button type="submit" id="submit">Carica</button>
    </p>
  </form>

  <p><progress id="progress" value="0" max="100" hidden></progress></p>
  <p id="status" role="status" aria-live="polite"></p>

  <script src="/static/app.js" defer></script>
</body>
</html>
<!-- web/templates/transfer.html -->
<!DOCTYPE html>
<html lang="it">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="referrer" content="no-referrer">
  <title>{{ if .Title }}{{ .Title }}{{ else }}Trasferimento{{ end }} &middot; GoTransfer</title>
</head>
<body>
  <h1>{{ if .Title }}{{ .Title }}{{ else }}File ricevuti{{ end }}</h1>

  {{ if .Message }}<blockquote>{{ .Message }}</blockquote>{{ end }}

  <p>{{ len .Files }} file &middot; {{ humanSize .TotalSize }}</p>
  <p>Disponibile fino al {{ .ExpiresAt.Format "02/01/2006 15:04" }} UTC</p>

  <ul>
    {{ range .Files }}
    <li>
      <a href="/t/{{ $.ID }}/files/{{ .ID }}">{{ .Name }}</a>
      ({{ humanSize .Size }})
    </li>
    {{ end }}
  </ul>

  <p><a href="/t/{{ .ID }}/download">Scarica tutto</a></p>
</body>
</html>

Il pacchetto html/template applica l'escaping contestuale in automatico: un titolo contenente <script> viene neutralizzato senza che dobbiamo fare nulla. È il motivo per cui non si usa mai text/template per l'HTML.

// internal/handler/pages.go (estratto)

func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
	h.render(w, "index.html", nil)
}

func (h *Handler) TransferPage(w http.ResponseWriter, r *http.Request) {
	t, ok := h.loadActiveTransfer(w, r)
	if !ok {
		return
	}
	h.render(w, "transfer.html", t)
}

// render scrive prima su un buffer: così un errore del template non produce
// una pagina a metà con status 200 già inviato.
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
	var buf bytes.Buffer
	if err := h.tpl.ExecuteTemplate(&buf, name, data); err != nil {
		h.log.Error("rendering template fallito", "template", name, "err", err)
		http.Error(w, "errore interno", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = buf.WriteTo(w)
}

Il client con barra di avanzamento

fetch() non espone ancora in modo uniforme il progresso di upload: XMLHttpRequest, per quanto datato, resta la scelta pragmatica.

// web/static/app.js
(function () {
  "use strict";

  const form = document.getElementById("upload-form");
  const progress = document.getElementById("progress");
  const status = document.getElementById("status");
  const submit = document.getElementById("submit");

  form.addEventListener("submit", function (event) {
    event.preventDefault();

    const data = new FormData();
    const files = document.getElementById("files").files;
    if (files.length === 0) {
      status.textContent = "Seleziona almeno un file.";
      return;
    }
    // Il campo si chiama "files" per ogni allegato: lato Go
    // le part arrivano in sequenza con lo stesso FormName.
    for (const file of files) {
      data.append("files", file, file.name);
    }
    data.append("title", document.getElementById("title").value);
    data.append("message", document.getElementById("message").value);

    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/transfers", true);

    xhr.upload.addEventListener("progress", function (e) {
      if (!e.lengthComputable) {
        return;
      }
      const percent = Math.round((e.loaded / e.total) * 100);
      progress.hidden = false;
      progress.value = percent;
      status.textContent = "Caricamento: " + percent + "%";
    });

    xhr.addEventListener("load", function () {
      submit.disabled = false;
      let body;
      try {
        body = JSON.parse(xhr.responseText);
      } catch (err) {
        status.textContent = "Risposta non valida dal server.";
        return;
      }
      if (xhr.status !== 201) {
        status.textContent = "Errore: " + (body.error || xhr.status);
        return;
      }
      // Mostriamo il link segreto: da qui in poi vale come credenziale.
      status.textContent = "";
      const link = document.createElement("a");
      link.href = body.url;
      link.textContent = body.url;
      status.appendChild(document.createTextNode("Pronto: "));
      status.appendChild(link);
    });

    xhr.addEventListener("error", function () {
      submit.disabled = false;
      status.textContent = "Connessione interrotta.";
    });

    submit.disabled = true;
    xhr.send(data);
  });
})();

Il punto di ingresso

// cmd/server/main.go
package main

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/example/gotransfer/internal/config"
	"github.com/example/gotransfer/internal/handler"
	"github.com/example/gotransfer/internal/janitor"
	"github.com/example/gotransfer/internal/repo"
	"github.com/example/gotransfer/internal/storage"
)

func main() {
	log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
	if err := run(log); err != nil {
		log.Error("avvio fallito", "err", err)
		os.Exit(1)
	}
}

func run(log *slog.Logger) error {
	cfg := config.Load()

	// Il contesto si chiude su SIGINT/SIGTERM: è il segnale per lo spegnimento pulito.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	store, err := storage.NewLocal(cfg.StorageDir)
	if err != nil {
		return err
	}

	db, err := repo.Open(cfg.DatabaseDSN)
	if err != nil {
		return err
	}
	defer db.Close()

	if err := db.Migrate(ctx); err != nil {
		return err
	}

	h, err := handler.New(cfg, db, store, log)
	if err != nil {
		return err
	}

	go janitor.New(db, store, log, cfg.CleanupInterval).Run(ctx)

	srv := &http.Server{
		Addr:              cfg.Addr,
		Handler:           h.Routes(),
		ReadHeaderTimeout: 10 * time.Second,
		IdleTimeout:       2 * time.Minute,
		// ReadTimeout e WriteTimeout restano a zero: un timeout globale
		// ucciderebbe upload e download legittimi ma lenti. Le scadenze
		// per fase si impostano con http.ResponseController.
		ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelError),
	}

	errCh := make(chan error, 1)
	go func() {
		log.Info("server in ascolto", "addr", cfg.Addr, "base_url", cfg.BaseURL)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
	}()

	select {
	case err := <-errCh:
		return err
	case <-ctx.Done():
		log.Info("spegnimento in corso")
	}

	// Diamo tempo ai trasferimenti in corso di concludersi.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx)
}

Il commento sui timeout è il punto più delicato del file. Impostare WriteTimeout: 30 * time.Second, come si legge in molti tutorial, interromperebbe qualunque download più lungo di trenta secondi. Per un servizio di file transfer i timeout vanno gestiti per fase, non globalmente: ReadHeaderTimeout protegge dagli attacchi Slowloris, e http.ResponseController permette di estendere le scadenze dentro il singolo handler.

Test

Testiamo il percorso critico: creare un trasferimento e riscaricarlo come ZIP.

// internal/handler/upload_test.go
package handler_test

import (
	"archive/zip"
	"bytes"
	"encoding/json"
	"io"
	"log/slog"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"testing"
	"time"

	"github.com/example/gotransfer/internal/config"
	"github.com/example/gotransfer/internal/handler"
	"github.com/example/gotransfer/internal/repo"
	"github.com/example/gotransfer/internal/storage"
)

// newTestServer costruisce l'applicazione completa su directory temporanee.
func newTestServer(t *testing.T) *httptest.Server {
	t.Helper()
	dir := t.TempDir()

	cfg := config.Config{
		BaseURL:         "http://test.local",
		StorageDir:      filepath.Join(dir, "objects"),
		MaxTransferSize: 1 << 20,
		MaxFiles:        5,
		DefaultTTL:      time.Hour,
	}

	store, err := storage.NewLocal(cfg.StorageDir)
	if err != nil {
		t.Fatal(err)
	}
	db, err := repo.Open("file:" + filepath.Join(dir, "test.db") + "?_pragma=foreign_keys(1)")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })

	if err := db.Migrate(t.Context()); err != nil {
		t.Fatal(err)
	}
	h, err := handler.New(cfg, db, store, slog.New(slog.DiscardHandler))
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(h.Routes())
	t.Cleanup(srv.Close)
	return srv
}

func TestUploadAndDownloadZip(t *testing.T) {
	srv := newTestServer(t)

	var body bytes.Buffer
	mw := multipart.NewWriter(&body)
	files := map[string]string{
		"primo.txt":  "contenuto del primo file",
		"secondo.md": "# contenuto del secondo file",
	}
	for name, content := range files {
		part, err := mw.CreateFormFile("files", name)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := io.WriteString(part, content); err != nil {
			t.Fatal(err)
		}
	}
	mw.WriteField("title", "Test")
	mw.Close()

	resp, err := http.Post(srv.URL+"/api/transfers", mw.FormDataContentType(), &body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("status atteso 201, ottenuto %d", resp.StatusCode)
	}

	var created struct {
		ID  string `json:"id"`
		URL string `json:"url"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
		t.Fatal(err)
	}

	dl, err := http.Get(srv.URL + "/t/" + created.ID + "/download")
	if err != nil {
		t.Fatal(err)
	}
	defer dl.Body.Close()

	raw, err := io.ReadAll(dl.Body)
	if err != nil {
		t.Fatal(err)
	}
	zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
	if err != nil {
		t.Fatalf("archivio zip non valido: %v", err)
	}
	if len(zr.File) != len(files) {
		t.Fatalf("attesi %d file nello zip, trovati %d", len(files), len(zr.File))
	}

	for _, zf := range zr.File {
		want, ok := files[zf.Name]
		if !ok {
			t.Fatalf("file inatteso nell'archivio: %q", zf.Name)
		}
		rc, err := zf.Open()
		if err != nil {
			t.Fatal(err)
		}
		got, _ := io.ReadAll(rc)
		rc.Close()
		if string(got) != want {
			t.Errorf("%s: contenuto errato", zf.Name)
		}
	}
}

func TestUploadTooLarge(t *testing.T) {
	srv := newTestServer(t)

	var body bytes.Buffer
	mw := multipart.NewWriter(&body)
	part, _ := mw.CreateFormFile("files", "grande.bin")
	part.Write(bytes.Repeat([]byte("x"), 2<<20)) // 2 MiB contro un limite di 1 MiB
	mw.Close()

	resp, err := http.Post(srv.URL+"/api/transfers", mw.FormDataContentType(), &body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusRequestEntityTooLarge {
		t.Fatalf("status atteso 413, ottenuto %d", resp.StatusCode)
	}
}
go test ./... -race -v

Il flag -race è d'obbligo: la mappa dei rate limiter e il pool dei buffer sono strutture condivise tra goroutine, ed è esattamente il tipo di codice in cui i bug di concorrenza si nascondono.

Deployment

Dockerfile

# syntax=docker/dockerfile:1

FROM golang:1.24-alpine AS builder
WORKDIR /src

# I layer delle dipendenze restano in cache finché go.mod non cambia.
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# CGO_ENABLED=0 è possibile perché modernc.org/sqlite è Go puro.
RUN CGO_ENABLED=0 GOOS=linux go build \
    -trimpath \
    -ldflags="-s -w" \
    -o /bin/gotransfer ./cmd/server

FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
    adduser -D -u 10001 gotransfer

COPY --from=builder /bin/gotransfer /usr/local/bin/gotransfer

RUN mkdir -p /data && chown gotransfer:gotransfer /data
USER gotransfer
VOLUME ["/data"]
EXPOSE 8080

ENV GOTRANSFER_STORAGE_DIR=/data/objects \
    GOTRANSFER_DSN="file:/data/gotransfer.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"

ENTRYPOINT ["/usr/local/bin/gotransfer"]

docker-compose.yml

services:
  gotransfer:
    build: .
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      GOTRANSFER_BASE_URL: "https://transfer.example.com"
      GOTRANSFER_MAX_SIZE: "5368709120"   # 5 GiB
      GOTRANSFER_TTL: "168h"              # 7 giorni
      GOTRANSFER_MAX_FILES: "30"
    volumes:
      - gotransfer-data:/data
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  gotransfer-data:

Nginx come reverse proxy

Questa configurazione è il punto in cui si vanificano tutti gli sforzi fatti sullo streaming, se sbagliata.

server {
    listen 443 ssl http2;
    server_name transfer.example.com;

    ssl_certificate     /etc/letsencrypt/live/transfer.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/transfer.example.com/privkey.pem;

    # 0 disattiva il limite di nginx: la quota la impone l'applicazione,
    # che sa restituire un JSON di errore invece di una pagina 413 generica.
    client_max_body_size 0;

    # Senza questa direttiva nginx bufferizza l'INTERO upload su disco
    # prima di inoltrarlo: la barra di avanzamento arriverebbe al 100%
    # mentre il trasferimento vero non è ancora iniziato.
    proxy_request_buffering off;
    proxy_buffering off;

    # I timeout devono coprire trasferimenti lunghi su connessioni lente.
    proxy_read_timeout    3600s;
    proxy_send_timeout    3600s;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Se il servizio sta dietro un proxy, il rate limiter per IP deve leggere X-Forwarded-For anziché RemoteAddr, altrimenti tutti gli utenti condivideranno il limite dell'IP del proxy. Fondamentale: fidarsi di quell'header solo se la richiesta arriva davvero dal proxy, altrimenti chiunque potrebbe falsificarlo per aggirare il limite.

Passare a S3 o MinIO

Il vantaggio dell'interfaccia Storage emerge ora: per scalare su più istanze basta una nuova implementazione, senza toccare una riga degli handler.

// internal/storage/s3.go (schema di implementazione)
package storage

import (
	"context"
	"io"

	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type S3 struct {
	client   *s3.Client
	uploader *manager.Uploader
	bucket   string
}

func (s *S3) Put(ctx context.Context, key string, r io.Reader) (int64, error) {
	// L'Uploader gestisce da solo il multipart upload: i file grandi
	// vengono spezzati in parti caricate in parallelo, sempre in streaming.
	cw := &countingReader{r: r}
	_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
		Bucket: &s.bucket,
		Key:    &key,
		Body:   cw,
	})
	return cw.n, err
}

// countingReader conta i byte transitati senza bufferizzarli.
type countingReader struct {
	r io.Reader
	n int64
}

func (c *countingReader) Read(p []byte) (int, error) {
	n, err := c.r.Read(p)
	c.n += int64(n)
	return n, err
}

Con S3 si apre anche un'ottimizzazione importante: le presigned URL. Invece di far transitare i byte dal nostro server, si genera un URL firmato a scadenza e il client parla direttamente con l'object storage. L'applicazione Go resta un puro gestore di metadati e la banda del server smette di essere il collo di bottiglia.

Riepilogo delle variabili d'ambiente

Variabile Default Descrizione
GOTRANSFER_ADDR:8080Indirizzo di ascolto HTTP
GOTRANSFER_BASE_URLhttp://localhost:8080URL pubblico per i link condivisi
GOTRANSFER_STORAGE_DIR./data/objectsRadice dello storage su disco
GOTRANSFER_DSNfile:./data/gotransfer.dbDSN SQLite con i pragma
GOTRANSFER_MAX_SIZE2147483648Byte massimi per trasferimento
GOTRANSFER_MAX_FILES20File massimi per trasferimento
GOTRANSFER_TTL168hDurata di validità
GOTRANSFER_CLEANUP_INTERVAL10mFrequenza del garbage collector

Considerazioni di sicurezza

Un servizio che accetta file arbitrari da sconosciuti è una superficie d'attacco notevole. I punti da presidiare:

  • Content-Type in uscita: servire sempre application/octet-stream con Content-Disposition: attachment e X-Content-Type-Options: nosniff. Servire un HTML caricato da un terzo con il suo tipo dichiarato significa consentire l'esecuzione di JavaScript arbitrario nel nostro dominio.
  • Dominio separato per i download: la difesa definitiva contro il punto precedente è servire i contenuti da un dominio distinto e senza cookie (come fa GitHub con raw.githubusercontent.com).
  • Path traversal: l'input utente non deve mai comporre un percorso. Le chiavi sono token generati dal server; la funzione resolve è solo una rete di sicurezza aggiuntiva.
  • Zip bomb in uscita: usando zip.Store il problema non si pone, ma se accettassimo archivi da espandere lato server sarebbe un rischio reale.
  • Referrer leaking: già coperto da Referrer-Policy: no-referrer e dal relativo meta tag.
  • Abusi: rate limiting per IP, quota giornaliera per indirizzo, e possibilmente scansione antivirus (ClamAV via clamd) prima di rendere disponibile il link.
  • Enumerazione: 128 bit di entropia rendono la ricerca esaustiva impossibile; un rate limit anche sulle 404 evita di regalare informazioni sui tempi di risposta.
  • Cifratura a riposo: per dati sensibili, cifrare i byte con AES-GCM usando una chiave derivata da un segreto contenuto nel fragment dell'URL (dopo il #), che il browser non invia mai al server. È l'approccio end-to-end adottato da servizi come Firefox Send.

Possibili estensioni

  • Notifiche via email: inviare al mittente il link e al destinatario l'avviso, con net/smtp o un servizio transazionale, sempre da una goroutine separata per non bloccare la risposta HTTP.
  • Upload ripartibili: implementare il protocollo tus per riprendere i caricamenti interrotti, indispensabile su connessioni mobili.
  • Deduplicazione: calcolare lo SHA-256 in streaming durante la scrittura e riusare gli oggetti identici già presenti.
  • Metriche: esporre byte caricati, byte scaricati, trasferimenti attivi e spazio occupato su un endpoint Prometheus.
  • Anteprime: generare miniature per le immagini con golang.org/x/image, sempre in una goroutine di lavoro asincrona.
  • Protezione con password: derivare una chiave con Argon2id e proteggere la pagina del trasferimento, aggiungendo un secondo fattore al capability URL.

Conclusione

Il servizio che abbiamo costruito occupa circa mille righe di Go, non dipende da alcun framework web e gira in un container da una decina di megabyte, con un consumo di memoria che resta costante indipendentemente dalla dimensione dei file trattati.

Le tre idee che contano davvero sono poche e vale la pena ripeterle: usare MultipartReader invece di ParseMultipartForm, così che nulla venga bufferizzato; separare i metadati dai byte dietro un'interfaccia minima, per poter sostituire lo storage senza riscrivere gli handler; e non fidarsi mai dell'input dell'utente per comporre percorsi o header di risposta.

Il resto — presigned URL, tus, deduplicazione, cifratura lato client — sono estensioni che si innestano naturalmente su questa struttura, proprio perché la struttura è rimasta semplice.