Creare un'app in stile WeTransfer con Laravel

Creare un'app in stile WeTransfer con Laravel

WeTransfer ha reso popolare un'idea molto semplice: caricare uno o più file, ottenere un link, condividerlo, e lasciare che tutto scada dopo qualche giorno. Dietro questa semplicità apparente si nasconde però una serie di problemi tecnici tutt'altro che banali: gestione di upload di grandi dimensioni, storage privato, link firmati e non indovinabili, streaming del download, archiviazione ZIP al volo, scadenze automatiche, notifiche e pulizia dello storage.

In questo articolo costruiremo, passo dopo passo, un'applicazione completa di questo tipo con Laravel. Il codice è pensato per Laravel 12/13 con PHP 8.3+, ma la stragrande maggioranza è compatibile anche con versioni precedenti a partire dalla 11 con minimi adattamenti (in particolare per quanto riguarda la posizione dello scheduler e la configurazione del middleware).

Architettura generale

Prima di scrivere una riga di codice conviene fissare il flusso funzionale, perché è quello che determina lo schema del database e le rotte.

  1. Il mittente apre la homepage e seleziona uno o più file.
  2. Il client carica i file a blocchi (chunk) verso un endpoint dedicato. Ogni chunk viene appeso a un file temporaneo identificato da un UUID.
  3. Quando tutti i file sono stati caricati, il client invia una richiesta di "finalizzazione" con i metadati del trasferimento: email mittente, destinatari, messaggio, scadenza, password facoltativa.
  4. Il server crea un record Transfer con un token casuale, sposta i file dalla cartella temporanea a quella definitiva, calcola dimensioni e checksum.
  5. Il server invia una email ai destinatari con un URL firmato e una email di conferma al mittente.
  6. Il destinatario apre il link, eventualmente inserisce la password, e scarica il singolo file oppure l'intero pacchetto in ZIP.
  7. Un job schedulato elimina periodicamente i trasferimenti scaduti e i relativi file fisici.

Da questo flusso emergono due entità: il trasferimento (transfers) e i file che lo compongono (transfer_files). Una terza tabella, transfer_downloads, ci servirà per tenere traccia degli accessi.

Creazione del progetto e configurazione

Partiamo da un'installazione pulita.

composer create-project laravel/laravel filedrop
cd filedrop
php artisan install:api --no-interaction
composer require league/flysystem-aws-s3-v3 "^3.0"
composer require maennchen/zipstream-php

Il pacchetto zipstream-php è opzionale ma fortemente consigliato: permette di generare un archivio ZIP in streaming senza mai materializzarlo su disco, cosa essenziale quando i trasferimenti pesano centinaia di megabyte. Il driver S3 di Flysystem serve invece se si vuole usare un object storage (S3, Hetzner Object Storage, MinIO, Backblaze B2) al posto del filesystem locale.

Il disco di storage

I file caricati non devono mai essere raggiungibili direttamente via HTTP: ogni download deve passare dall'applicazione, che verifica scadenza, password e limiti. Definiamo quindi due dischi privati in config/filesystems.php.

'disks' => [

    // Disco temporaneo per i chunk in corso di upload
    'chunks' => [
        'driver' => 'local',
        'root' => storage_path('app/chunks'),
        'throw' => true,
    ],

    // Disco definitivo per i file dei trasferimenti completati
    'transfers' => [
        'driver' => env('TRANSFERS_DISK_DRIVER', 'local'),
        'root' => storage_path('app/transfers'),
        'visibility' => 'private',
        'throw' => true,
    ],

],

Se si passa a S3, la voce transfers diventa:

'transfers' => [
    'driver' => 's3',
    'key' => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION'),
    'bucket' => env('AWS_BUCKET'),
    'endpoint' => env('AWS_ENDPOINT'),
    'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
    'visibility' => 'private',
    'throw' => true,
],

Variabili d'ambiente

APP_URL=https://filedrop.test

TRANSFERS_DISK_DRIVER=local

# Limiti applicativi
TRANSFER_MAX_FILE_SIZE=2147483648
TRANSFER_MAX_TOTAL_SIZE=4294967296
TRANSFER_MAX_FILES=50
TRANSFER_DEFAULT_EXPIRY_DAYS=7
TRANSFER_MAX_EXPIRY_DAYS=30
TRANSFER_CHUNK_SIZE=5242880

MAIL_MAILER=smtp
QUEUE_CONNECTION=redis

Raggruppiamo questi valori in un file di configurazione dedicato, config/transfers.php, per non disseminare chiamate a env() nel codice (che smetterebbero di funzionare con config:cache).

<?php

return [

    // Dimensione massima di un singolo file, in byte
    'max_file_size' => (int) env('TRANSFER_MAX_FILE_SIZE', 2 * 1024 * 1024 * 1024),

    // Dimensione massima complessiva di un trasferimento, in byte
    'max_total_size' => (int) env('TRANSFER_MAX_TOTAL_SIZE', 4 * 1024 * 1024 * 1024),

    // Numero massimo di file per trasferimento
    'max_files' => (int) env('TRANSFER_MAX_FILES', 50),

    // Giorni di validita' predefiniti e massimi
    'default_expiry_days' => (int) env('TRANSFER_DEFAULT_EXPIRY_DAYS', 7),
    'max_expiry_days' => (int) env('TRANSFER_MAX_EXPIRY_DAYS', 30),

    // Dimensione dei chunk attesi dal client, in byte
    'chunk_size' => (int) env('TRANSFER_CHUNK_SIZE', 5 * 1024 * 1024),

    // Ore di vita di un chunk orfano prima della rimozione
    'chunk_ttl_hours' => 24,

    // Estensioni sempre rifiutate
    'blocked_extensions' => [
        'php', 'phtml', 'phar', 'exe', 'bat', 'cmd', 'com', 'msi',
        'sh', 'jar', 'dll', 'scr', 'vbs', 'js',
    ],

];

Lo schema del database

