Stockage d'images — S3 & imgproxy
Les images (photos de prestataires, avatars, …) ne transitent jamais par l'application Laravel : les originaux vivent dans un bucket S3, et imgproxy les sert aux navigateurs en les redimensionnant/réencodant à la volée. Le backend n'intervient qu'en amont (upload vers le bucket — endpoints à venir) et en aval (génération d'URLs signées, seul couplage entre l'API et le pipeline d'images).
Le même motif — artefact dérivé rendu à la volée derrière une URL signée, jamais persisté — sert aussi aux PDF de codes d'activation, via un sidecar Gotenberg : voir generation-de-pdf.md.
Topologie
┌──▶ /img/ ──▶ imgproxy ──▶ bucket S3 (originaux)
client ──▶ reverse proxy ───┼──▶ /api/ ──▶ backend Laravel
└──▶ / ──▶ frontend- Le proxy (nginx de l'hôte en staging/prod, proxy Vite en dev) retire le préfixe
/imgavant de transmettre à imgproxy — les chemins signés ne le contiennent donc pas. - imgproxy n'accepte que des chemins signés (HMAC) et que des sources du bucket de l'application (
IMGPROXY_ALLOWED_SOURCES=s3://{bucket}/, slash final recommandé par la doc imgproxy) : pas d'URL devinable, pas de proxy ouvert, pas de redimensionnement arbitraire par un tiers.
Disque s3 Laravel
Le disque s3 de config/filesystems.php est opérationnel depuis l'ajout de league/flysystem-aws-s3-v3. Il lit les variables AWS_* (.env.example les documente toutes, y compris AWS_ENDPOINT, indispensable hors AWS).
FILESYSTEM_DISK reste local : rien n'écrit d'images par défaut, et le code d'images visera toujours le disque explicitement via Storage::disk('s3') — basculer le disque par défaut déplacerait silencieusement tous les usages de Storage.
Développement local — MinIO
En local, le bucket est fourni par MinIO (S3-compatible), service minio de docker-compose.yml :
- Ports :
9000(API S3),9001(console web : http://localhost:9001). - Identifiants : le root user/password MinIO réutilise
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(compose les interpole depuis le.envà la racine) — une seule paire à configurer, l'application est de fait toujours d'accord avec MinIO. - Bootstrap du bucket : le service one-shot
minio-init(minio/mc) créeAWS_BUCKET(défautroadtrip) idempotamment (mb --ignore-existing) — undocker compose upsuffit à obtenir un store fonctionnel. - Volume :
miniodata(les objets survivent aux redémarrages). AWS_USE_PATH_STYLE_ENDPOINT=trueest requis par MinIO.
Le service imgproxy du même compose écoute sur l'hôte au port 8081 (FORWARD_IMGPROXY_PORT), branché sur http://minio:9000 avec la même paire d'identifiants.
Hôte selon le contexte (piège AWS_ENDPOINT)
Même mécanique que DB_HOST / MAIL_HOST (voir Environnement de développement) :
- sur l'hôte, MinIO est joignable via
AWS_ENDPOINT=http://localhost:9000(défaut.env) ; - dans le conteneur
app, compose imposeAWS_ENDPOINT=http://minio:9000; AppServiceProvider::boot()ajouteAWS_ENDPOINTà la liste blancheServeCommand::$passthroughVariablespour que les workers dephp artisan servevoient la valeur du conteneur (régression couverte partests/Feature/Database/ServePassthroughTest.php).
Routage /img/ en dev
Il est assuré par le proxy de dev Vite du dépôt frontend (comme /api) : /img/* → http://localhost:8081 en retirant le préfixe. Aucun nginx local.
Signature des URLs — App\Services\ImgproxyUrlSigner
Service injectable (singleton, configuration immuable — sûr sous Octane) qui construit les URLs signées destinées aux navigateurs :
{préfixe}/{signature}/{options de traitement…/}plain/{clé d'objet}
p. ex. /img/W7qPy8UCsIA/plain/0197a1f8-1111-7222-8333-444455556666.png (~63 caractères)
/img/6_YC0pFrygY/rs:fit:300:300/plain/providers/test.pngLe format est optimisé pour des URLs courtes (~63 caractères contre ~120 avec le format naïf signature complète + source base64) :
- Signature : base64url des 8 premiers octets du
HMAC-SHA256(clé, sel . chemin)complet (11 caractères au lieu de 43), clé et sel hex-décodés (IMGPROXY_KEY/IMGPROXY_SALT). La troncature est pilotée parIMGPROXY_SIGNATURE_SIZE— la même variable des deux côtés via l'interpolation compose (défaut 8 partout ; attention, le défaut d'imgproxy est 32, la variable conteneur est donc obligatoire). 64 bits restent hors de portée d'une attaque en ligne (chaque essai est une requête HTTP répondue 403) pour un enjeu limité à « redimensionner nos propres images ». Le chemin signé exclut le préfixe/img(retiré par le proxy avant imgproxy) et la signature couvre le chemin tel qu'envoyé (vérifié dans le code d'imgproxy : signature contrôlée avant tout décodage). - Source : la clé d'objet seule, en source
plain/percent-encodée par segment (no-op pour les clés{uuid}.{ext}; indispensable pour%,@— marqueur de format imgproxy —, espaces ou non-ASCII). C'est le conteneur qui préfixe la source avecIMGPROXY_BASE_URL=s3://{bucket}/: le signeur ne connaît plus le bucket — l'accord backend/imgproxy se fait par interpolation deAWS_BUCKETdans compose. Le contrôleIMGPROXY_ALLOWED_SOURCESs'applique après l'ajout du préfixe (vérifié dans le code d'imgproxy), la restriction au bucket reste donc effective. - API :
signedUrlFor(string $objectKey, array|string $processingOptions = []): string— p. ex.signedUrlFor('providers/logo.png', ['rs:fit:300:300', 'q:80']). - Le constructeur échoue bruyamment (
InvalidArgumentException) si clé ou sel est vide ou non-hex, ou si la taille de signature sort de [1, 32] ; la résolution restant paresseuse, les environnements sans configuration imgproxy (tests, CI) démarrent normalement. - Changer le format ou la taille de signature invalide les URLs déjà émises (403) — sans conséquence : aucune URL n'est persistée, les resources signent à chaque sérialisation.
- L'algorithme est verrouillé par des vecteurs de test à réponse connue (
tests/Unit/Services/ImgproxyUrlSignerTest.php), reproductibles contre imgproxy lui-même (paire clé/sel d'exemple de sa documentation) ; le câblage env → config → conteneur l'est partests/Feature/Services/ImgproxyUrlSignerBindingTest.php.
Upload — POST /api/images
Point d'entrée du pipeline (StoreImageController, groupe OpenAPI Images), accessible à tout utilisateur authentifié (hors resolve-viewing-context, comme PUT /user) et throttlé (throttle:30,1 — seul endpoint authentifié qui fait croître le stockage objet). Corps multipart/form-data, champ image — première opération multipart du contrat (Scramble l'exporte en format: binary nativement).
- Validation (
StoreImageRequest) :required,image,mimes:jpg,jpeg,png,webp,max:10240(10 Mo). Les règlesimage/mimessniffent le contenu réel : un fichier renommé ou un MIME client falsifié sont rejetés. - Attribution Pexels : champs texte optionnels
pexels_url(url, max 255) etpexels_author(max 255), fournis par paire (required_withcroisé — une demi-attribution est un bug client) quand l'image provient de la recherche Pexels du frontend, dont les guidelines exigent le crédit (lien de la page photo + nom du photographe). Null pour un upload utilisateur. - Nommage : l'extension est re-dérivée du contenu sniffé (
jpeg→jpg), jamais du nom client ; l'original part sur le bucket sous la clé plate{id}.{extension}(id UUIDv7 de la ligne, accessorobject_keydu modèleImage— voir Base de données). - Dimensions : la largeur/hauteur intrinsèques sont capturées via
getimagesize()(PHP core, fonctionne sans GD — aucune extension image n'est installée) sur le fichier temporaire, et stockées danswidth/height. En cas d'échec de lecture (fichier accepté par finfo mais illisible), les colonnes restent null — la suppression d'erreur@est nécessaire car Laravel convertit les warnings PHP en exceptions. - Séquencement : objet d'abord, ligne ensuite — une écriture S3 en échec (le disque est
throw => false, le retourfalseest vérifié) ne persiste rien ; un objet orphelin est inoffensif, une ligne sans objet servirait des URLs cassées. Le disque est aussireport => true: l'exception AWS/Flysystem avalée est rapportée au handler d'exceptions, la cause réelle d'un échec reste donc visible dans les logs (invariant verrouillé parStoreImageTest). - Réponse (
ImageResource, 201) :id,url(URL signée de l'original, sans options de traitement),variants(l'objet{thumb, medium, large, full}d'ImageVariant::urlsFor()— voir plus bas), métadonnées (mime_type,size_bytes,width,height,original_name,pexels_url,pexels_author), identité de l'uploadeur à plat (uploaded_by,email,first_name,last_name— motifAuditLogResource) etcreated_at. La clé d'objet n'est pas exposée : les consommateurs n'ont besoin que deurlouvariants. - La provenance est doublement tracée : colonne
uploaded_byet journal d'audit (modèleAuditable, causer).
Requête d'exemple : bruno-requests/images/store.yml (multipart, avec un sample.png embarqué).
Limites PHP d'upload (piège du 422 trompeur)
Les défauts PHP (upload_max_filesize = 2M, post_max_size = 8M) rejettent l'upload avant Laravel : la règle image échoue alors sur un fichier tronqué et l'endpoint répond 422 « The image failed to upload. » pour un fichier pourtant valide. Les deux images Docker embarquent donc docker/php/conf.d/uploads.ini (upload_max_filesize = 12M, post_max_size = 16M — au-dessus de la règle max:10240) ; la cohérence ini ↔ règle ↔ Dockerfiles est verrouillée par tests/Feature/Images/UploadLimitsIniTest.php.
php artisan serve sur l'hôte (flux Vite du frontend) utilise le php.ini de l'hôte, qui garde les défauts 2M/8M : pour uploader plus gros, lancer PHP_INI_SCAN_DIR=":$(pwd)/docker/php/conf.d" php artisan serve (le : de tête conserve les répertoires de scan par défaut).
Variantes de tailles nommées — App\Enums\ImageVariant
Quatre tailles d'affichage nommées, émises comme URLs signées avec un redimensionnement à la volée par imgproxy (rs:fit:{largeur}:0, hauteur libre) — aucun resize côté PHP :
| Variante | Clé JSON | Largeur cible |
|---|---|---|
Thumb | thumb | 200 px |
Medium | medium | 600 px |
Large | large | 1200 px |
Full | full | 1920 px |
- Plafonnement à la taille réelle : quand la largeur cible ≥
widthde la ligne, la variante émet l'URL de l'original sans options — toutes les variantes plafonnées sont des chaînes identiques octet à octet, le navigateur ne fait qu'une requête et le cache la sert pour les autres. Une originale de 200 px produit donc quatre URLs identiques. widthnull (lignes antérieures à la capture des dimensions) : les quatre variantes gardent leur optionrs:fit:{largeur}:0non plafonnée — inoffensif, imgproxy n'agrandit jamais (enlargedésactivé par défaut).- API :
urlFor(Image $image): stringpar variante,ImageVariant::urlsFor(Image $image): arraypour l'objet complet keyé par clé JSON (l'ordre de déclaration des cases est l'ordre des clés émises). - Consommé par
ImageResource(clévariantsde la réponse d'upload) et parImageSummaryResource, la forme partagée de toute image imbriquée dans un autre payload :{variants: {thumb…full}, pexels_url, pexels_author}— l'attribution Pexels accompagne les URLs pour que le frontend puisse afficher le crédit partout où l'image apparaît.ImageSummaryResourceest émis parUserResourcepour la photo de profil (clésprofile_picture_idprofile_picture— voir Identité et accès), parActivityResourcepour l'image d'illustration des activités (activities.image_id, clésimage_id+image) et parOrganizationResource/OrganizationSummaryResourcepour l'image principale des organisations (organizations.primary_image_id, clésprimary_image_id+primary_image— le résumé nourrit les cartes de sélection de région du voyageur via les embedsregion/partnerdes tours) ; conçu pour être réutilisé quand les prestataires seront liés aux images.
- Verrouillé par
tests/Unit/Enums/ImageVariantTest.php(règle de plafond, frontière cible = largeur, repliwidthnull, ordre des clés).
Déploiement — staging & production
Voir aussi Déploiement.
Pas de MinIO déployé : les originaux vivent sur un service S3 externe (config uniquement —
AWS_*réels dans le.envde la VM).Le trafic d'images du conteneur
imgproxy(docker-compose.deploy.yml) est publié en loopback seul (127.0.0.1:8802) : seul le nginx de l'hôte y accède. Il ne reçoit pas leenv_filepartagé — uniquement les variables strictement nécessaires, pour ne jamais détenirAPP_KEYni les identifiants de la base.L'exporteur Prometheus natif d'imgproxy est activé (
IMGPROXY_PROMETHEUS_BIND: :8081, namespaceimgproxy) et publié sur8804:8081pour le scrape par le réseau privé — à restreindre au host Prometheus via le firewall de la VM (voir Observabilité).Limite mémoire 300M (les décodages d'images font des pics) ;
IMGPROXY_WORKERSdans le.envVM est le levier de réglage.nginx de l'hôte — nouvelle location (le slash final de
proxy_passretire le préfixe/img) :nginxlocation /img/ { proxy_pass http://127.0.0.1:8802/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }Clés du
.envVM :IMGPROXY_KEY/IMGPROXY_SALT(générer viaopenssl rand -hex 32; le backend et imgproxy lisant le même fichier, ils sont d'accord par construction),IMGPROXY_S3_ENDPOINT(endpoint du service S3 externe, côté imgproxy), plus lesAWS_*réels (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_DEFAULT_REGION,AWS_BUCKET,AWS_ENDPOINT,AWS_USE_PATH_STYLE_ENDPOINTselon le fournisseur).AWS_ENDPOINTdoit inclure le schéma (https://s3.pub1.infomaniak.cloud, pass3.pub1.infomaniak.cloud) : sans lui, le SDK AWS rejette l'endpoint (« was not a valid URI ») et chaque écriture échoue — cause de l'incident staging de juillet 2026.IMGPROXY_SIGNATURE_SIZEest optionnelle (défaut 8 dans compose comme dansconfig/imgproxy.php) ;IMGPROXY_BASE_URLetIMGPROXY_ALLOWED_SOURCESsont interpolées depuisAWS_BUCKET— volontairement sans défaut dansdocker-compose.deploy.yml, pour qu'une VM sansAWS_BUCKETéchoue bruyamment plutôt que de servir le mauvais bucket.Diagnostic S3 — premier réflexe quand
POST /api/imagesrépond 500 :docker compose -f docker-compose.deploy.yml exec backend php artisan app:diagnose-s3affiche la config effective du disque (endpoint, bucket, identifiants masqués, config cachée ou non) et sonde le bucket (écriture/lecture/suppression) en faisant remonter l'erreur réelle que le disque avale en temps normal. Deux pièges propres au deploy : rien n'y crée le bucket (pas d'équivalentminio-init— le créer chez le fournisseur), et l'entrypoint fige la config au démarrage (config:cache) — après toute modification desAWS_*du.envVM, recréer les conteneurs (docker compose -f docker-compose.deploy.yml up -d --force-recreate). Si le POST fait tomber toute l'app (réponse vide/502 puis redémarrage du conteneur), c'est l'OOM des workers Octane — voir « Octane — workers & mémoire » dans Déploiement.
Reste à construire
L'infrastructure, l'upload, les variantes de tailles et les liaisons photo de profil, image d'activité et image principale d'organisation sont en place ; il manque encore :
- la liaison images ↔ prestataires (la photo de profil —
users.profile_picture_id—, l'image d'activité —activities.image_id— et l'image principale d'organisation —organizations.primary_image_id— consomment la tableimagesaujourd'hui) ; - les presets imgproxy (
IMGPROXY_PRESETS=thumb=rs:fit:200:0, optionpreset:thumb, voire le modeIMGPROXY_ONLY_PRESETSqui réduit le segment d'options aux noms de presets) — levier de raccourcissement des URLs et de durcissement (options arbitraires refusées) ;ImageVariantcentralise déjà les tailles, le passage aux presets ne toucherait que lui ; - les endpoints GET / DELETE d'images (lecture = URLs signées seulement).

