Skip to content

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 /img avant 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éutiliseAWS_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ée AWS_BUCKET (défaut roadtrip) idempotamment (mb --ignore-existing) — un docker compose up suffit à obtenir un store fonctionnel.
  • Volume : miniodata (les objets survivent aux redémarrages).
  • AWS_USE_PATH_STYLE_ENDPOINT=true est 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 impose AWS_ENDPOINT=http://minio:9000 ;
  • AppServiceProvider::boot() ajoute AWS_ENDPOINT à la liste blanche ServeCommand::$passthroughVariables pour que les workers de php artisan serve voient la valeur du conteneur (régression couverte par tests/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.png

Le 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 par IMGPROXY_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 avec IMGPROXY_BASE_URL=s3://{bucket}/ : le signeur ne connaît plus le bucket — l'accord backend/imgproxy se fait par interpolation de AWS_BUCKET dans compose. Le contrôle IMGPROXY_ALLOWED_SOURCES s'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 par tests/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ègles image/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) et pexels_author (max 255), fournis par paire (required_with croisé — 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é (jpegjpg), jamais du nom client ; l'original part sur le bucket sous la clé plate {id}.{extension} (id UUIDv7 de la ligne, accessor object_key du modèle Image — 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 dans width/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 retour false est vérifié) ne persiste rien ; un objet orphelin est inoffensif, une ligne sans objet servirait des URLs cassées. Le disque est aussi report => 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é par StoreImageTest).
  • 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 — motif AuditLogResource) et created_at. La clé d'objet n'est pas exposée : les consommateurs n'ont besoin que de url ou variants.
  • La provenance est doublement tracée : colonne uploaded_by et journal d'audit (modèle Auditable, 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 :

VarianteClé JSONLargeur cible
Thumbthumb200 px
Mediummedium600 px
Largelarge1200 px
Fullfull1920 px
  • Plafonnement à la taille réelle : quand la largeur cible ≥ width de 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.
  • width null (lignes antérieures à la capture des dimensions) : les quatre variantes gardent leur option rs:fit:{largeur}:0 non plafonnée — inoffensif, imgproxy n'agrandit jamais (enlarge désactivé par défaut).
  • API : urlFor(Image $image): string par variante, ImageVariant::urlsFor(Image $image): array pour 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é variants de la réponse d'upload) et par ImageSummaryResource, 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. ImageSummaryResource est émis par UserResource pour la photo de profil (clés profile_picture_id
    • profile_picture — voir Identité et accès), par ActivityResource pour l'image d'illustration des activités (activities.image_id, clés image_id + image) et par OrganizationResource / OrganizationSummaryResource pour l'image principale des organisations (organizations.primary_image_id, clés primary_image_id + primary_image — le résumé nourrit les cartes de sélection de région du voyageur via les embeds region/partner des 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, repli width null, 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 .env de 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 le env_file partagé — uniquement les variables strictement nécessaires, pour ne jamais détenir APP_KEY ni les identifiants de la base.

  • L'exporteur Prometheus natif d'imgproxy est activé (IMGPROXY_PROMETHEUS_BIND: :8081, namespace imgproxy) et publié sur 8804:8081 pour 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_WORKERS dans le .env VM est le levier de réglage.

  • nginx de l'hôte — nouvelle location (le slash final de proxy_pass retire le préfixe /img) :

    nginx
    location /img/ {
        proxy_pass http://127.0.0.1:8802/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
  • Clés du .env VM : IMGPROXY_KEY / IMGPROXY_SALT (générer via openssl 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 les AWS_* réels (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT selon le fournisseur). AWS_ENDPOINT doit inclure le schéma (https://s3.pub1.infomaniak.cloud, pas s3.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_SIZE est optionnelle (défaut 8 dans compose comme dans config/imgproxy.php) ; IMGPROXY_BASE_URL et IMGPROXY_ALLOWED_SOURCES sont interpolées depuis AWS_BUCKETvolontairement sans défaut dans docker-compose.deploy.yml, pour qu'une VM sans AWS_BUCKET échoue bruyamment plutôt que de servir le mauvais bucket.

  • Diagnostic S3 — premier réflexe quand POST /api/images répond 500 : docker compose -f docker-compose.deploy.yml exec backend php artisan app:diagnose-s3 affiche 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'équivalent minio-init — le créer chez le fournisseur), et l'entrypoint fige la config au démarrage (config:cache) — après toute modification des AWS_* du .env VM, 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 table images aujourd'hui) ;
  • les presets imgproxy (IMGPROXY_PRESETS=thumb=rs:fit:200:0, option preset:thumb, voire le mode IMGPROXY_ONLY_PRESETS qui réduit le segment d'options aux noms de presets) — levier de raccourcissement des URLs et de durcissement (options arbitraires refusées) ; ImageVariant centralise déjà les tailles, le passage aux presets ne toucherait que lui ;
  • les endpoints GET / DELETE d'images (lecture = URLs signées seulement).

Contributors

No contributors

Changelog

No recent changes