Laravel 15 min de lecture

Uploads directs vers S3 avec URLs signées dans Laravel (PUT + vérification serveur)

#laravel#filamentphp

Uploads S3 directs avec URLs signées dans Laravel (PUT) : génère l’URL, upload navigateur, vérifie côté serveur avant persistance. Rapide et sécurisé.

Uploads directs vers S3 avec URLs signées dans Laravel (PUT + vérification serveur)

Uploader directement vers S3, sans faire transiter le binaire via PHP, réduit drastiquement la charge serveur et accélère l’UX. Dans ce tutoriel, tu vas mettre en place un flux complet: génération d’URL signée côté Laravel, upload direct depuis le navigateur avec barre de progression, puis confirmation/vérification côté serveur avant de persister en base. Le tout sécurisé et prêt pour la prod.

Objectif

Tu vas mettre en œuvre des uploads S3 qui n’atteignent jamais PHP côté binaire. Laravel n’émet qu’une URL signée pour un PUT S3 à durée de vie courte, le navigateur envoie le fichier directement à S3, puis le serveur vérifie l’objet et enregistre une ligne Media. Le but est d’obtenir un système rapide, sécurisé (CORS, TTL court, whitelist MIME, tailles limitées), et robuste pour la production.

Ce que tu vas construire

Tu vas d’abord créer un endpoint Laravel qui valide l’intention d’upload (nom, type MIME, taille et utilisateur) et génère une URL signée pour un PUT vers S3. Ensuite, un mini-uploader JavaScript enverra le fichier directement sur S3 avec une barre de progression fiable. Enfin, un endpoint de confirmation interrogera S3 (HeadObject) pour vérifier la taille, le MIME et l’ETag avant de créer un enregistrement Media en base, puis retournera une URL de lecture temporaire. Tout au long du flux, tu ajouteras des garde-fous: configuration CORS stricte, TTL courts, préfixes de clés par utilisateur et date, liste blanche de MIME et limites de taille, ainsi qu’un throttling d’API.

Pré-requis et configuration S3

Commence par configurer le disque S3 dans Laravel et le bucket côté AWS (ou MinIO).

  1. Disque S3 dans config/filesystems.php

Assure-toi que la config s3 ressemble à ceci (Laravel 10/11):

// config/filesystems.php
return [
    'default' => env('FILESYSTEM_DISK', 's3'),

    'disks' => [
        // ...

        's3' => [
            'driver' => 's3',
            'key' => env('AWS_ACCESS_KEY_ID'),         // Laisse vide si tu utilises un rôle IAM
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'region' => env('AWS_DEFAULT_REGION', 'eu-west-3'),
            'bucket' => env('AWS_BUCKET', 'myapp-prod-uploads'),
            'url' => env('AWS_URL'),                   // optionnel (CloudFront)
            'endpoint' => env('AWS_ENDPOINT'),         // optionnel (MinIO/compat S3)
            'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
        ],
    ],
];
  1. Variables d’environnement

Si tu utilises un rôle IAM sur EC2/ECS/Lambda, laisse key/secret vides et laisse le SDK les découvrir. Sinon, mets des identifiants (exemples tirés de la doc AWS):

# .env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=AKIAEXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_DEFAULT_REGION=eu-west-3
AWS_BUCKET=myapp-prod-uploads
# AWS_URL=https://dxxxxx.cloudfront.net
# AWS_ENDPOINT=http://127.0.0.1:9000
# AWS_USE_PATH_STYLE_ENDPOINT=true   # true pour MinIO en path-style
  1. CORS du bucket

Autorise uniquement ce qui est nécessaire depuis ton domaine (ou localhost en dev). Exemple:

[
  {
    "AllowedHeaders": [
      "Content-Type",
      "Content-MD5",
      "Authorization",
      "x-amz-*"
    ],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedOrigins": ["https://app.exemple.com", "http://localhost:5173"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 300
  }
]
  1. Confidentialité

Vérifie que le bucket est privé par défaut (bloque les ACL publiques). Tu utiliseras des URLs signées pour le PUT et, si besoin, Storage::temporaryUrl pour la lecture temporaire. Une CloudFront private distribution est aussi possible.