Tabella transfers

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('transfers', function (Blueprint $table) {
            $table->id();

            // Token pubblico usato negli URL: casuale e non sequenziale
            $table->string('token', 32)->unique();

            $table->string('sender_email')->nullable();
            $table->string('title')->nullable();
            $table->text('message')->nullable();

            // Elenco dei destinatari serializzato come JSON
            $table->json('recipients')->nullable();

            // Hash della password di protezione, se presente
            $table->string('password')->nullable();

            $table->unsignedBigInteger('total_size')->default(0);
            $table->unsignedInteger('files_count')->default(0);

            $table->unsignedInteger('download_count')->default(0);
            $table->unsignedInteger('max_downloads')->nullable();

            $table->ipAddress('sender_ip')->nullable();

            $table->timestamp('expires_at')->index();
            $table->timestamp('last_downloaded_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('transfers');
    }
};

Tabella transfer_files

Schema::create('transfer_files', function (Blueprint $table) {
    $table->id();
    $table->foreignId('transfer_id')->constrained()->cascadeOnDelete();

    // Nome originale mostrato all'utente
    $table->string('original_name');

    // Percorso relativo sul disco 'transfers'
    $table->string('path');

    $table->string('mime_type', 191)->nullable();
    $table->unsignedBigInteger('size')->default(0);

    // Checksum per verifiche di integrita'
    $table->string('checksum', 64)->nullable();

    $table->timestamps();
});

Tabella transfer_downloads

Schema::create('transfer_downloads', function (Blueprint $table) {
    $table->id();
    $table->foreignId('transfer_id')->constrained()->cascadeOnDelete();
    $table->foreignId('transfer_file_id')->nullable()->constrained()->nullOnDelete();
    $table->ipAddress('ip_address')->nullable();
    $table->string('user_agent')->nullable();
    $table->timestamp('created_at')->nullable();
});

I modelli Eloquent

Transfer

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class Transfer extends Model
{
    protected $fillable = [
        'token', 'sender_email', 'title', 'message', 'recipients',
        'password', 'total_size', 'files_count', 'max_downloads',
        'sender_ip', 'expires_at',
    ];

    protected $hidden = ['password'];

    protected function casts(): array
    {
        return [
            'recipients' => 'array',
            'password' => 'hashed',
            'expires_at' => 'datetime',
            'last_downloaded_at' => 'datetime',
        ];
    }

    // Usa il token al posto dell'id nel route model binding
    public function getRouteKeyName(): string
    {
        return 'token';
    }

    public function files(): HasMany
    {
        return $this->hasMany(TransferFile::class);
    }

    public function downloads(): HasMany
    {
        return $this->hasMany(TransferDownload::class);
    }

    // Genera un token opaco di 32 caratteri
    public static function generateToken(): string
    {
        do {
            $token = Str::lower(Str::random(32));
        } while (static::where('token', $token)->exists());

        return $token;
    }

    public function isExpired(): bool
    {
        return $this->expires_at->isPast();
    }

    public function hasReachedDownloadLimit(): bool
    {
        return $this->max_downloads !== null
            && $this->download_count >= $this->max_downloads;
    }

    public function isAvailable(): bool
    {
        return ! $this->isExpired() && ! $this->hasReachedDownloadLimit();
    }

    public function isProtected(): bool
    {
        return $this->password !== null;
    }

    public function checkPassword(string $plain): bool
    {
        return $this->isProtected() && Hash::check($plain, $this->password);
    }

    // Cartella dedicata al trasferimento sul disco 'transfers'
    public function storageDirectory(): string
    {
        return 'transfers/'.$this->token;
    }

    // Elimina i file fisici oltre al record
    public function purge(): void
    {
        Storage::disk('transfers')->deleteDirectory($this->storageDirectory());
        $this->delete();
    }

    public function scopeExpired(Builder $query): Builder
    {
        return $query->where('expires_at', '<', now());
    }
}

Da notare il cast 'password' => 'hashed': introdotto in Laravel 10, applica automaticamente Hash::make() in scrittura, evitando l'errore classico di salvare la password in chiaro o di ri-hashare un hash.

TransferFile

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;

class TransferFile extends Model
{
    protected $fillable = [
        'transfer_id', 'original_name', 'path',
        'mime_type', 'size', 'checksum',
    ];

    protected function casts(): array
    {
        return [
            'size' => 'integer',
        ];
    }

    public function transfer(): BelongsTo
    {
        return $this->belongsTo(Transfer::class);
    }

    // Restituisce lo stream di lettura del file dal disco privato
    public function readStream()
    {
        return Storage::disk('transfers')->readStream($this->path);
    }

    public function exists(): bool
    {
        return Storage::disk('transfers')->exists($this->path);
    }
}

L'upload a chunk

Questo è il cuore tecnico del progetto. Caricare un file da 2 GB con un normale <input type="file"> e una singola richiesta POST è una pessima idea: PHP dovrebbe bufferizzare l'intero corpo della richiesta, il timeout del web server scatterebbe, e qualsiasi interruzione di rete costringerebbe a ricominciare da zero. La soluzione è spezzare il file in blocchi da pochi megabyte e caricarli uno alla volta.

Il protocollo che implementiamo è volutamente minimale: il client genera un UUID per ogni file, invia i chunk in sequenza indicando indice e totale, e il server li appende a un file temporaneo. Quando arriva l'ultimo chunk, il server considera il file completo.

La Form Request per il chunk

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreChunkRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        // Il chunk puo' essere leggermente piu' grande del previsto: tolleranza del 20%
        $maxChunkKb = (int) ceil(config('transfers.chunk_size') * 1.2 / 1024);

        return [
            'upload_id' => ['required', 'uuid'],
            'file_name' => ['required', 'string', 'max:255'],
            'chunk_index' => ['required', 'integer', 'min:0'],
            'chunk_total' => ['required', 'integer', 'min:1', 'max:100000'],
            'file_size' => [
                'required', 'integer', 'min:1',
                'max:'.config('transfers.max_file_size'),
            ],
            'chunk' => ['required', 'file', 'max:'.$maxChunkKb],
        ];
    }

    public function withValidator($validator): void
    {
        $validator->after(function ($validator) {
            $extension = strtolower(
                pathinfo($this->input('file_name', ''), PATHINFO_EXTENSION)
            );

            // Blocca le estensioni potenzialmente eseguibili lato server
            if (in_array($extension, config('transfers.blocked_extensions'), true)) {
                $validator->errors()->add(
                    'file_name',
                    'Questo tipo di file non e\' consentito.'
                );
            }
        });
    }
}

Il servizio di assemblaggio

Isolare la logica in una classe di servizio mantiene il controller sottile e rende il codice testabile in isolamento.

<?php

namespace App\Services;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

class ChunkUploadService
{
    // Restituisce il percorso assoluto del file temporaneo di un upload
    protected function partialPath(string $uploadId): string
    {
        return storage_path('app/chunks/'.$uploadId.'.part');
    }

    // Percorso del file di stato che tiene traccia dei chunk ricevuti
    protected function statePath(string $uploadId): string
    {
        return storage_path('app/chunks/'.$uploadId.'.json');
    }

