Import initial de la documentation : statut, roadmap, plans V1/V2, specs verticales (creche, sport), audits securite, plan de test, analyse concurrentielle et maquettes de design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21 KiB
Médias & stockage — Plan
Contexte : décisions prises le 2026-08-07, en amont de la migration Postgres. Ce chantier est un prérequis à la release de migration — il touche des données déjà écrites (URL, tailles), contrairement au RAG (
rag-pgvector-integration-plan.md) qui est purement additif et peut suivre.
Pourquoi maintenant
La règle qui a servi à trancher :
Ce qui touche aux données déjà écrites se fait pendant la migration. Ce qui est additif attend.
Les URL absolues Firebase sont dispersées dans Resource.Url, ImageSource, VideoSource et le contenu JSONB des sections. Les reprendre plus tard = migrer les mêmes données deux fois. Une table ContentEmbedding vide, elle, s'ajoute quand on veut.
1. Compression des images côté client
État actuel : aucune compression
Le chemin d'upload réel (resources_screen.dart) pousse les octets bruts du fichier choisi :
UploadTask uploadTask = ref.putData(platformFile.bytes!, metadata);
Une photo 4K de 8 Mo part entière. Le redimensionnement + watermark qui existe dans ResourceController.Upload (ImageHelper.ResizeAndAddWatermark) est du code mort : il vit dans l'endpoint legacy base64 que manager-app n'appelle plus depuis la bascule Firebase.
ImageHelpers'appuie surSystem.Drawing.Common, qui ne fonctionne plus sur Linux depuis .NET 6 sans libgdiplus — et l'image finale estaspnet:8.0Linux. Ce code lèverait probablement une exception s'il était appelé. Si un traitement serveur redevenait nécessaire un jour, passer par ImageSharp ou SkiaSharp, pasSystem.Drawing.
Décision : compression navigateur avant upload
Le fichier ne transite plus par le backend (navigateur → Firebase en direct), donc le redimensionnement serveur est structurellement impossible sans réintroduire un proxy. Ça se fait côté client.
Paramètres retenus : 2560 px sur le grand côté, JPEG qualité 82.
Le raisonnement : les visiteurs peuvent zoomer sur un détail d'œuvre ou lire un cartel. Or pour du zoom, c'est la résolution qui compte, pas le facteur de qualité — une image 2560 px à q80 se regarde bien mieux agrandie qu'une 1920 px à q95, où l'on ne fait qu'agrandir du flou proprement compressé. D'où : dimension généreuse, qualité légèrement rognée.
| Avant | Après | |
|---|---|---|
| Photo 4K typique | 8 Mo | ~600-700 Ko |
| Gain | ~12× sur le stockage et sur chaque consultation visiteur |
Implémentation
manager-app est un back-office web : flutter_image_compress est hors jeu (natif uniquement). Deux voies :
Package image (pur Dart, multiplateforme) — retenu par défaut :
final decoded = img.decodeImage(platformFile.bytes!);
final resized = img.copyResize(decoded!, width: 2560, maintainAspect: true);
final bytes = Uint8List.fromList(img.encodeJpg(resized, quality: 82));
Canvas natif (createImageBitmap + OffscreenCanvas + toBlob) — accéléré matériellement, ne fige pas l'UI. Mais code web-only, donc imports conditionnels puisque manager-app builde aussi macOS/Windows. À basculer si le figement de l'UI sur une 4K devient gênant.
Le format retenu est JPEG, pas WebP. Le canvas sait encoder du WebP nativement (~30 % de moins à qualité égale), mais c'est 30 % sur un problème déjà réglé à 12× — pas de quoi complexifier la chaîne aujourd'hui. À reconsidérer seulement si le poids redevient un sujet, et en n'oubliant pas alors la table
Content-Type → extensionde visitapp.
Règles
- Convertir les PNG photo en JPEG. Garder le PNG uniquement s'il y a de la transparence (logos, pictos). Un PNG de photo pèse 5 à 10× son équivalent JPEG — gros gain souvent oublié.
- Sauter la compression sous ~300 Ko. Inutile de ré-encoder.
- Ne pas conserver d'original haute définition. Le musée a ses fichiers sources sur ses machines ; le CMS est un canal de diffusion, pas un service d'archivage.
Vidéo
Le transcodage navigateur n'est pas réaliste (ffmpeg.wasm trop lourd et trop lent). Plafond de taille à l'upload + orienter vers YouTube/Vimeo — SectionVideo supporte déjà les deux, l'échappatoire existe.
2. Watermark — flag d'instance
Aujourd'hui codé en dur sur un id d'instance, avec un TODO qui traîne :
bool isFort = instanceId == "633ee379d9405f32f166f047"; // TODO add watermark in configuration and model
Décision : colonne booléenne sur Instance, basculée manuellement en base. Pas d'UI, pas de support, pas de client qui s'en sert de travers. Si la demande vient, ajouter l'écran plus tard est trivial.
Deux conséquences :
- Le flag doit remonter jusqu'au client. Le watermark s'appliquant désormais dans le navigateur au moment de l'upload, il ne suffit pas qu'il existe en base : il doit être exposé dans la config d'instance que manager-app lit au démarrage.
- Un flag invisible s'oublie. Le nommer explicitement et le documenter dans la fiche d'instance — dans six mois personne ne saura qu'il existe.
Le watermark est appliqué à l'upload, donc cuit dans le fichier stocké : il protège en aval quel que soit le canal.
3. IStorageService + StoragePath
Le problème à corriger
Resource.Url stocke une URL de téléchargement Firebase absolue, à jeton. Le client Flutter la génère après l'upload et la renvoie au backend. Conséquences :
- c'est le client qui décide où vivent les fichiers
- changer de fournisseur imposerait de réécrire chaque URL en base, y compris celles noyées dans les champs JSONB des sections
Décision
Une colonne StoragePath (clé relative) devient la source de vérité, l'URL absolue étant résolue à la lecture derrière une interface :
public interface IStorageService
{
string GetPublicUrl(string storagePath);
Task<long> GetSizeAsync(string storagePath);
Task DeleteAsync(string storagePath);
}
Une seule implémentation pour l'instant : Firebase. C'est ce qui transforme une migration future de « réécrire toutes les URL » en « changer une implémentation et recopier les blobs ».
Le chemin est déjà déterministe
storage.ref().child('pictures/${instanceId}/${Path.basename(newResource.id)}')
pictures/{instanceId}/{resourceId}, sans extension. Donc inutile de parser les URL à jeton pour quoi que ce soit : la clé se reconstruit depuis la ligne Resource. Ça simplifie le backfill (§4).
Anomalie du flux actuel
La ligne Resource est créée avant l'upload, et l'URL écrite après coup par le client. Si l'upload échoue ou que l'onglet se ferme, il reste une ligne sans URL et sans fichier — l'orphelin dans l'autre sens. À traiter avec le même inventaire.
4. Quota de stockage
État actuel : non appliqué
Le contrôle StorageQuotaBytes n'existe que dans l'endpoint legacy Upload (chemin base64, plus utilisé). Create — le chemin réellement emprunté — ne vérifie aucun quota et ne renseigne pas SizeBytes.
Corrections, dans cet ordre
-
Endpoint de pré-vol —
POST /api/Resource/check-quota { instanceId, sizeBytes }, appelé avant de pousser dans Firebase. Répond OK / dépassement + octets restants. Purement UX : éviter d'attendre l'upload de 180 Mo pour se faire refuser. Non autoritaire, une course reste possible, sans gravité. -
Contrôle autoritaire à
Create, taille lue depuis Firebase. Ne jamais faire confiance ausizeBytesenvoyé par le client — un appelant qui envoie0bypasse le quota définitivement.FirebaseAdminest déjà référencé dans le.csproj. En cas de dépassement : supprimer le blob puis renvoyer 413. Sans la suppression, un refus laisse un orphelin qui occupe du stockage sans être compté. -
Deletedoit supprimer le blob. Aujourd'hui il nettoie les références en base et laisse le fichier. Le quota calculé (SUM(SizeBytes)) diverge donc du stockage réellement facturé, et l'écart ne fait que croître.
SizeBytes doit être renseigné à la création, pas seulement sur Update.
5. Backfill des tailles existantes
Les Resource actuelles ont SizeBytes = 0 : sans reprise, tous les clients démarrent à 0 octet consommé et le quota ne veut rien dire.
À faire pendant la migration Postgres, qui touche déjà chaque ligne Resource.
État mesuré en base le 2026-08-09 (45 ressources, instance du Fort)
| Colonne | Lignes renseignées |
|---|---|
StoragePath |
0 — Create depuis le 2026-08-10 ; les 45 lignes existantes restent à backfiller |
SizeBytes > 0 |
0 — le quota de stockage vaut donc 0 pour tout le monde ; renseignée à Create depuis le 2026-08-10 |
FileName |
0 |
⚠️ Le périmètre n'est pas « chaque ligne Resource ». Sur 45 lignes :
- 37 ont un blob Firebase au chemin attendu → seules celles-là sont concernées ;
- 7 sont des types URL (
ImageUrl,VideoUrl,JSONUrl— Wikipedia, YouTube,agenda.php,swagger.json) : elles pointent hors du bucket et n'ont aucun blob. Leur donner unStoragePathserait faux, et les compter dans le quota le serait aussi. À exclure explicitement ; - 1 est de type fichier (
Video, « Exemple webm ») mais sans aucune URL : une ligne sans fichier, l'inverse d'un blob orphelin. C'est le premier cas que l'inventaire doit remonter.
Deux moitiés, dont une gratuite
Le chemin étant déterministe pour les types fichier, StoragePath se backfille en SQL pur, sans lire le bucket :
UPDATE "Resources" SET "StoragePath" = 'pictures/' || "InstanceId" || '/' || "Id"
WHERE "Type" NOT IN (2, 3, 7) -- ImageUrl, VideoUrl, JSONUrl : pas de blob
AND "Url" IS NOT NULL; -- écarte les lignes sans fichier
Seul SizeBytes exige de lister Firebase. Découper ainsi permet de faire la moitié utile immédiatement.
Prérequis à ne pas inverser :✅ Fait le 2026-08-10 —StoragePathetSizeBytesdoivent d'abord être écrits àCreate.ResourceController.Createécrit désormaisStoragePath = pictures/{instanceId}/{resourceId}etSizeBytes, en excluant les types URL (2, 3, 7). Côtémanager-app,resources_screen.dartrenseignesizeBytesavant l'appel de création (la taille est connue avant l'upload) : elle n'arrivait jusque-là qu'auUpdatepost-upload. Le backfill peut donc se faire une seule fois.⚠️ Reste non autoritaire : la taille vient du client. Le contrôle relu depuis Firebase est le point 2 du §4, pas encore fait.
1. Lister le bucket Firebase une fois (paginé) → map { objectPath: sizeBytes }
2. Pour chaque Resource : reconstruire la clé `pictures/{instanceId}/{id}`, chercher dans la map
3. Trouvé → SizeBytes = taille réelle, StoragePath = clé
Pas trouvé → journaliser (référence morte : ligne sans fichier)
4. Objets du bucket sans Resource correspondante → journaliser (blob orphelin)
Lister le bucket une seule fois plutôt qu'un appel de métadonnées par ressource : quelques appels au lieu de plusieurs milliers.
Le bonus : l'étape 4 donne l'inventaire des blobs orphelins, qui existent forcément puisque Delete n'a jamais supprimé les fichiers. Tu récupères probablement du stockage réel avant même de mettre les quotas en service.
Ne pas purger automatiquement. Le script rapporte, il ne supprime pas. Un défaut de rapprochement supprimerait des fichiers vivants. On regarde la liste, on valide, on purge ensuite.
Bascule pour les clients existants
Une fois les tailles réelles connues, certains clients seront peut-être déjà au-dessus du quota de leur plan. Ne pas les bloquer du jour au lendemain : calculer, regarder les chiffres, puis décider — quota sur les seuls nouveaux uploads, ou dépassement toléré avec alerte. À trancher sur les vrais chiffres.
6. Fournisseur de stockage — décision et déclencheur
Situation
Bucket en Europe. Or les quotas gratuits Firebase Storage ne s'appliquent qu'aux régions us-central1, us-west1 et us-east1. Il n'y a donc aucun quota gratuit : la facturation court depuis le premier octet.
Tarifs Blaze (bucket legacy *.appspot.com, hors quotas gratuits) :
| Poste | Prix |
|---|---|
| Stockage | 0,026 $/Go/mois |
| Bande passante sortante | 0,12 $/Go |
| Ops upload | 0,05 $/10K |
| Ops download | 0,004 $/10K |
Pourquoi la facture reste faible aujourd'hui
Le canal actuel est la tablette kiosk : elle télécharge le contenu une fois et sert hors ligne. L'egress est donc proportionnel au nombre d'installations, pas au nombre de visiteurs. Le stockage seul ne fait jamais une grosse facture (50 Go ≈ 1,30 $/mois), et l'ingress est gratuit — un client qui déverse toutes ses données ne coûte quasiment rien.
Ce qui change avec le mobile
mymuseum-visitapp sur les téléphones des visiteurs rend l'egress proportionnel à la fréquentation.
Comportement confirmé : visitapp ne pré-télécharge pas le contenu, et ne doit pas le faire. Le chargement est à la demande — un visiteur qui consulte trois POI coûte trois POI. C'est le bon défaut et il faut le préserver.
L'exception, c'est la visite hors ligne : un visiteur peut télécharger explicitement une visite complète, pour les lieux sans réseau (le Fort Saint Héribert et ses murs épais). C'est là — et pratiquement là seulement — que vit la facture d'egress.
La visite hors ligne est le poste de coût
Ordre de grandeur pour une visite type de 100 images + audio :
| Sans compression | Avec compression (2560 / q82) | |
|---|---|---|
| Poids du paquet | ~1 Go | ~150 Mo |
| Coût par téléchargement (0,12 $/Go) | ~0,12 $ | ~0,018 $ |
| 900 téléchargements/mois (30/jour) | ~108 $/mois | ~16 $/mois |
C'est le chiffre qui justifie la compression à lui seul. Le reste de l'app, en chargement à la demande, pèse une fraction de ça.
Mécanisme actuel — downloadConfiguration.dart
configurationExport(configurationId, language)→ExportConfigurationDTO(sections + toutes les ressources)- Création d'un dossier local
{localPath}/{configurationId} - Filtre incrémental, puis téléchargement séquentiel des ressources retenues
- Insertion des métadonnées dans le SQLite local
Le coût d'egress est déjà maîtrisé — il existe bien un filtre incrémental :
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) =>
resource.type != ResourceType.ImageUrl && ... &&
resource.url != null &&
!fileList.any((fileL) => fileL.uri.pathSegments.last.contains(resource.id!)));
Une ressource dont l'id apparaît déjà dans un nom de fichier local est sautée. Un second téléchargement de la même visite ne re-télécharge donc rien.
Le vrai problème n'est pas le coût, c'est la fraîcheur
Le filtre teste la présence du fichier, pas sa version. Or lorsqu'un client remplace une image dans le CMS, Update conserve l'id de la ressource et ne change que l'Url.
Conséquence : une ressource modifiée n'est jamais re-téléchargée. Le fichier local existe, l'id correspond, elle est sautée. Le visiteur qui avait déjà téléchargé la visite garde l'ancienne image indéfiniment. Les ajouts fonctionnent, les mises à jour non.
C'est un bug de correction, pas d'optimisation — et il est invisible côté client, qui voit son contenu à jour dans le CMS et sur le web.
Correctif : comparer une version, pas une présence. Resource.DateUpdate existe déjà sur l'entité — le stocker dans le SQLite local à côté du chemin, et re-télécharger quand la valeur distante est plus récente. Alternative plus robuste : un hash de contenu, qui évite de re-télécharger quand seul un champ de métadonnée a bougé.
Le hors ligne est plus cassé que ça. Le
switchdu backend qui collecte les ressources internes des sections (images d'articles, audios, quiz, cartes, sliders) est entièrement commenté dansConfigurationController.Export, et le bloc symétrique côté visitapp aussi. Une visite « téléchargée » ne contient aujourd'hui que des métadonnées et quelques vignettes. Analyse complète et plan de remise en état :offline-visit-plan.md.
Trois autres constats dans le même fichier
- La purge des fichiers obsolètes est désactivée. La liste
resourceToDeleteest calculée puis ledeleteSync()est commenté (// Preserve call to firebase // TODO uncomment if needed), etcleanLocalResourcesest commenté aussi. Le stockage sur le device grossit indéfiniment. Pas un coût d'egress, mais un problème sur un téléphone de visiteur. - Seuls les types
ArticleetQuizsont embarqués (TODO explicite dans le code pour Game, Menu, Map, PDF, Video, Slider, Web, Weather, Agenda). La visite hors ligne est donc partielle aujourd'hui — à savoir avant de la vendre comme argument. - L'extension est déduite du
Content-Typevia une table de correspondance qui renvoie"unknown"en cas d'absence. Or elle contientaudio/mp3, qui n'est pas un type MIME standard — le vrai MIME d'un MP3 estaudio/mpeg, absent de la table. Les audios téléchargés atterrissent donc probablement déjà en.unknown. À vérifier sur un device, c'est du contenu hors ligne cassé sans bruit. (Accessoirement,image/webpn'y est pas non plus — sans conséquence tant qu'on reste en JPEG, à corriger le jour où on changerait de format.)
Piste
Servir un paquet pré-assemblé (archive générée au save) plutôt que N requêtes individuelles : l'egress est identique, mais le nombre d'opérations de download facturées chute et c'est nettement plus robuste sur une connexion instable — typiquement à l'entrée d'un fort.
Décision : rester sur Firebase, garder l'abstraction
L'abstraction (StoragePath + IStorageService) est la préparation. Elle réduit une migration future à : changer d'implémentation, recopier les blobs, aucun changement de schéma. Migrer le fournisseur maintenant n'apporte rien de plus et ajoute un chantier pendant la migration Postgres.
Le levier immédiat sur le coût, c'est la compression (12×), pas le fournisseur.
Si migration un jour : OVH Object Storage, avec ses conditions
Ce que ça implique concrètement
- S3-compatible →
AWSSDK.S3pointé sur l'endpoint OVH - L'upload direct navigateur passe par des URL présignées : le backend génère une URL PUT temporaire, le client uploade dessus. Même principe, autre plomberie. Plus la config CORS du bucket.
- Firebase donne des URL de téléchargement permanentes à jeton, stockables en base. En S3 : soit objets publics (aucun contrôle d'accès), soit URL présignées qui expirent — donc générées à la lecture.
StoragePathrend ce basculement indolore. - Les push notifications restent chez Firebase de toute façon.
Un bucket global, préfixé par instance — pas un bucket par client. C'est déjà le modèle actuel (pictures/{instanceId}/{id}). Un bucket par client impose d'en provisionner un à chaque onboarding, gérer des quotas de nombre de buckets et plus d'IAM, pour une isolation déjà obtenue par le préfixe et une comptabilité de quota qui vit en base. À réserver à un client qui exigerait une séparation physique — improbable avec des musées.
Durabilité : la sauvegarde devient obligatoire. OVH a un précédent — l'incendie de SBG2 (Strasbourg, 2021) a détruit les données de clients sans sauvegarde ailleurs. Google Cloud Storage annonce 11 neufs de durabilité et n'a pas cet historique. Partir chez OVH impose donc une sauvegarde des médias dans une autre région, ce qui mange une partie de l'économie sur l'egress et ajoute de l'ops.
Déclencheur de réouverture
Quand la facture mensuelle Firebase dépasse ~30 €. En dessous, ça ne vaut pas le temps passé. Mettre une alerte de budget GCP dès maintenant — c'est cinq minutes et c'est la vraie protection contre la surprise.
Ordre d'implémentation
| # | Tâche | Quand |
|---|---|---|
| 1 | Alerte de budget GCP | tout de suite, 5 min |
| 2 | Compression client 2560 px / q82 + PNG→JPEG | avant la release |
| 3 | Colonne StoragePath + IStorageService (impl. Firebase) |
migration |
| 4 | SizeBytes renseigné à Create, lu depuis Firebase |
migration |
| 5 | Backfill tailles + StoragePath + inventaire orphelins |
migration |
| 6 | Flag watermark sur Instance + exposition dans la config |
migration |
| 7 | Pré-vol quota + contrôle autoritaire + Delete du blob |
avant les uploads documentaires |
| 8 | Remise en état de la visite hors ligne → offline-visit-plan.md |
bugs — dès que possible |
Liens
rag-pgvector-integration-plan.md— formats de documents, extraction, ingestion (dépend de §3 et §4 de ce doc)myinfomate-ai-persona-analysis.md— vision d'ensemble