Endpoint Laravel: intention d’upload et URL signée (PUT)

Crée un endpoint POST /uploads/sign qui valide l’intention et génère l’URL signée. Protège-le par authentification et throttling.

  1. Routes
// routes/api.php (exemple avec Sanctum)
use App\Http\Controllers\UploadController;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;

RateLimiter::for('uploads', function (Request $request) {
    return [
        Limit::perMinute(30)->by(optional($request->user())->id ?: $request->ip()),
    ];
});

Route::middleware(['auth:sanctum', 'throttle:uploads'])->group(function () {
    Route::post('/uploads/sign', [UploadController::class, 'sign']);
    Route::post('/uploads/confirm', [UploadController::class, 'confirm']);
});
  1. FormRequest de validation
// app/Http/Requests/UploadIntentRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadIntentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    public function rules(): array
    {
        // Limite de 10 Mo pour l’exemple
        $maxBytes = 10 * 1024 * 1024;

        return [
            'filename' => ['required', 'string', 'max:255'],
            'content_type' => ['required', 'string', 'in:image/png,image/jpeg,application/pdf'],
            'size' => ['required', 'integer', 'min:1', 'max:' . $maxBytes],
            // 'content_md5' => ['sometimes','string'] // optionnel si tu veux forcer MD5
        ];
    }
}
  1. Helper S3 (facilite les tests et l’injection)
// app/Support/S3Helpers.php
namespace App\Support;

use Aws\S3\S3Client;

class S3Helpers
{
    public function client(): S3Client
    {
        $config = [
            'version' => 'latest',
            'region' => config('filesystems.disks.s3.region'),
        ];

        if ($endpoint = config('filesystems.disks.s3.endpoint')) {
            $config['endpoint'] = $endpoint;
            $config['use_path_style_endpoint'] = config('filesystems.disks.s3.use_path_style_endpoint', false);
        }

        // Laisse le SDK découvrir les credentials si non fournis
        if ($key = config('filesystems.disks.s3.key')) {
            $config['credentials'] = [
                'key' => $key,
                'secret' => config('filesystems.disks.s3.secret'),
            ];
        }

        return new S3Client($config);
    }

    public function bucket(): string
    {
        return config('filesystems.disks.s3.bucket');
    }
}
  1. Contrôleur: génération de l’URL signée
// app/Http/Controllers/UploadController.php
namespace App\Http\Controllers;

use App\Http\Requests\UploadIntentRequest;
use App\Models\Media;
use App\Support\S3Helpers;
use Aws\Exception\AwsException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class UploadController extends Controller
{
    public function __construct(private S3Helpers $s3) {}

    public function sign(UploadIntentRequest $request)
    {
        $user = $request->user();

        $filename = $request->string('filename');
        $contentType = $request->string('content_type');
        $size = (int) $request->integer('size');

        // Génère une clé sûre: uploads/{userId}/{Y/m/d}/{uuid}-{slug}.{ext}
        $slug = Str::slug(pathinfo($filename, PATHINFO_FILENAME)) ?: 'file';
        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        $uuid = (string) Str::uuid();
        $date = now()->format('Y/m/d');
        $key = "uploads/{$user->id}/{$date}/{$uuid}-{$slug}" . ($ext ? ".{$ext}" : '');

        $client = $this->s3->client();
        $bucket = $this->s3->bucket();

        $params = [
            'Bucket' => $bucket,
            'Key' => $key,
            'ContentType' => $contentType,
            'ACL' => 'private',
        ];

        // Optionnel: si tu veux exiger Content-MD5, décommente ci-dessous et fournis-le côté client
        // if ($request->filled('content_md5')) {
        //     $params['ContentMD5'] = $request->string('content_md5');
        // }

        try {
            $cmd = $client->getCommand('PutObject', $params);
            $expires = now()->addMinutes(5);
            $presigned = $client->createPresignedRequest($cmd, $expires);
            $url = (string) $presigned->getUri();

            // Stocke une intention courte en cache pour la confirmation
            Cache::put("upload_intent:{$key}", [
                'user_id' => $user->id,
                'size' => $size,
                'content_type' => $contentType,
            ], $expires->copy()->addMinutes(5)); // marge

            $headers = ['Content-Type' => $contentType];
            // if ($request->filled('content_md5')) {
            //     $headers['Content-MD5'] = $request->string('content_md5');
            // }

            return response()->json([
                'key' => $key,
                'url' => $url,
                'headers' => $headers,
                'expiresAt' => $expires->toIso8601String(),
            ]);
        } catch (AwsException $e) {
            return response()->json([
                'message' => 'Erreur lors de la signature S3',
                'error' => $e->getAwsErrorMessage() ?: $e->getMessage(),
            ], 500);
        }
    }

    // La méthode confirm est implémentée plus bas (section dédiée)
}