    public function ensureDirectory(): void
    {
        $directory = storage_path('app/chunks');

        if (! is_dir($directory)) {
            mkdir($directory, 0750, true);
        }
    }

    /**
     * Appende un chunk al file temporaneo e restituisce lo stato aggiornato.
     */
    public function append(
        string $uploadId,
        int $chunkIndex,
        int $chunkTotal,
        UploadedFile $chunk
    ): array {
        $this->ensureDirectory();

        $partial = $this->partialPath($uploadId);
        $state = $this->readState($uploadId, $chunkTotal);

        // Chunk gia' ricevuto: rispondiamo in modo idempotente
        if (in_array($chunkIndex, $state['received'], true)) {
            return $state;
        }

        // Accettiamo solo chunk in sequenza: semplifica l'append
        if ($chunkIndex !== count($state['received'])) {
            throw new RuntimeException(
                'Chunk fuori sequenza: atteso '.count($state['received'])
            );
        }

        $handle = fopen($partial, 'ab');

        if ($handle === false) {
            throw new RuntimeException('Impossibile aprire il file temporaneo.');
        }

        // Lock esclusivo per evitare scritture concorrenti sullo stesso upload
        if (! flock($handle, LOCK_EX)) {
            fclose($handle);
            throw new RuntimeException('Impossibile acquisire il lock.');
        }

        $source = fopen($chunk->getRealPath(), 'rb');
        stream_copy_to_stream($source, $handle);
        fclose($source);

        fflush($handle);
        flock($handle, LOCK_UN);
        fclose($handle);

        $state['received'][] = $chunkIndex;
        $state['bytes'] = filesize($partial);
        $state['completed'] = count($state['received']) === $chunkTotal;

        $this->writeState($uploadId, $state);

        return $state;
    }

    protected function readState(string $uploadId, int $chunkTotal): array
    {
        $path = $this->statePath($uploadId);

        if (! is_file($path)) {
            return [
                'total' => $chunkTotal,
                'received' => [],
                'bytes' => 0,
                'completed' => false,
            ];
        }

        return json_decode(file_get_contents($path), true);
    }

    protected function writeState(string $uploadId, array $state): void
    {
        file_put_contents(
            $this->statePath($uploadId),
            json_encode($state),
            LOCK_EX
        );
    }

    /**
     * Sposta il file temporaneo completo sul disco definitivo.
     */
    public function moveToTransfers(string $uploadId, string $destination): void
    {
        $partial = $this->partialPath($uploadId);

        if (! is_file($partial)) {
            throw new RuntimeException('File temporaneo inesistente: '.$uploadId);
        }

        $stream = fopen($partial, 'rb');
        Storage::disk('transfers')->writeStream($destination, $stream);

        if (is_resource($stream)) {
            fclose($stream);
        }

        $this->forget($uploadId);
    }

    public function sizeOf(string $uploadId): int
    {
        $partial = $this->partialPath($uploadId);

        return is_file($partial) ? (int) filesize($partial) : 0;
    }

    public function checksumOf(string $uploadId): ?string
    {
        $partial = $this->partialPath($uploadId);

        return is_file($partial) ? hash_file('sha256', $partial) : null;
    }

    public function mimeTypeOf(string $uploadId): ?string
    {
        $partial = $this->partialPath($uploadId);

        if (! is_file($partial)) {
            return null;
        }

        return mime_content_type($partial) ?: 'application/octet-stream';
    }

    // Rimuove file temporaneo e stato
    public function forget(string $uploadId): void
    {
        @unlink($this->partialPath($uploadId));
        @unlink($this->statePath($uploadId));
    }

    // Elimina i chunk orfani piu' vecchi del TTL configurato
    public function pruneStale(): int
    {
        $this->ensureDirectory();

        $threshold = now()->subHours(config('transfers.chunk_ttl_hours'))->getTimestamp();
        $removed = 0;

        foreach (glob(storage_path('app/chunks/*')) as $file) {
            if (filemtime($file) < $threshold) {
                @unlink($file);
                $removed++;
            }
        }

        return $removed;
    }
}

Una nota importante sul flock(): funziona solo su filesystem locali. Se si esegue l'applicazione su più server dietro un load balancer, i chunk di uno stesso file potrebbero finire su macchine diverse. In quel caso le opzioni sono due: usare sticky session sul balancer, oppure appoggiarsi al multipart upload nativo di S3, che è progettato esattamente per questo scenario.

Il controller di upload

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreChunkRequest;
use App\Services\ChunkUploadService;
use Illuminate\Http\JsonResponse;
use RuntimeException;

class ChunkUploadController extends Controller
{
    public function __construct(
        protected ChunkUploadService $chunks
    ) {
    }

    public function store(StoreChunkRequest $request): JsonResponse
    {
        try {
            $state = $this->chunks->append(
                $request->string('upload_id')->toString(),
                $request->integer('chunk_index'),
                $request->integer('chunk_total'),
                $request->file('chunk')
            );
        } catch (RuntimeException $exception) {
            return response()->json([
                'message' => $exception->getMessage(),
            ], 422);
        }

        return response()->json([
            'upload_id' => $request->input('upload_id'),
            'received' => count($state['received']),
            'total' => $state['total'],
            'bytes' => $state['bytes'],
            'completed' => $state['completed'],
        ]);
    }

    // Permette al client di annullare un upload in corso
    public function destroy(string $uploadId): JsonResponse
    {
        $this->chunks->forget($uploadId);

        return response()->json(['message' => 'Upload annullato.']);
    }
}

Finalizzazione del trasferimento

Una volta che tutti i file sono stati caricati, il client invia i metadati. Il server valida, crea il record e sposta i file. Tutta l'operazione va eseguita in transazione: se qualcosa fallisce a metà non vogliamo record parziali in tabella.

La Form Request

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreTransferRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => ['nullable', 'string', 'max:120'],
            'message' => ['nullable', 'string', 'max:2000'],
            'sender_email' => ['required', 'email:rfc,dns', 'max:191'],

            'recipients' => ['nullable', 'array', 'max:20'],
            'recipients.*' => ['email:rfc,dns', 'max:191'],

            'password' => ['nullable', 'string', 'min:6', 'max:191'],
            'max_downloads' => ['nullable', 'integer', 'min:1', 'max:10000'],

            'expiry_days' => [
                'nullable', 'integer', 'min:1',
                'max:'.config('transfers.max_expiry_days'),
            ],

            'files' => ['required', 'array', 'min:1', 'max:'.config('transfers.max_files')],
            'files.*.upload_id' => ['required', 'uuid'],
            'files.*.name' => ['required', 'string', 'max:255'],
        ];
    }

    public function messages(): array
    {
        return [
            'files.required' => 'Devi caricare almeno un file.',
            'files.max' => 'Hai superato il numero massimo di file consentiti.',
        ];
    }
}

