Nous developpons votre site vitrine SPA À PARTIR DE 300€. Oui, c'est dingue. Web SPA À PARTIR DE 300€ — Oui, c'est dingue. Parlons-en →

Comment téléverser des fichiers directement vers S3 et R2 sans passer par votre serveur

Comment téléverser des fichiers directement vers S3 et R2 sans passer par votre serveur

Recevoir un fichier sur le serveur puis le copier vers le bucket est la façon de faire par défaut, et pour un avatar elle convient parfaitement. Pour une vidéo de deux gigas, ce même chemin vous oblige à vous battre avec la mémoire, les délais d'attente et la moitié de la configuration PHP. Vous verrez ici comment signer le téléversement pour que l'octet aille du navigateur au bucket et les trois détails qui font échouer l'implémentation la première fois. Les exemples sont en Laravel, l'une de nos spécialités, mais la signature est un mécanisme propre à S3 et s'applique depuis n'importe quel langage.

Le fichier qui passe par PHP vous coûte de la mémoire, du temps et des limites

La façon par défaut de recevoir un fichier, c'est que le navigateur l'envoie à votre serveur, que votre serveur le stocke sur un disque temporaire et que votre code le copie vers le bucket.

Pour un avatar de 200 kilo-octets c'est parfait et il n'y a aucune raison de compliquer. Pour une vidéo de deux gigas, le même chemin vous oblige à vous battre avec quatre choses à la fois :

upload_max_filesize et post_max_size dans PHP, qu'il faut relever dans le conteneur et sur le serveur web. Le temps maximum de requête, parce qu'un envoi lent depuis une mauvaise connexion garde un process PHP occupé pendant des minutes. La mémoire, selon la façon dont le flux est géré. Et la bande passante, parce que l'octet voyage deux fois : du navigateur à votre machine et de votre machine au bucket.

Et il y a un coût qui n'apparaît dans aucune métrique : chaque gros envoi vous occupe un worker PHP qui ne sert pas de pages.

La signature vient de votre serveur, les octets partent en direct

L'alternative est dans le SDK d'S3 depuis des années et reste sous-utilisée. Le serveur ne reçoit pas le fichier, il reçoit la demande d'autorisation.

Le flux tient en trois étapes et seules la première et la troisième touchent votre application :

Un. Le navigateur demande une autorisation d'envoi en indiquant le type de fichier et la taille. Votre serveur signe une URL temporaire contre le bucket et la renvoie.

Deux. Le navigateur fait un PUT avec le binaire directement vers cette URL. Votre application n'en sait rien et ne consomme rien.

Trois. Le navigateur prévient votre application qu'il a terminé, avec la clé de l'objet. Votre application enregistre le fichier en base de données et déclenche ce qui doit suivre.

Le résultat, c'est que le fichier ne passe jamais par PHP et que les limites de configuration cessent de compter. Et la signature expire, donc l'autorisation n'est pas réutilisable.

Le fichier arrive avant le modèle, et c'est là le vrai problème

C'est là que presque toutes les implémentations maison se bloquent, et c'est un problème d'ordre.

Imaginez un formulaire de création. L'utilisateur joint le document avant d'avoir enregistré la fiche, donc au moment de signer l'envoi l'identifiant auquel ce fichier appartiendra n'existe pas encore. Vous ne pouvez pas construire le chemin définitif parce que vous ne le connaissez pas.

La solution qui marche se fait en deux temps. Si au moment de signer le modèle et la collection sont déjà connus, la clé se construit directement à sa place. Sinon, le fichier atterrit dans un dossier temporaire, et à la confirmation il est déplacé vers son chemin canonique.

Et ce déplacement est la partie qu'il faut bien faire : on ne télécharge pas pour re-téléverser, on copie à l'intérieur du bucket et on supprime la source.

$client->copyObject([
    'Bucket'     => $bucket,
    'CopySource' => "{$bucket}/" . rawurlencode($fromKey),
    'Key'        => $toKey,
]);

$client->deleteObject(['Bucket' => $bucket, 'Key' => $fromKey]);

Deux appels à l'API et zéro octet traversant votre serveur. Si à la place vous téléchargez et renvoyez, vous êtes revenu exactement au problème que vous vouliez éviter, sauf qu'il se produit maintenant plus tard.

Le détail du préfixe qui casse les envois signés