Ce endpoint ne renvoie jamais de secret AWS. Il retourne seulement la clé, l’URL signée, les en-têtes à envoyer et l’expiration.

Uploader côté navigateur (sans framework, avec progression)

Voici un mini-uploader vanilla JS qui signe puis envoie via PUT sur S3 avec barre de progression, gère les erreurs courantes, et appelle la confirmation.

// uploader.js
async function signUpload(file, token) {
  const payload = {
    filename: file.name,
    content_type: file.type || 'application/octet-stream',
    size: file.size,
    // content_md5: '...' // optionnel si tu l’exiges
  };

  const res = await fetch('/api/uploads/sign', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // Si tu utilises Sanctum + SPA sur le même domaine, le cookie suffit
      // Sinon, ajoute un Bearer token ou un X-CSRF-TOKEN adapté à ton app
      'Accept': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    },
    body: JSON.stringify(payload)
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.message || `Sign failed (${res.status})`);
  }
  return res.json();
}

function putToS3(file, url, headers, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', url, true);

    // S3 attend exactement Content-Type signé
    if (headers && headers['Content-Type']) {
      xhr.setRequestHeader('Content-Type', headers['Content-Type']);
    }
    // Optionnel: si utilisé lors de la signature
    // if (headers && headers['Content-MD5']) {
    //   xhr.setRequestHeader('Content-MD5', headers['Content-MD5']);
    // }

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable && typeof onProgress === 'function') {
        onProgress(Math.round((e.loaded / e.total) * 100));
      }
    };

    xhr.onerror = () => reject(new Error('Erreur réseau pendant le PUT S3'));
    xhr.onload = () => {
      if (xhr.status === 200 || xhr.status === 201) {
        resolve();
      } else {
        // Les erreurs CORS se manifestent parfois en 403 SignatureDoesNotMatch
        reject(new Error(`PUT failed (${xhr.status}): ${xhr.responseText || 'Unknown error'}`));
      }
    };

    xhr.send(file);
  });
}

async function confirmUpload(key, token) {
  const res = await fetch('/api/uploads/confirm', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    },
    body: JSON.stringify({ key })
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.message || `Confirm failed (${res.status})`);
  }
  return res.json();
}

// Exemple d’usage
async function handleFileInput(file) {
  try {
    const { url, headers, key, expiresAt } = await signUpload(file);

    console.log('URL signée expirera à', expiresAt);

    await putToS3(file, url, headers, (p) => {
      console.log(`Progression: ${p}%`);
    });

    const media = await confirmUpload(key);

    console.log('Upload confirmé:', media);
    // media.read_url est une temporaryUrl valide quelques minutes
  } catch (e) {
    console.error(e);
    alert(e.message);
  }
}

Points d’attention:

  • Envoie exactement le Content-Type signé, sinon S3 rejettera la requête (SignatureDoesNotMatch).
  • Si tu vois un 403 avec “SignatureDoesNotMatch” ou une erreur CORS, vérifie la CORS du bucket, l’horloge du poste client, et le TTL de l’URL signée.
  • Une politique côté bucket (ou la taille max S3) peut déclencher un 413; gère l’erreur et remonte un message clair.

À la fin du PUT, appelle l’endpoint /uploads/confirm avec la clé.

Endpoint de confirmation: vérification côté serveur

Après l’upload S3, confirme côté serveur pour créer l’enregistrement en base seulement si l’objet correspond à l’intention initiale.

  1. Modèle et migration Media