Il servizio di creazione

<?php

namespace App\Services;

use App\Models\Transfer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;

class TransferService
{
    public function __construct(
        protected ChunkUploadService $chunks
    ) {
    }

    /**
     * Crea un trasferimento a partire dagli upload completati.
     *
     * @param  array<int, array{upload_id: string, name: string}>  $files
     */
    public function create(array $attributes, array $files, ?string $ip = null): Transfer
    {
        $this->assertTotalSizeIsAllowed($files);

        $token = Transfer::generateToken();

        try {
            return DB::transaction(function () use ($attributes, $files, $token, $ip) {
                $expiryDays = $attributes['expiry_days']
                    ?? config('transfers.default_expiry_days');

                $transfer = Transfer::create([
                    'token' => $token,
                    'title' => $attributes['title'] ?? null,
                    'message' => $attributes['message'] ?? null,
                    'sender_email' => $attributes['sender_email'],
                    'recipients' => $attributes['recipients'] ?? [],
                    'password' => $attributes['password'] ?? null,
                    'max_downloads' => $attributes['max_downloads'] ?? null,
                    'sender_ip' => $ip,
                    'expires_at' => now()->addDays($expiryDays)->endOfDay(),
                ]);

                $totalSize = 0;

                foreach ($files as $file) {
                    $uploadId = $file['upload_id'];

                    // Nome sanificato per lo storage, nome originale per l'utente
                    $safeName = $this->sanitizeFileName($file['name']);
                    $path = $transfer->storageDirectory().'/'.Str::uuid().'-'.$safeName;

                    $size = $this->chunks->sizeOf($uploadId);
                    $checksum = $this->chunks->checksumOf($uploadId);
                    $mimeType = $this->chunks->mimeTypeOf($uploadId);

                    if ($size === 0) {
                        throw new RuntimeException('Upload incompleto: '.$uploadId);
                    }

                    $this->chunks->moveToTransfers($uploadId, $path);

                    $transfer->files()->create([
                        'original_name' => $file['name'],
                        'path' => $path,
                        'size' => $size,
                        'checksum' => $checksum,
                        'mime_type' => $mimeType,
                    ]);

                    $totalSize += $size;
                }

                $transfer->update([
                    'total_size' => $totalSize,
                    'files_count' => count($files),
                ]);

                return $transfer->fresh('files');
            });
        } catch (Throwable $exception) {
            // Rollback dello storage: la transazione DB non copre il filesystem
            \Storage::disk('transfers')->deleteDirectory('transfers/'.$token);

            throw $exception;
        }
    }

    protected function assertTotalSizeIsAllowed(array $files): void
    {
        $total = 0;

        foreach ($files as $file) {
            $total += $this->chunks->sizeOf($file['upload_id']);
        }

        if ($total > config('transfers.max_total_size')) {
            throw new RuntimeException(
                'La dimensione totale supera il limite consentito.'
            );
        }
    }

    // Rimuove caratteri pericolosi e sequenze di path traversal
    protected function sanitizeFileName(string $name): string
    {
        $name = basename(str_replace('\\', '/', $name));
        $name = preg_replace('/[^\pL\pN._-]+/u', '-', $name);
        $name = trim($name, '-.');

        return Str::limit($name ?: 'file', 100, '');
    }
}

Il blocco catch merita attenzione. DB::transaction() annulla le scritture sul database, ma non i file già spostati sul disco: sono due sistemi distinti. Il rollback manuale dello storage è quindi indispensabile per non lasciare file orfani che occuperebbero spazio senza corrispondere ad alcun record.

Il controller

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreTransferRequest;
use App\Mail\TransferReceipt;
use App\Mail\TransferShared;
use App\Services\TransferService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use RuntimeException;

class TransferController extends Controller
{
    public function __construct(
        protected TransferService $transfers
    ) {
    }

    public function store(StoreTransferRequest $request): JsonResponse
    {
        try {
            $transfer = $this->transfers->create(
                $request->safe()->except('files'),
                $request->input('files'),
                $request->ip()
            );
        } catch (RuntimeException $exception) {
            return response()->json([
                'message' => $exception->getMessage(),
            ], 422);
        }

        $url = route('transfers.show', $transfer);

        // Le email partono in coda per non rallentare la risposta
        foreach ($transfer->recipients ?? [] as $recipient) {
            Mail::to($recipient)->queue(new TransferShared($transfer, $url));
        }

        Mail::to($transfer->sender_email)->queue(new TransferReceipt($transfer, $url));

        return response()->json([
            'token' => $transfer->token,
            'url' => $url,
            'expires_at' => $transfer->expires_at->toIso8601String(),
        ], 201);
    }
}

Il client JavaScript

Lato browser usiamo File.prototype.slice() per tagliare il file e inviare i chunk in sequenza. Il codice è volutamente senza dipendenze; in produzione conviene valutare Uppy, che gestisce già retry, pausa e ripresa.

const CHUNK_SIZE = 5 * 1024 * 1024;

const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

// Carica un singolo file a blocchi e restituisce l'identificativo dell'upload
async function uploadFile(file, onProgress) {
  const uploadId = crypto.randomUUID();
  const chunkTotal = Math.ceil(file.size / CHUNK_SIZE);

  for (let index = 0; index < chunkTotal; index++) {
    const start = index * CHUNK_SIZE;
    const blob = file.slice(start, start + CHUNK_SIZE);

    const body = new FormData();
    body.append('upload_id', uploadId);
    body.append('file_name', file.name);
    body.append('file_size', file.size);
    body.append('chunk_index', index);
    body.append('chunk_total', chunkTotal);
    body.append('chunk', blob);

    await sendChunkWithRetry(body);

    onProgress(Math.round(((index + 1) / chunkTotal) * 100));
  }

  return uploadId;
}

// Tre tentativi con backoff esponenziale: le reti mobili perdono pacchetti
async function sendChunkWithRetry(body, attempts = 3) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const response = await fetch('/api/uploads/chunk', {
        method: 'POST',
        headers: { 'X-CSRF-TOKEN': csrfToken, Accept: 'application/json' },
        body,
      });

      if (response.ok) {
        return response.json();
      }

      // Gli errori di validazione non hanno senso da ritentare
      if (response.status === 422) {
        const error = await response.json();
        throw new Error(error.message);
      }
    } catch (error) {
      if (attempt === attempts) {
        throw error;
      }
    }

    await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
  }
}