Il existe une catégorie d'erreurs qui n'apparaît que lorsque deux couches font la même chose sans se connaître, et les chemins S3 sont un terrain propice. Le symptôme est un fichier qui se téléverse sans erreur et qui n'est ensuite pas là où il devrait.

Si votre disque a une racine configurée, du genre dev/ ou production/, Laravel l'applique tout seul à toutes les lectures et écritures. Mais quand vous signez un PUT que le navigateur va exécuter, cette couche n'intervient pas : le navigateur parle directement au bucket.

Si vous signez avec la clé relative, l'objet est envoyé sans le préfixe, et à partir de là aucune lecture depuis votre application ne le trouve. Le fichier est bien là, il occupe de l'espace et il est facturé, mais pour votre code il n'existe pas.

La solution est d'appliquer le préfixe à la main au moment de signer et d'enregistrer en base de données la clé relative, pour que le reste de l'application continue de fonctionner comme avant :

$root    = trim((string) ($cfg['root'] ?? ''), '/');
$fullKey = $root !== '' ? "{$root}/{$key}" : $key;
Andie recommande

Signez toujours avec ContentType et ContentLength inclus. Ce n'est pas cosmétique : ils font partie de ce qui est signé, donc le navigateur ne peut pas envoyer un type différent ni un fichier plus gros que celui autorisé. Si vous signez sans eux, vous avez délivré une autorisation d'envoi sans limite de taille, et c'est le genre de détail qui apparaît sur la facture avant d'apparaître dans les journaux.

L'endpoint qui signe est celui qui décide qui envoie où

Une signature est une autorisation d'écriture avec une date de péremption. Le point d'entrée qui la délivre ne renvoie pas une URL : il décide, à chaque appel, qui peut écrire quoi et à quel endroit de votre bucket.

Quand vous exposez un endpoint qui signe des envois, vous déléguez de l'écriture sur votre bucket. Quiconque peut l'appeler obtient un droit d'écriture sur la clé qu'il demande. Cet endpoint doit donc vérifier trois choses et aucune ne vient de série :

Que le disque figure dans une liste blanche. Si vous avez cinq buckets séparés par type de contenu, un utilisateur ne devrait pas pouvoir demander une signature contre n'importe lequel juste parce qu'il est authentifié.

Que l'utilisateur soit bien propriétaire du modèle auquel il dit joindre le fichier. Si le chemin canonique se construit à partir du type et de l'identifiant envoyés par le client, sans vérification de propriété, un utilisateur peut écrire dans le dossier d'un autre.

Et une limite de requêtes. Signer coûte peu, mais cela permet de démarrer des envois, et les envois démarrés puis abandonnés continuent d'occuper de l'espace.

Aucune bibliothèque ne peut résoudre cela à votre place, parce que la propriété d'un modèle relève de la logique de votre domaine. Ce qu'elle doit vous donner, en revanche, c'est le point où l'accrocher.

Gros fichiers : multipart, et le seuil n'est qu'une indication

Au-delà d'une certaine taille, une seule requête cesse d'être une bonne idée : si elle échoue à quatre-vingt-dix pour cent, tout est perdu.

C'est là qu'intervient l'envoi par parties, soit quatre opérations : démarrer la session, demander des URL signées pour chaque morceau, compléter, et abandonner si l'utilisateur change d'avis. Le navigateur envoie les morceaux en parallèle et peut ne réessayer que celui qui échoue.

Un détail d'architecture qu'il vaut mieux comprendre : le seuil à partir duquel le multipart est utilisé, c'est le navigateur qui le décide, parce que c'est lui qui connaît la taille réelle du fichier avant de l'envoyer. Le serveur ne l'impose pas. C'est logique, et cela signifie en même temps qu'un client peut démarrer un multipart pour un petit fichier, donc la limite de requêtes doit couvrir ces endpoints aussi.

Et il y a une conséquence opérationnelle qui ne se voit qu'à l'arrivée de la facture : une session multipart abandonnée laisse ses morceaux vivants dans le bucket. Ce n'est pas un objet visible, mais c'est facturé. Si vous activez le multipart, il vous faut une tâche planifiée qui abandonne les sessions expirées.

Les variantes sont des fichiers, pas des champs

Une miniature et une version moyenne sont la même sorte de chose que l'original : un fichier dans un bucket avec sa taille, son type mime et son chemin. Les stocker comme colonnes de l'enregistrement en fait autre chose, et cette décision se paie le jour où arrive la quatrième variante.

