diff --git a/v2/media-storage-plan.md b/v2/media-storage-plan.md index f982cc7..ada4c6f 100644 --- a/v2/media-storage-plan.md +++ b/v2/media-storage-plan.md @@ -129,19 +129,49 @@ La ligne `Resource` est créée **avant** l'upload, et l'URL écrite après coup ## 4. Quota de stockage -### État actuel : non appliqué +### État actuel — relevé dans le code le 2026-09-07 -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`**. +⚠️ **Cette section était en retard sur le code.** Les points 1 et 3 sont livrés, et le point 2 +tel qu'il était écrit ne peut pas fonctionner. Corrigé ci-dessous. -### Corrections, dans cet ordre +| # | Correction prévue | État réel | +|---|---|---| +| 1 | Endpoint de pré-vol | ✅ **livré** — `ExceedsStorageQuota` ([ResourceController.cs:966](../../manager-service/ManagerService/Controllers/ResourceController.cs#L966)) résout quota d'instance puis quota de plan, et garde les deux chemins ([:246](../../manager-service/ManagerService/Controllers/ResourceController.cs#L246) multipart, [:330](../../manager-service/ManagerService/Controllers/ResourceController.cs#L330) JSON) | +| 2 | Contrôle autoritaire à `Create`, taille lue depuis Firebase | ⛔ **infaisable tel quel** — voir ci-dessous | +| 3 | `Delete` doit supprimer le blob | ✅ **codé** — blob avant ligne, 502 qui interrompt plutôt que de créer un orphelin ([:581](../../manager-service/ManagerService/Controllers/ResourceController.cs#L581)). **Mais inerte** : `Firebase:StorageBucket` vaut `""`, donc `IsConfigured` est faux, `DeleteAsync` renvoie `NotConfigured`, et l'appelant ne bloque que sur `Failed`. Verrou = la carte kanban « Poser `Firebase:StorageBucket` » | -1. **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é. +### Pourquoi le point 2 ne peut pas être un contrôle synchrone -2. **Contrôle autoritaire à `Create`, taille lue depuis Firebase.** Ne **jamais** faire confiance au `sizeBytes` envoyé par le client — un appelant qui envoie `0` bypasse le quota définitivement. `FirebaseAdmin` est 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é. +manager-app crée la ligne **en annonçant** `sizeBytes`, **puis** téléverse le blob. À l'instant +du `Create`, le blob n'existe pas encore : il n'y a rien à interroger dans Firebase. Le +commentaire du code le dit déjà. -3. **`Delete` doit 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. +Le trou reste réel — `Create` croit le client, donc un appelant qui envoie `0` passe sous le +quota définitivement — mais le correctif est une **réconciliation asynchrone**, pas une +vérification à l'écriture : -`SizeBytes` doit être renseigné à la création, pas seulement sur `Update`. +- un job Hangfire récurrent (il y en a déjà huit, [Startup.cs:359-401](../../manager-service/ManagerService/Startup.cs#L359)) qui repasse sur les blobs, lit la taille réelle et corrige `SizeBytes`, en signalant les lignes où le déclaré divergeait ; +- la logique de sondage existe déjà dans le backfill ([:912](../../manager-service/ManagerService/Controllers/ResourceController.cs#L912)) : il s'agit de la rendre récurrente et de ne plus la limiter à `SizeBytes == 0` ; +- il faut ajouter une lecture de taille à `IResourceBlobService`, qui n'expose aujourd'hui que `DeleteAsync` ([ResourceBlobService.cs:32](../../manager-service/ManagerService/Services/ResourceBlobService.cs#L32)). + +### Et le garde-fou déclaratif, qui n'existe pas du tout + +Il n'y a **aucun `storage.rules` dans le repo**. Comme manager-app téléverse directement vers +Firebase, une règle `request.resource.size < N` est le seul contrôle qu'un client ne puisse pas +contourner — un endpoint de pré-vol l'est par construction. Ça ne plafonne pas le total, mais +ça bloque le fichier de 4 Go. + +### Un plafond de taille par bucket n'existe pas — vérifié le 2026-09-07 + +Ni chez Google, ni chez OVH. À ne pas rechercher une seconde fois : + +- **GCS / Firebase Storage** : stockage illimité au niveau bucket, seule limite 5 TiB **par objet** ([doc](https://docs.cloud.google.com/storage/quotas)). Le plafond de 1 To concerne les Rapid Buckets zonaux. +- **OVH Object Storage S3** : 100 buckets par projet, 48 TiB par objet, nombre d'objets « unlimited », **aucun quota de taille** ([limitations](https://docs.ovhcloud.com/en/guides/storage-and-backup/object-storage/s3-limitations)). +- L'ancienne interface **Swift** d'OVH avait un `X-Container-Meta-Quota-Bytes` — c'est Swift, pas S3, et c'est l'offre legacy. Ne s'applique pas. + +Et même s'il existait, ce serait la mauvaise granularité : le quota est **par instance**, alors +que tous les clients partagent un bucket préfixé (§ « Un bucket global, préfixé par instance »). +Le quota vit en base, il ne peut pas vivre dans le stockage. --- @@ -205,7 +235,68 @@ Une fois les tailles réelles connues, certains clients seront peut-être déjà ### 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. +Bucket **en Europe** — vérifié le 2026-09-07 : `gs://mymuseum-3b97f.appspot.com` est en `EU`, `multi-region`. 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. + +### 📏 Le volume réel, mesuré le 2026-09-07 : **1,43 Go** + +```sh +gcloud storage du -s gs://mymuseum-3b97f.appspot.com # 1433427569 octets +``` + +Ce chiffre était attendu du backfill (§5) alors que **le bucket le connaissait depuis le début**. +Le backfill reste nécessaire pour la comptabilité *par client* et l'inventaire des orphelins, +mais plus pour dimensionner un stockage ou arbitrer un fournisseur. + +Conséquence directe : les médias tiennent dans le bucket de sauvegarde déjà créé +(`gs://unov-myinfomate-backups`, préfixe `media/`), et **aucun second fournisseur n'est +nécessaire**. Voir `manager-service/ManagerService/Deployment/backup/README.md`. + +### 🔍 Inventaire des orphelins — première moitié faite le 2026-09-07 + +Obtenu en comparant les 2408 objets du bucket aux instances réelles, sans backfill. +**~27 objets ne peuvent appartenir à aucun client :** + +| Quoi | Objets | Pourquoi c'est un orphelin | +|---|---|---| +| `pictures/63514fd67ed8c735aaa4b8f1/` | 19 | L'id finit par **f1** ; l'instance MyInfoMate est `…f2`. Un caractère d'écart — instance supprimée ou faute de frappe historique | +| `pictures/3181820d61fb46639d234dc7/` | 2 | C'est MNAHA, qui existe en base **de dev** mais dans aucune instance de la prod Mongo | +| Racine du bucket | 6 | `giphy.gif`, `All 24 Cybertruck Accessories Revealed!.mp4`, `file_example_{AVI,MOV,WEBM}`, `video_2023-12-13_14-49-12.mp4` — des fichiers de test | + +Les 6 fichiers de la racine sont le cas le plus net : ils ne suivent pas le schéma +`pictures/{instanceId}/{id}`, donc **aucun code actuel ne peut les produire ni les désigner**. +Ils sont invisibles au quota et au `StoragePath`, et facturés depuis 2023. + +Répartition des 2402 objets restants, par instance : + +| Instance | Objets | +|---|---| +| Fort Saint Héribert (`633ee379…`) | 1204 | +| VisitNamur (`65c5e576…`) | 711 | +| MDLF (`65ccc672…`) | 430 | +| MyInfoMate démo (`63514fd6…f2`) | 36 | + +Fort Saint Héribert porte **la moitié du bucket** — cohérent avec l'instance la plus ancienne, +et avec le fait qu'elle n'a plus rien modifié depuis avril (voir STATUS.md §4). + +⚠️ À confirmer avant toute suppression : croiser ces 27 objets avec la table `Resources`. Le +raisonnement ci-dessus repose sur les préfixes, pas sur les références. + +### ✅ Versioning activé le 2026-09-07 — avant la clé, comme il fallait + +Le bucket n'avait **ni versioning ni aucune règle de cycle de vie**. Les deux sont posés : + +```sh +gcloud storage buckets update gs://mymuseum-3b97f.appspot.com --versioning +# + lifecycle : {"condition":{"daysSinceNoncurrentTime":30},"action":{"type":"Delete"}} +``` + +L'ordre importait. Tant que `Firebase:StorageBucket` est vide, `Delete` ne supprime aucun blob +et le risque est nul. Le jour où cette clé est renseignée, la suppression devient réelle — un +mauvais préfixe ou un bug emporterait les médias d'un client **sans retour**. Le filet est +désormais antérieur à ce basculement. + +La purge des versions non courantes à 30 jours évite que les écrasements s'accumulent sans fin. +Et le bucket portait déjà une `soft_delete_policy` de 7 jours depuis mars 2024 — seconde couche. Tarifs Blaze (bucket legacy `*.appspot.com`, hors quotas gratuits) :