// Finalizza il trasferimento dopo il caricamento di tutti i file
async function finalize(uploads, metadata) {
  const response = await fetch('/api/transfers', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-TOKEN': csrfToken,
      Accept: 'application/json',
    },
    body: JSON.stringify({ ...metadata, files: uploads }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message ?? 'Errore durante la creazione del trasferimento.');
  }

  return response.json();
}

async function handleSubmit(files, metadata, onProgress) {
  const uploads = [];

  for (const file of files) {
    const uploadId = await uploadFile(file, (percent) => onProgress(file.name, percent));
    uploads.push({ upload_id: uploadId, name: file.name });
  }

  return finalize(uploads, metadata);
}

Il download

Qui si concentrano i requisiti di sicurezza. Il token nell'URL è già un segreto da 32 caratteri, ma aggiungiamo diversi livelli di controllo.

Middleware di validità

<?php

namespace App\Http\Middleware;

use App\Models\Transfer;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureTransferIsAvailable
{
    public function handle(Request $request, Closure $next): Response
    {
        $transfer = $request->route('transfer');

        if (! $transfer instanceof Transfer) {
            abort(404);
        }

        if ($transfer->isExpired()) {
            abort(410, 'Questo trasferimento e\' scaduto.');
        }

        if ($transfer->hasReachedDownloadLimit()) {
            abort(410, 'Questo trasferimento ha raggiunto il limite di download.');
        }

        return $next($request);
    }
}

Il codice HTTP 410 Gone è semanticamente più corretto del 404 per una risorsa che è esistita e non esiste più.

Middleware per la password

La password sblocca il trasferimento per la durata della sessione. Memorizziamo l'avvenuta autorizzazione in sessione, indicizzata per token.

<?php

namespace App\Http\Middleware;

use App\Models\Transfer;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureTransferIsUnlocked
{
    public function handle(Request $request, Closure $next): Response
    {
        /** @var Transfer $transfer */
        $transfer = $request->route('transfer');

        if (! $transfer->isProtected()) {
            return $next($request);
        }

        $unlocked = $request->session()->get('unlocked_transfers', []);

        if (! in_array($transfer->token, $unlocked, true)) {
            return redirect()->route('transfers.unlock.form', $transfer);
        }

        return $next($request);
    }
}
<?php

namespace App\Http\Controllers;

use App\Models\Transfer;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

class TransferUnlockController extends Controller
{
    public function create(Transfer $transfer)
    {
        return view('transfers.unlock', compact('transfer'));
    }

    public function store(Request $request, Transfer $transfer)
    {
        $request->validate([
            'password' => ['required', 'string'],
        ]);

        if (! $transfer->checkPassword($request->input('password'))) {
            throw ValidationException::withMessages([
                'password' => 'Password non corretta.',
            ]);
        }

        // Rigenera l'id di sessione per prevenire session fixation
        $request->session()->regenerate();

        $unlocked = $request->session()->get('unlocked_transfers', []);
        $unlocked[] = $transfer->token;

        $request->session()->put('unlocked_transfers', array_unique($unlocked));

        return redirect()->route('transfers.show', $transfer);
    }
}

Download di un singolo file

Non usiamo Storage::download() perché carica l'intero contenuto in memoria quando il driver non lo supporta nativamente. Usiamo invece una StreamedResponse che copia lo stream a blocchi.

<?php

namespace App\Http\Controllers;

use App\Models\Transfer;
use App\Models\TransferFile;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;

class DownloadController extends Controller
{
    public function file(Request $request, Transfer $transfer, TransferFile $file): StreamedResponse
    {
        // Verifica che il file appartenga davvero al trasferimento richiesto
        abort_unless($file->transfer_id === $transfer->id, 404);
        abort_unless($file->exists(), 404);

        $this->registerDownload($request, $transfer, $file);

        $disposition = HeaderUtils::makeDisposition(
            HeaderUtils::DISPOSITION_ATTACHMENT,
            $file->original_name,
            // Fallback ASCII per i client che non supportano RFC 5987
            'download'
        );

        return response()->stream(function () use ($file) {
            $stream = $file->readStream();

            while (! feof($stream)) {
                echo fread($stream, 8192);

                // Svuota il buffer per non accumulare memoria
                flush();
            }

            fclose($stream);
        }, 200, [
            'Content-Type' => $file->mime_type ?? 'application/octet-stream',
            'Content-Length' => $file->size,
            'Content-Disposition' => $disposition,
            'X-Content-Type-Options' => 'nosniff',
            'Cache-Control' => 'private, no-store',
        ]);
    }

    protected function registerDownload(
        Request $request,
        Transfer $transfer,
        ?TransferFile $file = null
    ): void {
        $transfer->downloads()->create([
            'transfer_file_id' => $file?->id,
            'ip_address' => $request->ip(),
            'user_agent' => substr((string) $request->userAgent(), 0, 255),
            'created_at' => now(),
        ]);

        // increment() evita race condition sul contatore
        $transfer->increment('download_count');
        $transfer->update(['last_downloaded_at' => now()]);
    }
}

L'header X-Content-Type-Options: nosniff non è un dettaglio cosmetico: impedisce al browser di reinterpretare il MIME type dichiarato, chiudendo una via a possibili attacchi XSS tramite file caricati.

Download dell'intero pacchetto in ZIP

Costruire uno ZIP su disco e poi servirlo significa raddoppiare l'occupazione di spazio e far attendere l'utente per l'intera compressione. Con ZipStream generiamo l'archivio direttamente nel flusso di risposta.

public function archive(Request $request, Transfer $transfer): StreamedResponse
{
    $files = $transfer->files()->get();

    abort_if($files->isEmpty(), 404);

    $this->registerDownload($request, $transfer);

    $archiveName = Str::slug($transfer->title ?: 'transfer-'.$transfer->token).'.zip';

    return response()->stream(function () use ($files) {
        $zip = new ZipStream(
            outputName: null,
            sendHttpHeaders: false,
            // Nessuna compressione: i file grandi sono spesso gia' compressi
            defaultCompressionMethod: CompressionMethod::STORE,
            enableZip64: true
        );

        $usedNames = [];

        foreach ($files as $file) {
            $name = $this->uniqueNameWithin($file->original_name, $usedNames);
            $usedNames[] = $name;

            $stream = $file->readStream();
            $zip->addFileFromStream($name, $stream);

            if (is_resource($stream)) {
                fclose($stream);
            }
        }

        $zip->finish();
    }, 200, [
        'Content-Type' => 'application/zip',
        'Content-Disposition' => 'attachment; filename="'.$archiveName.'"',
        'X-Accel-Buffering' => 'no',
        'Cache-Control' => 'private, no-store',
    ]);
}