Quand vous envoyez une image et que vous générez une miniature et une version moyenne, il y a deux façons de les stocker. Comme colonnes de la même fiche, du genre thumb_path et medium_path, ou comme fiches à part entière qui pointent vers l'original.

La seconde demande plus de travail au début et résout toute seule tout ce qui suit : chaque variante a sa propre taille, son propre type, sa propre URL signée et son propre contrôle d'accès, et ajouter une nouvelle variante n'est pas une migration. Supprimer l'original supprime ses variantes en cascade parce que la relation est réelle.

Avec des colonnes, chaque nouveau format est une nouvelle colonne, et le jour où vous voulez un filigrane différent par variante, vous n'avez nulle part où le mettre.

Ce qui existe sur le marché et ce qui les distingue

Les trois options raisonnables, avec leur compromis réel.

Les envois de Livewire sont la voie la plus courte et, pour la plupart des cas, la bonne réponse. Un wire:model sur un champ fichier et c'est réglé. Le compromis est exactement celui de cet article : le binaire traverse PHP et atterrit sur un disque temporaire avant d'arriver à destination. Pour des documents et des photos c'est parfait ; pour de la vidéo, non.

spatie/laravel-medialibrary est la référence de l'écosystème pour associer des fichiers à des modèles, avec collections, conversions d'image et une communauté énorme derrière. Si votre besoin est "joindre des images à un modèle et générer des miniatures", c'est un choix solide et très éprouvé. La différence d'approche qui mérite d'être évaluée, c'est de savoir si l'envoi direct vers le bucket est une voie de première classe ou un ajout, parce que cela conditionne la taille de fichier que vous pouvez accepter sans toucher à la configuration du serveur.

Et Storage::putFile() tel quel n'a rien de mauvais. Si vous envoyez quatre petits fichiers par mois, toute abstraction par-dessus est du poids mort.

Le principe qui nous a guidés, c'est que les identifiants des disques continuent de vivre dans config/filesystems.php. Une couche de gestion de fichiers qui vous demande de reconfigurer les clés de votre bucket a dupliqué de la configuration, et les configurations dupliquées se désynchronisent.

Et si vous ne travaillez pas avec Laravel

Les critères relèvent de la conception et valent dans n'importe quel langage :

  • Signez sur le serveur, envoyez depuis le client. C'est la seule façon que la taille du fichier cesse d'être un problème d'infrastructure.
  • Incluez le type et la taille dans la signature. Une autorisation sans limites est une autorisation dangereuse.
  • Réglez l'ordre avec un dossier temporaire et un déplacement à l'intérieur du bucket. Jamais en téléchargeant pour re-téléverser.
  • Traitez l'endpoint de signature pour ce qu'il est, un point d'écriture déléguée, avec liste blanche de destinations et vérification de propriété.
  • Et nettoyez ce qui reste abandonné. Les fichiers temporaires et les sessions multipart expirées sont facturés comme les bons.

Où se trouve le code

Le paquet est sur Packagist, le code sur GitHub et la documentation du paquet reprend la configuration complète, qui est vaste : collections, politiques d'accès, variantes, aperçus de PDF et de vidéo, filigranes et extraction de texte.

Il est en production sur Crowd Legal, avec cinq buckets séparés par type de contenu, et sur Abodara.

Si votre produit accepte des fichiers d'utilisateurs et que vous avez déjà dû toucher aux limites de PHP pour qu'ils passent, c'est cela qu'il faut changer. Nous le travaillons en applications web et en SaaS sur mesure, et si vous nous racontez ce que vos utilisateurs envoient, nous vous dirons quel montage vous convient.

Écrit par
Edu Lazaro
Edu Lazaro
Founder & Lead Developer en AndorraDev

Développeur full-stack avec plus de 15 ans d'expérience en Laravel, React, Node.js et architectures cloud. J'aide les entreprises en Andorre à construire leur présence digitale.

Partner de diseño · ionospace.
Besoin d'aide? ×
Andie by AndorraDev
Assistant IA + équipe humaine
Assistant IA d'AndorraDev
Andie
Bonjour! Je suis Andie, l'assistant IA d'AndorraDev. Comment puis-je vous aider? Si vous souhaitez parler avec Edu, il suffit de le demander.
17:30