// database/migrations/2024_01_01_000000_create_media_table.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('media', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('disk');
            $table->string('key')->unique();
            $table->string('mime', 191);
            $table->unsignedBigInteger('size');
            $table->string('etag', 64)->nullable();
            $table->string('status', 32)->default('ready');
            $table->timestamps();
        });
    }
    public function down(): void {
        Schema::dropIfExists('media');
    }
};
// app/Models/Media.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Media extends Model
{
    protected $fillable = [
        'user_id', 'disk', 'key', 'mime', 'size', 'etag', 'status',
    ];
}
  1. Confirmation contrôleur
// app/Http/Controllers/UploadController.php (suite)
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;

public function confirm(Request $request)
{
    $user = $request->user();

    $data = Validator::make($request->all(), [
        'key' => ['required', 'string', 'max:1024'],
    ])->validate();

    $key = $data['key'];
    $expectedPrefix = "uploads/{$user->id}/";

    if (!str_starts_with($key, $expectedPrefix)) {
        return response()->json(['message' => 'Clé non autorisée pour cet utilisateur'], 403);
    }

    $intent = Cache::pull("upload_intent:{$key}");
    if (!$intent) {
        return response()->json(['message' => 'Intention introuvable ou expirée'], 422);
    }

    $client = $this->s3->client();
    $bucket = $this->s3->bucket();

    try {
        $head = $client->headObject([
            'Bucket' => $bucket,
            'Key' => $key,
        ]);

        $contentLength = (int) ($head['ContentLength'] ?? 0);
        $contentType = (string) ($head['ContentType'] ?? '');
        $etag = trim((string) ($head['ETag'] ?? ''), '"');

        if ($contentLength !== (int) $intent['size']) {
            return response()->json(['message' => 'Taille du fichier incohérente'], 422);
        }

        // S3 peut normaliser les MIME; si tu veux être strict, compare l’expected avec le relevé
        if (stripos($contentType, $intent['content_type']) !== 0) {
            return response()->json(['message' => 'Type MIME incohérent'], 422);
        }

        $media = Media::create([
            'user_id' => $user->id,
            'disk' => 's3',
            'key' => $key,
            'mime' => $contentType,
            'size' => $contentLength,
            'etag' => $etag ?: null,
            'status' => 'ready',
        ]);

        // URL de lecture temporaire (si bucket privé)
        $readUrl = Storage::disk('s3')->temporaryUrl($key, now()->addMinutes(10));

        return response()->json([
            'id' => $media->id,
            'disk' => $media->disk,
            'key' => $media->key,
            'mime' => $media->mime,
            'size' => $media->size,
            'etag' => $media->etag,
            'status' => $media->status,
            'read_url' => $readUrl,
        ]);
    } catch (\Throwable $e) {
        return response()->json([
            'message' => 'Erreur lors de la vérification S3',
            'error' => $e->getMessage(),
        ], 500);
    }
}

Cette étape empêche d’écrire en base un fichier partiel ou frauduleux et garantit que la clé appartient au bon utilisateur.

Sécurité et robustesse

La sécurité repose sur plusieurs couches complémentaires. D’abord, la validation stricte dans UploadIntentRequest, où seules des images PNG/JPEG et des PDF sont acceptés, avec une taille maximale explicite (par exemple 10 Mo). Ensuite, utilise un TTL court (2 à 5 minutes) pour l’URL signée, ce qui réduit la fenêtre d’attaque. Les clés S3 incluent un UUID et un préfixe par utilisateur et date pour éviter les collisions et empêcher toute devinette. Le serveur ne doit jamais signer une clé fournie par le client: il la génère lui-même et ne signe que cette clé.

Ajoute un throttling dédié sur /uploads/sign et /uploads/confirm (par exemple 30 requêtes/minute), comme montré avec RateLimiter. Côté S3, garde les ACL publiques désactivées et utilise une politique de bucket stricte; le PUT doit être possible uniquement avec signature valide. Pour une vérification renforcée de l’intégrité, tu peux exiger Content-MD5: le client calcule le MD5 du binaire et l’envoie dans l’en-tête; S3 recalculera et rejettera si ça ne correspond pas. Attention toutefois: l’ETag n’est égal au MD5 que pour les uploads non multipart; ne l’utilise pas comme preuve d’intégrité si tu passes au multipart.