// Evita collisioni quando due file hanno lo stesso nome
protected function uniqueNameWithin(string $name, array $used): string
{
    if (! in_array($name, $used, true)) {
        return $name;
    }

    $extension = pathinfo($name, PATHINFO_EXTENSION);
    $base = pathinfo($name, PATHINFO_FILENAME);
    $counter = 1;

    do {
        $candidate = $base.' ('.$counter.')'.($extension ? '.'.$extension : '');
        $counter++;
    } while (in_array($candidate, $used, true));

    return $candidate;
}

Due scelte da spiegare. La prima è CompressionMethod::STORE: comprimere un JPEG o un MP4 fa consumare CPU per guadagnare qualche punto percentuale. La seconda è l'header X-Accel-Buffering: no, che dice a nginx di non bufferizzare la risposta: senza di esso il reverse proxy accumulerebbe l'intero ZIP prima di inviarlo, annullando il vantaggio dello streaming.

Il parametro enableZip64: true serve invece a superare i limiti del formato ZIP originale, che non gestisce archivi oltre i 4 GB né più di 65.535 voci.

Le rotte

<?php
// routes/web.php

use App\Http\Controllers\DownloadController;
use App\Http\Controllers\TransferUnlockController;
use App\Http\Controllers\UploadPageController;
use Illuminate\Support\Facades\Route;

Route::get('/', UploadPageController::class)->name('home');

Route::prefix('t/{transfer}')
    ->middleware('transfer.available')
    ->group(function () {

        Route::get('/unlock', [TransferUnlockController::class, 'create'])
            ->name('transfers.unlock.form');

        Route::post('/unlock', [TransferUnlockController::class, 'store'])
            ->middleware('throttle:6,1')
            ->name('transfers.unlock');

        Route::middleware('transfer.unlocked')->group(function () {

            Route::get('/', [TransferPageController::class, 'show'])
                ->name('transfers.show');

            Route::get('/download', [DownloadController::class, 'archive'])
                ->middleware('throttle:downloads')
                ->name('transfers.download');

            Route::get('/download/{file}', [DownloadController::class, 'file'])
                ->middleware('throttle:downloads')
                ->name('transfers.download.file');
        });
    });
<?php
// routes/api.php

use App\Http\Controllers\ChunkUploadController;
use App\Http\Controllers\TransferController;
use Illuminate\Support\Facades\Route;

Route::middleware('throttle:uploads')->group(function () {
    Route::post('/uploads/chunk', [ChunkUploadController::class, 'store']);
    Route::delete('/uploads/{uploadId}', [ChunkUploadController::class, 'destroy']);
    Route::post('/transfers', [TransferController::class, 'store']);
});

Registrazione di middleware e rate limiter

In Laravel 11 e successivi la configurazione avviene in bootstrap/app.php.

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'transfer.available' => \App\Http\Middleware\EnsureTransferIsAvailable::class,
        'transfer.unlocked' => \App\Http\Middleware\EnsureTransferIsUnlocked::class,
    ]);
})
<?php
// app/Providers/AppServiceProvider.php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    // I chunk sono numerosi: il limite deve essere generoso
    RateLimiter::for('uploads', function (Request $request) {
        return Limit::perMinute(300)->by($request->ip());
    });

    // I download sono piu' rari e piu' costosi in banda
    RateLimiter::for('downloads', function (Request $request) {
        return Limit::perMinute(30)->by($request->ip());
    });
}

Le email

<?php

namespace App\Mail;

use App\Models\Transfer;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class TransferShared extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function __construct(
        public Transfer $transfer,
        public string $url
    ) {
    }

    public function envelope(): Envelope
    {
        $subject = $this->transfer->title
            ? $this->transfer->sender_email.' ti ha inviato "'.$this->transfer->title.'"'
            : $this->transfer->sender_email.' ti ha inviato dei file';

        return new Envelope(
            subject: $subject,
            // Non usiamo l'indirizzo del mittente come From: fallirebbe SPF/DKIM
            replyTo: [$this->transfer->sender_email],
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'mail.transfers.shared',
            with: [
                'expiresAt' => $this->transfer->expires_at,
                'filesCount' => $this->transfer->files_count,
                'totalSize' => $this->transfer->total_size,
            ],
        );
    }
}

Il commento sul From è importante e viene ignorato spesso: impostare come mittente l'indirizzo dell'utente significa che i suoi record SPF non autorizzeranno il nostro server, e le email finiranno in spam o verranno rifiutate. Si spedisce sempre dal proprio dominio, mettendo l'utente in Reply-To.

@component('mail::message')
# Hai ricevuto dei file

**{{ $transfer->sender_email }}** ti ha inviato {{ $filesCount }} file
({{ \Illuminate\Support\Number::fileSize($totalSize) }}).

@if($transfer->message)
> {{ $transfer->message }}
@endif

@component('mail::button', ['url' => $url])
Scarica i file
@endcomponent

Il link scade il {{ $expiresAt->format('d/m/Y') }}.

@if($transfer->isProtected())
Il trasferimento e' protetto da password: chiedila al mittente.
@endif
@endcomponent

La classe Illuminate\Support\Number, introdotta in Laravel 10.42, offre fileSize() che formatta i byte in modo leggibile senza dover scrivere l'ennesimo helper.

Pulizia automatica

Senza una pulizia periodica lo storage cresce all'infinito. Scriviamo un comando Artisan che elimina trasferimenti scaduti e chunk orfani.

<?php

namespace App\Console\Commands;

use App\Models\Transfer;
use App\Services\ChunkUploadService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;

class PruneTransfers extends Command
{
    protected $signature = 'transfers:prune
                            {--grace=0 : Giorni di tolleranza oltre la scadenza}
                            {--dry-run : Mostra cosa verrebbe eliminato senza agire}';

    protected $description = 'Elimina i trasferimenti scaduti e i chunk orfani';