Enfin, verrouille ta CORS pour n’autoriser que ton domaine, expose ETag si tu en as besoin côté client et teste les scénarios d’erreur: CORS, TTL expiré, taille excessive ou MIME interdit.

Multipart et fichiers volumineux (optionnel)

Au-delà de ~100 Mo, passe à l’upload multipart. Le flux général:

  1. Crée l’intention et signe CreateMultipartUpload (ou appelle CreateMultipartUpload côté serveur et renvoie uploadId).
  2. Pour chaque part, présigne UploadPart avec PartNumber. Le client PUT les morceaux en parallèle.
  3. Le client envoie à ton serveur la liste des ETags des parts; le serveur appelle CompleteMultipartUpload.
  4. Confirme comme précédemment (HeadObject) et enregistre Media.

Exemple minimal de présignature des parts:

// Créer l’upload
$cmd = $client->getCommand('CreateMultipartUpload', [
    'Bucket' => $bucket,
    'Key' => $key,
    'ContentType' => $contentType,
]);
$res = $client->execute($cmd);
$uploadId = $res['UploadId'];

// Présigner une part
$partNumber = 1;
$cmdPart = $client->getCommand('UploadPart', [
    'Bucket' => $bucket,
    'Key' => $key,
    'PartNumber' => $partNumber,
    'UploadId' => $uploadId,
    // 'ContentMD5' => $md5Base64, // optionnel
]);
$urlPart = (string) $client->createPresignedRequest($cmdPart, '+10 minutes')->getUri();

// Compléter après réception de tous les ETags
$client->completeMultipartUpload([
    'Bucket' => $bucket,
    'Key' => $key,
    'UploadId' => $uploadId,
    'MultipartUpload' => [
        'Parts' => [
            ['ETag' => $etagPart1, 'PartNumber' => 1],
            // ...
        ],
    ],
]);

Côté S3, ajoute une Lifecycle rule pour nettoyer les multipart incomplets:

{
  "Rules": [
    {
      "ID": "AbortIncompleteMultipart",
      "Status": "Enabled",
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
    }
  ]
}

Nettoyage et maintenance

Si tu conserves des intentions en base, supprime régulièrement celles qui n’ont pas été confirmées. Dans l’exemple ci-dessus, les intentions sont stockées en cache avec TTL et disparaissent d’elles-mêmes, mais tu peux ajouter une tâche de vérification pour supprimer les objets “fantômes” sur S3 dont aucun enregistrement Media ne fait référence.

Exemple de commande pour purger les objets orphelins dans un préfixe:

// app/Console/Commands/PurgeOrphanUploads.php
namespace App\Console\Commands;

use App\Models\Media;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

class PurgeOrphanUploads extends Command
{
    protected $signature = 'uploads:purge-orphans {--days=7}';
    protected $description = 'Purge les fichiers S3 sans enregistrement Media après N jours';

    public function handle(): int
    {
        $days = (int) $this->option('days');
        $cutoff = now()->subDays($days);

        $disk = Storage::disk('s3');
        $count = 0;

        foreach ($disk->allFiles('uploads') as $key) {
            $inDb = Media::where('key', $key)->exists();
            if ($inDb) {
                continue;
            }
            $meta = $disk->getDriver()->getAdapter()->getClient()->headObject([
                'Bucket' => config('filesystems.disks.s3.bucket'),
                'Key' => $key,
            ]);
            $lastModified = \Carbon\Carbon::parse($meta['LastModified']);
            if ($lastModified->lessThan($cutoff)) {
                $disk->delete($key);
                $this->info("Supprimé: {$key}");
                $count++;
            }
        }

        $this->info("Total supprimé: {$count}");
        return self::SUCCESS;
    }
}

Planifie son exécution:

// app/Console/Kernel.php
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
    $schedule->command('uploads:purge-orphans --days=7')->daily();
}

Ajuste selon ton contexte si tu utilises un préfixe temporaire distinct.

Tests rapides

Tu peux écrire quelques tests HTTP pour valider le flux, en mockant S3 pour la partie HeadObject. Comme nous avons encapsulé le client dans S3Helpers, on peut le substituer.