    public function handle(ChunkUploadService $chunks): int
    {
        $threshold = now()->subDays((int) $this->option('grace'));
        $dryRun = (bool) $this->option('dry-run');

        $freedBytes = 0;
        $deleted = 0;

        // chunkById evita di caricare tutto in memoria su tabelle grandi
        Transfer::where('expires_at', '<', $threshold)
            ->chunkById(100, function ($transfers) use (&$freedBytes, &$deleted, $dryRun) {
                foreach ($transfers as $transfer) {
                    $this->line(sprintf(
                        '%s %s (%s, scaduto il %s)',
                        $dryRun ? '[dry-run]' : 'Elimino',
                        $transfer->token,
                        \Illuminate\Support\Number::fileSize($transfer->total_size),
                        $transfer->expires_at->format('d/m/Y')
                    ));

                    if ($dryRun) {
                        continue;
                    }

                    try {
                        $freedBytes += $transfer->total_size;
                        $transfer->purge();
                        $deleted++;
                    } catch (Throwable $exception) {
                        Log::error('Pulizia trasferimento fallita', [
                            'token' => $transfer->token,
                            'error' => $exception->getMessage(),
                        ]);
                    }
                }
            });

        $staleChunks = $dryRun ? 0 : $chunks->pruneStale();

        $this->info(sprintf(
            'Trasferimenti eliminati: %d. Spazio liberato: %s. Chunk orfani rimossi: %d.',
            $deleted,
            \Illuminate\Support\Number::fileSize($freedBytes),
            $staleChunks
        ));

        return self::SUCCESS;
    }
}

Da Laravel 11 lo scheduler si dichiara in routes/console.php.

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('transfers:prune')
    ->dailyAt('03:00')
    ->withoutOverlapping()
    ->onOneServer()
    ->runInBackground();

withoutOverlapping() impedisce che una pulizia lenta si sovrapponga a quella successiva; onOneServer() è indispensabile in ambienti multi-server per non far eseguire il comando a tutte le istanze contemporaneamente (richiede un driver di cache condiviso come Redis).

Vale la pena aggiungere anche una notifica di scadenza imminente:

Schedule::call(function () {
    // Avvisa i mittenti dei trasferimenti che scadono domani
    Transfer::whereBetween('expires_at', [now()->addDay(), now()->addDays(2)])
        ->whereNotNull('sender_email')
        ->chunkById(100, function ($transfers) {
            foreach ($transfers as $transfer) {
                Mail::to($transfer->sender_email)
                    ->queue(new TransferExpiringSoon($transfer));
            }
        });
})->dailyAt('09:00')->onOneServer();

Configurazione del server

Anche il codice migliore fallisce se il web server rifiuta le richieste. Con chunk da 5 MB i limiti PHP sono già ampiamente sufficienti, ma vanno comunque verificati.

; php.ini
upload_max_filesize = 20M
post_max_size = 24M
max_execution_time = 300
max_input_time = 300
memory_limit = 256M

Il valore di post_max_size deve essere superiore a upload_max_filesize, perché il corpo della richiesta contiene anche i campi del form oltre al chunk. Se PHP supera post_max_size, l'array $_POST arriva vuoto senza alcun errore esplicito: è una delle cause più frustranti di upload che "spariscono".

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

    root /var/www/filedrop/public;
    index index.php;

    # Deve essere almeno pari a post_max_size
    client_max_body_size 24m;

    # Timeout generosi per i download di archivi grandi
    fastcgi_read_timeout 600;
    proxy_read_timeout 600;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;

        # Disattiva il buffering: essenziale per lo streaming dello ZIP
        fastcgi_buffering off;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

X-Sendfile e X-Accel-Redirect

Far passare gigabyte di dati attraverso PHP funziona, ma tiene occupato un worker FPM per tutta la durata del download. Se un utente su connessione lenta scarica un file da 2 GB, quel processo resta bloccato per minuti. Con pochi worker disponibili il sito diventa irraggiungibile.

La soluzione, quando lo storage è locale, è delegare la trasmissione a nginx tramite X-Accel-Redirect.

location /internal-transfers/ {
    internal;
    alias /var/www/filedrop/storage/app/transfers/;
}
public function file(Request $request, Transfer $transfer, TransferFile $file)
{
    abort_unless($file->transfer_id === $transfer->id, 404);

    $this->registerDownload($request, $transfer, $file);

    if (config('transfers.use_x_accel')) {
        // nginx serve il file direttamente: PHP libera subito il worker
        return response(null, 200, [
            'X-Accel-Redirect' => '/internal-transfers/'.$file->path,
            'Content-Type' => $file->mime_type ?? 'application/octet-stream',
            'Content-Disposition' => 'attachment; filename="'.$file->original_name.'"',
        ]);
    }

    return $this->streamedResponse($file);
}

La direttiva internal è ciò che rende sicuro il meccanismo: quella location è raggiungibile solo tramite redirect interno, mai da una richiesta esterna. L'equivalente su Apache è XSendFile; su S3 la strategia migliore è invece generare una URL pre-firmata a breve scadenza con Storage::temporaryUrl(), così il download non tocca affatto il nostro server.

I test

Il testing di questo dominio è più semplice di quanto sembri grazie a Storage::fake().

<?php

use App\Models\Transfer;
use App\Models\TransferFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

it('assembla i chunk in un unico file', function () {
    $uploadId = (string) Str::uuid();

    // Primo chunk
    $this->postJson('/api/uploads/chunk', [
        'upload_id' => $uploadId,
        'file_name' => 'report.pdf',
        'file_size' => 10,
        'chunk_index' => 0,
        'chunk_total' => 2,
        'chunk' => UploadedFile::fake()->createWithContent('part0', 'HELLO'),
    ])->assertOk()->assertJson(['completed' => false]);

    // Secondo e ultimo chunk
    $this->postJson('/api/uploads/chunk', [
        'upload_id' => $uploadId,
        'file_name' => 'report.pdf',
        'file_size' => 10,
        'chunk_index' => 1,
        'chunk_total' => 2,
        'chunk' => UploadedFile::fake()->createWithContent('part1', 'WORLD'),
    ])->assertOk()->assertJson(['completed' => true, 'bytes' => 10]);

    expect(file_get_contents(storage_path('app/chunks/'.$uploadId.'.part')))
        ->toBe('HELLOWORLD');
});

it('rifiuta le estensioni pericolose', function () {
    $this->postJson('/api/uploads/chunk', [
        'upload_id' => (string) Str::uuid(),
        'file_name' => 'shell.php',
        'file_size' => 4,
        'chunk_index' => 0,
        'chunk_total' => 1,
        'chunk' => UploadedFile::fake()->createWithContent('shell.php', 'test'),
    ])->assertStatus(422)->assertJsonValidationErrors('file_name');
});

it('nega l\'accesso a un trasferimento scaduto', function () {
    $transfer = Transfer::factory()->create([
        'expires_at' => now()->subDay(),
    ]);

    $this->get(route('transfers.show', $transfer))->assertStatus(410);
});

it('richiede la password quando il trasferimento e\' protetto', function () {
    $transfer = Transfer::factory()->create([
        'password' => 'secret-passphrase',
        'expires_at' => now()->addDays(3),
    ]);

    $this->get(route('transfers.show', $transfer))
        ->assertRedirect(route('transfers.unlock.form', $transfer));

    $this->post(route('transfers.unlock', $transfer), [
        'password' => 'secret-passphrase',
    ])->assertRedirect(route('transfers.show', $transfer));

    $this->get(route('transfers.show', $transfer))->assertOk();
});

it('impedisce di scaricare un file di un altro trasferimento', function () {
    Storage::fake('transfers');

    $first = Transfer::factory()->create(['expires_at' => now()->addDay()]);
    $second = Transfer::factory()->create(['expires_at' => now()->addDay()]);

    $file = TransferFile::factory()->for($second)->create();

    $this->get(route('transfers.download.file', [$first, $file]))
        ->assertNotFound();
});

it('elimina i trasferimenti scaduti e i loro file', function () {
    Storage::fake('transfers');

    $transfer = Transfer::factory()->create(['expires_at' => now()->subDays(2)]);
    Storage::disk('transfers')->put($transfer->storageDirectory().'/a.txt', 'x');

    $this->artisan('transfers:prune')->assertSuccessful();

    expect(Transfer::find($transfer->id))->toBeNull();
    Storage::disk('transfers')->assertMissing($transfer->storageDirectory().'/a.txt');
});

Considerazioni di sicurezza

Riassumo i punti che, per esperienza, vengono più spesso trascurati in progetti di questo tipo.

  • Path traversal. Il nome del file arriva dal client e non deve mai essere concatenato al percorso senza sanificazione. Un file chiamato ../../../.env è un tentativo di sovrascrittura. La funzione sanitizeFileName() vista sopra, combinata con l'UUID prefisso, chiude la questione.
  • Enumerazione dei token. Un token da 32 caratteri alfanumerici offre circa 165 bit di entropia: è inattaccabile per forza bruta. Un id autoincrementale nell'URL sarebbe invece una falla immediata.
  • Storage pubblico. I file non devono mai finire in storage/app/public né essere raggiungibili tramite symlink da public/. Il disco deve essere privato e ogni accesso mediato dall'applicazione.
  • MIME type dichiarato. Non fidarsi mai del Content-Type inviato dal browser: va ricalcolato lato server con mime_content_type() o finfo.
  • Timing attack sulla password. Hash::check() usa già un confronto a tempo costante; il rate limiting sull'endpoint di sblocco (throttle:6,1) protegge dal brute force.
  • Abuso della piattaforma. Un servizio anonimo di file hosting diventa rapidamente un veicolo per malware e phishing. Considerare la verifica dell'email del mittente prima dell'invio, l'integrazione di ClamAV tramite un job asincrono, e una procedura di segnalazione.
  • Zip bomb in uscita. Usando STORE anziché DEFLATE l'archivio generato non può essere una zip bomb, ma è comunque saggio limitare la dimensione totale.
  • GDPR. Indirizzi email e IP sono dati personali. La tabella transfer_downloads va sottoposta a retention: conviene aggiungere una regola di eliminazione dopo 30 o 90 giorni.

Scalare oltre il singolo server

L'architettura descritta regge senza problemi decine di migliaia di trasferimenti su un singolo VPS. Quando serve andare oltre, ecco le direttrici principali.

Collo di bottiglia Soluzione
Spazio disco Object storage S3-compatibile con lifecycle rule per la scadenza automatica
Banda in upload Upload diretto verso S3 con URL pre-firmate: i byte non toccano l'applicazione
Banda in download CDN davanti allo storage, oppure URL temporanee firmate
Worker FPM occupati X-Accel-Redirect su storage locale, temporaryUrl su S3
Chunk su server diversi Multipart upload nativo di S3, o sticky session sul load balancer
Generazione ZIP Pre-generazione asincrona in coda con notifica quando pronto

Il passaggio all'upload diretto su S3 è quello che cambia di più le carte in tavola: l'applicazione smette di essere un proxy di byte e diventa un semplice coordinatore di metadati. Il flusso diventa: il client chiede una URL pre-firmata, carica direttamente sul bucket, e comunica al server solo la chiave dell'oggetto. Il carico sul backend crolla di ordini di grandezza.

// Genera una URL pre-firmata per l'upload diretto su S3
public function presign(Request $request): JsonResponse
{
    $request->validate([
        'file_name' => ['required', 'string', 'max:255'],
        'content_type' => ['required', 'string', 'max:191'],
    ]);

    $key = 'uploads/'.Str::uuid().'/'.$this->sanitizeFileName($request->input('file_name'));

    $client = Storage::disk('transfers')->getClient();

    $command = $client->getCommand('PutObject', [
        'Bucket' => config('filesystems.disks.transfers.bucket'),
        'Key' => $key,
        'ContentType' => $request->input('content_type'),
    ]);

    // La URL vale 30 minuti: abbastanza per un file grande, non troppo per un abuso
    $presigned = $client->createPresignedRequest($command, '+30 minutes');

    return response()->json([
        'key' => $key,
        'url' => (string) $presigned->getUri(),
    ]);
}

Conclusione

Quello che abbiamo costruito è un servizio di file transfer completo: upload resiliente a chunk, storage privato, link opachi con scadenza, protezione con password, download in streaming, archiviazione ZIP senza materializzazione su disco, notifiche via email e pulizia automatica.

La lezione più interessante di questo progetto è che quasi tutta la complessità sta ai bordi, non nel dominio. Il modello dati è banale: due tabelle e una relazione. Sono invece l'attraversamento della rete, i limiti di PHP e del web server, il buffering dei proxy, il comportamento del filesystem sotto concorrenza a richiedere attenzione. È un promemoria utile: quando un'applicazione muove byte anziché record, il vero terreno di gioco è l'infrastruttura.

Le direzioni naturali di estensione sono diverse: un'area riservata con storico dei trasferimenti, la scansione antivirus asincrona, la generazione di anteprime per immagini e PDF, l'analytics sui download, o l'aggiunta di una API pubblica con Sanctum per l'integrazione in altri sistemi. La base descritta qui le regge tutte senza riscritture.