// tests/Feature/UploadsTest.php
namespace Tests\Feature;

use App\Models\User;
use App\Support\S3Helpers;
use Aws\Result;
use Aws\S3\S3Client;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class UploadsTest extends TestCase
{
    use RefreshDatabase;

    public function test_sign_allows_authorized_mime(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user, 'sanctum')
            ->postJson('/api/uploads/sign', [
                'filename' => 'photo.png',
                'content_type' => 'image/png',
                'size' => 1024,
            ])
            ->assertOk()
            ->assertJsonStructure(['key', 'url', 'headers' => ['Content-Type'], 'expiresAt']);
    }

    public function test_sign_rejects_disallowed_mime(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user, 'sanctum')
            ->postJson('/api/uploads/sign', [
                'filename' => 'script.sh',
                'content_type' => 'text/x-shellscript',
                'size' => 100,
            ])
            ->assertStatus(422);
    }

    public function test_confirm_checks_prefix_and_size(): void
    {
        $user = User::factory()->create();
        $key = "uploads/{$user->id}/2024/01/01/uuid-file.png";

        // Simule une intention existante
        Cache::put("upload_intent:{$key}", [
            'user_id' => $user->id,
            'size' => 2048,
            'content_type' => 'image/png',
        ], now()->addMinutes(5));

        // Mock S3Client pour renvoyer HeadObject
        $fake = $this->createMock(S3Client::class);
        $fake->method('headObject')->willReturn(new Result([
            'ContentLength' => 2048,
            'ContentType' => 'image/png',
            'ETag' => '"abc123"',
        ]));

        // Mock createPresignedRequest non utilisé ici
        $helpers = $this->partialMock(S3Helpers::class, function ($mock) use ($fake) {
            $mock->shouldReceive('client')->andReturn($fake);
            $mock->shouldReceive('bucket')->andReturn(config('filesystems.disks.s3.bucket'));
        });
        $this->app->instance(S3Helpers::class, $helpers);

        $this->actingAs($user, 'sanctum')
            ->postJson('/api/uploads/confirm', ['key' => $key])
            ->assertOk()
            ->assertJsonPath('key', $key)
            ->assertJsonPath('mime', 'image/png')
            ->assertJsonPath('size', 2048);
    }

    public function test_confirm_rejects_other_user_prefix(): void
    {
        $userA = User::factory()->create();
        $userB = User::factory()->create();
        $key = "uploads/{$userB->id}/2024/01/01/uuid-file.png";

        $this->actingAs($userA, 'sanctum')
            ->postJson('/api/uploads/confirm', ['key' => $key])
            ->assertStatus(403);
    }
}

L’objectif est de valider que /uploads/sign renvoie bien une URL signée pour un MIME autorisé, que les tailles sont respectées, et que /uploads/confirm refuse les clés hors préfixe utilisateur ou incohérentes.

Checklist

Avant de livrer en prod, relis la CORS du bucket, la whitelist MIME et la logique de génération des clés S3 pour t’assurer qu’aucune clé ne peut être imposée par le client. Teste les snippets: vérifie qu’un fichier image/png de 1 Mo suit le flux complet (sign, PUT, confirm) et qu’un MIME interdit renvoie bien 422. Assure-toi que le throttling est actif et calibré pour ta charge, et que les journaux d’erreur capturent les cas 403/SignatureDoesNotMatch afin de diagnostiquer rapidement tout décalage d’horloge ou mauvaise configuration. Enfin, publie tes modifications et surveille les métriques (latence, taux d’erreurs PUT, tailles moyennes) pour ajuster les limites si nécessaire.

Conclusion

Tu disposes maintenant d’un pipeline d’upload direct vers S3, sécurisé et performant, parfaitement intégré à Laravel. En signant côté serveur, en déchargeant le transfert binaire vers S3 et en vérifiant l’objet avant persistance, tu réduis la charge de PHP et garantis l’intégrité des données. Tu peux étendre ce socle au multipart pour les très gros fichiers, ajouter des antivirus côté backend ou des redimensionnements côté worker, et exposer les médias via CloudFront pour une distribution globale.

Ressources