Compare commits

...
266 Commits
Author SHA1 Message Date
fred b398e4059a Merge pull request 'Dev/3.4.2' (#150) from dev/3.4.2 into master
Reviewed-on: #150
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-10 20:58:21 +01:00
nicolas 69f943ea53 Notes de version du launcher 3.4.2 2026-11-10 20:52:22 +01:00
nicolas 4c368cb1e0 Documentation : audit des formulaires des demandes 2026-11-10 20:52:13 +01:00
nicolas eca812a718 UiSnapshot : exemples d'audit des formulaires 2026-11-10 20:51:58 +01:00
nicolas e4f2d443e8 Tests : audit détaillé des formulaires 2026-11-10 20:51:46 +01:00
nicolas 6e7b480db5 Menu d'administration : libellés des actions d'audit des types de demande 2026-11-10 20:51:33 +01:00
nicolas 1c7735d25a Launcher API : audit détaillé des formulaires des demandes (par type et par champ) 2026-11-10 20:51:16 +01:00
nicolas 7b153b81ee Anonymisation de xbhost en LanisysGames 2026-11-10 20:14:46 +01:00
nicolas 8fe67053e1 Merge pull request 'Dev/3.4.1' (#144) from dev/3.4.1 into master
Reviewed-on: #144
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:59:13 +01:00
nicolas 927df6c2b2 Version 3.4.1 2026-11-10 16:55:01 +01:00
nicolas 197f5186d0 UiSnapshot : captures en WebP (--format webp, --quality)
- --format png|webp (png par défaut), --quality 1-100 (85 par défaut,
  100 = sans perte) ; transparence conservée, pages .p2.webp comprises.
- Encodage WebP par SkiaSharp 3.119.1 (Microsoft, MIT) : WIC lit le WebP sans
  savoir l'écrire. Outil de développement uniquement.
- index.html et report.md reconnaissent PNG et WebP ; une série complète
  efface aussi les anciens .webp.
- LISEZMOI : captures pour le site vitrine ; README §5 et §9 mis à jour.
2026-11-10 16:50:13 +01:00
nicolas 98cf17c294 Merge pull request 'Web/1.0.0' (#143) from web/1.0.0 into master
Reviewed-on: #143
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:29:33 +01:00
nicolas 4da86bcc6a README : site vitrine (§9)
Ligne GameLauncher.Website dans la structure de la solution, nouvelle section
§9 : pages, thèmes, isolation de la Launcher API, téléchargement vérifié,
sécurité HTTP, service durci, liseuse Markdown, tests, captures à mettre à jour.
2026-11-10 16:17:16 +01:00
nicolas 52761f53b6 Site vitrine : publication et déploiement sur le VPS
- publish-site.bat : exe autonome linux-x64, archive et empreinte SHA-256.
- installer-site.sh :
  - archive vérifiée par son empreinte ;
  - binaires dans /opt/lanisys-site (propriétaire root) ;
  - appsettings.Production.json conservé d'une version à l'autre ;
  - sorties réseau autorisées recalculées (serveur du launcher + DNS) dans
    sortie.conf ;
  - retour à la version précédente si le site ne répond pas.
- lanisys-site.service :
  - compte temporaire (DynamicUser), système en lecture seule, aucun privilège ;
  - dossier personnel /var/lib/lanisys-site (700), nécessaire à .NET pour
    vérifier un certificat HTTPS sous Debian 11 ;
  - connexions sortantes limitées au serveur du launcher ;
  - mémoire limitée.
- nginx-site.conf :
  - redirection http → https et domaine nu → www ;
  - HTTP/2 compatible nginx 1.18 (listen … http2) ;
  - TLS 1.2 et 1.3, renouvellement Let's Encrypt par webroot ;
  - lectures seulement, fichiers cachés jamais servis, gzip, limite de
    requêtes (nginx-site-zones.conf).
- LISEZMOI.md : pourquoi un VPS séparé, domaine, publication, installation,
  nginx et HTTPS, mentions légales, journaux, mise à jour, écriture des
  actualités, ajout d'un jeu, test en local, dépannage, fichiers sur le VPS.
  Debian 11, 12 ou 13.
2026-11-10 16:16:58 +01:00
nicolas cf75a22f19 Tests : site vitrine
Les sources du site (Services, Options) et la clé publique sont compilées
dans le projet de tests, sans référence au projet du site : une référence
copierait l'appsettings.json du site à la place de celui de la Launcher API
dans le dossier des tests.

Tests :
- launcher-update.json : signature valide, contenu modifié, autre clé,
  exécutable hors du serveur (autre hôte, http, autre port, paramètres),
  produit inattendu, clé intégrée ;
- manifest : bornes et nettoyage des notes, autre jeu, fichier illisible ;
- réseau : adresses https uniquement, lecture bornée, message d'erreur
  détaillé (certificat refusé) ;
- Markdown : HTML brut, liens javascript:, data: et //, injection
  d'attribut, images externes, syntaxe prise en charge ;
- actualités : en-tête, noms de fichiers invalides, brouillons.
2026-11-10 16:16:26 +01:00
nicolas 009c84de9f Site vitrine : premières actualités
- Ouverture du site.
- Exovival Zombie Reborn 1.8 : zombies rôdeurs, ouïe des zombies, armes,
  soins.
- Launcher 3.4 : versions prioritaires.
2026-11-10 16:15:28 +01:00
nicolas 9f9b9b9d4d Site vitrine : images, polices et icônes
- Polices auto-hébergées Sora, Barlow et Barlow Condensed (WOFF2), avec leurs
  licences OFL : aucune requête vers un service externe.
- Captures d'Exovival : couloir de l'école, encre UV, images des cassettes VHS
  sans les dates qui révèlent un code, et vues de l'abri en développement. Le
  tout en WebP.
- Captures du launcher (docs/preview*.png et UiSnapshot) en WebP.
- Favicon et icônes reprises du launcher, images de partage (Open Graph) du
  launcher et d'Exovival.
2026-11-10 16:14:55 +01:00
nicolas 12ab8377c9 Site vitrine : pages, thèmes par univers et script
Pages :
- accueil ;
- /launcher : présentation des fonctions joueur, téléchargement vérifié
  (version, taille, empreinte SHA-256), première installation, notes de la
  dernière version, lien vers les publications Gitea, questions fréquentes ;
- /jeux et /jeux/exovival-zombie-reborn : histoire, contenu, cassettes VHS,
  extension « L'abri » en développement, configuration ;
- /jeux/<jeu>/notes-de-version : toutes les versions ;
- page générique des jeux en préparation (projet multijoueur) ;
- actualités, mentions légales, confidentialité, erreurs 404 et 500.

Thèmes par page (data-theme) :
- launcher : violet et cyan, police Sora ;
- exovival : noir et vert, Barlow Condensed ; en-tête « lampe UV » qui révèle
  le message de Léa sous le pointeur ;
- multi : ambre.

Notes de version affichées comme dans le launcher (titre, sous-titre,
introduction, sections, étiquettes), textes toujours encodés.

Bouton « Nous contacter » : simple lien mailto. Aucun formulaire.

Responsive jusqu'au mobile, focus clavier visible, mouvement réduit respecté.
Script sans dépendance (menu mobile, copie de l'empreinte, lampe UV),
compatible avec la CSP (aucun script en ligne).
2026-11-10 16:14:00 +01:00
nicolas 5aa22df409 Site vitrine : nouveau projet GameLauncher.Website (ASP.NET Core Razor Pages)
Projet ajouté à la solution (dossier « website »), sans paquet NuGet tiers.
Références : GameLauncher.Contracts et GameLauncher.Core.

Isolé de la Launcher API : le site ne l'appelle jamais. En arrière-plan, côté
serveur, il lit seulement les fichiers publics launcher-update.json et
manifest.json :
- toutes les 10 minutes, avec requêtes conditionnelles (ETag, Last-Modified) ;
- https uniquement, aucune redirection suivie, taille et délai bornés.
Les pages ne déclenchent aucune requête sortante.

launcher-update.json :
- signature vérifiée avec la clé update-signing.pub, intégrée à l'exe du site,
  par le même code que le launcher (UpdateSignature) ;
- l'exécutable doit être sur le même serveur, en https ;
- produit attendu facultatif (Site:Launcher:Product) ;
- fichier refusé : téléchargement retiré ; serveur injoignable : dernière
  version valide conservée.

Notes de version : bornées et nettoyées comme dans le launcher
(ChangelogLimits.Normalize). Sections réservées à l'équipe masquées pour le
launcher (Site:Launcher:HiddenSections).

Certificat HTTPS du serveur du launcher : même règle que par défaut ; en cas de
refus, le journal détaille la chaîne de certificats (TlsDiagnostics) et la
cause complète de l'erreur réseau.

Actualités en Markdown (Content/News) : liseuse maison SafeMarkdown.
- tout le texte est encodé ;
- liens http(s), mailto et chemins du site seulement ;
- images du site (/img/) seulement.

Sécurité HTTP :
- CSP stricte (aucun script en ligne, aucune ressource externe), HSTS,
  nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP ;
- GET/HEAD seulement (405 sinon) ;
- limite de requêtes par IP ;
- aucun cookie, clés de protection des données en mémoire ;
- en-tête Server retiré.

Autres :
- réglages contrôlés au démarrage (SiteOptionsValidator) ;
- dates en français sans dépendance à ICU (InvariantGlobalization) ;
- robots.txt, sitemap.xml, /healthz réservé à la machine locale.
2026-11-10 16:12:47 +01:00
nicolas 3df009912b Git : fins de ligne LF pour website/deploy/*.conf
Les configurations nginx du site vitrine sont copiées telles quelles sur le VPS :
même règle que server/deploy/*.conf.
2026-11-10 16:10:33 +01:00
nicolas 89570947aa Merge pull request 'Dev/3.4.0' (#142) from dev/3.4.0 into master
Reviewed-on: #142
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 13:49:24 +01:00
nicolas 14b2167218 Launcher 3.4.0 : mises à jour prioritaires
- Version 3.3.1 → 3.4.0
- Notes de version (notes-launcher.txt, RELEASE-3.4.0.md)
- README §7 : section « Version prioritaire », sécurité, publication
2026-11-10 13:35:25 +01:00
nicolas 2e4d2ee76e UiSnapshot : scène launcher-update-priority 2026-11-10 13:34:37 +01:00
nicolas 7d9032fe20 Publication : options --priority et --clear-priority
- LauncherPublisher publish : --priority (confirmation o/N), --clear-priority,
  --yes ; priorité reprise du launcher-update.json précédent s'il est signé
- Ligne « Priorité » dans publish et verify
- publish-launcher-update.bat : paramètres --priority, --clear-priority,
  --help (shift /1 pour garder %~dp0)
2026-11-10 13:34:13 +01:00
nicolas ccd6b1a430 Interface : pastille « Prioritaire » dans la fenêtre de mise à jour du launcher
Sous-titre dédié précisant que le réglage du joueur ne change pas.
2026-11-10 13:33:02 +01:00
nicolas e4a21c6f5d Launcher : version prioritaire installée même si la MAJ auto est désactivée
- Recherche au démarrage même option désactivée ; seule une version
  prioritaire est alors installée, self-update.json jamais modifié
- Erreur de téléchargement d'une version prioritaire : retentée au
  démarrage suivant
- Version prioritaire non installée : avertissement dans le bandeau et
  statut orange dans les Paramètres du launcher
2026-11-10 13:32:42 +01:00
nicolas 1a7f45c38b Tests : versions prioritaires du launcher (8 tests, 12 cas)
Signature, compatibilité sans priorité, launchers forcés ou non,
validation, reprise / retrait par l'outil de publication,
self-update.json sans champ de priorité.
2026-11-10 13:32:05 +01:00
nicolas b48d4222db Core : version prioritaire du launcher dans launcher-update.json (signée)
- LauncherRelease.PriorityVersion : dernière version prioritaire publiée,
  dans le payload signé (ignorée par les launchers < 3.4.0)
- IsPriorityRelease, IsPriorityFor(), TryGetPriorityVersion()
- ResolvePriorityVersion() : marquer, reprendre ou retirer la priorité
  d'une publication à l'autre
- Validate() refuse une version prioritaire illisible ou supérieure à la
  version publiée
- LauncherUpdateCheck.IsPriority
2026-11-10 13:31:36 +01:00
nicolas 51eecbb57a Merge pull request 'Dev/3.3.1' (#139) from dev/3.3.1 into master
Reviewed-on: #139
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 12:30:21 +01:00
nicolas edd2633455 chore(release): launcher 3.3.1 2026-11-10 12:18:55 +01:00
nicolas 0e26e6eb11 docs: rafraîchissement du trousseau (3.3.1) 2026-11-10 12:18:22 +01:00
nicolas 916b53446a fix(trust): retélécharger le trousseau quand une clé connue n'est pas autorisée, âge max 30 min 2026-11-10 12:17:50 +01:00
nicolas 0e371518b5 Merge pull request 'Dev/3.3.0' (#138) from dev/3.3.0 into master
Reviewed-on: #138
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 11:50:25 +01:00
nicolas 153e498345 Tests et documentation du mode maintenance
- 11 nouveaux tests (367 au total) ; SigningPanelTests : ligne « Mode maintenance » en dernier
- README, ADMIN-ARCHITECTURE (§ 7, § 11 septies, § 13), notes-launcher 3.3.0
2026-11-10 11:40:43 +01:00
nicolas 1e9f49a733 ui-snapshot : scènes du mode maintenance
main-maintenance, main-maintenance-plain, admin-page-maintenance, admin-page-maintenance-edit ;
routes v1/maintenance simulées ; ligne « Mode maintenance » dans admin-page-signing
2026-11-10 11:37:50 +01:00
nicolas 5f6c667030 Launcher 3.3.0 : mode maintenance
- Thème rouge / orange, carte « Maintenance en cours » au centre, bouton « MAINTENANCE »
- Tout est bloqué sauf la désinstallation et le menu d'administration
  (commandes désactivées + refus à l'entrée de chaque action)
- État lu avant la recherche des mises à jour (4 s au plus), puis relu périodiquement
- Page « Maintenance » et bandeau du tableau de bord (Admin.xaml), icône TrafficCone
- Version 3.3.0
2026-11-10 11:37:28 +01:00
nicolas 93dd844221 Menu admin : page « Maintenance »
- Activer (confirmation), modifier le message, terminer ; contrôle du trousseau des joueurs
- Le launcher de ce PC relit l'état aussitôt (NotifyMaintenanceChanged)
- Tableau de bord : bandeau « Maintenance en cours »
- Page « Signature » : ligne « Mode maintenance » ; journal d'audit : catégorie dédiée
2026-11-10 11:36:01 +01:00
nicolas 5ad8025946 Core : lecture et vérification du mode maintenance ; client de l'API
- MaintenanceService : lecture au démarrage puis toutes les 1 à 2 min,
  signature vérifiée (contentSigning), retour en arrière refusé, hors ligne = pas de maintenance
- ContentTrustService.VerifyMaintenanceAsync
- AdminSession : GetMaintenanceAsync, PublishMaintenanceAsync
2026-11-10 11:34:55 +01:00
nicolas 080000e13c Déploiement : bloc nginx maintenance.json et étape 3 decies 2026-11-10 11:34:30 +01:00
nicolas 3422b92f81 Serveur : module Maintenance (GET / PUT v1/maintenance)
- Publication signée (maintenance.manage + document signé par la clé de la session)
- Révision attendue (409), début inchangé pendant une maintenance
- Fichier public maintenance.json à côté du trousseau, historique de 20 révisions
- Audit maintenance.enable / update / disable
- GET v1/signing renvoie la signature du mode maintenance
2026-11-10 11:34:12 +01:00
nicolas 683cc7a9dc Contrats : document du mode maintenance, permission et autorisation de signature
- MaintenanceDocument / MaintenanceState / MaintenancePublishRequest
- MaintenanceRules : règles communes (message 300 car., 6 lignes, sans lien, condensé signé)
- Permission maintenance.manage (Propriétaire, Administrateur sur installation neuve)
- Type de contenu et autorisation du trousseau « maintenance »
- Route v1/maintenance ; SigningState.Maintenance
2026-11-10 11:33:04 +01:00
nicolas 7bc229de53 Merge pull request 'Dev/3.2.1' (#136) from dev/3.2.1 into master
Reviewed-on: #136
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 10:03:15 +01:00
nicolas 83e3d61634 Version 3.2.1 et notes de mise à jour 2026-11-10 10:01:53 +01:00
nicolas b088c6caa7 Docs et notes 3.2.0 : page « Signature » 2026-11-10 09:48:15 +01:00
nicolas 658a1cce72 ui-snapshot : formulaires dans la page « Signature » 2026-11-10 09:47:47 +01:00
nicolas bac8ebeb55 Admin : formulaires des demandes dans la page « Signature » 2026-11-10 09:47:26 +01:00
nicolas 76eaa48342 Signature : état de signature des formulaires des demandes (serveur et contrat) 2026-11-10 09:46:55 +01:00
nicolas 97df0ba721 Merge pull request 'Dev/3.2.0' (#135) from dev/3.2.0 into master
Reviewed-on: #135
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 09:25:36 +01:00
nicolas f0f403ce66 Version 3.2.0 et notes de mise à jour 2026-11-10 09:16:42 +01:00
nicolas 977e2ae892 Launcher : fenêtre « Aide » avec formulaires publiés, nouveaux champs, icônes importées, captures par type 2026-11-10 09:16:32 +01:00
nicolas 1913fcdb01 Docs : formulaires des demandes modifiables (§ 11 sexies, README) 2026-11-10 09:16:06 +01:00
nicolas f25051c627 ui-snapshot : page « Formulaires des demandes » et formulaires publiés 2026-11-10 09:15:32 +01:00
nicolas 82ad4ec500 Tests : formulaires des demandes (contrats, serveur, menu, trousseau) 2026-11-10 09:15:14 +01:00
nicolas a4ef606ff7 Admin : page « Formulaires des demandes » et demandes affichées avec leur formulaire 2026-11-10 09:14:52 +01:00
nicolas fcb27d40b1 Launcher : fenêtre « Aide » avec formulaires publiés, nouveaux champs, icônes importées, captures par type 2026-11-10 09:13:34 +01:00
nicolas 45140ba78a Déploiement : blocs nginx des formulaires (lecture publique, publication 300 Ko) 2026-11-10 09:12:38 +01:00
nicolas 5457e66144 Serveur : publication signée des formulaires, validation des demandes par révision 2026-11-10 09:12:18 +01:00
nicolas 05c5e24327 Signature : type de contenu feedback-forms, autorisation « forms » du trousseau, permission feedback.forms 2026-11-10 09:11:31 +01:00
nicolas d920702353 Contrats : formulaires des demandes signés, catégories dynamiques et icônes .ico 2026-11-10 09:10:37 +01:00
nicolas e96edfce4f Merge pull request 'Dev/3.1.0' (#133) from dev/3.1.0 into master
Reviewed-on: #133
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-09 16:43:55 +01:00
nicolas 11721e9260 Version 3.1.0 : captures d'écran, liens signalés, journal d'audit lisible 2026-11-09 16:37:42 +01:00
nicolas 5d9f413f57 UiSnapshot : fenêtre « Aide » et fiche des demandes des joueurs 2026-11-09 16:29:39 +01:00
nicolas cde3b337e7 Aide : fenêtre centrée et formulaire avec ascenseur (taille de base réduite) 2026-11-09 16:19:47 +01:00
nicolas 1c198f1204 Docs : captures d'écran, avertissement sur les liens, signature des requêtes 2026-11-09 16:03:25 +01:00
nicolas 2d26416a7a Tests : liens, captures d'écran et libellés du journal d'audit 2026-11-09 16:02:59 +01:00
nicolas 24de56cdcb Demandes des joueurs : avertissement sur les liens et affichage des captures 2026-11-09 16:01:56 +01:00
nicolas 8a7e51fa07 Aide : ajout de captures d'écran par fichier ou Ctrl+V, réencodées sans métadonnées 2026-11-09 16:01:32 +01:00
nicolas e18ebf818f Client API : envoi des captures (joueur) et lecture des captures (menu) 2026-11-09 16:00:47 +01:00
nicolas 821d31ce30 Déploiement : bloc nginx des captures d'écran et mise à jour de l'étape 3 octies 2026-11-09 16:00:33 +01:00
nicolas db4c874e13 Serveur : captures d'écran des demandes (jeton d'envoi, contrôle de l'image, plafond 1 Go) 2026-11-09 16:00:16 +01:00
nicolas 64fb944038 Contrats : détection des liens et contrôle des captures d'écran (PNG/JPEG sans métadonnées) 2026-11-09 15:59:27 +01:00
nicolas bc3bf4fbba Audit : libellés lisibles pour les actions des demandes des joueurs 2026-11-09 15:58:55 +01:00
nicolas ade18e84d2 Merge pull request 'Dev/3.0.0' (#131) from dev/3.0.0 into master
Reviewed-on: #131
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-09 15:06:00 +01:00
nicolas df48ee142e Version 3.0.0 2026-11-09 15:03:00 +01:00
nicolas 39a0088ed2 message affiché au joueur quand le serveur n’est pas encore à jour ; à ajouter au commit 5 « Module joueur », ou à faire en commit séparé. 2026-11-09 15:02:39 +01:00
nicolas 3a2d87b726 Documentation : architecture § 11 quinquies (phase 7d) et README 2026-11-08 23:17:18 +01:00
nicolas eeb4818df4 Tests : règles, module serveur, formulaire joueur et page d'administration 2026-11-08 23:17:02 +01:00
nicolas 3772b339e6 Launcher : bouton Aide, fenêtre de demande et affichage de la page d'administration 2026-11-08 23:16:37 +01:00
nicolas a9f5f39f36 Menu d'administration : page « Demandes des joueurs » et carte du tableau de bord 2026-11-08 23:14:59 +01:00
nicolas 86c98eef99 Nouveau projet GameLauncher.Modules.Feedback : formulaire du joueur 2026-11-08 23:14:19 +01:00
nicolas 4bdc627d6b Déploiement : nginx et LISEZMOI pour les demandes des joueurs (étape 3 octies) 2026-11-08 23:13:37 +01:00
nicolas a13b1841f5 Serveur : module Feedback et purge automatique (demandes + journal d'audit) 2026-11-08 23:12:53 +01:00
nicolas efbe9b03f9 Client API : envoi anonyme d'une demande et routes d'administration 2026-11-08 23:11:24 +01:00
nicolas 6207147202 Contrats : formulaires, règles et preuve anti-spam des demandes des joueurs 2026-11-08 23:10:49 +01:00
nicolas c9a491622b Merge pull request 'Dev/2.5.0' (#125) from dev/2.5.0 into master
Reviewed-on: #125
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-08 15:55:01 +01:00
nicolas c210754a18 Version 2.5.0 2026-11-08 15:46:22 +01:00
nicolas 389593a3b4 Plein écran : fenêtre agrandie sur la zone utile de l'écran, barre des tâches toujours visible 2026-11-08 15:28:20 +01:00
nicolas 22ee3a6dee Documentation
Message : Docs : plein écran, règles responsive pour les futurs modules, nom de l'appareil dans l'audit
2026-11-08 15:22:18 +01:00
nicolas a72761ccd0 Boîtes de dialogue : taille adaptée à l’écran de la fenêtre parente
Message : Dialogues : taille limitée à l'écran de la fenêtre parente, message long qui défile
2026-11-08 15:21:52 +01:00
nicolas 62011342ec Menu d’administration : plein écran et responsive
Message : Menu admin : plein écran, bandeaux pleine largeur, pages en deux colonnes qui défilent sur petit écran
2026-11-08 15:21:13 +01:00
nicolas 0f93b784f5 Fenêtre principale : plein écran et responsive
Message : Fenêtre principale : bouton plein écran, colonnes fluides, présentation du jeu qui défile
2026-11-08 15:20:44 +01:00
nicolas 3ad27965fd Plein écran : socle réutilisable
Message : Plein écran : comportement WindowModeAssist (F11, mode mémorisé), mise en page responsive ScaleToFitDecorator
2026-11-08 15:20:17 +01:00
nicolas 69e710d3b7 Audit : nom de l’appareil défini à l’invitation au lieu du nom Windows
Message : Audit : afficher le nom de l'appareil choisi à l'invitation, ne plus envoyer le nom Windows du PC
2026-11-08 15:19:04 +01:00
fred d8219acaab Merge pull request 'Dev/2.4.1' (#122) from dev/2.4.1 into master
Reviewed-on: #122
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-07 20:59:39 +01:00
nicolas 987dab286b Version 2.4.1 2026-11-07 20:54:40 +01:00
nicolas 6592b09d14 docs: avertissement avant publication (trousseau des joueurs) 2026-11-07 20:48:30 +01:00
nicolas 35b47b1b9d test(admin): avertissement avant publication signée 2026-11-07 20:48:17 +01:00
nicolas b67636e29b feat(launcher): branchement du contrôle du trousseau dans le menu d'administration 2026-11-07 20:47:58 +01:00
nicolas db37cd9d46 feat(admin): avertissement clé non reconnue (pages Jeux et Publier) 2026-11-07 20:47:43 +01:00
nicolas ffbd98d800 feat(admin): contrôle du trousseau des joueurs avant publication 2026-11-07 20:47:15 +01:00
fred c281ca1781 Merge pull request 'Dev/2.4.0' (#121) from dev/2.4.0 into master
Reviewed-on: #121
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-07 18:46:20 +01:00
nicolas dbb843b1bb Version 2.4.0 2026-11-07 18:32:24 +01:00
nicolas 745a9f4f63 UiSnapshot : la dernière capture montre le bas de la page 2026-11-07 18:26:24 +01:00
nicolas 314703d7e5 Groupes et permissions : formulaire de création adapté à la colonne de droite 2026-11-07 18:26:02 +01:00
nicolas dd661210a5 Page Jeux : aide sur l'emplacement de l'icône du jeu 2026-11-07 18:13:41 +01:00
nicolas fb0f70a4ff Fichiers du serveur : la navigation dans les dossiers garde la position de l'ascenseur 2026-11-07 18:13:28 +01:00
nicolas 735a5c0a3e Menu admin : colonnes à ascenseurs séparés (Jeux, Notes de version, Groupes) 2026-11-07 18:12:57 +01:00
nicolas 3b8492d5fb Version 2.4.0 2026-11-07 17:27:36 +01:00
nicolas 7529519684 Docs : groupes et permissions, envoi des images ; déploiement (étape 3 septies)
- nginx : bloc location ^~ /api/v1/catalog/images/ (client_max_body_size 5m)
- LISEZMOI : sauvegarde d'admins.json avant la mise à jour, bloc nginx, dossier
  catalog/images
- Architecture : §7.1 Groupes et permissions, §11 quater Images, phase 7c
- README : pages Groupes et permissions et Jeux (« Parcourir… »)
2026-11-07 17:26:51 +01:00
nicolas 1b58860347 Tests : groupes, permissions et images du catalogue
Groupes par défaut, groupe personnalisé, effet immédiat, conflit de révision,
protections (système, membres, élévation, propre groupe), permissions
personnalisées, signature exigée, migration du format 1 ; images (stockage,
refus, droits, taille, dossier absent, vérification côté joueur) ; pages
Groupes, Administrateurs et Jeux.
2026-11-07 17:26:28 +01:00
nicolas 5647ba0cfb Outils : noms de groupes dans AdminKeyTool, nouvelles routes simulées dans UiSnapshot 2026-11-07 17:26:03 +01:00
nicolas be2c4fe17d Interface : page Groupes et permissions, boutons Parcourir et aperçu des images
- Modèles de la page « Groupes et permissions » (liste, fiche, interrupteurs de
  permissions, liste des permissions, création) et sa barre d'actions
- Page Jeux : « Parcourir… », vider, aperçu de l'icône et du fond
- Convertisseurs BytesToImage (décodage en mémoire, réduit) et NullToVisibility
- « Rôle » devient « Groupe » dans la page Administrateurs
2026-11-07 17:25:47 +01:00
nicolas a29f688bdb Menu admin : « Parcourir… » pour l'icône et l'image de fond (sans client FTP)
Fichier lu avec une taille bornée, contrôlé sur le PC avec les mêmes règles que le
serveur, envoyé (requête signée), adresse vérifiée (https, hôte du catalogue,
empreinte du fichier) puis renseignée dans le champ ; aperçu ; bouton pour vider.
Appliqué aux joueurs à la publication du catalogue.
2026-11-07 17:25:29 +01:00
nicolas be5c409e2f Menu admin : page « Groupes et permissions »
- Nouvelle page : liste des groupes, fiche (nom, description, permissions à cocher),
  nouveau groupe, suppression, permissions personnalisées (création, suppression) ;
  cases grisées avec la raison quand le serveur refuserait ; confirmation renforcée
  pour admins.manage
- Administrateurs : groupes venant du serveur, seuls ceux attribuables sont proposés ;
  « rôle » devient « groupe »
- Nom du groupe affiché (barre latérale, tableau de bord, Mes appareils, invitations)
- Signature : autorisations du trousseau d'après les permissions du groupe
- Journal d'audit : libellés des nouvelles actions
2026-11-07 17:25:09 +01:00
nicolas 5b090a74ed Client API : groupes, permissions et envoi d'images du catalogue
GetGroupsAsync, CreateGroupAsync, UpdateGroupAsync, DeleteGroupAsync,
CreatePermissionAsync, DeletePermissionAsync, UploadCatalogImageAsync
(identifiants contrôlés avant l'envoi).
2026-11-07 17:23:54 +01:00
nicolas dc55ab4843 Launcher : vérifie l'empreinte des images du catalogue envoyées depuis le menu
Une image dont le nom est une empreinte SHA-256 est refusée si son contenu ne
correspond pas : comme son adresse est dans le catalogue signé, l'image est couverte
par cette signature.
2026-11-07 17:23:30 +01:00
nicolas e6cd32aa15 Serveur : envoi des images du catalogue depuis le menu d'administration
- PUT v1/catalog/images/{icon|background} : games.edit, requête signée, 4 Mo au plus
  (limite propre à la route)
- CatalogImageStore : contrôle complet avant écriture, nom <sha256>.png|.jpg dans
  catalog/images, écriture atomique en 0644, dossier symbolique refusé,
  300 images / 300 Mo au plus, espace disque vérifié
- PublicFileWriter : écriture de contenu binaire
- Audit catalog.image_upload (refus compris)
2026-11-07 17:23:13 +01:00
nicolas d98bac7eaf Serveur : groupes d'administrateurs et permissions personnalisées
- admins.json format 2 (groupes, permissions personnalisées) ; un fichier au format 1
  est converti à la lecture (groupes par défaut avec les droits des anciens rôles)
- Les permissions du groupe sont relues à chaque requête (effet immédiat)
- Module Groups : lister, créer, modifier (révision attendue), supprimer les groupes ;
  créer et supprimer les permissions personnalisées ; tout est signé et audité
- AdminRules, appliqué sous verrou : pas d'élévation de droits ; seul un propriétaire
  donne, retire ou gère le groupe Propriétaire ; on ne gère pas un administrateur
  plus privilégié que soi ; on ne modifie pas les permissions de son propre groupe
- Groupe Propriétaire non modifiable ; groupe avec membres ou invitations non supprimable
- CLI list : affiche les noms et permissions des groupes
2026-11-07 17:22:49 +01:00
nicolas 2da7d11bd0 Contrats : groupes d'administrateurs, permissions et images du catalogue
- AdminGroups : règles des groupes (identifiant, nom, description), permissions
  personnalisées « module.action », groupe système Propriétaire
- AdminPermissions : libellés et descriptions des permissions intégrées, IsBuiltIn
- AdminRoles : devient la liste des groupes par défaut (permissions initiales)
- DTO des groupes (GroupSummary, PermissionSummary, GroupsState, GroupRequest,
  CreatePermissionRequest) ; RoleName et Permissions ajoutés au profil, aux
  administrateurs et aux invitations
- CatalogImages : contrôle PNG/JPEG au contenu, taille, dimensions, fichier complet ;
  nom = empreinte SHA-256
- Routes v1/groups, v1/permissions, v1/catalog/images/{kind}
2026-11-07 17:21:35 +01:00
nicolas 94cf7147e3 Merge pull request 'Dev/2.3.0' (#118) from dev/2.3.0 into master
Reviewed-on: #118
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 11:54:14 +01:00
nicolas 5ac3d153ab Version 2.3.0 2026-11-07 11:50:35 +01:00
nicolas 7d26a4d59c docs: pages multi-jeu 2026-11-07 11:40:26 +01:00
nicolas 924735a0dc chore(uisnapshot): captures avec choix du jeu 2026-11-07 11:40:09 +01:00
nicolas d5a992ad62 test: pages multi-jeu 2026-11-07 11:39:50 +01:00
nicolas e2523d1bff feat(ui): carte de choix du jeu, liste « Jeu » et texte indicatif des listes déroulantes
src/GameLauncher/Themes/Admin.xaml : style des tuiles, modèles de choix et de changement de jeu, utilisés par les deux pages, texte indicatif du nettoyage.
src/GameLauncher/Themes/Controls.xaml : texte indicatif des listes déroulantes vides.
2026-11-07 11:39:33 +01:00
nicolas d444940b63 feat(admin): choix du jeu obligatoire pour les pages Notes de version et Publier une version
src/GameLauncher.Modules.Admin/ViewModels/GameTile.cs (nouveau) : tuiles et règle du jeu choisi d’office.
src/GameLauncher.Modules.Admin/Changelog/ChangelogPanelViewModel.cs
src/GameLauncher.Modules.Admin/Publishing/PublishPanelViewModel.cs
src/GameLauncher.Modules.Admin/Files/FilesPanelViewModel.cs
2026-11-07 11:39:04 +01:00
nicolas 8e69ec108e Merge pull request 'Dev/2.2.0' (#117) from dev/2.2.0 into master
Reviewed-on: #117
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 11:04:50 +01:00
nicolas 84bcb80108 Version 2.2.0 2026-11-07 11:02:29 +01:00
nicolas e9f650ef9e docs: traçabilité des consultations et exports du journal 2026-11-07 10:53:55 +01:00
nicolas 27b57526ee chore(uisnapshot): route d'export et exemples de lignes 2026-11-07 10:53:44 +01:00
nicolas 3996f580f6 test: consultations et exports du journal d'audit 2026-11-07 10:53:31 +01:00
nicolas ca0eb044e8 feat(admin): l'export CSV est annoncé au serveur avant l'écriture du fichier
src/GameLauncher.ApiClient/AdminSession.cs : nouvelle méthode RecordAuditExportAsync.
src/GameLauncher.Modules.Admin/Audit/AuditPanelViewModel.cs : annonce de l’export avant l’écriture du fichier, libellés des deux nouvelles actions et nouvelle catégorie de filtre.
2026-11-07 10:53:13 +01:00
nicolas 5b0999e47c feat(server): consultations et exports du journal d'audit inscrits au journal
src/GameLauncher.Contracts/Dtos/AuditDtos.cs : description de l’export (nombre d’entrées, période, filtres) et ses limites.
src/GameLauncher.Contracts/ApiRoutes.cs : nouvelle route v1/audit/export.
server/GameLauncher.Server/Modules/Audit/AuditModule.cs : inscription des consultations (avec la règle des 10 minutes pour les pages suivantes) et des exports, avec contrôle des données reçues.
2026-11-07 10:52:46 +01:00
nicolas 9f6210598c Version 2.2.0 2026-11-07 10:14:42 +01:00
nicolas 08e1476726 docs: Fichiers du serveur, période de l'audit, déploiement 2026-11-07 10:13:39 +01:00
nicolas 818716b7a9 test: Fichiers du serveur, nettoyage, période de l'audit 2026-11-07 10:13:13 +01:00
nicolas 4971c926ac chore(uisnapshot): fausses routes Fichiers et nettoyage 2026-11-07 10:12:50 +01:00
nicolas 99502a2003 feat(ui): barre d'actions fixe, molette sûre, textes indicatifs, page Fichiers 2026-11-07 10:12:28 +01:00
nicolas 81108699c3 feat(admin): retour en haut après une action, période de l'audit, page Fichiers 2026-11-07 10:11:56 +01:00
nicolas 7cce8676b4 feat(server): page Fichiers du serveur, nettoyage d'objects, filtre de date de l'audit 2026-11-07 10:10:05 +01:00
nicolas 0f8f64f00f Merge pull request 'Dev/2.1.0' (#115) from dev/2.1.0 into master
Reviewed-on: #115
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 08:53:25 +01:00
nicolas 68ada960cd Note de version 2.1.0 2026-11-07 08:51:08 +01:00
nicolas b08cad3e8e Ajout du dossier Claude outputs 2026-11-07 08:50:56 +01:00
nicolas 42fc1e58cc Version 2.1.0 2026-11-07 08:45:39 +01:00
nicolas b2f1415bff docs: raccourci, nom du launcher, blocage TPM
README.md : nom du raccourci, où apparaît le nom du launcher, comment le blocage TPM est lu.
2026-11-07 08:45:15 +01:00
nicolas d499bfb11a feat(raccourci): le raccourci du Bureau suit le nom affiché du jeu
src/GameLauncher/Services/ShortcutService.cs : nom affiché, renommage sans écrasement, repli sur le nom de launcher.json, suppression des raccourcis nommés par le launcher à la désinstallation.
src/GameLauncher/Services/Interfaces.cs : ajout du nom du raccourci, du renommage et du commentaire du raccourci.
src/GameLauncher/ViewModels/GameViewModel.cs : « Réparer » renomme le raccourci et le signale ; le bouton « Mettre à jour » affiche le nouveau nom.
src/GameLauncher.Modules.Admin/Catalog/CatalogEditors.cs : texte d’aide du champ « Nom affiché ».
src/GameLauncher.Core/Models/LauncherConfig.cs, src/GameLauncher.Core/Catalog/CatalogMerger.cs, src/GameLauncher.Contracts/Catalog/CatalogDocument.cs : commentaires du code mis à jour.

tools/UiSnapshot/Fakes/FakeServices.cs. J’y ai ajouté les deux membres demandés par l’interface :

ShortcutName : renvoie un nom fixe (« Exovival Zombie Reborn ») ;
RenameDesktopShortcuts() : renvoie 0 (aucun raccourci renommé).
2026-11-07 08:45:00 +01:00
nicolas bddd46e9bb fix(admin): la puce TPM n'est plus vue comme toujours bloquée
src/GameLauncher.Modules.Admin/Services/IAdminTpm.cs : nouvelle lecture de l’état de la puce (bon indicateur de blocage, contrôles de la réponse).
src/GameLauncher/Platform/TpmKeyStore.cs : utilise cette lecture ; l’ancienne, fautive, est supprimée.
tests/GameLauncher.Tests/Admin/AdminTpmTests.cs : 3 tests (PC normal non bloqué ; blocage détecté par l’indicateur ou par le compteur ; réponses en erreur ou incomplètes).
2026-11-07 08:43:54 +01:00
nicolas b774ca119f fix(admin): listes lisibles et sans fond blanc pendant le rechargement
src/GameLauncher/Themes/Admin.xaml : nouveau style de liste, couleur de texte des titres (jeux, versions, clés). Ce fichier contient aussi le texte d’aide « Nom du launcher » (point 2). Garde-le dans ce commit, ou indexe ces lignes à part.
src/GameLauncher/Views/Admin/AdminWindow.xaml : la navigation utilise le nouveau style.
src/GameLauncher/Views/Admin/LoginView.xaml : la liste des clés utilise le nouveau style.
2026-11-07 08:43:09 +01:00
nicolas fbfb456b5d Merge pull request 'Dev/2.0.0' (#114) from dev/2.0.0 into master
Reviewed-on: #114
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 08:01:45 +01:00
nicolas 15b234995b Ajout : tools/UiSnapshot/ (projet UiSnapshot.csproj, Program.cs, SnapshotOptions.cs, SnapshotRunner.cs, Capture.cs, Report.cs, Poke.cs, Fixture.cs, Fakes/FakeApi.cs, Fakes/FakeServices.cs, Fakes/SampleData.cs, Scenes/PlayerScenes.cs, Scenes/AdminScenes.cs, LISEZMOI.md)
Ajout : tools/ui-snapshot.bat
Modif : GameLauncher.sln — projet UiSnapshot dans le dossier tools (configurations Debug/Release, Any CPU/x64), ui-snapshot.bat dans Solution Items
Modif : src/GameLauncher/Views/MainWindow.xaml et Views/Admin/AdminWindow.xaml — l’icône utilise l’adresse complète pack://application:,,,/GameLauncher;component/Assets/launcher.ico, comme Default.xaml. Aucun effet sur le launcher, et l’outil la trouve même depuis un autre exécutable.
Modif : README.md — l’outil est ajouté au §2 (structure) et au §5 (personnalisation).
2026-11-07 07:58:27 +01:00
fred 3eada0b878 Merge pull request 'Admin/phase7' (#113) from admin/phase7 into dev/2.0.0
Reviewed-on: #113
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-01 21:40:51 +01:00
nicolas 92ae5cbe63 Version 2.0.0 2026-11-01 21:37:38 +01:00
nicolas 86593a43ee Montée en version 2.0.0 2026-11-01 21:26:15 +01:00
nicolas 8a7902e5e8 Version 2.0.0 2026-11-01 21:04:06 +01:00
nicolas 50ef1025d8 Changelog version 2.0.0 2026-11-01 20:54:52 +01:00
nicolas bcae86f1a1 Documentation : phase 7 (signature de bout en bout)
README.md, docs/ADMIN-ARCHITECTURE.md, server/deploy/LISEZMOI.md
2026-11-01 20:54:38 +01:00
nicolas bcde2a0dac Tests : signature du contenu, trousseau, page Signature, module serveur
tests/GameLauncher.Tests/Signing/SigningKit.cs, ContentSigningTests.cs, ContentTrustTests.cs (nouveaux)
Server/SigningModuleTests.cs, Admin/SigningPanelTests.cs (nouveaux)
Admin/AdminServicesTests.cs, Admin/AdminTestKit.cs
2026-11-01 20:54:11 +01:00
nicolas 86fef875a6 Outils : sign-keyring (signature hors ligne du trousseau, comparaison au précédent)
tools/LauncherPublisher/Program.cs, tools/sign-keyring.bat (nouveau), GameLauncher.sln
2026-11-01 20:53:13 +01:00
nicolas 1b397ec930 Launcher : configuration contentSigning/keyringMinSequence, blocage par sécurité, vues de la page Signature
src/GameLauncher/App.xaml.cs, Platform/LauncherUpdateService.cs, Services/AdminWindowService.cs, Themes/Admin.xaml, ViewModels/GameViewModel.cs, launcher.json
2026-11-01 20:52:45 +01:00
nicolas c89bd62aff Launcher (joueurs) : vérification du trousseau, du manifest et du catalogue (contentSigning)
src/GameLauncher.Core/Trust/ContentTrustService.cs (nouveau)
Models/GameManifest.cs, Models/LauncherCatalog.cs, Services/ManifestClient.cs, Catalog/CatalogService.cs
2026-11-01 20:51:37 +01:00
nicolas f556848da4 Menu d’administration : page Signature, signature à la publication, clé racine refusée à la connexion
src/GameLauncher.ApiClient/AdminSession.cs
src/GameLauncher.Modules.Admin/Signing/SigningAdminModule.cs, SigningPanelViewModel.cs (nouveaux)
Publishing/ReleaseUploader.cs, Publishing/PublishPanelViewModel.cs, Catalog/GamesPanelViewModel.cs, Accounts/AccountItems.cs, Services/AdminLoginService.cs, ViewModels/LoginViewModel.cs, ViewModels/AdminEnvironment.cs, ViewModels/AdminWorkspaceViewModel.cs
src/GameLauncher.Modules/Admin/IAdminContext.cs
2026-11-01 20:50:31 +01:00
nicolas 1cf9e87e82 Serveur : publication du trousseau, vérification des signatures du manifest et du catalogue
Nouveaux : server/GameLauncher.Server/Modules/Signing/SigningModule.cs, Security/ContentSignatures.cs, Storage/KeyringStore.cs, Storage/ManifestSignatures.cs, Storage/PublicFileWriter.cs
Modifiés : Hosting/IApiModule.cs, Hosting/LauncherApiOptions.cs, Storage/CatalogStore.cs, Storage/PublicationStore.cs, Modules/Catalog/CatalogModule.cs, Modules/Publishing/PublishingModule.cs, Modules/Admins/AdminsModule.cs, appsettings.json
server/deploy/nginx-catalog.conf
2026-11-01 20:48:51 +01:00
nicolas dc09c3ecef Contrats : trousseau des clés et signature du contenu (phase 7)
src/GameLauncher.Contracts/Signing/ContentSigning.cs, Keyring.cs, SigningDtos.cs (nouveaux)
Security/SignedMessages.cs, ApiRoutes.cs, Catalog/CatalogDocument.cs, Dtos/AdminDtos.cs
2026-11-01 20:46:19 +01:00
nicolas cbcc0b4f44 Documentation : phase 6 (administrateurs et journal d'audit) 2026-11-01 14:45:25 +01:00
nicolas d0db02d7f3 Tests : pages Mes appareils, Administrateurs et Journal d'audit
tests/GameLauncher.Tests/Admin/AccountsPanelTests.cs (nouveau, 4 tests)
tests/GameLauncher.Tests/Admin/AuditPanelTests.cs (nouveau, 2 tests)
2026-11-01 14:45:03 +01:00
nicolas 63c2e37dad Launcher : vues des pages Mes appareils, Administrateurs et Journal d'audit, enregistrement des modules
src/GameLauncher/Themes/Admin.xaml : modèles des trois pages, plus les liens du tableau de bord.
src/GameLauncher/Themes/Icons.xaml : icônes Laptop et AccountRemove.
src/GameLauncher/App.xaml.cs : AccountsAdminModule et AuditAdminModule.
2026-11-01 14:44:43 +01:00
nicolas 78780c20f1 Tableau de bord : liens vers « Administrateurs » et « Mes appareils » 2026-11-01 14:44:16 +01:00
nicolas 3b95317d4c Administration : page « Journal d'audit » (filtres, pages, export CSV) 2026-11-01 14:44:01 +01:00
nicolas bb165c4be0 Administration : pages « Mes appareils » et « Administrateurs » (ViewModels)
src/GameLauncher.Modules.Admin/Accounts/AccountItems.cs (nouveau) : base commune, appareils, invitations, code affiché une seule fois.
src/GameLauncher.Modules.Admin/Accounts/AdminsPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/MyDevicesPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/AccountsAdminModule.cs (nouveau)
2026-11-01 14:43:38 +01:00
nicolas d2cb48b901 Serveur : route « mon compte », suppression de ses propres invitations « nouvel appareil », audit détaillé
server/GameLauncher.Server/Modules/Admins/AdminsModule.cs
tests/GameLauncher.Tests/Server/AdminManagementTests.cs : nouveau test (son compte, ses invitations, droits de suppression, journal).
2026-11-01 14:43:01 +01:00
nicolas e06ddacf04 Contrats et client : compte de l'administrateur connecté (GET v1/admins/me), auteur des invitations
src/GameLauncher.Contracts/ApiRoutes.cs : route MyAccount.
src/GameLauncher.Contracts/Dtos/AdminDtos.cs : MyAccount, et InvitationSummary.CreatedById.
src/GameLauncher.ApiClient/AdminSession.cs : GetMyAccountAsync
2026-11-01 14:39:59 +01:00
nicolas 2e4b779be5 Déploiement : dossiers objects/00 à ff créés sur le VPS avec le bit setgid 2026-11-01 13:36:14 +01:00
nicolas 10558a6e5f Documentation : création des dossiers objects sous systemd 2026-11-01 12:52:36 +01:00
nicolas 54941a1009 Tests : droits des dossiers objects, test du bandeau d'envoi interrompu stabilisé
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : nouveau test sur les droits des dossiers objects/ et objects/xx.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : test du bandeau rendu stable (le refus 429 tombe sur un petit envoi).
tests/GameLauncher.Tests/Server/ChangelogModuleTests.cs : le faux dossier du jeu ne redemande pas le setgid s’il l’a déjà.
2026-11-01 12:52:25 +01:00
nicolas bd8b76015f Serveur : dossiers objects créés sans demander le bit setgid (RestrictSUIDSGID de systemd) 2026-11-01 12:51:55 +01:00
nicolas 0d3d76a68e Déploiement : zone nginx publication_par_ip, guide et architecture
server/deploy/nginx-publications.conf
server/deploy/LISEZMOI.md
docs/ADMIN-ARCHITECTURE.md
2026-11-01 12:27:08 +01:00
nicolas 8862047e36 Tests : limite des envois, délais d'attente, bandeau d'envoi interrompu:
tests/GameLauncher.Tests/Infrastructure/TestApi.cs : valeur par défaut de la nouvelle limite.
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : deux tests.
Le premier vérifie qu’un envoi n’est pas freiné par la limite générale, et que les refus indiquent le délai d’attente.
Le second vérifie le calcul des attentes.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : un test vérifie que le bandeau n’apparaît pas pendant l’envoi, que la pause est affichée, qu’on peut interrompre pendant la pause puis abandonner.
2026-11-01 12:26:52 +01:00
nicolas 25c87d672e Publication : bandeau « interrompu » masqué pendant l'envoi 2026-11-01 12:25:58 +01:00
nicolas d687414872 Publication : pauses courtes après un refus (Retry-After), pause affichée sur la page 2026-11-01 12:25:42 +01:00
nicolas fb71661f17 Serveur : limite de requêtes propre aux envois de publication, Retry-After sur les refus 429 2026-11-01 12:25:04 +01:00
nicolas 19ccd3bf9a Version 1.8 du jeu 2026-11-01 11:59:29 +01:00
nicolas 370443f3d4 Commandes : notification des boutons renvoyée sur le fil de l'interface, tests 2026-11-01 11:59:13 +01:00
nicolas 213cb9a533 Publication : avancement et ouverture rappelés sur le fil de l'interface 2026-11-01 11:58:39 +01:00
nicolas dc740e0452 Documentation : phase 5 (publication d'une version) 2026-10-31 20:17:22 +01:00
nicolas 72e979afd6 Déploiement : routes d'envoi dans nginx, guide du VPS 2026-10-31 20:17:02 +01:00
nicolas 1d5f85a77c Launcher : page « Publier une version » (vues, icônes) et enregistrement du module 2026-10-31 20:16:38 +01:00
nicolas 629fb600e9 Administration : module « Publication » (contrôle du dossier, envoi avec reprise, opération longue) 2026-10-31 20:16:04 +01:00
nicolas 9455277c25 Client API : requêtes d'envoi signées, délai par requête 2026-10-31 20:14:47 +01:00
nicolas f1032df74a Serveur : module Publishing (envoi par morceaux, reprise, mise en ligne, audit) 2026-10-31 20:14:27 +01:00
nicolas a341bc641a Serveur : fichiers publics des jeux communs aux notes de version et à la publication 2026-10-31 20:13:55 +01:00
nicolas 9b544cbc03 Contrats : publication d'une version (routes, échanges, contrôle des manifests) 2026-10-31 20:12:46 +01:00
nicolas 001ab82fda VErsion 1.7 du jeu 2026-10-31 17:51:29 +01:00
nicolas 67f4c02efc Documentation : phase 4 (notes de version) 2026-10-31 11:55:23 +01:00
nicolas 8808fe2615 Déploiement : notes de version modifiables sur le VPS (complément systemd, guide) 2026-10-31 11:55:09 +01:00
nicolas bd5b9d46ca Launcher : page « Notes de version » (vues) et enregistrement du module 2026-10-31 11:54:45 +01:00
nicolas 688b726e24 Administration : module « Notes de version » (éditeur, aperçu, enregistrement) 2026-10-31 11:54:21 +01:00
nicolas 929b27dbeb Client API : lecture et modification des notes de version 2026-10-31 11:53:48 +01:00
nicolas 090d8c64ec Serveur : module Changelog (notes d'une version dans le manifest publié, empreinte, audit) 2026-10-31 11:53:28 +01:00
nicolas 93f3ba630c Notes de version : modèle partagé dans Contracts, historique en ligne pour ManifestBuilder (--remote-manifest) 2026-10-31 11:52:25 +01:00
nicolas 89775bd73b Documentation : phase 3 (catalogue des jeux) 2026-10-31 10:34:13 +01:00
nicolas 944e47dea7 Déploiement : catalogue sur le VPS (complément systemd, nginx, guide) 2026-10-31 10:33:56 +01:00
nicolas c44c9fe3ef Launcher : catalogue au démarrage, fiche du jeu (description, liens), vues de la page « Jeux » 2026-10-31 10:33:25 +01:00
nicolas 6d44b5680a Administration : module « Catalogue des jeux » et page « Jeux » (ViewModels) 2026-10-31 10:32:30 +01:00
nicolas a6b2f2d6bb Core : catalogue côté joueur (téléchargement borné, cache, images, application) 2026-10-31 10:31:31 +01:00
nicolas e888b4037b Client API : lecture et publication du catalogue 2026-10-31 10:30:31 +01:00
nicolas 417dd78df5 Serveur : module Catalog (GET/PUT v1/catalog, fichier public, révisions, audit) 2026-10-31 10:30:12 +01:00
nicolas c8836a9baf Contrats : format et règles du catalogue des jeux (catalog.json) 2026-10-31 10:27:51 +01:00
nicolas e887a91d9b Docs : suppression et blocage TPM 2026-10-30 21:43:02 +01:00
nicolas c1ad053861 Tests : suppression et blocage TPM 2026-10-30 21:42:51 +01:00
nicolas 5195398943 Admin : aide en cas de blocage de la puce (essais restants, décompte) 2026-10-30 21:42:43 +01:00
nicolas fd90c436e7 Admin : suppression des clés TPM 2026-10-30 21:42:20 +01:00
nicolas ca194c2bf8 Docs : phase 2b 2026-10-30 21:08:32 +01:00
nicolas 4b7b7bb83c Tests : clés TPM 2026-10-30 21:08:21 +01:00
nicolas 270e311fb4 Launcher : puce TPM (Windows) et écrans 2026-10-30 21:08:07 +01:00
nicolas 80d0eb93c5 Admin : connexion et invitation avec une clé TPM 2026-10-30 21:07:30 +01:00
nicolas 6d4219228e Admin : contrat et fichier de référence des clés TPM 2026-10-30 21:06:26 +01:00
nicolas 20991ddc61 Module Administration 2026-10-30 19:57:44 +01:00
nicolas fa01962d03 Ils ont été déplacés et ne sont plus utiles 2026-10-30 19:57:12 +01:00
nicolas 53d87f3cc6 Docs : phase 2 2026-10-30 19:56:43 +01:00
nicolas dc18e01540 Tests : administration 2026-10-30 19:55:59 +01:00
nicolas 723da51c65 Launcher : menu d’administration (WPF) 2026-10-30 19:55:30 +01:00
nicolas cf3e3f5b57 Module Administration 2026-10-30 19:54:10 +01:00
nicolas 7c7e12f694 Configuration : apiUrl 2026-10-30 19:53:41 +01:00
nicolas 950d6acc4d Client API : clé et session 2026-10-30 19:53:01 +01:00
nicolas 39dd8300e4 Modules : socle commun 2026-10-30 19:52:44 +01:00
nicolas 920d215fdd docs(admin): corrections du document d'architecture
- § 6.2 : ligne de vérification en double retirée, ordre aligné sur le code
- § 8 : codes d'invitation de 160 bits (et non 128)
2026-10-25 21:43:17 +01:00
nicolas 22468122e0 Vesion 1.6 du jeu 2026-10-25 21:02:25 +01:00
nicolas c2c67a63f2 docs(readme): nouveaux projets et section « 8. Administration (Launcher API) » 2026-10-25 21:02:03 +01:00
nicolas d5aa875178 build: ajout des nouveaux projets à la solution
Contracts, ApiClient, Server, AdminKeyTool, Tests ; dossiers de solution
server, deploy et tests ; architecture et scripts en éléments de solution.
2026-10-25 21:01:55 +01:00
nicolas b9fea61e64 test: 84 tests du protocole et de la Launcher API
Unitaires (clés, empreintes, messages signés, invitations, rôles) et de bout
en bout contre un serveur démarré en mémoire : connexion, rejeu, expiration,
requêtes falsifiées, invitations, approbation, droits, révocation, limites,
HTTPS, audit, CLI.
2026-10-25 21:01:39 +01:00
nicolas 14a76927e1 feat(tools): AdminKeyTool, clé d'administrateur et gestion en ligne de commande
- create, fingerprint, login-test, enroll
- admins, invite, approve, role, revoke-key, revoke-admin, audit
- admin-key.bat : double-clic = création de la clé
2026-10-25 21:01:07 +01:00
nicolas 5e68c4b16a feat(deploy): installation de la Launcher API sur le VPS
- Service systemd durci (compte deploy, sans connexion sortante)
- Bloc nginx /api/ avec limite de requêtes
- installer-api.sh : empreinte SHA-256 obligatoire, retour arrière automatique
- publish-server.bat : publication linux-x64 autonome + archive + empreinte
- Guide pas à pas : installation, premier propriétaire, récupération
2026-10-25 21:00:37 +01:00
nicolas e5b9ebf3ff feat(server): Launcher API, serveur d'administration (phase 1)
- Architecture en modules (System, Auth, Admins, Audit)
- Connexion par défis sans état scellés par HMAC, à usage unique
- Sessions en mémoire (20 min d'inactivité, 8 h max)
- Toute modification signée par la clé de l'admin (anti-rejeu, horloge ±2 min)
- Invitations, enrôlement avec preuve de possession, approbation par empreinte
- Nouvel appareil approuvé uniquement par le titulaire du compte
- Rôles, révocation, au moins un propriétaire conservé, droits revérifiés
  au moment d'écrire
- Audit mensuel lu à rebours, tentatives anonymes regroupées
- Limites par IP (/64 en IPv6), HTTPS exigé derrière nginx, en-têtes de sécurité
- admins.json atomique, verrou partagé avec la CLI, fichiers en 600
- CLI : add-owner, add-key, list, revoke-admin, revoke-key, server-id
2026-10-25 21:00:05 +01:00
nicolas fecf95ae3e feat(api-client): client de la Launcher API
- Connexion par défi signé, session en mémoire, requêtes signées
- Fichier de clé chiffré (AES-256, PBKDF2 600 000 itérations)
- HTTPS obligatoire, aucune redirection, jeton limité à l'adresse de l'API
- Heure calée sur le serveur (écart limité à 1 h), réponses bornées
2026-10-25 20:58:30 +01:00
nicolas 3a788c18d2 feat(contracts): protocole partagé launcher ⇄ Launcher API
- Routes, objets échangés, codes d'erreur, options JSON strictes
- Rôles (propriétaire, administrateur, éditeur) et permissions module.action
- Clés ECDSA P-256 : contrôle, signature, vérification, empreinte 80 bits
  séparée de l'identifiant de clé
- Messages signés séparés par usage (connexion, enrôlement, requête)
- Codes d'invitation 160 bits, nettoyage des textes saisis
2026-10-25 20:58:02 +01:00
nicolas dbe5d9051d docs: architecture du module d'administration et plan en 7 phases
Modèle de sécurité, authentification par clé publique/privée, rôles et
permissions, ajout d'administrateurs, séparation des informations fixes et
modifiables des jeux, modules launcher/serveur, phases du chantier.
2026-10-25 20:56:41 +01:00
nicolas ae2a5a4ab7 chore(git): fins de ligne LF pour les fichiers Linux, ignorer les clés privées
- .gitattributes : *.sh, *.service et server/deploy/*.conf en LF
- .gitignore : *.key (clés privées) et data-dev/ (données locales de l'API)
- preparer-vps.sh converti en LF (fins de ligne Windows incompatibles avec sh)
2026-10-25 20:56:23 +01:00
nicolas 9aa3876a0e Changelog 1.3.0 2026-10-25 12:11:21 +01:00
nicolas_gitea e8e08f792e Merge pull request 'Désinstallation : mêmes sécurités pour les dossiers Roaming et LocalLow' (#87) from dev/1.3.0 into master
Reviewed-on: #87
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-10-25 12:01:54 +01:00
nicolas_gitea c41bcda853 Merge branch 'master' into dev/1.3.0 2026-10-25 12:01:34 +01:00
nicolas e41c3a977e Désinstallation : mêmes sécurités pour les dossiers Roaming et LocalLow
- Sauvegardes (Roaming) et journaux (LocalLow) supprimés comme le
  dossier du jeu : fichier par fichier, fichiers du launcher conservés
  s'il s'y trouve, fichiers bloqués signalés sans interrompre.
- Règle commune : FileUtil.DeleteDirectoryContents (dossier du jeu,
  sauvegardes, journaux).
- Confirmation : « Le launcher se trouve dans le dossier des données du
  jeu : il sera conservé. » ; bandeau final : emplacement du launcher
  conservé, total des fichiers bloqués, dossiers refusés.
- README : section Désinstallation.
2026-10-25 11:59:43 +01:00
nicolas f5e6c99aca Merge pull request 'Dev/1.3.0' (#86) from dev/1.3.0 into master
Reviewed-on: #86
2026-10-25 11:51:09 +01:00
nicolas 35664045ce Désinstallation : « Supprimer aussi… » supprime les données du jeu
- L'option supprime désormais, en plus du dossier d'installation :
  * sauvegardes et réglages (%APPDATA%\<Société>\<Produit>) ;
  * journaux de Unity (AppData\LocalLow\<Société>\<Produit>) ;
  * réglages d'affichage de Unity (HKCU\Software\<Société>\<Produit>).
- Option proposée aussi quand les fichiers du jeu ont disparu.
- Garde-fous : chemin Société\Produit complet uniquement, jamais le
  dossier ni la clé du launcher ni le dossier d'installation ; dossier
  et clé de la société retirés seulement s'ils sont vides.
- Messages : emplacement des sauvegardes conservées, éléments non
  supprimés signalés.
- README : section Désinstallation.

Désinstallation : launcher conservé et fichiers bloqués tolérés

- Launcher dans le dossier du jeu (ou un sous-dossier) : signalé dans
  la confirmation et épargné, même avec « Supprimer aussi… » (exe,
  launcher.json, fichiers de mise à jour, DLL en développement) ;
  bandeau final indiquant où il a été conservé.
- Suppression fichier par fichier : un fichier bloqué n'interrompt
  plus la désinstallation ; fichiers non supprimés signalés (bandeau
  + journal), registre, raccourci et données du jeu traités ensuite.
- Nouveau LauncherSelfFiles (Core/Utils).
- README : section Désinstallation.
2026-10-25 11:44:03 +01:00
nicolas ad5d92ee8b Notes de version du jeu 1.5.1 : sauvegardes et réglages dans Roaming 2026-10-25 11:13:26 +01:00
nicolas 047ec0d1db Données du launcher : dossier et registre « GameLauncher »
- %APPDATA%\<Publisher>\GameLauncher (au lieu de <GameId>Launcher) :
  paramètres, mise à jour du launcher, journaux ; paramètres de chaque
  jeu dans Games\<GameId>\settings.json.
- Registre : HKCU\Software\<Publisher>\GameLauncher\Games\<GameId>
  (au lieu de HKCU\Software\<Publisher>\<GameId>).
- Déplacement automatique au démarrage depuis les emplacements de la
  1.2.1 (dossier renommé, clé copiée et vérifiée puis supprimée), sauf
  si un autre launcher est ouvert ou au redémarrage après mise à jour du
  launcher ; ancien emplacement utilisé en attendant.
- Nouveau réglage facultatif « dataFolder » (launcher.json), distinct de
  launcherId (identifiant de produit pour les mises à jour, inchangé).
- README : registre et fichiers.

Registre : LauncherPath unique à la racine de GameLauncher

- LauncherPath écrit une seule fois dans
  HKCU\Software\<Publisher>\GameLauncher (et non plus dans la clé de
  chaque jeu) ; anciennes valeurs par jeu supprimées ; retiré à la
  désinstallation du dernier jeu.
- Seul le launcher réellement ouvert (verrou d'instance unique) écrit
  dans le registre : un second launcher (« Le launcher est déjà
  ouvert » ou raccourci transmis) n'écrase jamais LauncherPath ni le
  bouton « Désinstaller » de Windows.
- README : schéma du registre.
2026-10-25 11:08:43 +01:00
nicolas 6243bd2574 Données du jeu : dossiers des journaux (LocalLow) et des données (Roaming)
- Paramètres du jeu › Informations :
  * « Ouvrir le dossier des journaux » ouvre le dossier LocalLow du jeu
    (Player.log de Unity) ;
  * nouveau bouton « Ouvrir le dossier des données » : sauvegardes et
    réglages du jeu dans %APPDATA%\LanisysGames\Exovival Zombie Reborn.
  Info-bulles avec le chemin ; boutons grisés si le dossier n'existe pas.
- launcher.json : nouveau réglage facultatif « gameDataFolder »
  (Société\Produit de Unity, par défaut publisher\gameName), validé.
- README : section « Données du jeu (Unity) ».
2026-10-25 10:38:10 +01:00
nicolas 7ee4d84dd2 Raccourci du Bureau : bouton dans les paramètres du jeu + icône du jeu
- Paramètres du jeu (menu « … ») : nouveau bloc « Raccourci » avec
  « Créer le raccourci » / « Mettre à jour » (action immédiate,
  résultat affiché, grisé si le jeu n'est pas installé).
- Icône du raccourci : celle de l'exécutable du jeu (emplacement lu
  dans le registre), sinon celle du launcher. Les raccourcis existants
  prennent l'icône du jeu au démarrage ; elle suit le jeu s'il est
  déplacé. Une icône personnalisée par le joueur est conservée.
- Explorateur notifié après chaque modification (icône à jour tout de
  suite).
- README : création du raccourci et icône.
2026-10-25 10:01:05 +01:00
nicolas f4ebb680ef Merge pull request 'Note de version 1.2.1' (#85) from dev/1.2.1 into master
Reviewed-on: #85
2026-10-25 09:46:03 +01:00
nicolas 756a76582d Merge branch 'master' into dev/1.2.1 2026-10-25 09:45:34 +01:00
nicolas b5b922019c Note de version 1.2.1 2026-10-25 09:43:41 +01:00
nicolas 7ee8da3e67 Merge pull request 'Dev/1.2.1' (#84) from dev/1.2.1 into master
Reviewed-on: #84
2026-10-25 09:40:20 +01:00
nicolas 0e8c918922 README : zone de notification 2026-10-25 09:32:12 +01:00
nicolas e2f2ae5817 Zone de notification : options « pendant la partie » et « croix »
- Paramètres du launcher :
  * Lancement du jeu › Réduire › « Dans la zone de notification » ;
  * nouveau bloc « Fermeture du launcher » : la croix (et Alt+F4)
    réduit dans la zone de notification au lieu de quitter.
  Options désactivées par défaut.
- Quitter depuis l'icône : confirmation si une opération est en cours.
- Raccourci du Bureau : réaffiche le launcher s'il est masqué.
- Fermeture normale à l'arrêt de Windows ; message « déjà ouvert »
  complété.
2026-10-25 09:32:00 +01:00
nicolas 9dda708c94 Zone de notification : icône du launcher et menu Ouvrir / Quitter
- Platform/TrayIcon : icône près de l'horloge (API Shell_NotifyIcon,
  sans Windows Forms), affichée quand la fenêtre est masquée. Clic
  gauche : ouvrir ; clic droit : menu. Recréée si l'Explorateur
  redémarre.
- Views/TrayMenuWindow : menu Ouvrir / Quitter au style du launcher,
  fermé en cliquant ailleurs, avec Échap ou après un choix.
- IWindowService.HideToTray() ; Restore() réaffiche une fenêtre masquée.
- Icônes WindowRestore et ExitToApp.
2026-10-25 09:31:14 +01:00
fred a17f0efcd7 Merge pull request 'Dev/1.2' (#82) from dev/1.2 into master
Reviewed-on: #82
Reviewed-by: Deville <fred@lanisys.fr>
2026-10-24 20:19:40 +02:00
nicolas 5472522429 Notes de version du launcher 1.2.0
- Lancement du jeu depuis l'icône du Bureau, launcher fermé ou déjà
  ouvert (réglages Réduire / Fermer respectés).
- Réparation des raccourcis après un déplacement du launcher.
2026-10-24 17:31:49 +02:00
nicolas a443433a58 Version 1.2.0 2026-10-24 17:21:31 +02:00
nicolas 5a1985a2b5 Raccourci du Bureau : lance le jeu même si le launcher était fermé
- Lancé avec --game, le launcher vérifie les mises à jour du jeu
  (et les installe si la mise à jour automatique est activée), puis
  lance le jeu s'il est prêt, en respectant les réglages « Réduire le
  launcher pendant la partie » et « Fermer le launcher au lancement
  du jeu ». Sinon, le bandeau explique pourquoi.
- Nouvel argument interne --launch : après une mise à jour du launcher
  faite au démarrage depuis un raccourci, la nouvelle version lance le
  jeu. Après une mise à jour demandée depuis les paramètres, le jeu
  n'est pas lancé.
- README : raccourcis du Bureau et options --game / --updated.
2026-10-24 17:19:57 +02:00
nicolas 95238a2e84 README : raccourcis du Bureau et déplacement du launcher
- Nouvelle section « Raccourcis du Bureau et déplacement du launcher ».
- Réparation : mention du contrôle des raccourcis.
- Option --game : comportement si le launcher est déjà ouvert.
- Structure : ajout d'InstanceChannel.
2026-10-24 17:05:38 +02:00
nicolas 8797f9e3d3 Raccourci ouvert avec le launcher déjà ouvert : le jeu est lancé
- Lancé avec --game alors qu'une instance est déjà ouverte, le launcher
  lui transmet la demande puis se ferme. L'instance ouverte revient au
  premier plan sur ce jeu et le lance s'il est prêt et pas déjà lancé.
- Sans --game (ou sans réponse sous 2 s) : message « Le launcher est
  déjà ouvert » inchangé.
- Rien n'est lancé si une boîte de dialogue est ouverte.
- Canal fermé avant le redémarrage d'une mise à jour du launcher,
  puis recréé par la nouvelle version.
- Sécurité : canal nommé réservé à l'utilisateur courant
  (CurrentUserOnly), nom propre à la session et au SID, un seul
  message de 256 octets max en 2 s, identifiant de jeu uniquement
  (validé puis comparé à launcher.json, aucun chemin ni argument).
2026-10-24 17:05:25 +02:00
nicolas b6d364901a Réparer : contrôle des raccourcis + pas de double lancement du jeu
- Réparer vérifie les raccourcis du Bureau et l'entrée « Applications
  installées » : redirection vers l'emplacement actuel du launcher
  (sans question si l'ancien exe a disparu, avec confirmation s'il
  s'agit d'une autre copie encore présente). Fonctionne hors ligne.
  Le résultat est indiqué dans le bandeau.
- JOUER ne lance pas le jeu si son exécutable tourne déjà dans la
  session Windows (lancé hors du launcher ou avant un redémarrage).
- Nouvelle méthode LaunchFromShortcut() : lance le jeu s'il est prêt,
  attend la fin d'une vérification en cours, sinon explique dans le
  bandeau pourquoi le jeu n'est pas lancé.
2026-10-24 17:04:56 +02:00
nicolas 434471dfc1 Raccourcis du Bureau : recherche par argument --game et redirection
- Un raccourci du jeu est reconnu à ses arguments « --game <GameId> »,
  même s'il a été renommé (Bureau de l'utilisateur, 300 raccourcis max).
- Au démarrage, les raccourcis vers un launcher disparu (déplacé ou
  renommé) sont redirigés automatiquement vers le launcher actuel.
- La redirection conserve l'icône si le joueur l'a personnalisée.
- IShortcutService : LauncherPath, FindDesktopShortcuts(),
  RetargetDesktopShortcut().
- IRegistryService : RefreshLauncherPath() ajouté à l'interface.
- Nouveau record DesktopShortcut.
2026-10-24 17:04:46 +02:00
nicolas 6512110eb0 Version 1.5 du jeu 2026-10-24 16:44:59 +02:00
nicolas 1401d9909c Passage à la version 1.4.2 du jeu 2026-10-21 22:30:23 +02:00
nicolas 8fbb12e52e Ajout de l'argument --prune-objects qui permet de supprimer les fichiers du dossier objects\ qui ne sont plus référencés dans le manifest JSON. 2026-10-21 22:28:52 +02:00
fred 7e52faf831 Merge pull request 'Ajout d'un module de mise à jour automatique' (#1) from dev/1.1 into master
Reviewed-on: #1
Reviewed-by: Deville <fred@lanisys.fr>
2026-10-18 12:22:58 +02:00
407 changed files with 61997 additions and 471 deletions

No files matched your search

+8
View File
@@ -61,3 +61,11 @@
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
###############################################################################
# Fichiers executes sur le VPS (Linux) : fins de ligne LF obligatoires
###############################################################################
*.sh text eol=lf
*.service text eol=lf
server/deploy/*.conf text eol=lf
website/deploy/*.conf text eol=lf
+6
View File
@@ -5,6 +5,7 @@ Publish/
.vs/
*.user
*.suo
Claude outputs/
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
@@ -412,3 +413,8 @@ FodyWeavers.xsd
# Clé PRIVÉE de signature des mises à jour du launcher : ne jamais versionner
launcher-signing.key
*.download
# Cles privees (signature du launcher, administrateurs) : jamais dans Git
*.key
# Donnees locales de la Launcher API (F5)
data-dev/
+3 -3
View File
@@ -3,9 +3,9 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Xbhost</Company>
<Authors>Xbhost</Authors>
<Copyright>© Xbhost</Copyright>
<Company>LanisysGames</Company>
<Authors>LanisysGames</Authors>
<Copyright>© LanisysGames</Copyright>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
+145
View File
@@ -28,9 +28,62 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
server-example\launcher\publier-launcher.txt = server-example\launcher\publier-launcher.txt
server-example\launcher\preparer-vps.sh = server-example\launcher\preparer-vps.sh
README.md = README.md
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
tools\admin-key.bat = tools\admin-key.bat
tools\ui-snapshot.bat = tools\ui-snapshot.bat
tools\sign-keyring.bat = tools\sign-keyring.bat
tools\publish-server.bat = tools\publish-server.bat
tools\publish-site.bat = tools\publish-site.bat
server-example\web.config = server-example\web.config
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "server", "server", "{E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF}"
ProjectSection(SolutionItems) = preProject
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
server\deploy\installer-api.sh = server\deploy\installer-api.sh
server\deploy\launcher-api-catalog.conf = server\deploy\launcher-api-catalog.conf
server\deploy\launcher-api-games.conf = server\deploy\launcher-api-games.conf
server\deploy\launcher-api.service = server\deploy\launcher-api.service
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.conf
server\deploy\nginx-publications.conf = server\deploy\nginx-publications.conf
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Contracts", "src\GameLauncher.Contracts\GameLauncher.Contracts.csproj", "{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.ApiClient", "src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj", "{3BB233EF-1252-4D1D-9808-9C75EB393065}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Server", "server\GameLauncher.Server\GameLauncher.Server.csproj", "{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\AdminKeyTool\AdminKeyTool.csproj", "{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UiSnapshot", "tools\UiSnapshot\UiSnapshot.csproj", "{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Tests", "tests\GameLauncher.Tests\GameLauncher.Tests.csproj", "{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src\GameLauncher.Modules\GameLauncher.Modules.csproj", "{9EBFA383-7928-46F6-8958-F4B38287E380}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
ProjectSection(SolutionItems) = preProject
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
website\deploy\installer-site.sh = website\deploy\installer-site.sh
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,6 +124,86 @@ Global
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|Any CPU.Build.0 = Release|Any CPU
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.ActiveCfg = Release|x64
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.Build.0 = Release|x64
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.Build.0 = Debug|Any CPU
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.ActiveCfg = Debug|x64
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.Build.0 = Debug|x64
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.ActiveCfg = Release|Any CPU
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.Build.0 = Release|Any CPU
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.ActiveCfg = Release|x64
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.Build.0 = Release|x64
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.ActiveCfg = Debug|x64
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.Build.0 = Debug|x64
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.Build.0 = Release|Any CPU
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.ActiveCfg = Release|x64
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.Build.0 = Release|x64
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.ActiveCfg = Debug|x64
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.Build.0 = Debug|x64
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.Build.0 = Release|Any CPU
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.ActiveCfg = Release|x64
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.Build.0 = Release|x64
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.ActiveCfg = Debug|x64
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.Build.0 = Debug|x64
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.Build.0 = Release|Any CPU
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.ActiveCfg = Release|x64
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.Build.0 = Release|x64
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.ActiveCfg = Debug|x64
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.Build.0 = Debug|x64
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.Build.0 = Release|Any CPU
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.ActiveCfg = Release|x64
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.Build.0 = Release|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.ActiveCfg = Debug|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.Build.0 = Debug|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.Build.0 = Release|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.ActiveCfg = Release|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.Build.0 = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.ActiveCfg = Debug|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.Build.0 = Debug|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.ActiveCfg = Debug|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.Build.0 = Debug|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.Build.0 = Release|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.ActiveCfg = Release|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.Build.0 = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.Build.0 = Debug|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -80,6 +213,18 @@ Global
{7384678B-D7F7-4682-B99D-2DDCBC605BA4} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{35F7D646-FAC8-46A6-ACC8-0E22AE78B603} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{3BB233EF-1252-4D1D-9808-9C75EB393065} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F} = {266A4FF5-5752-46BA-803C-09E351720DB2}
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+438 -23
View File
@@ -32,9 +32,19 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| Projet | Rôle |
|---|---|
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`), mode maintenance (`Maintenance/`) |
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
```
src/GameLauncher/
@@ -42,13 +52,20 @@ src/GameLauncher/
├─ launcher.json Configuration éditeur : un jeu, ou plusieurs (voir launcher.multi-jeux.example.json)
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
│ Changelog.xaml (modèles des notes de version)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
│ dont les pages « Jeux », « Notes de version » et « Publier une version »),
│ Maintenance.xaml (palette rouge / orange du mode maintenance)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow,
│ FeedbackWindow (bouton « Aide »)
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
│ GameViewModel (machine à états d'un jeu),
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
├─ Services/ ThemeManager, RegistryService, ShortcutService, GameScopedServices, interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF)
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration),
│ FeedbackWindowService (bouton « Aide »), interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
```
## 3. Publier une version du jeu (ManifestBuilder)
@@ -61,12 +78,25 @@ dotnet run --project tools\ManifestBuilder -c Release -- ^
--exe MonJeu.exe ^
--game-id MonJeu ^
--notes notes.txt ^
--remote-manifest https://launcher.example/monjeu/manifest.json ^
--exclude "*.pdb;*.log"
```
L'historique des notes est conservé d'une version à l'autre (30 versions) et affiché dans le
launcher (panneau « Notes de version », bouton **Agrandir** pour les lire en grand).
`--remote-manifest` (recommandé, déjà dans `tools\publish-example.bat`) : l'historique est repris du
manifest **en ligne**, et non du `manifest.json` local. Les notes corrigées depuis le menu
d'administration (page « Notes de version », §8) ne sont donc pas écrasées à la publication
suivante. Pas de manifest en ligne (première publication) : historique local. Serveur injoignable :
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
**Mise en ligne** : depuis le menu d'administration, page **Publier une version** (§8) : choisissez le
`manifest.json` du dossier `--output`, puis « Publier ». Seuls les fichiers absents du serveur sont
envoyés, l'envoi reprend après une coupure, et le manifest est mis en ligne en dernier. WinSCP reste
possible (objects/ et l'archive d'abord, `manifest.json` en dernier), mais pas les deux en même temps.
### Format de `notes.txt`
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
@@ -151,6 +181,52 @@ enregistrées localement.
Si le dossier a disparu ou est presque vide → réinstallation via l'archive ZIP dans le même
dossier. Sinon → vérification complète + téléchargement des seuls fichiers défectueux.
La réparation contrôle aussi les **raccourcis du Bureau** du jeu et l'entrée « Applications
installées » de Windows (voir « Raccourcis du Bureau » ci-dessous) : s'ils désignent un ancien
emplacement du launcher, ils sont redirigés vers le launcher actuel (même hors ligne).
### Raccourcis du Bureau et déplacement du launcher
Le raccourci créé à l'installation pointe vers **le launcher** (`GameLauncher.exe --game <GameId>`),
pas vers le jeu : les mises à jour sont ainsi toujours vérifiées. Un raccourci « du jeu » est
reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bureau de l'utilisateur).
* **Créer le raccourci** : à l'installation (case à cocher), ou à tout moment depuis le menu **« … »** ›
*Paramètres du jeu* › bloc *Raccourci* (« Créer le raccourci », ou « Mettre à jour » s'il existe déjà).
* **Icône** : celle de l'exécutable du jeu (dossier d'installation et exécutable lus dans le registre),
ou celle du launcher si le jeu n'est pas installé ou introuvable. Elle suit le jeu s'il est déplacé
(localisation, réparation) ; les raccourcis créés par une version précédente prennent l'icône du jeu
au démarrage du launcher. Une icône choisie par le joueur n'est jamais remplacée.
* **Nom** : le nom affiché du jeu (page « Jeux » du menu d'administration), sinon celui de `launcher.json`.
Un raccourci nommé par le launcher est renommé quand ce nom change : au démarrage du launcher, avec
« Mettre à jour » (*Paramètres du jeu* › *Raccourci*) et avec **Réparer**. Un raccourci renommé par le
joueur garde son nom, et aucun fichier existant du Bureau n'est remplacé (le nom de `launcher.json`
est alors conservé).
| Situation | Comportement |
|---|---|
| Raccourci ouvert, launcher **fermé** | Le launcher s'ouvre sur ce jeu, vérifie les mises à jour (et les installe si la mise à jour automatique du jeu est activée), puis **lance le jeu** s'il est prêt, en appliquant le réglage *Lancement du jeu* (réduire / fermer le launcher). Sinon le bandeau explique pourquoi (mise à jour manuelle à faire, réparation, jeu non installé). Hors ligne, le jeu installé est lancé. |
| Launcher déplacé ou renommé, raccourci vers l'ancien emplacement (fichier disparu) | Au prochain démarrage du launcher depuis son nouvel emplacement : raccourci redirigé automatiquement, `LauncherPath` et bouton « Désinstaller » de Windows mis à jour |
| Raccourci vers un **autre exemplaire** du launcher, encore présent (copie) | Rien d'automatique ; **Réparer** propose de le rediriger (confirmation) |
| Icône personnalisée par le joueur | Conservée lors de la redirection |
| Raccourci ouvert alors que le launcher est **déjà ouvert** | La demande est transmise au launcher ouvert : il revient au premier plan sur ce jeu et **lance le jeu** s'il est prêt et pas déjà en cours d'exécution (sinon le bandeau explique pourquoi : mise à jour, réparation, opération en cours…). Rien n'est lancé si une boîte de dialogue est ouverte. |
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert » (qui rappelle de regarder dans la zone de notification) |
| Raccourci ouvert alors que le launcher est **réduit dans la zone de notification** | La fenêtre est réaffichée, puis même comportement que « déjà ouvert » |
Tant que le launcher n'a pas été relancé depuis son nouvel emplacement, un raccourci vers l'ancien
emplacement ne fonctionne pas (Windows le signale, et le retrouve parfois tout seul). Le chemin du
launcher est enregistré dans le registre (`LauncherPath`, `UninstallString`) pour chaque jeu
installé et mis à jour à chaque démarrage. Les paramètres (`%AppData%`) ne dépendent pas de
l'emplacement du launcher ; un `launcher.json` posé à côté de l'exe doit être déplacé avec lui.
Lancer le jeu, avec ou sans raccourci, ne démarre pas une seconde copie du jeu si son exécutable
tourne déjà (lancé directement ou avant un redémarrage du launcher).
**Communication entre instances** : canal nommé local réservé à l'utilisateur courant
(`PipeOptions.CurrentUserOnly`, nom propre à la session et à l'utilisateur), une seule commande
bornée (256 octets, 2 s) contenant uniquement un identifiant de jeu, validé puis comparé aux jeux
de `launcher.json`. Aucun chemin ni argument n'est transmis. Le canal est fermé avant le
redémarrage du launcher lors de sa mise à jour, puis recréé par la nouvelle version.
### Localiser le jeu
Sélection d'un dossier : l'exécutable est recherché dans le dossier et ses sous-dossiers
(2 niveaux). Le jeu est enregistré dans le registre puis analysé : s'il diffère de la dernière
@@ -159,17 +235,51 @@ version, une mise à jour (différentielle) est proposée.
### Désinstallation
Depuis le menu « … » ou depuis **Paramètres Windows › Applications installées** (le launcher est
relancé avec `--uninstall <GameId>`). Par défaut seuls les fichiers installés par le launcher sont
supprimés ; une option permet de supprimer tout le dossier (refusé pour les dossiers sensibles :
racine de disque, Program Files, Documents…).
supprimés : les sauvegardes et réglages du jeu (`%APPDATA%\<Société>\<Produit>`) et les fichiers
personnels du dossier d'installation sont conservés.
L'option **« Supprimer aussi les sauvegardes, les réglages, les journaux et tous les autres fichiers du jeu »**
(proposée aussi quand les fichiers du jeu ont déjà disparu) supprime en plus :
| Élément | Emplacement |
|---|---|
| Tout le dossier d'installation | refusé pour les dossiers sensibles : racine de disque, Program Files, Documents… |
| Sauvegardes et réglages | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` |
| Journaux de Unity (`Player.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` |
| Réglages d'affichage de Unity (résolution, plein écran, position de la fenêtre) | `HKCU\Software\LanisysGames\Exovival Zombie Reborn` |
Garde-fous : chemin « Société\Produit » complet uniquement (`gameDataFolder`, jamais le dossier de la
société entier), jamais le dossier ni la clé du launcher (`GameLauncher`) ni le dossier d'installation ;
dossier et clé de la société retirés seulement s'ils sont vides. Ce qui n'a pas pu être supprimé (fichier
ouvert) est indiqué dans le bandeau et le journal.
**Launcher placé dans un dossier supprimé** — dossier du jeu, ou (avec l'option) dossier des sauvegardes dans
Roaming ou des journaux dans LocalLow, à toute profondeur : la confirmation l'indique (« Le launcher se trouve
dans ce dossier / dans le dossier des données du jeu : il sera conservé. ») et ses fichiers sont épargnés :
son exécutable, `launcher.json`, les fichiers temporaires de sa mise à jour (`GameLauncher.exe.old`,
`.download`…) et, en version de développement, les bibliothèques chargées depuis son dossier. Tout le reste
est supprimé, les sous-dossiers vides sont retirés, et le bandeau final indique où le launcher a été conservé.
Les trois dossiers suivent la même règle (`FileUtil.DeleteDirectoryContents`).
**Fichier bloqué** (jeu mal fermé, antivirus…) : la désinstallation ne s'interrompt plus. Les fichiers sont
supprimés un par un ; ceux qui ne peuvent pas l'être sont signalés (bandeau + liste complète dans le journal),
puis registre, raccourci et données du jeu sont traités normalement.
### Registre (HKEY_CURRENT_USER, aucun droit administrateur requis)
```
HKCU\Software\<Publisher>\<GameId>
InstallPath, Version, Executable, InstallDate, LauncherPath
HKCU\Software\<Publisher>\GameLauncher (ex. …\LanisysGames\GameLauncher)
LauncherPath emplacement du launcher (une seule fois)
HKCU\Software\<Publisher>\GameLauncher\Games\<GameId> (ex. …\GameLauncher\Games\EZR)
InstallPath, Version, Executable, InstallDate
HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<Publisher>.<GameId>
DisplayName, DisplayVersion, Publisher, InstallLocation, DisplayIcon,
UninstallString ("GameLauncher.exe" --uninstall <GameId>), EstimatedSize…
```
* `LauncherPath` est écrit une seule fois pour tous les jeux, à l'installation d'un jeu et à chaque démarrage
(s'il y a au moins un jeu installé), et supprimé à la désinstallation du dernier jeu. Seul le launcher
réellement ouvert l'écrit : un second launcher, arrêté par « Le launcher est déjà ouvert » (ou qui transmet
un raccourci au launcher ouvert), ne touche pas au registre. Il est informatif : la vérification des
raccourcis compare la cible de chaque raccourci à l'exécutable en cours, pas au registre.
### Paramètres
@@ -177,19 +287,76 @@ Deux fenêtres distinctes :
| Accès | Contenu | Portée |
|---|---|---|
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire / fermer le launcher pendant la partie), dossier des journaux | Commun à tous les jeux |
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Informations* (versions, taille, dossier du jeu, journaux) | Jeu sélectionné uniquement |
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire — dans la barre des tâches ou la zone de notification — ou fermer le launcher pendant la partie), bloc *Fermeture du launcher* (la croix ferme ou réduit dans la zone de notification), dossier des journaux | Commun à tous les jeux |
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Raccourci* (créer / mettre à jour le raccourci du Bureau), bloc *Informations* (versions, taille ; boutons *dossier du jeu*, *dossier des journaux* du jeu dans LocalLow, *dossier des données* du jeu dans Roaming) | Jeu sélectionné uniquement |
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
sont repris de ceux du jeu affiché par défaut.
### Plein écran
La fenêtre principale et le menu d'administration ont deux modes : **fenêtré** (taille d'origine) ou
**plein écran** (fenêtre agrandie sur tout l'écran, la barre des tâches restant visible). Bascule : bouton entre « Réduire » et
« Fermer », ou **F11**. Le mode de chaque fenêtre est retenu pour la fois suivante. Les fenêtres ne se
redimensionnent jamais par les bords ou les coins ; la mise en page s'adapte à la place disponible
(plein écran : colonnes plus larges ; petit écran ou mise à l'échelle 125-150 % : contenu réduit d'un
bloc, sans rien rogner). Les autres fenêtres (paramètres, notes de version agrandies, boîtes de
dialogue) gardent leur taille fixe.
### Zone de notification (icônes cachées, près de l'horloge)
Deux options des *Paramètres du launcher* (désactivées par défaut) :
| Option | Effet |
|---|---|
| *Lancement du jeu* › Réduire le launcher pendant la partie › **Dans la zone de notification** | Pendant la partie, la fenêtre est masquée et une icône apparaît près de l'horloge ; la fenêtre revient à la fermeture du jeu |
| *Fermeture du launcher* › **La croix réduit le launcher dans la zone de notification** | La croix (et Alt+F4) masque la fenêtre au lieu de quitter : téléchargements, mises à jour et surveillance du jeu continuent |
L'icône n'est affichée que lorsque la fenêtre est masquée. **Clic gauche** : réouvre le launcher.
**Clic droit** : menu *Ouvrir* / *Quitter* (même style arrondi que les autres menus, couleurs du thème
du jeu affiché). *Quitter* demande confirmation si une opération est en cours (la fenêtre est alors
réaffichée). À la fermeture de session ou à l'arrêt de Windows, le launcher se ferme normalement.
L'icône est gérée par l'API Windows (`Shell_NotifyIcon`, `Platform/TrayIcon.cs`), sans Windows Forms,
et réapparaît si l'Explorateur Windows redémarre. Le menu est une petite fenêtre (`Views/TrayMenuWindow`)
et non un `ContextMenu` WPF : launcher masqué, un `ContextMenu` ne peut pas capturer la souris et se
referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) ou avec Échap.
### Autres fichiers
* Paramètres d'un jeu : `%AppData%\<Publisher>\<GameId>Launcher\settings.json`
* Paramètres du launcher (communs) : `%AppData%\<Publisher>\<LauncherId>\launcher-settings.json`
(en mono-jeu, `<LauncherId>` = `<GameId>Launcher` : même dossier que ci-dessus)
* Journaux : `%AppData%\<Publisher>\<GameId>Launcher\logs\launcher.log` (accessibles depuis les deux fenêtres de paramètres) ;
en multi-jeux : `%AppData%\<Publisher>\<launcherId>\logs\` (+ `launcher-state.json` : dernier jeu affiché)
```
%AppData%\<Publisher>\GameLauncher (ex. %AppData%\LanisysGames\GameLauncher)
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
├─ self-update.json mise à jour du launcher (§7)
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
├─ window-modes.json plein écran ou fenêtré, par fenêtre (« main », « admin »)
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
```
* État de l'installation : `<dossier du jeu>\.launcher\state.json` (dossier caché)
* Nom du dossier et de la clé de registre : `launcher.json` → `"dataFolder"` (à la racine ; `"GameLauncher"`
par défaut). Il est indépendant de `launcherId`, qui ne doit plus changer (§7).
**Versions 1.2.1 et antérieures** : les données étaient dans `%AppData%\<Publisher>\<LauncherId>` (ex.
`EZRLauncher`, + `<GameId>Launcher` par jeu en multi-jeux) et dans `HKCU\Software\<Publisher>\<GameId>`.
Au démarrage, le launcher les déplace (renommage du dossier, paramètres de chaque jeu vers `Games\<GameId>`,
copie vérifiée puis suppression de l'ancienne clé de registre). Ce déplacement n'a lieu que si aucun autre
launcher n'est ouvert, et jamais au redémarrage qui suit une mise à jour du launcher (l'ancienne version doit
pouvoir être remise en place) : l'ancien emplacement est alors utilisé pour la session et le déplacement est
fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\<Publisher>.<GameId>`) ne changent pas.
### Données du jeu (Unity)
| Contenu | Emplacement | Bouton (*Paramètres du jeu* › *Informations*) |
|---|---|---|
| Sauvegardes (`Data\SavedGame`) et réglages (`Data\Config`) | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` (Roaming, à côté de `GameLauncher`) | *Ouvrir le dossier des données* |
| Journaux de Unity (`Player.log`, `Player-prev.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` | *Ouvrir le dossier des journaux* |
* « LanisysGames\Exovival Zombie Reborn » correspond à *Company Name* / *Product Name* des Player Settings
de Unity ; côté launcher : `launcher.json` → `gameDataFolder` (par défaut `publisher\gameName`).
* Le jeu choisit l'emplacement dans `SerializationAsset.cs` (UHFPS, classe `ExovivalDataLocation`). Au
premier lancement d'une version qui l'intègre, le dossier `Data` de LocalLow est copié vers Roaming,
vérifié (mêmes fichiers, mêmes tailles), puis supprimé de LocalLow. En cas d'échec, le jeu continue
d'utiliser LocalLow et réessaie au lancement suivant. Dans l'éditeur Unity, rien ne change (`Assets/Data`).
* `Player.log` reste dans LocalLow : son emplacement est fixé par Unity (`Application.persistentDataPath`).
## 5. Personnalisation
@@ -201,8 +368,10 @@ sont repris de ceux du jeu affiché par défaut.
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
### Thèmes
@@ -234,18 +403,18 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
rien ne change pour un jeu déjà installé avec la version mono-jeu.
* Les jeux sont vérifiés en parallèle ; une pastille sur la vignette signale une mise à jour,
une opération en cours ou un problème. Un message concernant un jeu sélectionne ce jeu.
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu).
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu et le lance s'il est prêt).
* Fermer le launcher pendant des téléchargements demande une seule confirmation pour tous les jeux.
### Options de la ligne de commande
| Option | Rôle |
|---|---|
| `--game <GameId>` | Ouvre le launcher sur ce jeu |
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, ses `apiUrl` et `catalogUrl` sont ignorées (seules celles intégrées à l'exe comptent) |
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7) |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
## 6. Bon à savoir
@@ -261,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
simplement qu'une nouvelle version existe.
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
### Sécurité
@@ -273,6 +442,7 @@ simplement qu'une nouvelle version existe.
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
### Déroulement
@@ -295,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
### Version prioritaire (correctif critique)
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
| Point | Comportement |
|---|---|
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
### Mise en place (une seule fois)
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
@@ -322,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
Vérification à tout moment :
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
@@ -333,5 +522,231 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
### Fichiers
* Préférences : `%AppData%\<Publisher>\<LauncherId>\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
* Préférences : `%AppData%\<Publisher>\GameLauncher\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
* À côté de l'exe, temporairement : `GameLauncher.exe.<version>.download`, `GameLauncher.exe.old`, `GameLauncher.exe.replaced`, `GameLauncher.exe.failed`
## 8. Administration (Launcher API)
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
page ; après « Recharger » ou « Publier », la page remonte en haut pour montrer le bandeau du résultat. La
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
étape 3 decies.
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
l'envoi avec les règles du serveur ; informations techniques facultatives (versions, Windows, langue) montrées avant
l'envoi, cochées par défaut pour un bug. Demande **anonyme** : aucun compte, aucune adresse IP enregistrée ; le
joueur reçoit une référence `FB-XXXX-XXXX`. **Captures d'écran** : 3 au plus (fichier ou Ctrl+V), image reconnue à
son contenu, réencodée en PNG/JPEG **sans métadonnées** (ni GPS, ni appareil), revérifiée par le serveur (PNG/JPEG
seulement, 4 Mo). En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
sans `apiUrl` ; réception à activer sur le serveur (`server/deploy/LISEZMOI.md`, étape 3 octies).
* **Page « Formulaires des demandes »** (permission `feedback.forms`) : types de demande (1 à 8 : ajout, suppression,
ordre d'affichage, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées ou non) et
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi. Journal d'audit : chaque publication indique
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
demande contenant un **lien** (http, https, www, domaine, IP, e-mail, formes déguisées…) est signalée par un
avertissement, liens désamorcés et jamais cliquables. Statut, note et suppression sont des requêtes **signées**.
Chaque consultation est inscrite au journal d'audit.
Purge automatique : 12 mois après la clôture, 24 mois au plus ; le journal d'audit est purgé au-delà de 12 mois.
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
vérifiée par le serveur, protège l'administration.
* **Adresse du serveur** : `"apiUrl"` dans `src\GameLauncher\launcher.json` (racine du fichier,
https). En Release, seule la valeur **intégrée à l'exe** est utilisée ; en Debug, celle du
`launcher.json` utilisé (ex. `--config` avec `http://127.0.0.1:5080/api/` pour un serveur local).
Sans `apiUrl`, le menu indique qu'il n'est relié à aucun serveur.
* **Se connecter** : choisir sa clé (dossier `%USERPROFILE%\.launcher-keys`, ou « Choisir un autre
fichier de clé… »), saisir sa phrase de passe. Verrouillage automatique après 10 min sans activité,
quand Windows est verrouillé, ou avec `Ctrl+L` ; fermer la fenêtre efface la clé de la mémoire.
* **« J'ai une invitation »** : crée la clé sur ce PC et l'enregistre avec le code reçu, puis
affiche l'**empreinte** à dicter à la personne qui approuve. Sans code : clé seule (premier
propriétaire, à déclarer sur le VPS). Deux rangements au choix :
* **fichier chiffré** (phrase de passe, 12 caractères min.) : copiable et sauvegardable ;
* **puce de sécurité (TPM 2.0) de ce PC** (code PIN, 6 caractères min.) : la clé est créée dans la
puce et ne peut pas être copiée. Proposé seulement après vérification réelle de la puce. Elle
disparaît avec la puce (carte mère, réinitialisation, Windows réinstallé) : gardez aussi une
clé fichier de secours. Sur le disque : `admin.tpmkey` (référence, sans secret) et `admin.pub`.
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code
(blocage lu dans la puce : attribut `inLockout` et compteur d'erreurs `TPM_PT_LOCKOUT_COUNTER`).
* **Page « Jeux »** (permission `games.edit`) : nom affiché, accroche, description, liens (Discord,
YouTube…), site, thème intégré, icône et fond (bouton **« Parcourir… »** : l'image choisie dans
l'explorateur Windows est contrôlée puis envoyée au serveur, sans client FTP ; aperçu, adresse
renseignée automatiquement), fréquence des recherches de mise à jour, jeu masqué,
nom du launcher, jeu affiché au premier lancement, ordre des jeux. Champ vide = valeur de
`launcher.json`. « Publier » contrôle les valeurs puis écrit `catalog.json` sur le serveur (requête
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
le nom affiché renomme le raccourci du Bureau (voir « Raccourcis du Bureau »), mais ni le registre,
ni les dossiers. Le **nom du launcher** apparaît dans les paramètres du launcher (roue crantée),
l'info-bulle de l'icône de la zone de notification et, avec plusieurs jeux, le titre de la fenêtre
(barre des tâches) ; avec un seul jeu, l'en-tête montre le nom du jeu. Le catalogue publié est pris
en compte au démarrage suivant du launcher.
* **Page « Notes de version »** (permission `changelog.edit`) : corrige les notes des versions **déjà
publiées** dans le `manifest.json` du serveur. Liste des versions, en-tête (titre, sous-titre,
introduction), sections › éléments (modification ou commentaire, étiquette, sous-points, note),
boutons ↑ ↓ pour l'ordre, **Aperçu** avec la présentation exacte du launcher. Mêmes limites que le
launcher, vérifiées avant l'envoi. « Enregistrer » ne change que les notes (fichiers, archive,
version et exécutable restent identiques) ; si le manifest a changé entre-temps (nouvelle version
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
`server/deploy/LISEZMOI.md`, étape 3 ter.
* **Plusieurs jeux** (pages « Notes de version » et « Publier une version ») : la page s'ouvre sur le
**choix du jeu** (une tuile par jeu de `launcher.json`, aucun jeu par défaut) et n'affiche rien d'autre
avant ce choix ; une liste « Jeu » permet ensuite d'en changer (pas pendant un chargement, un envoi, ni
avec des notes non enregistrées sans confirmation). Rien d'un jeu ne reste affiché pour un autre
(dossier choisi, version en ligne, signature) ; un envoi interrompu reste attaché à son jeu. Les
confirmations et résultats de publication nomment le jeu. Avec un seul jeu, il est choisi d'office.
Page « Fichiers du serveur » : le jeu à nettoyer se choisit de la même façon.
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
si même numéro ou version inférieure), puis envoi : manifest, fichiers **absents du serveur**
seulement (petits fichiers par lots, gros par morceaux), archive par morceaux de 4 Mo, chaque fichier
vérifié (SHA-256) des deux côtés, et mise en ligne avec le manifest **en dernier** (historique des
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
étape 3 quater.
* **Page « Mes appareils »** (tout administrateur) : vos PC (clés), avec leur empreinte et leur dernière
connexion. « Ajouter un appareil » crée une invitation « nouvel appareil » (code affiché une seule
fois) ; sur le nouveau PC, « J'ai une invitation » ; de retour ici, l'appareil en attente s'approuve
en saisissant l'empreinte affichée sur le nouveau PC. Personne d'autre ne peut approuver un appareil
à votre nom. Révoquer un appareil perdu : propriétaires (sinon, demandez à un propriétaire).
* **Page « Administrateurs »** (permission `admins.manage`) : inviter (nom, groupe → code
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
qu'il vous dicte, changer le groupe d'un compte, révoquer un appareil ou un compte, supprimer une invitation.
Impossible de changer son propre groupe, de se révoquer, ou de retirer le dernier propriétaire actif ;
seuls les groupes dont vous avez toutes les permissions sont proposés.
* **Page « Groupes et permissions »** (permission `admins.manage`) : créer, renommer, décrire et supprimer des
groupes d'administrateurs, cocher leurs permissions ; créer des permissions **personnalisées**
(« module.action », pour un module futur) et les supprimer (retirées de tous les groupes). Les permissions
**intégrées** (vérifiées par le code) ne se suppriment pas. Le groupe **Propriétaire** a toujours toutes les
permissions (ni modifiable ni supprimable). On ne modifie pas les permissions de son propre groupe, on n'accorde
que des permissions que l'on a soi-même, et un groupe qui a des membres ou des invitations ne se supprime pas.
Effet immédiat (sessions ouvertes comprises), requêtes signées, journal d'audit. Mise en place :
`server/deploy/LISEZMOI.md`, étape 3 septies.
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
(`keyring.json`), signé **hors ligne** par la clé racine du launcher avec `tools\sign-keyring.bat` et publié
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
de 200). Filtres par **période** (aujourd'hui, 7 ou 30 derniers jours, ce mois-ci, mois précédent, ou dates
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, appareil, IP, clé…). La période est demandée au
serveur (paramètre `before`) et les pages sont chargées jusqu'à son début (5000 entrées au plus) ; l'**export
CSV** reprend toutes les entrées filtrées, période comprise. Le journal se surveille lui-même : chaque
**consultation** (ouverture, « Actualiser », période changée) est inscrite par le serveur (« Journal d'audit
consulté » : auteur, appareil, IP, nombre d'entrées lues), et chaque **export** l'est avant l'écriture du
fichier (« Journal d'audit exporté (CSV) » : nombre d'entrées, période, filtres) ; si le serveur ne peut pas
l'inscrire, le fichier n'est pas écrit. Catégorie de filtre « Consultations et exports du journal ».
Chaque entrée indique l'**appareil** sous le nom choisi à l'enregistrement de sa clé (« J'ai une
invitation »), jamais le nom Windows du PC (colonne « Appareil » de l'export CSV).
* **Page « Fichiers du serveur »** (permission `files.manage` : propriétaires et administrateurs) : contenu du
site public (dossiers, tailles, dates, espace libre du disque) et état de chaque fichier : **Utilisé** (archive,
fichier d'une version, exécutable du launcher, image du catalogue), **Protégé** (`.json`), **Inutilisé**
(supprimable) ou **Non vérifiable**. Un fichier inutilisé se supprime après confirmation ; « Nettoyer le
dossier objects » supprime les fichiers d'un jeu que la version en ligne n'utilise plus (analyse d'abord). Le
serveur relit tous les manifests du site avant chaque suppression et refuse tout fichier encore désigné, ainsi
que pendant une publication. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 sexies.
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
PNG/JPEG du même serveur, dans `catalog/images/` (envoyées depuis la page Jeux sous le nom de leur
empreinte SHA-256, vérifiée par le launcher au téléchargement ; ou déposées à la main sous un nouveau nom).
Installation sur le VPS : `server/deploy/LISEZMOI.md`, étape 3 bis.
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
la clé (un jeton volé ne permet que de lire). Journal d'audit de chaque action.
* **Groupes** (anciennement « rôles ») : propriétaire (tout, dont la gestion des administrateurs ; groupe
système), administrateur (contenu, publication, journal d'audit, fichiers du serveur), éditeur (contenu) par
défaut ; modifiables et complétables depuis la page « Groupes et permissions ».
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
| Pour… | Où |
|---|---|
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
## 9. Site vitrine
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
| Page | Contenu |
|---|---|
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
pas affichées (`Site:Launcher:HiddenSections`).
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
toujours encodés (jamais interprétés comme du HTML).
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
aucune donnée écrite sur le disque.
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
liens `javascript:`/`data:` retirés, images du site uniquement.
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 416 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 395 KiB

+260
View File
@@ -0,0 +1,260 @@
using System.Security.Cryptography;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Cli;
/// <summary>
/// Outil en ligne de commande, à lancer SUR LE VPS sous le compte du service (accès SSH
/// administrateur = preuve d'identité). Sert à créer le premier propriétaire et à reprendre la main
/// si toutes les clés sont perdues. Le service n'a pas besoin d'être arrêté : il relit admins.json.
///
/// sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub
/// </summary>
public static class AdminCli
{
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
public static bool IsCommand(string[] args) =>
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
{
var o = output ?? Console.Out;
try
{
var command = args[0].ToLowerInvariant();
if (command == "help") return Help(o);
var options = ReadOptions(args);
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
// appartiendraient à root et le service ne pourrait plus les lire.
if (dataDirectoryOverride is null && !OperatingSystem.IsWindows() && Environment.UserName == "root")
throw new InvalidOperationException(
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
if (command != "add-owner" && command != "server-id" &&
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
throw new InvalidOperationException(
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
if (output is null) Console.Error.WriteLine($"Données : {dataDirectory}");
var store = new AdminStore(dataDirectory);
var audit = new AuditLog(dataDirectory, TimeProvider.System);
var actor = $"cli ({Environment.UserName})";
switch (command)
{
case "add-owner": return AddOwner(store, audit, actor, options, o);
case "add-key": return AddKey(store, audit, actor, options, o);
case "list": return List(store, o);
case "revoke-admin": return RevokeAdmin(store, audit, actor, options, o);
case "revoke-key": return RevokeKey(store, audit, actor, options, o);
case "server-id":
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
return 0;
default: return Help(o);
}
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or InvalidDataException or IOException
or UnauthorizedAccessException or CryptographicException or ApiRejection or StoreBusyException)
{
(output ?? Console.Error).WriteLine($"Erreur : {ex.Message}");
return 2;
}
}
private static int AddOwner(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
{
var name = ApiText.CleanLine(Get(options, "name"), ApiText.MaxDisplayNameLength)
?? throw new ArgumentException("--name obligatoire (64 caractères au plus).");
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
var keyText = File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg;
var spki = AdminKeys.ParsePublicKey(keyText);
var keyId = AdminKeys.ComputeKeyId(spki);
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
var now = DateTimeOffset.UtcNow;
var id = store.Update(db =>
{
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
var admin = new AdminRecord
{
Id = Guid.NewGuid(),
DisplayName = name,
Role = AdminRoles.Owner,
Status = AdminStatus.Active,
CreatedAt = now,
CreatedBy = actor,
ApprovedAt = now,
ApprovedBy = actor,
Keys =
{
new KeyRecord
{
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
}
}
};
db.Admins.Add(admin);
return admin.Id;
});
audit.Write("cli.add_owner", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
o.WriteLine($"Propriétaire créé : {name} ({id:D})");
o.WriteLine($" Clé : {keyId}");
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
return 0;
}
/// <summary>Restaure l'accès d'un administrateur qui a perdu tous ses appareils (clé active immédiatement).</summary>
private static int AddKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
{
if (!Guid.TryParse(Get(options, "admin"), out var adminId)) throw new ArgumentException("--admin <identifiant> obligatoire (voir « list »).");
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
var spki = AdminKeys.ParsePublicKey(File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg);
var keyId = AdminKeys.ComputeKeyId(spki);
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
var now = DateTimeOffset.UtcNow;
var name = store.Update(db =>
{
var admin = db.FindAdmin(adminId) ?? throw new InvalidOperationException("Administrateur introuvable.");
if (admin.Status == AdminStatus.Revoked) throw new InvalidOperationException("Cet administrateur est révoqué : créez un nouveau compte.");
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
admin.Keys.Add(new KeyRecord
{
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
});
if (admin.Status == AdminStatus.Pending)
{
admin.Status = AdminStatus.Active;
admin.ApprovedAt = now;
admin.ApprovedBy = actor;
}
return admin.DisplayName;
});
audit.Write("cli.add_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
o.WriteLine($"Clé ajoutée à {name} : {keyId}");
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
return 0;
}
private static int List(AdminStore store, TextWriter o)
{
var db = store.Read();
if (db.Admins.Count == 0) o.WriteLine("Aucun administrateur.");
foreach (var admin in db.Admins.OrderBy(a => a.CreatedAt))
{
o.WriteLine($"{admin.DisplayName} [{db.GroupName(admin.Role)}, {admin.Status}] {admin.Id:D}");
foreach (var key in admin.Keys)
o.WriteLine($" clé {key.KeyId} {AdminKeys.ComputeFingerprint(key.PublicKeyBytes())} {key.Status,-8} {key.Label}" +
(key.LastUsedAt is { } used ? $" (dernière connexion {used:yyyy-MM-dd HH:mm} UTC)" : ""));
}
var now = DateTimeOffset.UtcNow;
foreach (var invitation in db.Invitations.Where(i => i.ExpiresAt > now))
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({db.GroupName(invitation.Role)}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
foreach (var group in db.Groups)
o.WriteLine($"Groupe {group.Id} : {group.Name} — " +
(Contracts.Security.AdminGroups.IsSystem(group.Id) ? "toutes les permissions" : string.Join(", ", db.PermissionsOf(group.Id))));
return 0;
}
private static int RevokeAdmin(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
{
if (!Guid.TryParse(Get(options, "id"), out var id)) throw new ArgumentException("--id <identifiant> obligatoire (voir « list »).");
var now = DateTimeOffset.UtcNow;
var name = store.Update(db =>
{
var admin = db.FindAdmin(id) ?? throw new InvalidOperationException("Administrateur introuvable.");
admin.Status = AdminStatus.Revoked;
admin.RevokedAt = now;
admin.RevokedBy = actor;
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
{
key.Status = AdminStatus.Revoked;
key.RevokedAt = now;
key.RevokedBy = actor;
}
db.Invitations.RemoveAll(i => i.ForAdminId == id);
return admin.DisplayName;
});
audit.Write("cli.revoke_admin", AuditResults.Ok, actorName: actor, target: name);
o.WriteLine($"Administrateur révoqué : {name}. Ses sessions sont fermées à sa prochaine requête.");
return 0;
}
private static int RevokeKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
{
var keyId = Get(options, "key")?.ToLowerInvariant();
if (!AdminKeys.IsValidKeyId(keyId)) throw new ArgumentException("--key <identifiant de clé> obligatoire (voir « list »).");
var name = store.Update(db =>
{
var (admin, key) = db.FindKey(keyId!) ?? throw new InvalidOperationException("Clé introuvable.");
key.Status = AdminStatus.Revoked;
key.RevokedAt = DateTimeOffset.UtcNow;
key.RevokedBy = actor;
return admin.DisplayName;
});
audit.Write("cli.revoke_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
o.WriteLine($"Clé {keyId} de {name} révoquée.");
return 0;
}
private static int Help(TextWriter o)
{
o.WriteLine("""
Launcher API — outil d'administration (à lancer sur le VPS, sous le compte du service)
add-owner --name <nom> --public-key <fichier.pub | Base64> [--label <poste>]
Crée un propriétaire actif (premier administrateur, ou reprise de la main).
add-key --admin <identifiant> --public-key <fichier.pub | Base64> [--label <poste>]
Restaure l'accès d'un administrateur qui a perdu tous ses appareils.
list Administrateurs, clés (empreintes) et invitations en cours.
revoke-admin --id <identifiant> Révoque un administrateur et toutes ses clés.
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
server-id Affiche l'identifiant de ce serveur.
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
""");
return 0;
}
private static Dictionary<string, string> ReadOptions(string[] args)
{
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 1; i < args.Length; i++)
{
if (!args[i].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException($"Argument inattendu : {args[i]}");
if (i + 1 >= args.Length) throw new ArgumentException($"Valeur manquante pour {args[i]}");
options[args[i][2..]] = args[++i];
}
return options;
}
/// <summary>
/// Même configuration que le service : appsettings.json, puis appsettings.{environnement}.json
/// (Production par défaut, comme le service systemd), puis variables d'environnement.
/// </summary>
private static string ResolveDataDirectory(Dictionary<string, string> options)
{
if (Get(options, "data-dir") is { Length: > 0 } explicitDirectory) return Path.GetFullPath(explicitDirectory);
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
var directory = configuration[$"{LauncherApiOptions.Section}:DataDirectory"] ?? new LauncherApiOptions().DataDirectory;
return Path.GetFullPath(Path.IsPathRooted(directory) ? directory : Path.Combine(AppContext.BaseDirectory, directory));
}
private static string? Get(Dictionary<string, string> options, string name) =>
options.TryGetValue(name, out var value) ? value : null;
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Launcher API : serveur d'administration (et, plus tard, des fonctionnalités « joueurs »).
Déployé sur le VPS derrière nginx (https://<hote>/api/), service systemd durci.
Voir docs\ADMIN-ARCHITECTURE.md et server\deploy\LISEZMOI.md.
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
tools\publish-server.bat
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Server</RootNamespace>
<AssemblyName>GameLauncher.Server</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
<Version>1.0.0</Version>
<Product>Launcher API</Product>
<!-- Pas de dépendance à libicu sur le VPS ; les textes restent en UTF-8. -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
using GameLauncher.Contracts.Dtos;
namespace GameLauncher.Server.Hosting;
/// <summary>
/// Refus d'une requête (levé n'importe où, y compris dans <see cref="Storage.AdminStore.Update{T}"/> :
/// rien n'est alors écrit). Transformé en réponse <see cref="ApiError"/> par ServerApp.
/// </summary>
public sealed class ApiRejection : Exception
{
public ApiRejection(int status, string code, string message) : base(message)
{
Status = status;
Code = code;
}
public int Status { get; }
public string Code { get; }
public static ApiRejection BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => new(400, code, message);
public static ApiRejection NotFound(string message = "Élément introuvable.") => new(404, ApiErrorCodes.NotFound, message);
public static ApiRejection Conflict(string message, string code = ApiErrorCodes.Conflict) => new(409, code, message);
}
@@ -0,0 +1,26 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
namespace GameLauncher.Server.Hosting;
/// <summary>Réponses d'erreur uniformes (<see cref="ApiError"/>), sans détail interne.</summary>
public static class ApiResults
{
public static IResult Error(int status, string code, string message) =>
Results.Json(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options, statusCode: status);
public static IResult BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => Error(400, code, message);
public static IResult Unauthorized(string message = "Authentification requise.", string code = ApiErrorCodes.Unauthorized) =>
Error(401, code, message);
public static IResult Forbidden(string message = "Action non autorisée.") => Error(403, ApiErrorCodes.Forbidden, message);
public static IResult NotFound(string message = "Élément introuvable.") => Error(404, ApiErrorCodes.NotFound, message);
public static IResult Conflict(string message, string code = ApiErrorCodes.Conflict) => Error(409, code, message);
public static IResult Busy() => Error(503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
/// <summary>Écrit une erreur directement dans la réponse (middlewares, authentification).</summary>
public static Task WriteAsync(HttpContext context, int status, string code, string message)
{
context.Response.StatusCode = status;
return context.Response.WriteAsJsonAsync(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options);
}
}
@@ -0,0 +1,25 @@
using System.Net;
using System.Net.Sockets;
namespace GameLauncher.Server.Hosting;
public static class HttpContextExtensions
{
/// <summary>Adresse IP du client (celle transmise par nginx, seul proxy de confiance).</summary>
public static string? ClientIp(this HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
/// <summary>
/// Partition des limites de débit : adresse IPv4, ou préfixe /64 en IPv6 (un abonné dispose
/// généralement de tout un /64 : sans regroupement, chaque adresse aurait sa propre limite).
/// </summary>
public static string RateLimitPartition(this HttpContext context)
{
var ip = context.Connection.RemoteIpAddress;
if (ip is null) return "inconnue";
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
if (ip.AddressFamily != AddressFamily.InterNetworkV6) return ip.ToString();
var bytes = ip.GetAddressBytes();
Array.Clear(bytes, 8, 8);
return new IPAddress(bytes) + "/64";
}
}
@@ -0,0 +1,54 @@
namespace GameLauncher.Server.Hosting;
/// <summary>
/// Module du serveur : enregistre ses services et ses routes (sous « /api »). Les modules futurs
/// (catalogue, notes de version, publication, puis fonctionnalités « joueurs ») s'ajoutent à
/// <see cref="ApiModules.All"/> sans toucher au reste.
///
/// Règles : routes de lecture protégées par RequirePermission(…) (ou RequireAdmin()) ; routes qui
/// modifient protégées en plus par RequireSignedRequest() ; chaque modification écrite dans l'audit.
/// </summary>
public interface IApiModule
{
string Name { get; }
void ConfigureServices(IServiceCollection services, LauncherApiOptions options);
void MapEndpoints(IEndpointRouteBuilder api);
}
public static class ApiModules
{
/// <summary>Modules actifs, dans l'ordre d'enregistrement.</summary>
public static IReadOnlyList<IApiModule> All { get; } = new IApiModule[]
{
new Modules.System.SystemModule(),
new Modules.Auth.AuthModule(),
new Modules.Admins.AdminsModule(),
new Modules.Admins.GroupsModule(),
new Modules.Audit.AuditModule(),
new Modules.Catalog.CatalogModule(),
new Modules.Changelog.ChangelogModule(),
new Modules.Publishing.PublishingModule(),
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule(),
new Modules.Feedback.FeedbackModule(),
new Modules.Maintenance.MaintenanceModule()
};
}
/// <summary>Noms des limites de débit (voir ServerApp).</summary>
public static class RateLimitPolicies
{
public const string Auth = "auth";
public const string Enrollment = "enrollment";
/// <summary>
/// Morceaux envoyés pendant une publication (manifest, objets, archive) : limite propre, plus haute,
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
/// </summary>
public const string PublicationUpload = "publication-upload";
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
public const string Feedback = "feedback";
}
@@ -0,0 +1,545 @@
using System.Net;
namespace GameLauncher.Server.Hosting;
/// <summary>Réglages de la Launcher API (section « LauncherApi » d'appsettings.json ou variables d'environnement).</summary>
public sealed class LauncherApiOptions
{
public const string Section = "LauncherApi";
/// <summary>
/// Dossier des données privées (administrateurs, audit, identifiant du serveur). Jamais dans le
/// dossier servi par nginx. Relatif : par rapport au dossier de l'application.
/// </summary>
public string DataDirectory { get; set; } = "/var/lib/launcher-api";
/// <summary>
/// Adresse publique de l'API, sans chemin (ex. « https://launcher.lanisys.fr »). Incluse dans les
/// messages signés : une signature produite pour un autre serveur est refusée.
/// </summary>
public string PublicOrigin { get; set; } = "";
/// <summary>
/// Exige que les requêtes arrivent en HTTPS par le proxy (en-tête X-Forwarded-Proto de nginx).
/// Seule la route de santé reste accessible en HTTP, et seulement depuis la machine elle-même.
/// </summary>
public bool RequireHttps { get; set; } = true;
/// <summary>
/// Proxys autorisés à transmettre l'adresse et le protocole du client (X-Forwarded-For/Proto).
/// Vide : nginx sur la même machine (127.0.0.1 et ::1). Pas de valeur par défaut dans le tableau :
/// la configuration .NET AJOUTE les valeurs lues à celles d'un tableau déjà rempli, ce qui
/// laisserait 127.0.0.1 de confiance même si la configuration en désigne d'autres.
/// </summary>
public string[] TrustedProxies { get; set; } = Array.Empty<string>();
private static readonly string[] DefaultTrustedProxies = { "127.0.0.1", "::1" };
public SessionSettings Sessions { get; set; } = new();
public LimitSettings Limits { get; set; } = new();
public CatalogSettings Catalog { get; set; } = new();
public KeyringSettings Keyring { get; set; } = new();
public ContentSigningSettings ContentSigning { get; set; } = new();
public FilesSettings Files { get; set; } = new();
/// <summary>Demandes des joueurs (bouton « Aide » du launcher) : réception anonyme, consultation dans le menu.</summary>
public FeedbackSettings Feedback { get; set; } = new();
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
public AuditSettings Audit { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
/// </summary>
public Dictionary<string, GameSettings> Games { get; set; } = new();
/// <summary>Durée de validité d'un défi de connexion.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 60;
/// <summary>Durée de validité d'un code d'invitation.</summary>
public int InvitationLifetimeHours { get; set; } = 24;
public sealed class SessionSettings
{
public int IdleMinutes { get; set; } = 20;
public int MaxHours { get; set; } = 8;
public int MaxPerAdmin { get; set; } = 5;
public int MaxTotal { get; set; } = 500;
}
/// <summary>Catalogue des jeux (informations modifiables), publié en fichier statique servi par nginx.</summary>
public sealed class CatalogSettings
{
/// <summary>
/// Fichier public écrit par le service (chemin absolu, ex. « /srv/exovival/www/catalog/catalog.json »).
/// Vide : publication désactivée (le catalogue reste consultable dans le menu d'administration).
/// Le dossier doit exister, appartenir au compte du service, et être autorisé en écriture par
/// systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
/// </summary>
public string PublicFile { get; set; } = "";
/// <summary>Adresse publique du fichier (information). Vide : « PublicOrigin/catalog/catalog.json ».</summary>
public string PublicUrl { get; set; } = "";
}
/// <summary>
/// Trousseau (phase 7) : clés autorisées à signer le contenu, signé par la clé racine HORS LIGNE puis publié
/// depuis la page « Signature » du menu d'administration.
/// </summary>
public sealed class KeyringSettings
{
/// <summary>
/// Fichier public (chemin absolu .json). Vide : « keyring.json » dans le dossier du catalogue (déjà autorisé
/// en écriture et servi par nginx), si le catalogue est configuré ; sinon publication désactivée.
/// </summary>
public string PublicFile { get; set; } = "";
/// <summary>Adresse publique (information, « keyringUrl » de launcher.json). Vide : à côté du catalogue.</summary>
public string PublicUrl { get; set; } = "";
}
/// <summary>Signature du contenu servi aux joueurs (phase 7).</summary>
public sealed class ContentSigningSettings
{
/// <summary>
/// Vrai : catalogue et versions ne sont publiés que signés (refus sinon). À activer une fois le trousseau
/// publié et les launchers en mode « require » : évite de mettre en ligne un contenu que ces launchers refuseraient.
/// </summary>
public bool Require { get; set; }
}
/// <summary>Page « Fichiers du serveur » du menu d'administration.</summary>
public sealed class FilesSettings
{
/// <summary>
/// Racine du site public (chemin absolu, ex. « /srv/exovival/www ») : seul dossier consultable depuis le menu.
/// Vide : dossier parent commun des jeux configurés et du catalogue. La suppression n'est possible que là où
/// systemd autorise l'écriture (ReadWritePaths : dossiers des jeux et du catalogue).
/// </summary>
public string Root { get; set; } = "";
}
/// <summary>
/// Demandes des joueurs (avis, évolutions, bugs…), envoyées SANS compte depuis le bouton « Aide » du launcher.
/// Route anonyme : preuve de travail, limites par adresse IP (en mémoire uniquement) et plafonds globaux. Aucune
/// adresse IP n'est enregistrée avec une demande ni dans l'audit de son envoi.
/// </summary>
public sealed class FeedbackSettings
{
/// <summary>Réception activée (faux par défaut : les routes répondent 503 « not_configured »).</summary>
public bool Enabled { get; set; }
/// <summary>
/// Identifiants des jeux acceptés (GameId de launcher.json). Vide : les jeux de <see cref="LauncherApiOptions.Games"/>.
/// Une demande pour un autre jeu est refusée.
/// </summary>
public string[] Games { get; set; } = Array.Empty<string>();
/// <summary>Difficulté du défi anti-spam (bits à zéro) : 18 ≈ une fraction de seconde sur un PC. À relever en cas d'attaque.</summary>
public int ProofDifficulty { get; set; } = 18;
/// <summary>Durée de validité d'un défi.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 600;
/// <summary>Demandes acceptées par adresse IP (IPv6 : par /64) sur 10 minutes, et sur 24 heures.</summary>
public int PerIpPer10Minutes { get; set; } = 3;
public int PerIpPerDay { get; set; } = 10;
/// <summary>Défis et envois par minute et par adresse IP (limite de débit ASP.NET Core, avant tout traitement).</summary>
public int RequestsPerMinute { get; set; } = 10;
/// <summary>Plafonds : demandes reçues par jeu sur 24 heures, demandes conservées par jeu, taille totale (Mo).</summary>
public int MaxPerGamePerDay { get; set; } = 300;
public int MaxPerGame { get; set; } = 5000;
public int MaxTotalMegabytes { get; set; } = 1024; // captures d'écran comprises (4 Mo chacune au plus)
/// <summary>Espace disque libre minimal (Mo) : en dessous, les nouvelles demandes sont refusées.</summary>
public int MinFreeDiskMegabytes { get; set; } = 500;
/// <summary>Conservation : demande close supprimée N mois après sa clôture ; toute demande au plus M mois après réception.</summary>
public int RetentionClosedMonths { get; set; } = 12;
public int RetentionMaxMonths { get; set; } = 24;
}
/// <summary>Journal d'audit (fichiers audit-AAAA-MM.jsonl).</summary>
public sealed class AuditSettings
{
/// <summary>
/// Mois conservés : un fichier mensuel est supprimé une fois tous ses jours plus anciens que cette durée
/// (CNIL : 6 mois à 1 an pour les journaux de sécurité). Vérifié au démarrage puis chaque jour.
/// </summary>
public int RetentionMonths { get; set; } = 12;
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
/// <summary>
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
/// </summary>
public string ManifestFile { get; set; } = "";
}
public sealed class LimitSettings
{
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
public int RequestsPerMinute { get; set; } = 300;
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
public int AuthPerMinute { get; set; } = 20;
/// <summary>
/// Morceaux envoyés pendant une publication, par minute et par adresse IP (à la place de
/// <see cref="RequestsPerMinute"/> pour ces routes). 1500 morceaux de 4 Mo ≈ 100 Mo/s. Ces requêtes
/// exigent une session et le droit de publier : le corps n'est lu qu'après ces contrôles.
/// </summary>
public int PublicationUploadsPerMinute { get; set; } = 1500;
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
public int EnrollmentsPerHour { get; set; } = 10;
/// <summary>Invitations actives simultanément.</summary>
public int MaxActiveInvitations { get; set; } = 20;
/// <summary>Taille maximale d'un corps de requête (octets).</summary>
public long MaxRequestBodyBytes { get; set; } = 64 * 1024;
}
/// <summary>Origine normalisée (« https://hote[:port] »), calculée par <see cref="Validate"/>.</summary>
public string NormalizedOrigin { get; private set; } = "";
/// <summary>Dossier des données en chemin absolu, calculé par <see cref="Validate"/>.</summary>
public string DataDirectoryFullPath { get; private set; } = "";
public IReadOnlyList<IPAddress> TrustedProxyAddresses { get; private set; } = Array.Empty<IPAddress>();
/// <summary>Fichier public du catalogue en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
public string? CatalogPublicFileFullPath { get; private set; }
/// <summary>Adresse publique du catalogue, calculée par <see cref="Validate"/>.</summary>
public string CatalogPublicUrl { get; private set; } = "";
/// <summary>
/// Hôte de <see cref="CatalogPublicUrl"/> : seul hôte autorisé pour les images du catalogue (le
/// launcher n'accepte que des images du même hôte que le catalogue).
/// </summary>
public string CatalogImageHost { get; private set; } = "";
/// <summary>Fichier public du trousseau en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
public string? KeyringPublicFileFullPath { get; private set; }
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
public string KeyringPublicUrl { get; private set; } = "";
/// <summary>
/// Fichier public des formulaires des demandes (feedback-forms.json, à côté du trousseau ; null : publication
/// désactivée), calculé par <see cref="Validate"/>. Les launchers le lisent à la même adresse que le trousseau.
/// </summary>
public string? FeedbackFormsPublicFileFullPath { get; private set; }
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
public string FeedbackFormsPublicUrl { get; private set; } = "";
/// <summary>
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
/// </summary>
public string? MaintenancePublicFileFullPath { get; private set; }
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
public string MaintenancePublicUrl { get; private set; } = "";
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
public string? FilesRootFullPath { get; private set; }
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
public void Validate(string contentRoot)
{
if (!Uri.TryCreate(PublicOrigin, UriKind.Absolute, out var origin) ||
!string.IsNullOrEmpty(origin.Query) || origin.AbsolutePath != "/")
throw new InvalidOperationException(
$"{Section}:PublicOrigin doit être une adresse sans chemin, ex. « https://launcher.lanisys.fr ».");
if (origin.Scheme != Uri.UriSchemeHttps && !(origin.Scheme == Uri.UriSchemeHttp && origin.IsLoopback && !RequireHttps))
throw new InvalidOperationException($"{Section}:PublicOrigin doit être en https://.");
NormalizedOrigin = Contracts.Security.SignedMessages.NormalizeOrigin(origin);
if (string.IsNullOrWhiteSpace(DataDirectory))
throw new InvalidOperationException($"{Section}:DataDirectory est obligatoire.");
DataDirectoryFullPath = Path.GetFullPath(Path.IsPathRooted(DataDirectory)
? DataDirectory
: Path.Combine(contentRoot, DataDirectory));
var proxies = new List<IPAddress>();
foreach (var proxy in TrustedProxies is { Length: > 0 } configured ? configured : DefaultTrustedProxies)
{
if (!IPAddress.TryParse(proxy, out var address))
throw new InvalidOperationException($"{Section}:TrustedProxies : adresse invalide « {proxy} ».");
proxies.Add(address);
}
TrustedProxyAddresses = proxies;
ValidateCatalog();
ValidateKeyring();
ValidateGames();
ValidateFiles();
ValidateFeedback();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1 &&
Limits.PublicationUploadsPerMinute >= 1, "Limits");
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
}
private void ValidateFeedback()
{
var feedback = Feedback;
Require(feedback.ProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
$"Feedback:ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
Require(feedback.ChallengeLifetimeSeconds is >= 60 and <= 3600, "Feedback:ChallengeLifetimeSeconds (60 à 3600)");
Require(feedback.PerIpPer10Minutes is >= 1 and <= 1000 && feedback.PerIpPerDay is >= 1 and <= 10_000 &&
feedback.RequestsPerMinute is >= 1 and <= 10_000, "Feedback : limites par adresse IP");
Require(feedback.MaxPerGamePerDay is >= 1 and <= 100_000, "Feedback:MaxPerGamePerDay");
Require(feedback.MaxPerGame is >= 1 and <= 50_000, "Feedback:MaxPerGame (1 à 50000)");
Require(feedback.MaxTotalMegabytes is >= 1 and <= 10_000, "Feedback:MaxTotalMegabytes");
Require(feedback.MinFreeDiskMegabytes is >= 0 and <= 1_000_000, "Feedback:MinFreeDiskMegabytes");
Require(feedback.RetentionClosedMonths is >= 1 and <= 120, "Feedback:RetentionClosedMonths (1 à 120)");
Require(feedback.RetentionMaxMonths is >= 1 and <= 120 && feedback.RetentionMaxMonths >= feedback.RetentionClosedMonths,
"Feedback:RetentionMaxMonths (1 à 120, au moins RetentionClosedMonths)");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var id in feedback.Games is { Length: > 0 } configured ? configured : Games.Keys.ToArray())
{
if (!Contracts.Catalog.CatalogRules.IsValidGameId(id))
throw new InvalidOperationException($"{Section}:Feedback:Games : identifiant de jeu invalide « {id} ».");
ids.Add(id);
}
FeedbackGameIds = ids;
}
private void ValidateCatalog()
{
CatalogPublicFileFullPath = null;
if (!string.IsNullOrWhiteSpace(Catalog.PublicFile))
{
var file = Catalog.PublicFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile doit être un chemin absolu vers un fichier .json.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
CatalogPublicFileFullPath = full;
}
if (string.IsNullOrWhiteSpace(Catalog.PublicUrl))
{
CatalogPublicUrl = NormalizedOrigin + "/catalog/catalog.json";
}
else if (Uri.TryCreate(Catalog.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
{
CatalogPublicUrl = url.AbsoluteUri;
}
else
{
throw new InvalidOperationException($"{Section}:Catalog:PublicUrl doit être une adresse https://.");
}
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
}
private void ValidateKeyring()
{
KeyringPublicFileFullPath = null;
if (!string.IsNullOrWhiteSpace(Keyring.PublicFile))
{
var file = Keyring.PublicFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être un chemin absolu vers un fichier .json.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, "Keyring:PublicFile");
KeyringPublicFileFullPath = full;
}
else if (CatalogPublicFileFullPath is { } catalog)
{
KeyringPublicFileFullPath = Path.Combine(Path.GetDirectoryName(catalog)!, "keyring.json");
}
if (KeyringPublicFileFullPath is { } keyring && CatalogPublicFileFullPath is { } catalogFile &&
string.Equals(keyring, catalogFile, StringComparison.Ordinal))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être différent du fichier du catalogue.");
if (string.IsNullOrWhiteSpace(Keyring.PublicUrl))
{
KeyringPublicUrl = new Uri(new Uri(CatalogPublicUrl), "keyring.json").AbsoluteUri;
}
else if (Uri.TryCreate(Keyring.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
{
KeyringPublicUrl = url.AbsoluteUri;
}
else
{
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
}
// Formulaires des demandes : toujours à côté du trousseau (les launchers en déduisent l'adresse).
FeedbackFormsPublicFileFullPath = KeyringPublicFileFullPath is { } keyringFile
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
: null;
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
: null;
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
if (MaintenancePublicFileFullPath is { } maintenance &&
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException(
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
}
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
public const string FeedbackFormsFileName = "feedback-forms.json";
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
public const string MaintenanceFileName = "maintenance.json";
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (gameId, game) in Games)
{
var name = $"Games:{gameId}:ManifestFile";
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
var file = game.ManifestFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, name);
if (files.Values.Contains(full, StringComparer.Ordinal))
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
files[gameId] = full;
}
GameManifestFiles = files;
}
private void ValidateFiles()
{
FilesRootFullPath = null;
if (!string.IsNullOrWhiteSpace(Files.Root))
{
var root = Files.Root.Trim();
if (!Path.IsPathRooted(root))
throw new InvalidOperationException($"{Section}:Files:Root doit être un chemin absolu (racine du site, ex. « /srv/exovival/www »).");
var full = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
if (!Directory.Exists(full))
throw new InvalidOperationException($"{Section}:Files:Root : le dossier « {full} » n'existe pas.");
if (IsFileSystemRoot(full))
throw new InvalidOperationException($"{Section}:Files:Root ne peut pas être la racine du disque.");
EnsureSeparatedFromData(full, "Files:Root");
FilesRootFullPath = full;
return;
}
// Par défaut : dossier parent commun des jeux et du catalogue (ex. /srv/exovival/www). Rien de configuré,
// racine du disque ou mélange avec les données privées : page désactivée (sans empêcher le démarrage).
var folders = GameManifestFiles.Values.Select(Path.GetDirectoryName)
.Concat(new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Where(f => f is not null).Select(Path.GetDirectoryName))
.OfType<string>().Select(d => Path.GetFullPath(d).TrimEnd(Path.DirectorySeparatorChar)).Distinct().ToList();
if (folders.Count == 0) return;
var common = folders.Count == 1 ? folders[0] : CommonParent(folders);
if (common is null || IsFileSystemRoot(common) || !Directory.Exists(common)) return;
var dataDirectory = RealPath(DataDirectoryFullPath);
var realCommon = RealPath(common);
if (IsSameOrInside(realCommon, dataDirectory) || IsSameOrInside(dataDirectory, realCommon)) return;
FilesRootFullPath = common;
}
private static string? CommonParent(IReadOnlyList<string> folders)
{
string? common = folders[0];
foreach (var folder in folders.Skip(1))
while (common is not null && !IsSameOrInside(folder, common))
common = Path.GetDirectoryName(common);
return common;
}
private static bool IsFileSystemRoot(string path)
{
var full = Path.GetFullPath(path);
return string.Equals(Path.GetPathRoot(full)?.TrimEnd(Path.DirectorySeparatorChar), full.TrimEnd(Path.DirectorySeparatorChar),
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ||
full == "/";
}
/// <summary>
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
/// dans l'autre (liens symboliques résolus).
/// </summary>
private void EnsureSeparatedFromData(string directory, string name)
{
var publicDirectory = RealPath(directory);
var dataDirectory = RealPath(DataDirectoryFullPath);
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
throw new InvalidOperationException(
$"{Section}:{name} : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
}
/// <summary>Chemin absolu, dernier élément résolu s'il s'agit d'un lien symbolique.</summary>
private static string RealPath(string directory)
{
var full = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar);
try
{
var target = new DirectoryInfo(full).ResolveLinkTarget(returnFinalTarget: true);
return target is null ? full : Path.GetFullPath(target.FullName).TrimEnd(Path.DirectorySeparatorChar);
}
catch (IOException)
{
return full;
}
}
private static bool IsSameOrInside(string path, string parent)
{
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return string.Equals(path, parent, comparison) ||
path.StartsWith(parent + Path.DirectorySeparatorChar, comparison);
}
private static void Require(bool condition, string name)
{
if (!condition) throw new InvalidOperationException($"{Section}:{name} : valeur hors limites.");
}
}
@@ -0,0 +1,80 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Hosting;
/// <summary>
/// Durées de conservation (RGPD, recommandations de la CNIL) appliquées automatiquement, au démarrage du service
/// puis toutes les 24 heures :
/// • journal d'audit : fichiers mensuels plus anciens que <c>LauncherApi:Audit:RetentionMonths</c> (12 mois) ;
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
/// <c>Feedback:RetentionMaxMonths</c> (24).
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
/// </summary>
public sealed class RetentionService : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
private readonly AuditLog _audit;
private readonly FeedbackStore _feedback;
private readonly LauncherApiOptions _options;
private readonly TimeProvider _time;
private readonly ILogger<RetentionService> _logger;
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
ILogger<RetentionService> logger)
{
_audit = audit;
_feedback = feedback;
_options = options.Value;
_time = time;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
RunOnce();
try
{
await Task.Delay(Interval, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
/// <summary>Une passe de purge (appelée aussi par les tests).</summary>
public void RunOnce()
{
try
{
var months = _audit.Purge(_options.Audit.RetentionMonths);
if (months.Count > 0)
_audit.Write("audit.purge", AuditResults.Ok,
details: $"{months.Count} mois supprimé(s) ({string.Join(", ", months)}) ; conservation {_options.Audit.RetentionMonths} mois");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge du journal d'audit impossible");
}
try
{
var removed = _feedback.Purge(_time.GetUtcNow());
if (removed > 0)
_audit.Write("feedback.purge", AuditResults.Ok,
details: $"{removed} demande(s) supprimée(s) ; conservation {_options.Feedback.RetentionClosedMonths} mois après clôture, " +
$"{_options.Feedback.RetentionMaxMonths} mois au plus");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
}
}
}
@@ -0,0 +1,212 @@
using System.Globalization;
using System.Threading.RateLimiting;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Hosting;
/// <summary>
/// Construction du serveur : réglages, sécurité (proxy, HTTPS, en-têtes, limites, authentification)
/// puis modules. Utilisé par Program et par les tests (serveur démarré en mémoire).
/// </summary>
public static class ServerApp
{
public static WebApplication Build(string[] args, Action<WebApplicationBuilder>? configure = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
ContentRootPath = AppContext.BaseDirectory // appsettings.json à côté de l'exécutable, quel que soit le dossier courant
});
configure?.Invoke(builder);
var options = builder.Configuration.GetSection(LauncherApiOptions.Section).Get<LauncherApiOptions>() ?? new LauncherApiOptions();
options.Validate(builder.Environment.ContentRootPath);
builder.Services.AddSingleton(Options.Create(options));
builder.Services.TryAddTimeProvider();
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.AddServerHeader = false;
kestrel.Limits.MaxRequestBodySize = options.Limits.MaxRequestBodyBytes;
kestrel.Limits.MaxRequestHeaderCount = 40;
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
kestrel.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
});
builder.Services.Configure<ForwardedHeadersOptions>(forwarded =>
{
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
forwarded.ForwardLimit = 1;
forwarded.KnownNetworks.Clear();
forwarded.KnownProxies.Clear();
foreach (var proxy in options.TrustedProxyAddresses) forwarded.KnownProxies.Add(proxy);
});
builder.Services.ConfigureHttpJsonOptions(json =>
{
var source = ApiJson.Options;
json.SerializerOptions.PropertyNamingPolicy = source.PropertyNamingPolicy;
json.SerializerOptions.PropertyNameCaseInsensitive = source.PropertyNameCaseInsensitive;
json.SerializerOptions.DefaultIgnoreCondition = source.DefaultIgnoreCondition;
json.SerializerOptions.ReadCommentHandling = source.ReadCommentHandling;
json.SerializerOptions.AllowTrailingCommas = source.AllowTrailingCommas;
json.SerializerOptions.MaxDepth = source.MaxDepth;
json.SerializerOptions.NumberHandling = source.NumberHandling;
});
// Corps JSON invalide : exception transformée en erreur 400 lisible (voir HandleErrors).
builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>(o => o.ThrowOnBadRequest = true);
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = (context, _) =>
{
// Délai avant la prochaine fenêtre : le launcher reprend juste à temps au lieu d'attendre à l'aveugle.
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
};
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
IsPublicationUpload(context)
? RateLimitPartition.GetNoLimiter("publication") // limite propre (politique PublicationUpload)
: RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.PublicationUpload, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.PublicationUploadsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
});
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
// mais initialisée par le framework. Ses clés restent dans le dossier privé des données (rien dans $HOME).
var dataProtection = Path.Combine(options.DataDirectoryFullPath, "data-protection");
SecureFiles.EnsureDirectory(options.DataDirectoryFullPath);
SecureFiles.EnsureDirectory(dataProtection);
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtection));
builder.Logging.AddFilter("Microsoft.AspNetCore.DataProtection", LogLevel.Error);
builder.Services.AddSingleton<ServerIdentity>();
builder.Services.AddSingleton<AdminStore>();
builder.Services.AddAdminAuthentication();
foreach (var module in ApiModules.All) module.ConfigureServices(builder.Services, options);
// Conservation (RGPD) : purge du journal d'audit et des demandes des joueurs, au démarrage puis chaque jour.
builder.Services.AddHostedService<RetentionService>();
var app = builder.Build();
app.UseForwardedHeaders();
app.Use(SecurityHeaders);
app.Use(HandleErrors);
app.Use((context, next) => RequireHttps(context, next, options));
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseRequestBodyCapture(); // après les limites et les droits : une requête refusée n'est pas lue
var api = app.MapGroup(ApiRoutes.Prefix);
foreach (var module in ApiModules.All) module.MapEndpoints(api);
app.MapFallback((HttpContext context) => ApiResults.NotFound("Adresse inconnue."));
// Identité et fichiers vérifiés dès le démarrage (un fichier illisible empêche le démarrage).
var identity = app.Services.GetRequiredService<ServerIdentity>();
var admins = app.Services.GetRequiredService<AdminStore>().Read();
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
if (!admins.UsableOwners().Any())
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
return app;
}
/// <summary>Route d'envoi de morceaux d'une publication (politique <see cref="RateLimitPolicies.PublicationUpload"/>).</summary>
private static bool IsPublicationUpload(HttpContext context) =>
context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == RateLimitPolicies.PublicationUpload;
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
{
PermitLimit = permits,
Window = window,
QueueLimit = 0,
AutoReplenishment = true
};
private static void TryAddTimeProvider(this IServiceCollection services)
{
if (services.All(s => s.ServiceType != typeof(TimeProvider))) services.AddSingleton(TimeProvider.System);
}
/// <summary>En-têtes de sécurité sur toutes les réponses (l'API ne sert jamais de page web).</summary>
private static Task SecurityHeaders(HttpContext context, Func<Task> next)
{
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
headers.CacheControl = "no-store";
headers.Pragma = "no-cache";
headers.XContentTypeOptions = "nosniff";
headers.XFrameOptions = "DENY";
headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
headers["Referrer-Policy"] = "no-referrer";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
return Task.CompletedTask;
});
return next();
}
/// <summary>Exceptions → réponses <see cref="ApiError"/> sans détail interne (le détail va dans journalctl).</summary>
private static async Task HandleErrors(HttpContext context, Func<Task> next)
{
try
{
await next();
}
catch (ApiRejection rejection) when (!context.Response.HasStarted)
{
await ApiResults.WriteAsync(context, rejection.Status, rejection.Code, rejection.Message);
}
catch (StoreBusyException ex) when (!context.Response.HasStarted)
{
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogWarning("{Message}", ex.Message);
await ApiResults.WriteAsync(context, 503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
}
catch (BadHttpRequestException ex) when (!context.Response.HasStarted)
{
var status = ex.StatusCode == StatusCodes.Status413PayloadTooLarge ? 413 : 400;
await ApiResults.WriteAsync(context, status, ApiErrorCodes.InvalidRequest,
status == 413 ? "Requête trop volumineuse." : "Requête invalide.");
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// client parti
}
catch (Exception ex) when (!context.Response.HasStarted)
{
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogError(ex, "Erreur sur {Path}", context.Request.Path);
await ApiResults.WriteAsync(context, 500, ApiErrorCodes.Error, "Erreur interne du serveur.");
}
}
/// <summary>
/// HTTPS exigé (via nginx). Exception : la route de santé, appelée en HTTP depuis la machine
/// elle-même (surveillance), sans passer par nginx.
/// </summary>
private static Task RequireHttps(HttpContext context, Func<Task> next, LauncherApiOptions options)
{
if (!options.RequireHttps || context.Request.IsHttps) return next();
var local = context.Connection.RemoteIpAddress is { } ip && System.Net.IPAddress.IsLoopback(ip) &&
!context.Request.Headers.ContainsKey("X-Forwarded-For");
if (local && context.Request.Path.Equals(ApiRoutes.Prefix + "/" + ApiRoutes.Health, StringComparison.OrdinalIgnoreCase))
return next();
return ApiResults.WriteAsync(context, 400, ApiErrorCodes.HttpsRequired, "HTTPS obligatoire.");
}
}
@@ -0,0 +1,68 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Admins;
/// <summary>
/// Règles communes aux modifications des administrateurs et des groupes, appliquées DANS la modification
/// (sous le verrou d'admins.json, sur les données relues) : l'auteur et ses droits sont revérifiés au moment
/// d'écrire, et personne ne peut se donner (ni donner à un autre) plus de droits qu'il n'en a.
/// </summary>
internal static class AdminRules
{
/// <summary>
/// L'auteur est toujours actif, sa clé aussi, et il a encore <paramref name="requiredPermission"/> (null : simple
/// administrateur connecté). Sinon : 403, rien n'est écrit. Renvoie ses permissions actuelles.
/// </summary>
public static IReadOnlyList<string> EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, string? requiredPermission)
{
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active)
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Vos droits ont changé entre-temps : action refusée.");
var permissions = db.PermissionsOf(current.Admin.Role);
if (requiredPermission is not null && !permissions.Contains(requiredPermission, StringComparer.Ordinal))
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
return permissions;
}
/// <summary>L'auteur est propriétaire (groupe système), d'après les données relues.</summary>
public static bool IsOwner(AdminDatabase db, AuthenticatedAdmin actor) =>
db.FindAdmin(actor.Admin.Id) is { } admin && AdminGroups.IsSystem(admin.Role);
/// <summary>
/// Placer quelqu'un dans ce groupe (invitation, changement de groupe, approbation d'un compte en attente) :
/// seul un propriétaire donne le groupe « Propriétaire », et personne ne donne des permissions qu'il n'a pas.
/// </summary>
public static void EnsureCanAssign(AdminDatabase db, AuthenticatedAdmin actor, string groupId)
{
if (AdminGroups.IsSystem(groupId) && !IsOwner(db, actor))
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Seul un propriétaire peut donner le groupe « Propriétaire ».");
EnsureWithinOwnRights(db, actor, db.PermissionsOf(groupId),
missing => $"Ce groupe a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas y placer un administrateur.");
}
/// <summary>
/// Gérer un autre administrateur (changer son groupe, le révoquer, révoquer un de ses appareils) : seul un
/// propriétaire gère un propriétaire, et personne ne gère un administrateur qui a des permissions qu'il n'a pas.
/// </summary>
public static void EnsureCanManage(AdminDatabase db, AuthenticatedAdmin actor, AdminRecord target)
{
if (AdminGroups.IsSystem(target.Role) && !IsOwner(db, actor))
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Seul un propriétaire peut gérer un autre propriétaire.");
EnsureWithinOwnRights(db, actor, db.PermissionsOf(target.Role),
missing => $"{target.DisplayName} a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas gérer son compte.");
}
/// <summary>Toutes ces permissions sont aussi celles de l'auteur (sinon 403, en les nommant).</summary>
public static void EnsureWithinOwnRights(AdminDatabase db, AuthenticatedAdmin actor, IEnumerable<string> permissions,
Func<string, string> message)
{
var own = db.FindAdmin(actor.Admin.Id) is { } admin ? db.PermissionsOf(admin.Role) : Array.Empty<string>();
var missing = permissions.Where(p => !own.Contains(p, StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToList();
if (missing.Count > 0)
throw new ApiRejection(403, ApiErrorCodes.Forbidden, message(string.Join(", ", missing.Take(5))));
}
}
@@ -0,0 +1,374 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Admins;
/// <summary>
/// Gestion des administrateurs (permission admins.manage = propriétaires) : invitations,
/// approbation des clés par empreinte, changement de rôle, révocation d'une clé ou d'un compte.
/// Toutes les modifications sont des requêtes signées.
///
/// Nouvel appareil d'un compte actif : approuvé par le TITULAIRE du compte, depuis un de ses appareils
/// déjà actifs. Personne d'autre ne peut ajouter une clé à son nom, pas même un propriétaire (qui
/// pourrait sinon révoquer ses clés puis y ajouter la sienne). Compte sans aucun appareil actif :
/// restauration sur le VPS (outil en ligne de commande, « add-key »).
///
/// Garde-fous : on ne change pas son propre groupe, on ne se révoque pas soi-même, on ne révoque pas
/// la clé de sa propre session, et il reste toujours au moins un propriétaire capable de se connecter.
/// Groupes (voir <see cref="AdminRules"/>) : on ne place personne dans un groupe qui a des permissions que
/// l'on n'a pas soi-même, on ne gère pas un administrateur plus privilégié que soi, et seul un propriétaire
/// donne, retire ou révoque le groupe « Propriétaire ».
/// </summary>
public sealed class AdminsModule : IApiModule
{
public string Name => "Admins";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
public void MapEndpoints(IEndpointRouteBuilder api)
{
var manage = api.MapGroup("").RequirePermission(AdminPermissions.AdminsManage);
manage.MapGet(ApiRoutes.Admins, List);
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
// Ouvertes à tout administrateur connecté : chacun peut voir son compte, ajouter et approuver SES
// appareils, et supprimer les invitations « nouvel appareil » qu'il a créées pour lui-même ;
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
api.MapGet(ApiRoutes.MyAccount, MyAccount).RequireAdmin();
api.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
manage.MapPost("v1/admins/{adminId:guid}/role", ChangeRole).RequireSignedRequest();
manage.MapPost("v1/admins/{adminId:guid}/revoke", RevokeAdmin).RequireSignedRequest();
}
// ------------------------------------------------------------------ Lecture
private static IResult List(AdminStore store)
{
var db = store.Read();
return Results.Json(db.Admins.OrderBy(a => a.CreatedAt).Select(a => ToSummary(a, db)).ToList(), ApiJson.Options);
}
/// <summary>
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
/// la liste complète : il la lit sur le nouvel appareil) et ses invitations « nouvel appareil » en cours.
/// </summary>
private static IResult MyAccount(HttpContext context, AdminStore store, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var db = store.Read();
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
return Results.Json(new MyAccount
{
Admin = ToSummary(admin, db),
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
.Select(i => ToSummary(i, db)).ToList()
}, ApiJson.Options);
}
private static IResult ListInvitations(AdminStore store, TimeProvider time)
{
var now = time.GetUtcNow();
var db = store.Read();
return Results.Json(db.Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
.Select(i => ToSummary(i, db)).ToList(), ApiJson.Options);
}
// ------------------------------------------------------------------ Invitations
private static IResult CreateInvitation(CreateInvitationRequest request, HttpContext context, AdminStore store,
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var code = InvitationCodes.Generate();
var limits = options.Value;
var invitation = store.Update(db =>
{
if (request.ForAdminId == actor.Admin.Id) EnsureActor(db, actor, AdminPermission.None); // son propre appareil
else EnsureActor(db, actor);
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
if (!actor.Has(AdminPermissions.AdminsManage) && db.Invitations.Count(i => i.CreatedById == actor.Admin.Id) >= 2)
throw ApiRejection.Conflict("Vous avez déjà 2 invitations en cours : utilisez-les ou attendez leur expiration.");
if (db.Invitations.Count >= limits.Limits.MaxActiveInvitations)
throw ApiRejection.Conflict("Trop d'invitations en cours : supprimez-en avant d'en créer une autre.");
string displayName, role;
if (request.ForAdminId is { } adminId)
{
var target = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
if (target.Status == AdminStatus.Revoked)
throw ApiRejection.Conflict("Cet administrateur est révoqué.");
(displayName, role) = (target.DisplayName, target.Role);
}
else
{
displayName = ApiText.CleanLine(request.DisplayName, ApiText.MaxDisplayNameLength)
?? throw ApiRejection.BadRequest($"Nom obligatoire ({ApiText.MaxDisplayNameLength} caractères au plus).");
role = db.FindGroup(request.Role)?.Id ?? throw ApiRejection.BadRequest("Groupe inconnu : rechargez la page.");
AdminRules.EnsureCanAssign(db, actor, role);
}
var record = new InvitationRecord
{
Id = Guid.NewGuid(),
CodeHash = SignedMessages.HashInvitationCode(InvitationCodes.Normalize(code)!),
DisplayName = displayName,
Role = role,
ForAdminId = request.ForAdminId,
CreatedAt = now,
CreatedBy = actor.Admin.DisplayName,
CreatedById = actor.Admin.Id,
ExpiresAt = now + TimeSpan.FromHours(limits.InvitationLifetimeHours)
};
db.Invitations.Add(record);
return record;
});
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation, store.Read()), Code = code }, ApiJson.Options);
}
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
{
var actor = context.Admin();
var deleted = store.Update(db =>
{
// Sans admins.manage : seulement une invitation « nouvel appareil » créée par soi-même, pour soi-même.
var invitation = db.Invitations.FirstOrDefault(i => i.Id == invitationId);
var own = invitation is not null && invitation.ForAdminId == actor.Admin.Id && invitation.CreatedById == actor.Admin.Id;
EnsureActor(db, actor, own ? AdminPermission.None : AdminPermission.Manage);
if (invitation is null) throw ApiRejection.NotFound("Invitation introuvable.");
db.Invitations.Remove(invitation);
return invitation;
});
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
return Results.NoContent();
}
// ------------------------------------------------------------------ Clés
/// <summary>Approuve une clé en attente : l'empreinte saisie doit être celle de la clé.</summary>
private static IResult ApproveKey(Guid adminId, string keyId, ApproveKeyRequest request, HttpContext context,
AdminStore store, AuditLog audit, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var name = store.Update(db =>
{
var (admin, key) = FindKey(db, adminId, keyId);
// Nouvel appareil d'un compte déjà actif : seul son titulaire l'approuve (depuis un de ses
// appareils actifs). S'il les a tous perdus : outil en ligne de commande sur le VPS (add-key).
if (admin.Status == AdminStatus.Active && !admin.Keys.Any(k => k.Status == AdminStatus.Active && k.KeyId != key.KeyId))
throw new ApiRejection(409, ApiErrorCodes.Conflict,
$"{admin.DisplayName} n'a plus aucun appareil actif : restaurez son accès sur le VPS (« add-key »), " +
"ou révoquez ce compte et invitez-le de nouveau.");
if (admin.Status == AdminStatus.Active)
{
EnsureActor(db, actor, AdminPermission.None);
if (actor.Admin.Id != admin.Id)
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
$"Ce nouvel appareil doit être approuvé par {admin.DisplayName} lui-même, depuis un de ses appareils déjà actifs.");
}
else
{
EnsureActor(db, actor);
// Premier appareil d'un compte en attente : son groupe a été choisi à l'invitation, peut-être par
// quelqu'un d'autre ; l'approbateur ne fait entrer personne au-dessus de ses propres droits.
if (admin.Status == AdminStatus.Pending) AdminRules.EnsureCanAssign(db, actor, admin.Role);
}
if (key.Status != AdminStatus.Pending) throw ApiRejection.Conflict("Cette clé n'est pas en attente d'approbation.");
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
if (!AdminKeys.FingerprintMatches(key.PublicKeyBytes(), request.Fingerprint))
{
Audit(audit, context, "admins.key_approve", $"{admin.DisplayName} / clé {keyId}", "empreinte différente", AuditResults.Denied);
throw new ApiRejection(400, ApiErrorCodes.FingerprintMismatch,
"L'empreinte saisie ne correspond pas à cette clé. Ne l'approuvez pas : vérifiez-la de nouveau avec la personne invitée.");
}
key.Status = AdminStatus.Active;
key.ApprovedAt = now;
key.ApprovedBy = actor.Admin.DisplayName;
if (admin.Status == AdminStatus.Pending)
{
admin.Status = AdminStatus.Active;
admin.ApprovedAt = now;
admin.ApprovedBy = actor.Admin.DisplayName;
}
return admin.DisplayName;
});
Audit(audit, context, "admins.key_approve", $"{name} / clé {keyId}");
return Summary(store, adminId);
}
private static IResult RevokeKey(Guid adminId, string keyId, HttpContext context, AdminStore store, SessionStore sessions,
AuditLog audit, TimeProvider time)
{
var actor = context.Admin();
if (string.Equals(keyId, actor.Key.KeyId, StringComparison.Ordinal))
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer la clé de votre session en cours.");
var name = store.Update(db =>
{
EnsureActor(db, actor);
var (admin, key) = FindKey(db, adminId, keyId);
if (admin.Id != actor.Admin.Id) AdminRules.EnsureCanManage(db, actor, admin);
if (key.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cette clé est déjà révoquée.");
key.Status = AdminStatus.Revoked;
key.RevokedAt = time.GetUtcNow();
key.RevokedBy = actor.Admin.DisplayName;
EnsureUsableOwner(db);
return admin.DisplayName;
});
sessions.RemoveWhere(s => s.KeyId == keyId);
Audit(audit, context, "admins.key_revoke", $"{name} / clé {keyId}");
return Summary(store, adminId);
}
// ------------------------------------------------------------------ Comptes
private static IResult ChangeRole(Guid adminId, ChangeRoleRequest request, HttpContext context, AdminStore store, AuditLog audit)
{
var actor = context.Admin();
if (adminId == actor.Admin.Id)
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre groupe.");
var (name, previous) = store.Update(db =>
{
EnsureActor(db, actor);
var group = db.FindGroup(request.Role) ?? throw ApiRejection.BadRequest("Groupe inconnu : rechargez la page.");
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
AdminRules.EnsureCanManage(db, actor, admin);
AdminRules.EnsureCanAssign(db, actor, group.Id);
var old = admin.Role;
admin.Role = group.Id;
EnsureUsableOwner(db);
return (admin.DisplayName, old);
});
// Les droits sont relus à chaque requête : le nouveau groupe s'applique immédiatement.
Audit(audit, context, "admins.role", name, $"{previous} → {request.Role}");
return Summary(store, adminId);
}
private static IResult RevokeAdmin(Guid adminId, HttpContext context, AdminStore store, SessionStore sessions,
AuditLog audit, TimeProvider time)
{
var actor = context.Admin();
if (adminId == actor.Admin.Id)
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer votre propre compte.");
var now = time.GetUtcNow();
var name = store.Update(db =>
{
EnsureActor(db, actor);
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est déjà révoqué.");
AdminRules.EnsureCanManage(db, actor, admin);
admin.Status = AdminStatus.Revoked;
admin.RevokedAt = now;
admin.RevokedBy = actor.Admin.DisplayName;
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
{
key.Status = AdminStatus.Revoked;
key.RevokedAt = now;
key.RevokedBy = actor.Admin.DisplayName;
}
db.Invitations.RemoveAll(i => i.ForAdminId == adminId);
EnsureUsableOwner(db);
return admin.DisplayName;
});
sessions.RemoveWhere(s => s.AdminId == adminId);
Audit(audit, context, "admins.revoke", name);
return Summary(store, adminId);
}
private static IResult Summary(AdminStore store, Guid adminId)
{
var db = store.Read();
return Results.Json(ToSummary(db.FindAdmin(adminId)!, db), ApiJson.Options);
}
// ------------------------------------------------------------------ Outils
private static (AdminRecord Admin, KeyRecord Key) FindKey(AdminDatabase db, Guid adminId, string keyId)
{
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
var key = admin.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))
?? throw ApiRejection.NotFound("Clé introuvable.");
return (admin, key);
}
/// <summary>
/// Revérifie l'auteur DANS la modification (sous verrou) : s'il a été révoqué ou rétrogradé entre
/// l'authentification de sa requête et l'écriture (autre propriétaire, outil en ligne de commande),
/// rien n'est écrit.
/// </summary>
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage) =>
AdminRules.EnsureActor(db, actor, required == AdminPermission.Manage ? AdminPermissions.AdminsManage : null);
private enum AdminPermission { None, Manage }
/// <summary>Annule la modification s'il ne reste plus aucun propriétaire capable de se connecter.</summary>
private static void EnsureUsableOwner(AdminDatabase db)
{
if (!db.UsableOwners().Any())
throw new ApiRejection(409, ApiErrorCodes.LastOwner,
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
}
private static string InvitationDetails(InvitationRecord invitation) =>
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}";
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
string result = AuditResults.Ok)
{
var actor = context.Admin();
audit.Write(action, result, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
}
internal static AdminSummary ToSummary(AdminRecord admin, AdminDatabase db) => new()
{
Id = admin.Id,
DisplayName = admin.DisplayName,
Role = admin.Role,
RoleName = db.GroupName(admin.Role),
Permissions = admin.Status == AdminStatus.Revoked ? Array.Empty<string>() : db.PermissionsOf(admin.Role),
Status = admin.Status,
CreatedAt = admin.CreatedAt,
Keys = admin.Keys.Select(k => new AdminKeySummary
{
KeyId = k.KeyId,
Label = k.Label,
Status = k.Status,
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
PublicKey = k.Status == AdminStatus.Active ? Convert.ToBase64String(k.PublicKeyBytes()) : null,
AddedAt = k.AddedAt,
LastUsedAt = k.LastUsedAt
}).ToList()
};
private static InvitationSummary ToSummary(InvitationRecord invitation, AdminDatabase db) => new()
{
Id = invitation.Id,
DisplayName = invitation.DisplayName,
Role = invitation.Role,
RoleName = db.GroupName(invitation.Role),
ForAdminId = invitation.ForAdminId,
CreatedAt = invitation.CreatedAt,
CreatedBy = invitation.CreatedBy,
CreatedById = invitation.CreatedById,
ExpiresAt = invitation.ExpiresAt
};
}
@@ -0,0 +1,310 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Admins;
/// <summary>
/// Groupes d'administrateurs et permissions (permission admins.manage). Toutes les modifications sont des
/// requêtes signées, revérifiées sous le verrou d'admins.json, et inscrites au journal d'audit.
///
/// Garde-fous :
/// • le groupe système « Propriétaire » a toujours toutes les permissions : ni modifiable ni supprimable
/// (il reste donc toujours un groupe capable de tout réparer) ;
/// • on ne modifie pas les permissions de son propre groupe (comme on ne change pas son propre groupe) ;
/// • on n'accorde à un groupe que des permissions que l'on a soi-même ;
/// • un groupe qui a des membres (actifs ou en attente) ou des invitations en cours ne se supprime pas ;
/// • modification préparée sur une révision dépassée (autre administrateur) : refusée (409) ;
/// • les permissions INTÉGRÉES (vérifiées par le code) ne se suppriment pas ; une permission personnalisée
/// supprimée est retirée de tous les groupes.
/// Les droits sont relus à chaque requête : une modification s'applique aussitôt aux sessions ouvertes.
/// </summary>
public sealed class GroupsModule : IApiModule
{
public string Name => "Groups";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
public void MapEndpoints(IEndpointRouteBuilder api)
{
var manage = api.MapGroup("").RequirePermission(AdminPermissions.AdminsManage);
manage.MapGet(ApiRoutes.Groups, List);
manage.MapPost(ApiRoutes.Groups, Create).RequireSignedRequest();
manage.MapPut("v1/groups/{groupId}", Update).RequireSignedRequest();
manage.MapDelete("v1/groups/{groupId}", Delete).RequireSignedRequest();
manage.MapPost(ApiRoutes.Permissions, CreatePermission).RequireSignedRequest();
manage.MapDelete("v1/permissions/{name}", DeletePermission).RequireSignedRequest();
}
// ------------------------------------------------------------------ Lecture
private static IResult List(AdminStore store, TimeProvider time) => Results.Json(State(store.Read(), time.GetUtcNow()), ApiJson.Options);
internal static GroupsState State(AdminDatabase db, DateTimeOffset now)
{
var groups = db.Groups
.OrderBy(g => g.Id switch { AdminRoles.Owner => 0, AdminRoles.Admin => 1, AdminRoles.Editor => 2, _ => 3 })
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(g => new GroupSummary
{
Id = g.Id,
Name = g.Name,
Description = g.Description,
Permissions = db.PermissionsOf(g.Id),
System = AdminGroups.IsSystem(g.Id),
Revision = g.Revision,
MemberCount = Members(db, g.Id),
InvitationCount = Invitations(db, g.Id, now),
UpdatedAt = g.UpdatedAt ?? g.CreatedAt,
UpdatedBy = g.UpdatedBy ?? g.CreatedBy
}).ToList();
var permissions = AdminPermissions.All.Select(p => new PermissionSummary
{
Name = p,
Label = AdminPermissions.BuiltInLabel(p) ?? p,
Description = AdminPermissions.BuiltInDescription(p),
BuiltIn = true,
GroupCount = groups.Count(g => g.Permissions.Contains(p, StringComparer.Ordinal))
})
.Concat(db.CustomPermissions.OrderBy(p => p.Name, StringComparer.Ordinal).Select(p => new PermissionSummary
{
Name = p.Name,
Label = p.Label,
Description = p.Description,
BuiltIn = false,
GroupCount = groups.Count(g => g.Permissions.Contains(p.Name, StringComparer.Ordinal)),
CreatedAt = p.CreatedAt,
CreatedBy = p.CreatedBy
})).ToList();
return new GroupsState { Groups = groups, Permissions = permissions };
}
private static int Members(AdminDatabase db, string groupId) =>
db.Admins.Count(a => a.Status != AdminStatus.Revoked && string.Equals(a.Role, groupId, StringComparison.Ordinal));
private static int Invitations(AdminDatabase db, string groupId, DateTimeOffset now) =>
db.Invitations.Count(i => i.ExpiresAt > now && i.ForAdminId is null && string.Equals(i.Role, groupId, StringComparison.Ordinal));
// ------------------------------------------------------------------ Groupes
private static IResult Create(GroupRequest request, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var (name, description) = CheckText(request);
var created = store.Update(db =>
{
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
if (db.Groups.Count >= AdminGroups.MaxGroups)
throw ApiRejection.Conflict($"{AdminGroups.MaxGroups} groupes au plus : supprimez-en un avant d'en créer un autre.");
EnsureUniqueName(db, name, exceptId: null);
var permissions = CheckPermissions(db, request.Permissions);
AdminRules.EnsureWithinOwnRights(db, actor, permissions,
missing => $"Vous ne pouvez pas accorder des permissions que vous n'avez pas ({missing}).");
var baseId = AdminGroups.Slug(name);
var id = baseId;
// Identifiant jamais réutilisé tant qu'un compte (même révoqué) ou une invitation le désigne encore.
for (var n = 2; db.FindGroup(id) is not null || db.Admins.Any(a => a.Role == id) || db.Invitations.Any(i => i.Role == id); n++)
id = $"{baseId}-{n}";
var group = new GroupRecord
{
Id = id,
Name = name,
Description = description,
Permissions = permissions,
Revision = 1,
CreatedAt = now,
CreatedBy = actor.Admin.DisplayName
};
db.Groups.Add(group);
return group;
});
Audit(audit, context, "admins.group_create", $"{created.Name} ({created.Id})", Describe(created.Permissions));
return Results.Json(State(store.Read(), now), ApiJson.Options);
}
private static IResult Update(string groupId, GroupRequest request, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
if (!AdminGroups.IsValidGroupId(groupId)) throw ApiRejection.NotFound("Groupe introuvable.");
if (AdminGroups.IsSystem(groupId))
throw ApiRejection.Conflict("Le groupe « Propriétaire » a toujours toutes les permissions : il ne peut pas être modifié.");
var (name, description) = CheckText(request);
var (group, changes) = store.Update(db =>
{
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
var group = db.FindGroup(groupId) ?? throw ApiRejection.NotFound("Groupe introuvable : il a peut-être été supprimé.");
if (group.Revision != request.Revision)
throw ApiRejection.Conflict("Ce groupe a été modifié entre-temps par un autre administrateur : rechargez la page.",
ApiErrorCodes.RevisionConflict);
EnsureUniqueName(db, name, exceptId: group.Id);
var permissions = CheckPermissions(db, request.Permissions);
var added = permissions.Except(group.Permissions, StringComparer.Ordinal).ToList();
var removed = group.Permissions.Except(permissions, StringComparer.Ordinal).ToList();
if ((added.Count > 0 || removed.Count > 0) && db.FindAdmin(actor.Admin.Id)?.Role == group.Id)
throw new ApiRejection(409, ApiErrorCodes.SelfAction,
"Vous ne pouvez pas modifier les permissions de votre propre groupe : demandez-le à un autre administrateur.");
AdminRules.EnsureWithinOwnRights(db, actor, added,
missing => $"Vous ne pouvez pas accorder des permissions que vous n'avez pas ({missing}).");
var changes = new List<string>();
if (group.Name != name) changes.Add($"nom : {group.Name} → {name}");
if (group.Description != description) changes.Add("description modifiée");
if (added.Count > 0) changes.Add("+ " + string.Join(", ", added));
if (removed.Count > 0) changes.Add("− " + string.Join(", ", removed));
group.Name = name;
group.Description = description;
group.Permissions = permissions;
group.Revision++;
group.UpdatedAt = now;
group.UpdatedBy = actor.Admin.DisplayName;
return (group, changes);
});
Audit(audit, context, "admins.group_update", $"{group.Name} ({group.Id})",
changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes));
return Results.Json(State(store.Read(), now), ApiJson.Options);
}
private static IResult Delete(string groupId, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
if (!AdminGroups.IsValidGroupId(groupId)) throw ApiRejection.NotFound("Groupe introuvable.");
if (AdminGroups.IsSystem(groupId)) throw ApiRejection.Conflict("Le groupe « Propriétaire » ne peut pas être supprimé.");
var deleted = store.Update(db =>
{
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
var group = db.FindGroup(groupId) ?? throw ApiRejection.NotFound("Groupe introuvable : il a peut-être déjà été supprimé.");
if (db.FindAdmin(actor.Admin.Id)?.Role == group.Id)
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas supprimer votre propre groupe.");
AdminRules.EnsureWithinOwnRights(db, actor, group.Permissions,
missing => $"Ce groupe a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas le supprimer.");
var members = Members(db, group.Id);
var invitations = Invitations(db, group.Id, now);
if (members > 0 || invitations > 0)
throw ApiRejection.Conflict(
$"Ce groupe a encore {members} membre(s) et {invitations} invitation(s) en cours : placez ces administrateurs dans " +
"un autre groupe (page « Administrateurs ») et supprimez ces invitations avant de le supprimer.");
// Invitations expirées vers ce groupe : sans effet, retirées pour ne pas désigner un groupe disparu.
db.Invitations.RemoveAll(i => i.ExpiresAt <= now && string.Equals(i.Role, group.Id, StringComparison.Ordinal));
db.Groups.Remove(group);
return group;
});
Audit(audit, context, "admins.group_delete", $"{deleted.Name} ({deleted.Id})", Describe(deleted.Permissions));
return Results.Json(State(store.Read(), now), ApiJson.Options);
}
// ------------------------------------------------------------------ Permissions personnalisées
private static IResult CreatePermission(CreatePermissionRequest request, HttpContext context, AdminStore store, AuditLog audit,
TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var name = request.Name?.Trim() ?? "";
if (!AdminGroups.IsValidPermissionName(name))
throw ApiRejection.BadRequest("Nom de permission invalide : format « module.action » en minuscules (ex. « news.edit »).");
if (AdminPermissions.IsBuiltIn(name)) throw ApiRejection.Conflict("Cette permission existe déjà (permission intégrée).");
var label = ApiText.CleanLine(request.Label, AdminGroups.MaxPermissionLabelLength)
?? throw ApiRejection.BadRequest($"Libellé obligatoire ({AdminGroups.MaxPermissionLabelLength} caractères au plus, une ligne).");
var description = AdminGroups.CleanDescription(request.Description, AdminGroups.MaxDescriptionLength, out var validDescription);
if (!validDescription) throw ApiRejection.BadRequest($"Description invalide ({AdminGroups.MaxDescriptionLength} caractères au plus, une ligne).");
store.Update(db =>
{
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
if (db.CustomPermissions.Any(p => string.Equals(p.Name, name, StringComparison.Ordinal)))
throw ApiRejection.Conflict("Cette permission existe déjà.");
if (db.CustomPermissions.Count >= AdminGroups.MaxCustomPermissions)
throw ApiRejection.Conflict($"{AdminGroups.MaxCustomPermissions} permissions personnalisées au plus.");
db.CustomPermissions.Add(new PermissionRecord
{
Name = name,
Label = label,
Description = description,
CreatedAt = now,
CreatedBy = actor.Admin.DisplayName
});
});
// Le groupe « Propriétaire » la reçoit automatiquement (il a toutes les permissions).
Audit(audit, context, "admins.permission_create", name, label);
return Results.Json(State(store.Read(), now), ApiJson.Options);
}
private static IResult DeletePermission(string name, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
if (AdminPermissions.IsBuiltIn(name))
throw ApiRejection.Conflict("Permission intégrée : elle est vérifiée par le serveur et le launcher, elle ne peut pas être supprimée.");
if (!AdminGroups.IsValidPermissionName(name)) throw ApiRejection.NotFound("Permission introuvable.");
var groups = store.Update(db =>
{
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
var permission = db.CustomPermissions.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal))
?? throw ApiRejection.NotFound("Permission introuvable : elle a peut-être déjà été supprimée.");
AdminRules.EnsureWithinOwnRights(db, actor, new[] { name },
missing => $"Vous ne pouvez pas supprimer une permission que vous n'avez pas ({missing}).");
var affected = new List<string>();
foreach (var group in db.Groups.Where(g => g.Permissions.Contains(name, StringComparer.Ordinal)))
{
group.Permissions.RemoveAll(p => string.Equals(p, name, StringComparison.Ordinal));
group.Revision++;
group.UpdatedAt = now;
group.UpdatedBy = actor.Admin.DisplayName;
affected.Add(group.Name);
}
db.CustomPermissions.Remove(permission);
return affected;
});
Audit(audit, context, "admins.permission_delete", name,
groups.Count == 0 ? "attribuée à aucun groupe" : "retirée de : " + string.Join(", ", groups.Take(20)));
return Results.Json(State(store.Read(), now), ApiJson.Options);
}
// ------------------------------------------------------------------ Outils
private static (string Name, string? Description) CheckText(GroupRequest request)
{
var name = AdminGroups.CleanName(request.Name)
?? throw ApiRejection.BadRequest($"Nom du groupe obligatoire ({AdminGroups.MaxNameLength} caractères au plus, une ligne).");
var description = AdminGroups.CleanDescription(request.Description, AdminGroups.MaxDescriptionLength, out var valid);
if (!valid) throw ApiRejection.BadRequest($"Description invalide ({AdminGroups.MaxDescriptionLength} caractères au plus, une ligne).");
return (name, description);
}
private static void EnsureUniqueName(AdminDatabase db, string name, string? exceptId)
{
if (db.Groups.Any(g => g.Id != exceptId && string.Equals(g.Name, name, StringComparison.CurrentCultureIgnoreCase)))
throw ApiRejection.Conflict($"Un groupe s'appelle déjà « {name} ».");
}
/// <summary>Permissions demandées : connues du serveur, sans doublon, dans l'ordre des permissions connues.</summary>
private static List<string> CheckPermissions(AdminDatabase db, IReadOnlyList<string>? requested)
{
requested ??= Array.Empty<string>();
if (requested.Count > AdminPermissions.All.Count + AdminGroups.MaxCustomPermissions)
throw ApiRejection.BadRequest("Trop de permissions.");
var unknown = requested.Where(p => p is null || !db.IsKnownPermission(p)).Select(p => p ?? "(vide)").Distinct().Take(5).ToList();
if (unknown.Count > 0) throw ApiRejection.BadRequest($"Permission inconnue : {string.Join(", ", unknown)}. Rechargez la page.");
var set = requested.ToHashSet(StringComparer.Ordinal);
return db.KnownPermissions().Where(set.Contains).ToList();
}
private static string Describe(IReadOnlyCollection<string> permissions) =>
permissions.Count == 0 ? "aucune permission" : "permissions : " + string.Join(", ", permissions);
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null)
{
var actor = context.Admin();
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
}
}
@@ -0,0 +1,141 @@
using System.Globalization;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Audit;
/// <summary>
/// Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord. Chaque consultation est
/// elle-même inscrite au journal (« audit.view »), ainsi que chaque export CSV annoncé par le launcher (« audit.export »).
/// </summary>
public sealed class AuditModule : IApiModule
{
public string Name => "Audit";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<AuditLog>();
services.AddSingleton<AuditConsultations>();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Audit, Read).RequirePermission(AdminPermissions.AuditRead);
api.MapPost(ApiRoutes.AuditExport, RecordExport).RequirePermission(AdminPermissions.AuditRead).RequireSignedRequest();
}
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations,
AdminStore store)
{
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
DateTimeOffset? until = null;
if (!string.IsNullOrEmpty(before))
{
// Format ISO 8601 uniquement (« 2026-11-07T00:00:00Z »), borné à des dates plausibles.
if (before.Length > 40 ||
!DateTimeOffset.TryParse(before, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) ||
parsed.Year is < 2000 or > 9998)
return ApiResults.BadRequest("Date « before » invalide (format ISO 8601 attendu).");
until = parsed;
}
var page = WithDeviceNames(audit.Read(limit ?? 50, cursor, until), store);
// Consultation inscrite APRÈS la lecture (la page renvoyée montre le journal tel qu'il était). Première page
// (ouverture, « Actualiser », période changée) : toujours ; pages suivantes (« Charger plus ») : une fois par
// clé tant que la consultation date de moins de 10 minutes (pas une ligne par page de 200 entrées).
var actor = context.Admin();
var continuation = !string.IsNullOrEmpty(cursor);
if (consultations.ShouldRecord(actor.Key.KeyId, continuation))
{
var details = new List<string> { $"{page.Entries.Count} entrée(s) lue(s)" };
if (continuation) details.Add("entrées plus anciennes");
if (until is { } date) details.Add($"antérieures au {date.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC");
audit.Write("audit.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
details: string.Join(" ; ", details));
}
return Results.Json(page, ApiJson.Options);
}
/// <summary>
/// Nom de l'appareil de chaque clé (défini à l'enregistrement de la clé, avec l'invitation), ajouté à la lecture :
/// les anciennes entrées en profitent aussi, et le journal sur le disque reste inchangé (ajout seul).
/// </summary>
internal static AuditPage WithDeviceNames(AuditPage page, AdminStore store)
{
if (page.Entries.All(e => e.KeyId is null && e.KeyLabel is null)) return page;
var database = store.Read();
var labels = new Dictionary<string, string?>(StringComparer.Ordinal);
var entries = page.Entries.Select(entry =>
{
if (entry.KeyId is not { } keyId) return entry with { KeyLabel = null };
if (!labels.TryGetValue(keyId, out var label))
labels[keyId] = label = database.FindKey(keyId) is { } found ? Auth.AuthModule.DeviceName(found.Key.Label) : null;
return entry with { KeyLabel = label }; // seul le serveur renseigne ce champ (jamais lu depuis le fichier)
}).ToList();
return page with { Entries = entries };
}
private static IResult RecordExport(HttpContext context, AuditLog audit)
{
AuditExportRequest? request;
try
{
request = JsonSerializer.Deserialize<AuditExportRequest>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Annonce d'export illisible.");
}
if (request is null || request.Count is < 0 or > AuditExportLimits.MaxCount ||
request.Filters is { Length: > AuditExportLimits.MaxFiltersLength } ||
request.From is { Year: < 2000 or > 9998 } || request.To is { Year: < 2000 or > 9998 } ||
(request.From is { } from && request.To is { } to && from > to))
return ApiResults.BadRequest("Annonce d'export invalide.");
var period = (request.From, request.To) switch
{
(null, null) => "toutes les dates",
({ } f, null) => $"depuis le {Day(f)}",
(null, { } t) => $"jusqu'au {Day(t)}",
({ } f, { } t) => $"du {Day(f)} au {Day(t)}"
};
var actor = context.Admin();
audit.Write("audit.export", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
"fichier CSV",
$"{request.Count} entrée(s) ; {period}" + (string.IsNullOrWhiteSpace(request.Filters) ? "" : $" ; filtres : {request.Filters}"));
return Results.NoContent();
}
private static string Day(DateOnly day) => day.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
}
/// <summary>Dernière consultation inscrite par clé (évite une ligne par page lors de « Charger plus »).</summary>
public sealed class AuditConsultations
{
/// <summary>Pages suivantes non réinscrites pendant ce délai après la dernière consultation inscrite.</summary>
public static readonly TimeSpan ContinuationWindow = TimeSpan.FromMinutes(10);
private readonly TimeProvider _time;
private readonly Dictionary<string, DateTimeOffset> _lastRecorded = new(StringComparer.Ordinal);
public AuditConsultations(TimeProvider time) => _time = time;
public bool ShouldRecord(string keyId, bool continuation)
{
var now = _time.GetUtcNow();
lock (_lastRecorded)
{
if (continuation && _lastRecorded.TryGetValue(keyId, out var last) && now - last < ContinuationWindow && now >= last)
return false;
if (_lastRecorded.Count > 10_000) _lastRecorded.Clear(); // seules les clés enregistrées arrivent ici
_lastRecorded[keyId] = now;
return true;
}
}
}
@@ -0,0 +1,270 @@
using System.Security.Cryptography;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Auth;
/// <summary>
/// Authentification par clé : défi, connexion (signature du défi), session, déconnexion, et
/// enrôlement d'une clé avec un code d'invitation (preuve de possession de la clé).
/// </summary>
public sealed class AuthModule : IApiModule
{
private const string LoginRefused = "Connexion refusée.";
public string Name => "Auth";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<ChallengeStore>();
services.AddSingleton<SessionStore>();
services.AddSingleton<ReplayCache>();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapPost(ApiRoutes.AuthChallenge, Challenge).RequireRateLimiting(RateLimitPolicies.Auth);
api.MapPost(ApiRoutes.AuthLogin, Login).RequireRateLimiting(RateLimitPolicies.Auth);
api.MapPost(ApiRoutes.AuthLogout, Logout).RequireAdmin();
api.MapGet(ApiRoutes.AuthMe, Me).RequireAdmin();
api.MapPost(ApiRoutes.Enrollment, Enroll).RequireRateLimiting(RateLimitPolicies.Enrollment);
}
/// <summary>
/// Défi de connexion. Même réponse que la clé soit connue ou non (pas d'énumération des clés) :
/// la vérification a lieu à la connexion.
/// </summary>
private static IResult Challenge(ChallengeRequest request, ChallengeStore challenges, ServerIdentity server, TimeProvider time)
{
if (!AdminKeys.IsValidKeyId(request.KeyId)) return ApiResults.BadRequest("Identifiant de clé invalide.");
var challenge = challenges.Create(request.KeyId);
return Results.Json(new ChallengeResponse
{
ChallengeId = challenge.Id,
Nonce = challenge.Nonce,
ServerId = server.ServerId,
Origin = server.Origin,
ExpiresAt = challenge.ExpiresAt,
ServerTime = time.GetUtcNow()
}, ApiJson.Options);
}
private static IResult Login(LoginRequest request, HttpContext context, ChallengeStore challenges, SessionStore sessions,
AdminStore store, ServerIdentity server, AuditLog audit, TimeProvider time)
{
var ip = context.ClientIp();
var signature = AdminKeys.DecodeSignature(request.Signature);
if (!AdminKeys.IsValidKeyId(request.KeyId) || string.IsNullOrEmpty(request.ChallengeId) ||
request.ChallengeId.Length > ChallengeStore.MaxIdLength || signature is null)
return ApiResults.BadRequest("Demande de connexion incomplète.");
// 1. Échecs AVANT toute signature valide (défi, clé ou signature) : bruit possible, journal limité.
AdminRecord? admin = null;
KeyRecord? key = null;
string? unsigned = null;
if (!challenges.TryValidate(request.ChallengeId, request.KeyId, out var challenge))
unsigned = "défi inconnu, expiré ou destiné à une autre clé";
else if (store.Read().FindKey(request.KeyId) is not { } found)
unsigned = "clé inconnue";
else
{
(admin, key) = found;
if (!AdminKeys.Verify(key.PublicKeyBytes(),
SignedMessages.Login(server.Origin, server.ServerId, challenge.Id, challenge.Nonce, key.KeyId), signature))
unsigned = "signature invalide";
}
if (unsigned is not null || admin is null || key is null)
{
audit.WriteAnonymousFailure("auth.login", request.KeyId, ip, unsigned);
return ApiResults.Unauthorized(LoginRefused);
}
// 2. Signature VALIDE mais connexion refusée (clé révoquée ou en attente, défi rejoué) : signal fort,
// toujours journalisé en détail (ex. clé volée puis révoquée encore utilisée).
// (Le défi n'est marqué « utilisé » qu'une fois la connexion acceptée : une clé révoquée ou en
// attente ne peut pas remplir la liste des défis utilisés.)
string? refused = null;
if (admin.Status != AdminStatus.Active) refused = $"administrateur {admin.Status}";
else if (key.Status != AdminStatus.Active) refused = $"clé {key.Status}";
else if (!challenges.TryMarkUsed(challenge)) refused = "défi déjà utilisé (rejeu)";
if (refused is not null)
{
audit.WriteSignedRefusal("auth.login", admin.Id, admin.DisplayName, key.KeyId, ip, refused);
return ApiResults.Unauthorized(LoginRefused);
}
var clientName = ClientSoftware(ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength));
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
// admins.json à chaque connexion).
var now = time.GetUtcNow();
if (key.LastUsedAt is null || now - key.LastUsedAt > TimeSpan.FromHours(1))
{
try
{
store.Update(db =>
{
if (db.FindKey(key.KeyId) is { } current) current.Key.LastUsedAt = now;
});
}
catch (StoreBusyException)
{
// sans conséquence
}
}
// Appareil : le nom choisi à l'enregistrement de la clé (invitation), jamais le nom Windows du PC.
var device = $"appareil « {DeviceName(key.Label)} »";
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip,
details: clientName is null ? device : $"{device} · {clientName}");
var database = store.Read();
var authenticated = new AuthenticatedAdmin(session, admin, key, database.PermissionsOf(admin.Role), database.GroupName(admin.Role));
return Results.Json(new LoginResponse
{
Token = token,
SessionId = session.Id,
ExpiresAt = session.ExpiresAt,
IdleExpiresAt = sessions.IdleExpiresAt(session),
Admin = authenticated.ToProfile()
}, ApiJson.Options);
}
/// <summary>Nom de l'appareil tel que défini à l'enregistrement de la clé (invitation).</summary>
internal static string DeviceName(string? keyLabel) => string.IsNullOrWhiteSpace(keyLabel) ? "sans nom" : keyLabel;
/// <summary>
/// Logiciel client (« GameLauncher 2.5.0 »), pour le journal. Les launchers jusqu'à la version 2.4.1 y ajoutaient le
/// nom Windows du PC entre parenthèses (« GameLauncher 2.4.1 (DESKTOP-ABC123) ») : il est retiré, seul le nom de
/// l'appareil choisi à l'enregistrement de la clé identifie le poste (et le nom Windows n'est pas conservé).
/// </summary>
internal static string? ClientSoftware(string? clientName)
{
if (clientName is null) return null;
if (clientName.StartsWith("GameLauncher ", StringComparison.Ordinal) && clientName.EndsWith(')') &&
clientName.IndexOf(" (", StringComparison.Ordinal) is var open and > 0)
return clientName[..open];
return clientName;
}
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
{
var admin = context.Admin();
sessions.Remove(admin.Session);
audit.Write("auth.logout", AuditResults.Ok, admin.Admin.Id, admin.Admin.DisplayName, admin.Key.KeyId, context.ClientIp());
return Results.NoContent();
}
private static IResult Me(HttpContext context, SessionStore sessions)
{
var admin = context.Admin();
return Results.Json(new SessionInfo
{
SessionId = admin.Session.Id,
ExpiresAt = admin.Session.ExpiresAt,
IdleExpiresAt = sessions.IdleExpiresAt(admin.Session),
Admin = admin.ToProfile()
}, ApiJson.Options);
}
/// <summary>
/// Enrôlement d'une clé avec un code d'invitation. La clé (et, pour une nouvelle personne, le
/// compte) reste EN ATTENTE jusqu'à l'approbation par un propriétaire, qui doit saisir l'empreinte
/// communiquée par la personne invitée. Le code est consommé (usage unique).
/// </summary>
private static IResult Enroll(EnrollmentRequest request, HttpContext context, AdminStore store, ServerIdentity server,
AuditLog audit, TimeProvider time)
{
var ip = context.ClientIp();
var code = InvitationCodes.Normalize(request.InvitationCode);
var proof = AdminKeys.DecodeSignature(request.Proof);
if (code is null || proof is null) return Reject("code ou preuve mal formés");
byte[] spki;
try
{
spki = AdminKeys.ParsePublicKey(request.PublicKey);
}
catch (CryptographicException)
{
return ApiResults.BadRequest("Clé publique invalide : une clé ECDSA P-256 est attendue.");
}
var keyId = AdminKeys.ComputeKeyId(spki);
var codeHash = SignedMessages.HashInvitationCode(code);
if (!AdminKeys.Verify(spki, SignedMessages.Enrollment(server.Origin, server.ServerId, codeHash, keyId), proof))
return Reject("preuve de possession invalide", keyId);
var label = ApiText.CleanLine(request.KeyLabel, ApiText.MaxKeyLabelLength);
if (request.KeyLabel is not null && label is null)
return ApiResults.BadRequest($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus).");
var now = time.GetUtcNow();
var result = store.Update(db =>
{
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
var invitation = db.Invitations.FirstOrDefault(i => string.Equals(i.CodeHash, codeHash, StringComparison.Ordinal));
if (invitation is null)
{
audit.WriteAnonymousFailure("admins.enroll", keyId, ip, "code inconnu, expiré ou déjà utilisé");
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
}
if (db.FindKey(keyId) is not null)
throw ApiRejection.Conflict("Cette clé est déjà enregistrée.");
var key = new KeyRecord
{
KeyId = keyId,
PublicKey = Convert.ToBase64String(spki),
Label = label,
Status = AdminStatus.Pending,
AddedAt = now
};
AdminRecord admin;
if (invitation.ForAdminId is { } existingId)
{
admin = db.FindAdmin(existingId) ?? throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid,
"Code d'invitation invalide, expiré ou déjà utilisé.");
if (admin.Status == AdminStatus.Revoked)
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
}
else
{
admin = new AdminRecord
{
Id = Guid.NewGuid(),
DisplayName = invitation.DisplayName,
Role = invitation.Role,
Status = AdminStatus.Pending,
CreatedAt = now,
CreatedBy = invitation.CreatedBy
};
db.Admins.Add(admin);
}
admin.Keys.Add(key);
db.Invitations.Remove(invitation); // usage unique
return (admin.Id, admin.DisplayName, admin.Status, InvitationId: invitation.Id);
});
audit.Write("admins.enroll", AuditResults.Ok, result.Id, result.DisplayName, keyId, ip,
target: $"invitation {result.InvitationId:D}", details: label);
return Results.Json(new EnrollmentResponse
{
AdminId = result.Id,
DisplayName = result.DisplayName,
KeyId = keyId,
Fingerprint = AdminKeys.ComputeFingerprint(spki),
Status = AdminStatus.Pending
}, ApiJson.Options);
IResult Reject(string reason, string? key = null)
{
audit.WriteAnonymousFailure("admins.enroll", key, ip, reason);
return ApiResults.Error(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
}
}
}
@@ -0,0 +1,147 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Catalog;
/// <summary>
/// Catalogue des jeux (informations MODIFIABLES : nom affiché, accroche, présentation, liens, thème
/// intégré, images, intervalle de mise à jour, ordre et visibilité). Les jeux restent déclarés dans le
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
/// révision attendue (409 si quelqu'un a publié entre-temps), audit ;
/// • PUT images/{kind} : envoi d'une icône ou d'une image de fond (games.edit, requête signée : la signature
/// couvre l'empreinte du contenu), contrôlée puis rangée dans catalog/images (<see cref="CatalogImageStore"/>),
/// audit. L'image n'est visible des joueurs qu'une fois le catalogue qui la désigne publié.
/// </summary>
public sealed class CatalogModule : IApiModule
{
public string Name => "Catalog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<CatalogStore>();
services.AddSingleton<CatalogImageStore>();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
// Seule route du catalogue qui accepte un gros corps (image de 4 Mo au plus) ; voir nginx-api.conf.
api.MapPut(ApiRoutes.CatalogImagePattern, UploadImage).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest()
.WithBodyLimit(CatalogImages.MaxRequestBytes);
}
private static IResult UploadImage(string kind, HttpContext context, CatalogImageStore images, AuditLog audit,
ILogger<CatalogModule> logger)
{
if (!CatalogImages.IsValidKind(kind)) return ApiResults.BadRequest("Type d'image inconnu (icon ou background).");
var actor = context.Admin();
var content = context.CapturedBody();
CatalogImageUploaded uploaded;
try
{
uploaded = images.Save(kind, content);
}
catch (ApiRejection rejection) when (rejection.Status == 400)
{
audit.Write("catalog.image_upload", AuditResults.Denied, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId,
context.ClientIp(), CatalogImages.KindLabel(kind), $"{content.Length / 1024} Ko : {rejection.Message}");
throw;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Envoi d'une image du catalogue");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire l'image : vérifiez le dossier catalog/images et ses droits (server/deploy/LISEZMOI.md, étape 3 bis).");
}
audit.Write("catalog.image_upload", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
CatalogImages.KindLabel(kind),
$"{uploaded.FileName} ({uploaded.Width} × {uploaded.Height} px, {uploaded.Bytes / 1024} Ko)" + (uploaded.AlreadyPresent ? " ; déjà présente" : ""));
return Results.Json(uploaded, ApiJson.Options);
}
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
Results.Json(State(store.Read(), store, options.Value), ApiJson.Options);
private static IResult Publish(CatalogUpdateRequest request, HttpContext context, CatalogStore store, AuditLog audit,
TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<CatalogModule> logger)
{
if (request.Catalog is null) return ApiResults.BadRequest("Catalogue absent.");
if (request.BaseRevision < 0) return ApiResults.BadRequest("Révision invalide.");
var errors = new List<string>();
var catalog = CatalogRules.Normalize(request.Catalog with
{
Format = CatalogDocument.FormatName,
Version = CatalogDocument.CurrentVersion,
Revision = 0
}, options.Value.CatalogImageHost, errors);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
// Signature (phase 7) : sur le catalogue tel qu'il sera publié (après contrôle), par la clé de cette session.
var actor = context.Admin();
if (request.Catalog.Signature is { } signature)
{
ContentSignatures.RequireFromActor(signature, ContentKinds.Catalog, ContentKinds.CatalogId,
ContentSigning.CatalogDigest(catalog), actor, time.GetUtcNow());
catalog = catalog with { Signature = signature };
}
else if (options.Value.ContentSigning.Require)
{
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
"Le serveur exige un catalogue signé (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
}
CatalogDocument published, previous;
try
{
(published, previous) = store.Publish(catalog, request.BaseRevision, time.GetUtcNow());
}
catch (IOException ex)
{
logger.LogError(ex, "Publication du catalogue");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}",
Describe(previous, published) + (published.Signature is null ? " ; non signé" : $" ; signé (clé {published.Signature.KeyId})"));
return Results.Json(State(published, store, options.Value), ApiJson.Options);
}
private static CatalogState State(CatalogDocument catalog, CatalogStore store, LauncherApiOptions options) => new()
{
Catalog = catalog,
PublishingEnabled = store.PublishingEnabled,
PublicUrl = options.CatalogPublicUrl
};
/// <summary>Résumé pour l'audit : jeux et réglages modifiés.</summary>
private static string Describe(CatalogDocument before, CatalogDocument after)
{
var changed = new List<string>();
if (Serialize(before.Launcher) != Serialize(after.Launcher)) changed.Add("launcher");
foreach (var id in before.Games.Keys.Union(after.Games.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase))
{
before.Games.TryGetValue(id, out var a);
after.Games.TryGetValue(id, out var b);
if (Serialize(a) != Serialize(b)) changed.Add(id);
}
return changed.Count == 0 ? "aucun changement" : "modifié : " + string.Join(", ", changed.Take(20));
}
private static string Serialize(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -0,0 +1,90 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Changelog;
/// <summary>
/// Notes de version des jeux (éditeur du menu d'administration) : modification des versions EXISTANTES
/// dans le manifest.json publié de chaque jeu (LauncherApi:Games). La création d'une version reste
/// l'affaire de ManifestBuilder (et de la publication).
/// • GET : tout administrateur connecté (le manifest est public de toute façon) ;
/// • PUT (une version) : permission changelog.edit, requête signée, mêmes bornes que le launcher,
/// empreinte du manifest attendue (409 si le manifest a changé entre-temps), audit.
/// </summary>
public sealed class ChangelogModule : IApiModule
{
public string Name => "Changelog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<ChangelogStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.GameChangelogPattern, Get).RequireAdmin();
api.MapPut(ApiRoutes.GameChangelogEntryPattern, Save).RequirePermission(AdminPermissions.ChangelogEdit).RequireSignedRequest();
}
private static IResult Get(string gameId, ChangelogStore store, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
try
{
return Results.Json(store.Read(gameId), ApiJson.Options);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Lecture du manifest de {GameId}", gameId);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex is InvalidDataException
? $"Le serveur n'a pas pu lire le manifest de « {gameId} » : {ex.Message}"
: $"Le serveur n'a pas pu lire le manifest de « {gameId} » : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static IResult Save(string gameId, string version, ChangelogUpdateRequest request, HttpContext context,
ChangelogStore store, AuditLog audit, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
if (!ChangelogLimits.IsEditableVersion(version)) return ApiResults.BadRequest("Numéro de version invalide.");
if (request.Entry is null) return ApiResults.BadRequest("Notes de version absentes.");
if (string.IsNullOrWhiteSpace(request.BaseSha256) || request.BaseSha256.Length != 64)
return ApiResults.BadRequest("Empreinte du manifest absente ou invalide.");
if (!string.Equals(request.Entry.Version, version, StringComparison.Ordinal))
return ApiResults.BadRequest("La version des notes ne correspond pas à l'adresse.");
var errors = ChangelogLimits.Check(request.Entry);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
ChangelogState state;
ChangelogEntry previous;
try
{
(state, previous) = store.Save(gameId, version, request.Entry, request.BaseSha256);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Modification des notes de {GameId} {Version}", gameId, version);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex switch
{
InvalidDataException => $"Le serveur n'a pas pu lire le manifest : {ex.Message}",
ManifestWriteRefusedException => $"Modification refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).",
_ => "Le serveur n'a pas pu modifier le manifest : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter)."
});
}
var actor = context.Admin();
var after = state.Entries.FirstOrDefault(e => e.Version == version);
audit.Write("changelog.edit", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{state.GameId} {version}",
$"{Count(previous)} → {Count(after)} élément(s), manifest {state.ManifestSha256[..12]}");
return Results.Json(state, ApiJson.Options);
}
private static int Count(ChangelogEntry? entry) => entry is null ? 0 : ChangelogLimits.DisplayedItems(entry);
}
@@ -0,0 +1,361 @@
using System.Globalization;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Feedback;
/// <summary>
/// Demandes des joueurs (bouton « Aide » du launcher : avis, demande d'évolution, bug, autre), rangées par jeu.
/// • Joueurs, SANS compte (routes anonymes, seules routes d'écriture ouvertes à tous) : défi anti-spam à résoudre
/// (<see cref="FeedbackGuard"/>), limites par adresse IP en mémoire, corps de 16 Ko au plus, contrôle complet
/// (<see cref="FeedbackRules"/>), plafonds globaux (<see cref="FeedbackStore"/>). Ni l'adresse IP ni aucun
/// identifiant du PC ne sont enregistrés avec la demande, ni dans l'audit de son envoi.
/// • Administrateurs : lecture (feedback.read, consultations inscrites au journal), statut et note interne,
/// suppression (feedback.manage, requêtes signées). Pas de réponse au joueur (module futur « comptes »).
/// • Formulaires (communs à tous les jeux, <see cref="FeedbackFormsStore"/>) : lecture par tout administrateur (contenu
/// public), publication par feedback.forms, requête signée ET formulaires signés par la clé de la session
/// (autorisation « forms » du trousseau, vérifiée par les launchers). Chaque demande est validée avec le formulaire
/// de la révision qu'elle annonce (0 : formulaires d'origine) ; une révision dépassée répond 409.
/// </summary>
public sealed class FeedbackModule : IApiModule
{
public string Name => "Feedback";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<FeedbackStore>();
services.AddSingleton<FeedbackFormsStore>();
services.AddSingleton<FeedbackGuard>();
services.AddSingleton<FeedbackConsultations>();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapPost(ApiRoutes.FeedbackChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback);
api.MapPost(ApiRoutes.Feedback, Submit).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
.WithBodyLimit(FeedbackRules.MaxRequestBytes);
api.MapPut(ApiRoutes.FeedbackAttachmentPattern, UploadAttachment).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
.WithBodyLimit(FeedbackImages.MaxRequestBytes);
api.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.FeedbackForms, GetForms).RequireAdmin();
api.MapPut(ApiRoutes.FeedbackForms, PublishForms).RequirePermission(AdminPermissions.FeedbackForms).RequireSignedRequest()
.WithBodyLimit(FeedbackFormRules.MaxRequestBytes); // icônes importées comprises (bloc nginx dédié)
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackAttachmentPattern, GetAttachment).RequirePermission(AdminPermissions.FeedbackRead);
api.MapPut(ApiRoutes.GameFeedbackItemPattern, Update).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
api.MapDelete(ApiRoutes.GameFeedbackItemPattern, Delete).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
}
private static IResult NotConfigured() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"La réception des demandes n'est pas activée sur le serveur.");
// ------------------------------------------------------------------ Joueurs (anonymes)
private static IResult Challenge(FeedbackStore store, FeedbackGuard guard) =>
store.Enabled ? Results.Json(guard.CreateChallenge(), ApiJson.Options) : NotConfigured();
private static IResult Submit(HttpContext context, FeedbackStore store, FeedbackFormsStore forms, FeedbackGuard guard, AuditLog audit,
TimeProvider time)
{
if (!store.Enabled) return NotConfigured();
FeedbackSubmission? submission;
try
{
submission = JsonSerializer.Deserialize<FeedbackSubmission>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Demande illisible.");
}
// 1. Défi anti-spam (une empreinte à calculer) : avant tout autre traitement.
if (submission is null || !guard.TryConsume(submission.ChallengeId, submission.Solution))
{
audit.WriteAnonymousFailure("feedback.submit", null, null, "défi anti-spam absent, expiré, déjà utilisé ou mal résolu");
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-spam échouée : réessayez.");
}
// 2. Jeu accepté par ce serveur (identifiant de la configuration : seul celui-ci sert à construire un chemin).
var gameId = store.ResolveGame(submission.GameId);
if (gameId is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, "jeu non accepté");
return ApiResults.BadRequest("Ce jeu n'accepte pas de demandes sur ce serveur.");
}
// 3. Formulaire de la révision annoncée (409 si les formulaires ont été republiés pendant la saisie), puis contenu
// (mêmes règles que le launcher).
FeedbackForm form;
bool snapshot;
try
{
(form, snapshot) = forms.Resolve(submission.FormRevision, submission.Kind);
}
catch (ApiRejection ex)
{
audit.WriteAnonymousFailure("feedback.submit", null, null,
ex.Status == 409 ? $"formulaire dépassé (révision {submission.FormRevision})" : "type de demande refusé");
throw;
}
var errors = new List<string>();
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, form, errors);
if (clean is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, $"contenu refusé ({errors.Count} erreur(s))");
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
}
// 4. Limites par adresse IP (en mémoire uniquement), puis plafonds globaux.
if (!guard.TryCountSubmission(context.RateLimitPartition(), out var retryAfter))
{
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return ApiResults.Error(429, ApiErrorCodes.RateLimited,
"Vous avez envoyé plusieurs demandes récemment : patientez un peu avant d'en envoyer une autre.");
}
var now = time.GetUtcNow();
var record = store.Add(gameId, clean, now, snapshot ? form : null);
if (record is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, $"plafond atteint ({store.FullReason(gameId, now) ?? "?"})");
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull,
"Le service des demandes est momentanément saturé. Réessayez plus tard.");
}
// Audit SANS adresse IP : l'audit ne doit pas permettre de relier une demande « anonyme » à une adresse.
audit.Write("feedback.submit", AuditResults.Ok, target: record.Reference,
details: $"jeu {gameId} ; {KindLabel(record)}" +
(record.FormRevision > 0 ? $" ; formulaires révision {record.FormRevision}" : "") + (record.Tech is null ? "" : " ; informations techniques jointes") +
(record.AttachmentCount > 0 ? $" ; {record.AttachmentCount} capture(s) annoncée(s)" : ""));
return Results.Json(new FeedbackSubmitted
{
Reference = record.Reference,
UploadToken = record.AttachmentCount > 0 ? guard.CreateUploadToken(record.Id, record.AttachmentCount) : null
}, ApiJson.Options);
}
/// <summary>
/// Capture d'écran d'une demande qui vient d'être envoyée (jeton d'envoi remis à ce seul launcher). L'image est
/// contrôlée (vraie image PNG ou JPEG, dimensions, structure) puis nettoyée de ses métadonnées avant d'être
/// enregistrée ; sans adresse IP dans l'audit, comme la demande.
/// </summary>
private static IResult UploadAttachment(int index, HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit,
TimeProvider time)
{
if (!store.Enabled) return NotConfigured();
var id = guard.CheckUpload(context.Request.Headers[FeedbackUpload.TokenHeader].ToString(), index);
if (id is null || store.FindById(id) is null)
{
audit.WriteAnonymousFailure("feedback.attachment", null, null, "jeton d'envoi absent, expiré ou invalide");
return ApiResults.Error(403, ApiErrorCodes.Forbidden, "Envoi de la capture refusé (délai dépassé ?).");
}
var error = FeedbackImages.Sanitize(context.CapturedBody(), out var info, out var clean);
if (error is not null)
{
audit.WriteAnonymousFailure("feedback.attachment", null, null, $"capture refusée ({error})");
return ApiResults.BadRequest(error);
}
var record = store.AddAttachment(id, index, clean!, info!, time.GetUtcNow());
if (record is null)
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull, "Le service des demandes est momentanément saturé : capture non enregistrée.");
audit.Write("feedback.attachment", AuditResults.Ok, target: record.Reference,
details: $"jeu {record.GameId} ; capture {index} : {info!.Format.ToUpperInvariant()} {info.Width} × {info.Height}, {clean!.Length / 1024} Ko (métadonnées retirées)");
return Results.Json(new FeedbackAttachmentStored { Index = index }, ApiJson.Options);
}
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés), sinon nom d'origine.</summary>
private static string KindLabel(FeedbackRecord record) => record.Form?.Label ?? FeedbackForms.KindLabel(record.Kind);
// ------------------------------------------------------------------ Formulaires (communs à tous les jeux)
private static IResult GetForms(FeedbackFormsStore forms, AdminStore admins) => Results.Json(FormsState(forms, admins), ApiJson.Options);
private static FeedbackFormsState FormsState(FeedbackFormsStore forms, AdminStore admins)
{
var current = forms.Current;
return new FeedbackFormsState
{
Document = current,
PublishingEnabled = forms.PublishingEnabled,
PublicUrl = forms.PublicUrl,
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
};
}
/// <summary>
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
/// révision attendue (409 si quelqu'un a publié entre-temps), audit détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
/// </summary>
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
{
if (request.Document is null) return ApiResults.BadRequest("Formulaires absents.");
var document = request.Document with { UpdatedAt = null };
var actor = context.Admin();
if (document.Signature is not { } signature)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Les formulaires doivent être signés.");
ContentSignatures.RequireFromActor(signature, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId,
FeedbackFormRules.Digest(document), actor, time.GetUtcNow());
FeedbackFormsDocument published, previous;
try
{
(published, previous) = forms.Publish(document, request.BaseRevision, time.GetUtcNow());
}
catch (IOException ex)
{
logger.LogError(ex, "Publication des formulaires des demandes");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
var changes = FeedbackFormsAudit.Changes(previous, published);
foreach (var (action, name, details) in changes
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
.Reverse())
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"« {name} »", details);
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; signé (clé {signature.KeyId})");
return Results.Json(FormsState(forms, admins), ApiJson.Options);
}
// ------------------------------------------------------------------ Administrateurs
private static IResult Overview(FeedbackStore store, IOptions<LauncherApiOptions> options, TimeProvider time)
{
var now = time.GetUtcNow();
var counts = store.Counts();
return Results.Json(new FeedbackOverview
{
Enabled = store.Enabled,
StorageFull = store.Enabled && counts.Any(c => store.FullReason(c.GameId, now) is not null),
Games = counts,
RetentionClosedMonths = options.Value.Feedback.RetentionClosedMonths,
RetentionMaxMonths = options.Value.Feedback.RetentionMaxMonths
}, ApiJson.Options);
}
private static IResult List(string gameId, string? kind, string? status, string? cursor, int? limit, HttpContext context,
FeedbackStore store, AuditLog audit, FeedbackConsultations consultations)
{
var game = store.ResolveGame(gameId);
if (game is null) return ApiResults.NotFound("Ce jeu ne reçoit pas de demandes sur ce serveur.");
if (kind is not null && !FeedbackFormRules.IsValidId(kind)) return ApiResults.BadRequest("Type de demande inconnu.");
if (!FeedbackStore.IsValidStatusFilter(status)) return ApiResults.BadRequest("Statut inconnu.");
if (cursor is { Length: > 60 }) return ApiResults.BadRequest("Curseur invalide.");
var page = store.List(game, kind, status, cursor, Math.Clamp(limit ?? 100, 1, 200));
var actor = context.Admin();
if (consultations.ShouldRecord(actor.Key.KeyId, "list:" + game, continuation: !string.IsNullOrEmpty(cursor)))
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"jeu {game}", $"liste : {page.Items.Count} demande(s) sur {page.Total}");
return Results.Json(page, ApiJson.Options);
}
private static IResult Get(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit,
FeedbackConsultations consultations)
{
var game = store.ResolveGame(gameId);
if (game is null || store.Find(game, id) is not { } record) return ApiResults.NotFound("Demande introuvable.");
var actor = context.Admin();
// Lecture d'une demande (texte libre : données personnelles possibles) inscrite au journal, une fois par
// demande et par clé toutes les 10 minutes (sans le contenu).
if (consultations.ShouldRecord(actor.Key.KeyId, "item:" + record.Id, continuation: true))
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
record.Reference, $"jeu {game} ; {KindLabel(record)}");
return Results.Json(record.ToDetail(), ApiJson.Options);
}
private static IResult GetAttachment(string gameId, string id, int index, HttpContext context, FeedbackStore store, AuditLog audit,
FeedbackConsultations consultations)
{
var game = store.ResolveGame(gameId);
if (game is null || store.Find(game, id) is not { } record || store.ReadAttachment(record, index) is not { } bytes)
return ApiResults.NotFound("Capture introuvable.");
var actor = context.Admin();
if (consultations.ShouldRecord(actor.Key.KeyId, $"attachment:{record.Id}:{index}", continuation: true))
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
record.Reference, $"jeu {game} ; capture {index}");
// Type générique : le contenu n'est jamais interprété comme une page par un navigateur.
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
return Results.Bytes(bytes, FeedbackUpload.ContentType);
}
private static IResult Update(string gameId, string id, FeedbackUpdateRequest request, HttpContext context, FeedbackStore store,
AuditLog audit, TimeProvider time)
{
var game = store.ResolveGame(gameId);
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
if (!FeedbackStatuses.IsValid(request.Status)) return ApiResults.BadRequest("Statut inconnu.");
var note = FeedbackRules.CleanAdminNote(request.AdminNote, out var noteValid);
if (!noteValid)
return ApiResults.BadRequest($"Note interne : {FeedbackRules.MaxAdminNoteLength} caractères au plus, sans caractère invisible.");
var actor = context.Admin();
var updated = store.Update(game, id, request.Revision, request.Status, note, actor.Admin.DisplayName, time.GetUtcNow(), out var previous);
var changes = new List<string>();
if (previous.Status != updated.Status)
changes.Add($"statut : {FeedbackStatuses.Label(previous.Status)} → {FeedbackStatuses.Label(updated.Status)}");
if (previous.AdminNote != updated.AdminNote) changes.Add(updated.AdminNote is null ? "note interne retirée" : "note interne modifiée");
audit.Write("feedback.update", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
updated.Reference, $"jeu {game} ; " + (changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes)));
return Results.Json(updated.ToDetail(), ApiJson.Options);
}
private static IResult Delete(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit)
{
var game = store.ResolveGame(gameId);
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
var actor = context.Admin();
var deleted = store.Delete(game, id);
audit.Write("feedback.delete", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
deleted.Reference, $"jeu {game} ; {KindLabel(deleted)} ; {FeedbackStatuses.Label(deleted.Status)}");
return Results.NoContent();
}
}
/// <summary>
/// Dernière consultation inscrite par clé et par élément (liste d'un jeu, demande) : une ligne d'audit par consultation,
/// pas une par page ni par réaffichage dans les 10 minutes.
/// </summary>
public sealed class FeedbackConsultations
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
private readonly TimeProvider _time;
private readonly Dictionary<string, DateTimeOffset> _last = new(StringComparer.Ordinal);
public FeedbackConsultations(TimeProvider time) => _time = time;
/// <param name="continuation">Vrai : pas réinscrit si la dernière consultation date de moins de 10 minutes.</param>
public bool ShouldRecord(string keyId, string item, bool continuation)
{
var now = _time.GetUtcNow();
var key = keyId + "|" + item;
lock (_last)
{
if (continuation && _last.TryGetValue(key, out var last) && now - last < Window && now >= last) return false;
if (_last.Count > 20_000) _last.Clear(); // seules les clés enregistrées arrivent ici
_last[key] = now;
return true;
}
}
}
@@ -0,0 +1,129 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Files;
/// <summary>
/// Page « Fichiers du serveur » (permission files.manage : propriétaires, administrateurs) : consultation du site
/// public, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu. Suppressions signées et
/// inscrites au journal d'audit ; le serveur revérifie les manifests au moment de supprimer (ServerFilesStore).
/// </summary>
public sealed class FilesModule : IApiModule
{
public string Name => "Files";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<ServerFilesStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
const string permission = AdminPermissions.FilesManage;
api.MapGet(ApiRoutes.Files, List).RequirePermission(permission);
api.MapPost(ApiRoutes.FilesDelete, Delete).RequirePermission(permission).RequireSignedRequest();
api.MapGet(ApiRoutes.ObjectsPrunePattern, PreviewPrune).RequirePermission(permission);
api.MapPost(ApiRoutes.ObjectsPrunePattern, Prune).RequirePermission(permission).RequireSignedRequest();
}
private static IResult List(string? path, ServerFilesStore store, ILogger<FilesModule> logger)
{
if (path is { Length: > ServerFilesLimits.MaxPathLength }) return ApiResults.BadRequest("Chemin trop long.");
return Guard(logger, "Consultation des fichiers", () => Results.Json(store.List(path), ApiJson.Options));
}
private static IResult Delete(HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
{
ServerFileDeleteRequest? request;
try
{
request = JsonSerializer.Deserialize<ServerFileDeleteRequest>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Requête de suppression illisible.");
}
if (request is null || string.IsNullOrWhiteSpace(request.Path) || request.Path.Length > ServerFilesLimits.MaxPathLength || request.Size < 0)
return ApiResults.BadRequest("Requête de suppression invalide.");
return Guard(logger, "Suppression d'un fichier", () =>
{
var result = store.Delete(request.Path, request.Size);
Audit(audit, context, "files.delete", result.Path, $"{Megabytes(result.FreedBytes)} libéré(s)");
return Results.Json(result, ApiJson.Options);
});
}
private static IResult PreviewPrune(string gameId, ServerFilesStore store, ILogger<FilesModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
return Guard(logger, "Analyse du dossier objects", () => Results.Json(store.PreviewPrune(gameId), ApiJson.Options));
}
private static IResult Prune(string gameId, HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
ObjectsPruneRequest? request;
try
{
request = JsonSerializer.Deserialize<ObjectsPruneRequest>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Requête de nettoyage illisible.");
}
var manifestSha = (request?.ManifestSha256 ?? "").ToLowerInvariant();
if (!PublishingLimits.IsValidSha256(manifestSha)) return ApiResults.BadRequest("Empreinte du manifest analysé invalide.");
return Guard(logger, "Nettoyage du dossier objects", () =>
{
var result = store.Prune(gameId, manifestSha,
(file, ex) => logger.LogWarning(ex, "Nettoyage du dossier objects : {File} non supprimé", Path.GetFileName(file)));
Audit(audit, context, "files.prune", $"{result.GameId} objects",
$"{result.Deleted} fichier(s) supprimé(s), {Megabytes(result.FreedBytes)} libéré(s)" +
(result.Failed > 0 ? $" ; {result.Failed} échec(s)" : "") + $" ; manifest {manifestSha[..12]}");
return Results.Json(result, ApiJson.Options);
});
}
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin du serveur (le détail va dans journalctl).</summary>
private static IResult Guard(ILogger logger, string what, Func<IResult> action)
{
try
{
return action();
}
catch (InvalidDataException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
}
catch (UnauthorizedAccessException ex)
{
logger.LogError(ex, "{What}", what);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas le droit de modifier ce dossier : seuls les dossiers des jeux et du catalogue sont autorisés en écriture " +
"(ReadWritePaths, server/deploy/LISEZMOI.md).");
}
catch (IOException ex)
{
logger.LogError(ex, "{What}", what);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu accéder au fichier : droits du dossier (ReadWritePaths) ou fichier en cours d'utilisation (server/deploy/LISEZMOI.md).");
}
}
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string details)
{
var actor = context.Admin();
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
}
private static string Megabytes(long bytes) =>
bytes >= 1024 * 1024 ? $"{bytes / (1024.0 * 1024):0.#} Mo" : bytes >= 1024 ? $"{bytes / 1024.0:0.#} Ko" : $"{bytes} octet(s)";
}
@@ -0,0 +1,104 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Maintenance;
/// <summary>
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
/// maintenance (c'est elle qui permet de la terminer).
/// </summary>
public sealed class MaintenanceModule : IApiModule
{
public string Name => "Maintenance";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<MaintenanceStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
}
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
{
try
{
return Results.Json(State(store, admins), ApiJson.Options);
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Lecture du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
"dans les données privées du serveur.");
}
}
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
{
var current = store.Current;
return new MaintenanceState
{
Document = current,
PublishingEnabled = store.PublishingEnabled,
PublicUrl = store.PublicUrl,
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
};
}
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
{
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
var document = request.Document with { UpdatedAt = null };
var actor = context.Admin();
if (document.Signature is not { } signature)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
var now = time.GetUtcNow();
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
MaintenanceRules.Digest(document), actor, now);
MaintenanceDocument published, previous;
try
{
(published, previous) = store.Publish(document, request.BaseRevision, now);
}
catch (InvalidDataException ex)
{
logger.LogError(ex, "Publication du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
}
catch (IOException ex)
{
logger.LogError(ex, "Publication du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
"(server/deploy/LISEZMOI.md).");
}
var action = published.Enabled switch
{
true when !previous.Enabled => "maintenance.enable",
false when previous.Enabled => "maintenance.disable",
_ => "maintenance.update"
};
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
return Results.Json(State(store, admins), ApiJson.Options);
}
}
@@ -0,0 +1,151 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Publishing;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5) : permission releases.publish,
/// TOUTES les requêtes signées (y compris chaque morceau de fichier), audit du début, de la mise en
/// ligne et de l'abandon. Les routes d'envoi acceptent des corps jusqu'à 8 Mo (les autres : 64 Ko).
/// </summary>
public sealed class PublishingModule : IApiModule
{
public string Name => "Publishing";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<PublicationStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
const string permission = AdminPermissions.ReleasesPublish;
api.MapPost(ApiRoutes.PublicationStartPattern, Start).RequirePermission(permission).RequireSignedRequest();
api.MapGet(ApiRoutes.PublicationPattern, GetState).RequirePermission(permission);
api.MapDelete(ApiRoutes.PublicationPattern, Cancel).RequirePermission(permission).RequireSignedRequest();
api.MapPut(ApiRoutes.PublicationManifestPattern, Manifest).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationArchivePattern, Archive).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationObjectPattern, UploadObject).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationObjectsPattern, UploadObjects).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationCommitPattern, Commit).RequirePermission(permission).RequireSignedRequest();
}
private static IResult Start(string gameId, PublicationStartRequest request, HttpContext context, PublicationStore store, AuditLog audit,
ILogger<PublishingModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
var actor = context.Admin();
return Guard(logger, () =>
{
var (state, replaced) = store.Start(gameId, request, actor.Admin.Id, actor.Admin.DisplayName);
if (replaced is not null)
Audit(audit, context, "release.cancel", replaced.GameId, replaced.Version, $"remplacée par la version {state.Version}");
if (!state.Resumed)
Audit(audit, context, "release.start", state.GameId, state.Version, $"version en ligne : {Display(state.PublishedVersion)}");
return Results.Json(state, ApiJson.Options);
});
}
private static IResult GetState(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.GetState(id, context.Admin().Admin.Id), ApiJson.Options));
private static IResult Cancel(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
var record = store.Cancel(id, context.Admin().Admin.Id);
Audit(audit, context, "release.cancel", record.GameId, record.Version, "abandonnée par l'administrateur");
return Results.NoContent();
});
private static IResult Manifest(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendManifest(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult Archive(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendArchive(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult UploadObject(string id, string sha256, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger)
{
if (!PublishingLimits.IsValidSha256(sha256)) return ApiResults.BadRequest("Empreinte d'objet invalide.");
return Guard(logger, () =>
Results.Json(store.AppendObject(id, context.Admin().Admin.Id, sha256, offset, context.CapturedBody()), ApiJson.Options));
}
private static IResult UploadObjects(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AddObjects(id, context.Admin().Admin.Id, context.CapturedBody()), ApiJson.Options));
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
// Corps facultatif : signature du contenu du manifest (phase 7). Sans corps : mise en ligne non signée.
PublicationCommitRequest? request = null;
var body = context.CapturedBody();
if (body.Length > 0)
{
try
{
request = JsonSerializer.Deserialize<PublicationCommitRequest>(body, ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Requête de mise en ligne illisible.");
}
}
var signature = request?.Signature;
if (signature is null && options.Value.ContentSigning.Require)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
"Le serveur exige une version signée (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
var actor = context.Admin();
var (result, record) = store.Commit(id, actor.Admin.Id, signature,
(gameId, digest) => ContentSignatures.RequireFromActor(signature!, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()));
if (record is null) return Results.Json(result, ApiJson.Options); // déjà en ligne (réponse précédente perdue)
Audit(audit, context, "release.publish", result.GameId, result.Version,
$"version précédente : {Display(result.PreviousVersion)} ; {result.ObjectsAdded} fichier(s) ajouté(s)" +
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}" +
(signature is null ? " ; non signée" : $" ; signée (clé {signature.KeyId})"));
return Results.Json(result, ApiJson.Options);
});
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin (le détail va dans journalctl).</summary>
private static IResult Guard(ILogger logger, Func<IResult> action)
{
try
{
return action();
}
catch (ManifestWriteRefusedException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Publication refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
}
catch (InvalidDataException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Publication d'une version");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire dans le dossier du jeu : vérifiez ses droits et l'espace disque (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static void Audit(AuditLog audit, HttpContext context, string action, string gameId, string version, string details)
{
var actor = context.Admin();
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{gameId} {version}", details);
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,245 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Signing;
/// <summary>
/// Signature du contenu servi aux joueurs (phase 7) :
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue, des formulaires des demandes
/// et du mode maintenance ;
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
/// </summary>
public sealed class SigningModule : IApiModule
{
public string Name => "Signing";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<KeyringStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Signing, State).RequirePermission(AdminPermissions.AdminsManage);
api.MapPut(ApiRoutes.Keyring, PublishKeyring).RequirePermission(AdminPermissions.AdminsManage).RequireSignedRequest();
api.MapGet(ApiRoutes.ManifestSignaturePattern, GetManifest).RequirePermission(AdminPermissions.ReleasesPublish);
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
}
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, MaintenanceStore maintenance,
AdminStore admins, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
{
var settings = options.Value;
var db = admins.Read();
var manifests = new List<ManifestSignatureState>();
foreach (var (gameId, path) in settings.GameManifestFiles.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
{
try
{
manifests.Add(ManifestSignatures.Read(gameId, path, db));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or
FormatException or InvalidOperationException)
{
// Manifest illisible (fichier modifié à la main…) : affiché « non signé », la page reste utilisable.
logger.LogWarning(ex, "Signature du manifest de {GameId}", gameId);
manifests.Add(new ManifestSignatureState { GameId = gameId });
}
}
CatalogSignatureState? catalog = null;
if (catalogs.PublishingEnabled)
{
CatalogDocument? published;
try
{
published = catalogs.ReadPublished();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature du catalogue");
published = null;
}
catalog = published is null
? new CatalogSignatureState()
: new CatalogSignatureState
{
Revision = published.Revision,
Digest = ContentSigning.CatalogDigest(published),
Signature = published.Signature,
SignerName = ContentSignatures.SignerName(db, published.Signature)
};
}
// Formulaires des demandes en vigueur (référence privée, recopiée à l'identique dans le fichier public).
FeedbackFormsSignatureState? feedbackForms = null;
if (forms.PublishingEnabled)
{
try
{
var current = forms.Current;
feedbackForms = current.Revision == 0
? new FeedbackFormsSignatureState()
: new FeedbackFormsSignatureState
{
Revision = current.Revision,
Digest = FeedbackFormRules.Digest(current),
Signature = current.Signature,
SignerName = ContentSignatures.SignerName(db, current.Signature)
};
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature des formulaires des demandes");
feedbackForms = new FeedbackFormsSignatureState { Unreadable = true };
}
}
// Mode maintenance en vigueur (référence privée, recopiée à l'identique dans le fichier public).
MaintenanceSignatureState? maintenanceState = null;
if (maintenance.PublishingEnabled)
{
try
{
var current = maintenance.Current;
maintenanceState = current.Revision == 0
? new MaintenanceSignatureState()
: new MaintenanceSignatureState
{
Revision = current.Revision,
Enabled = current.Enabled,
Digest = MaintenanceRules.Digest(current),
Signature = current.Signature,
SignerName = ContentSignatures.SignerName(db, current.Signature)
};
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature du mode maintenance");
maintenanceState = new MaintenanceSignatureState { Unreadable = true };
}
}
KeyringDocument? keyring;
try
{
keyring = keyrings.Read();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
// Trousseau de référence illisible : la page propose d'en publier un nouveau (numéro choisi par le serveur au contrôle).
logger.LogWarning(ex, "Trousseau de référence illisible");
keyring = null;
}
return Results.Json(new SigningState
{
Keyring = keyring,
KeyringPublishingEnabled = keyrings.PublishingEnabled,
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
RequireSignatures = settings.ContentSigning.Require,
Manifests = manifests,
Catalog = catalog,
FeedbackForms = feedbackForms,
Maintenance = maintenanceState
}, ApiJson.Options);
}
private static IResult PublishKeyring(HttpContext context, KeyringStore keyrings, AuditLog audit, ILogger<SigningModule> logger)
{
KeyringDocument? keyring;
try
{
keyring = Keyring.Parse(context.CapturedBody());
}
catch (JsonException ex)
{
return ApiResults.BadRequest($"Trousseau illisible : {ex.Message}");
}
if (keyring is null) return ApiResults.BadRequest("Trousseau absent.");
KeyringDocument? previous;
try
{
previous = keyrings.Publish(keyring);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Publication du trousseau");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le trousseau public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
catch (Exception ex) when (ex is InvalidDataException or JsonException)
{
// Trousseau de référence du serveur illisible : refus (le numéro du trousseau publié ne peut pas être contrôlé).
logger.LogError(ex, "Trousseau de référence illisible");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le trousseau de référence du serveur est illisible : restaurez-le depuis l'historique (keyring-history/) avant de publier.");
}
var actor = context.Admin();
var added = keyring.Keys.Select(k => k.KeyId).Except(previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).ToList();
var removed = (previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).Except(keyring.Keys.Select(k => k.KeyId)).ToList();
audit.Write("keyring.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"trousseau n° {keyring.Sequence}",
$"{keyring.Keys.Count} clé(s), racine {keyring.Signature?.RootKeyId}" +
(added.Count > 0 ? $" ; ajoutées : {string.Join(", ", added)}" : "") +
(removed.Count > 0 ? $" ; retirées : {string.Join(", ", removed)}" : ""));
return Results.Json(keyring, ApiJson.Options);
}
private static IResult GetManifest(string gameId, AdminStore admins, IOptions<LauncherApiOptions> options)
{
var path = ManifestPath(gameId, options.Value);
return Results.Json(ManifestSignatures.Read(gameId, path, admins.Read()), ApiJson.Options);
}
private static IResult SignManifest(string gameId, ManifestSignRequest request, HttpContext context, AdminStore admins,
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
{
var path = ManifestPath(gameId, options.Value);
if (request.Signature is null || !PublishingSha(request.Digest)) return ApiResults.BadRequest("Signature ou condensé absent.");
var actor = context.Admin();
ManifestSignatureState state;
try
{
state = ManifestSignatures.Sign(gameId, path, options.Value.DataDirectoryFullPath, request,
digest => ContentSignatures.RequireFromActor(request.Signature, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()),
admins.Read());
}
catch (ManifestWriteRefusedException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Signature refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Signature du manifest de {GameId}", gameId);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu réécrire le manifest : vérifiez les droits du dossier du jeu (server/deploy/LISEZMOI.md, étape 3 ter).");
}
audit.Write("release.sign", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{gameId} {state.Version}", $"version en ligne signée (contenu {state.Digest[..12]})");
return Results.Json(state, ApiJson.Options);
}
private static bool PublishingSha(string? value) => Contracts.Publishing.PublishingLimits.IsValidSha256(value);
private static string ManifestPath(string gameId, LauncherApiOptions options)
{
if (!CatalogRules.IsValidGameId(gameId)) throw ApiRejection.BadRequest("Identifiant de jeu invalide.");
return options.GameManifestFiles.TryGetValue(gameId, out var path)
? path
: throw new ApiRejection(404, ApiErrorCodes.NotConfigured,
"Jeu non configuré sur le serveur (LauncherApi:Games, voir server/deploy/LISEZMOI.md).");
}
}
@@ -0,0 +1,35 @@
using System.Reflection;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.System;
/// <summary>Informations publiques du serveur et état de santé.</summary>
public sealed class SystemModule : IApiModule
{
public string Name => "System";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
public void MapEndpoints(IEndpointRouteBuilder api)
{
var version = typeof(SystemModule).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "?";
var plus = version.IndexOf('+');
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
{
ServerId = server.ServerId,
Origin = server.Origin,
ApiVersion = ApiRoutes.Version,
ServerVersion = version,
KeyAlgorithms = new[] { AdminKeys.Algorithm }
}, ApiJson.Options));
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
}
}
+21
View File
@@ -0,0 +1,21 @@
using GameLauncher.Server.Cli;
using GameLauncher.Server.Hosting;
namespace GameLauncher.Server;
/// <summary>
/// Sans commande : démarre la Launcher API (service systemd).
/// Avec une commande (add-owner, list, revoke-admin, revoke-key, server-id, help) : outil
/// d'administration en ligne de commande, à lancer SUR LE VPS sous le compte du service.
/// </summary>
public static class Program
{
public static async Task<int> Main(string[] args)
{
if (AdminCli.IsCommand(args)) return AdminCli.Run(args);
var app = ServerApp.Build(args);
await app.RunAsync();
return 0;
}
}
@@ -0,0 +1,12 @@
{
"profiles": {
"GameLauncher.Server": {
"commandName": "Project",
"launchBrowser": false,
"applicationUrl": "http://127.0.0.1:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,131 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
/// <param name="Permissions">Permissions de son groupe, lues au moment de l'authentification de la requête.</param>
/// <param name="RoleName">Nom affiché de son groupe.</param>
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions,
string? RoleName = null)
{
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
public AdminProfile ToProfile() => new()
{
Id = Admin.Id,
DisplayName = Admin.DisplayName,
Role = Admin.Role,
RoleName = RoleName ?? AdminRoles.DisplayName(Admin.Role),
Permissions = Permissions,
KeyId = Key.KeyId,
KeyLabel = Key.Label
};
}
/// <summary>
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le groupe et ses
/// permissions sont relus à chaque requête : un changement de groupe, ou des permissions d'un groupe,
/// s'applique aussitôt.
/// </summary>
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "AdminSession";
public const string PermissionClaim = "perm";
private const string ContextKey = "GameLauncher.Admin";
private readonly SessionStore _sessions;
private readonly AdminStore _store;
public AdminSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
SessionStore sessions, AdminStore store)
: base(options, logger, encoder)
{
_sessions = sessions;
_store = store;
}
public static AuthenticatedAdmin? Get(HttpContext context) =>
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedAdmin : null;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var header = Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(header)) return Task.FromResult(AuthenticateResult.NoResult());
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
var session = _sessions.Find(header["Bearer ".Length..].Trim());
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
var database = _store.Read();
var match = database.FindKey(session.KeyId);
if (match is not { } found || found.Admin.Id != session.AdminId ||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
{
_sessions.Remove(session);
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
}
var permissions = database.PermissionsOf(found.Admin.Role);
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
new(ClaimTypes.Name, found.Admin.DisplayName),
new(ClaimTypes.Role, found.Admin.Role),
new("key", found.Key.KeyId),
new("sid", session.Id)
};
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions, database.GroupName(found.Admin.Role));
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Authentification requise (session absente ou expirée).");
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
}
public static class AdminAuthorization
{
public static string PolicyName(string permission) => "perm:" + permission;
/// <summary>Route réservée aux administrateurs disposant de cette permission.</summary>
public static TBuilder RequirePermission<TBuilder>(this TBuilder builder, string permission)
where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PolicyName(permission));
/// <summary>Route réservée à un administrateur connecté (quelles que soient ses permissions).</summary>
public static TBuilder RequireAdmin<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(AdminSessionHandler.SchemeName);
public static IServiceCollection AddAdminAuthentication(this IServiceCollection services)
{
services.AddAuthentication(AdminSessionHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
services.AddAuthorization(options =>
{
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
foreach (var permission in AdminPermissions.All)
options.AddPolicy(PolicyName(permission),
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
});
return services;
}
/// <summary>Administrateur authentifié de la requête (les routes protégées en ont toujours un).</summary>
public static AuthenticatedAdmin Admin(this HttpContext context) =>
AdminSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequireAdmin/RequirePermission.");
}
@@ -0,0 +1,106 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Défi de connexion : à signer avec la clé, valable quelques secondes, utilisable une seule fois.</summary>
public sealed record LoginChallenge(string Id, string Nonce, string KeyId, DateTimeOffset ExpiresAt);
/// <summary>
/// Défis de connexion SANS ÉTAT : l'identifiant du défi contient la clé visée, l'échéance et le
/// nonce, scellés par un HMAC (secret aléatoire, en mémoire). Il n'existe donc aucune réserve
/// partagée qu'un attaquant pourrait remplir ou vider : demander des défis ne coûte rien au serveur.
/// Seuls les défis UTILISÉS avec succès sont mémorisés (jusqu'à leur échéance) pour refuser le rejeu ;
/// on n'y entre qu'avec une signature valide.
/// </summary>
public sealed class ChallengeStore
{
private const int NonceLength = 32;
private const int MacLength = 16;
private const int PayloadLength = 8 + 8 + NonceLength; // clé (8 octets) + échéance + nonce
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Admin/Challenge/v1\n");
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
private readonly TimeProvider _time;
private readonly TimeSpan _lifetime;
private readonly int _maxUsed;
public ChallengeStore(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_time = time;
_lifetime = TimeSpan.FromSeconds(options.Value.ChallengeLifetimeSeconds);
_maxUsed = 100_000;
}
/// <summary>Longueur maximale d'un identifiant de défi (contrôle des entrées).</summary>
public const int MaxIdLength = 96;
public LoginChallenge Create(string keyId)
{
var expires = _time.GetUtcNow() + _lifetime;
var payload = new byte[PayloadLength];
Convert.FromHexString(keyId).CopyTo(payload, 0);
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(8), expires.ToUnixTimeSeconds());
RandomNumberGenerator.Fill(payload.AsSpan(16));
var id = new byte[PayloadLength + MacLength];
payload.CopyTo(id, 0);
Mac(payload).CopyTo(id, PayloadLength);
return new LoginChallenge(Base64Url.Encode(id), Base64Url.Encode(payload.AsSpan(16)), keyId,
DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds()));
}
/// <summary>Vérifie le sceau, la clé visée et l'échéance (sans consommer le défi).</summary>
public bool TryValidate(string challengeId, string keyId, out LoginChallenge challenge)
{
challenge = null!;
if (challengeId.Length > MaxIdLength || !Base64Url.TryDecode(challengeId, out var id) ||
id.Length != PayloadLength + MacLength || !AdminKeysHex(keyId, out var keyBytes))
return false;
var payload = id.AsSpan(0, PayloadLength);
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
if (!payload[..8].SequenceEqual(keyBytes)) return false;
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[8..16]));
if (expires <= _time.GetUtcNow() || expires > _time.GetUtcNow() + _lifetime + TimeSpan.FromSeconds(1)) return false;
challenge = new LoginChallenge(challengeId, Base64Url.Encode(payload[16..]), keyId, expires);
return true;
}
/// <summary>Marque le défi comme utilisé (après vérification de la signature). Faux s'il l'était déjà (rejeu).</summary>
public bool TryMarkUsed(LoginChallenge challenge)
{
if (_used.Count >= _maxUsed)
{
var now = _time.GetUtcNow();
foreach (var (id, expires) in _used)
if (expires <= now) _used.TryRemove(id, out _);
if (_used.Count >= _maxUsed) throw new StoreBusyException("Trop de connexions récentes.");
}
return _used.TryAdd(challenge.Id, challenge.ExpiresAt);
}
private byte[] Mac(ReadOnlySpan<byte> payload)
{
var data = new byte[Domain.Length + payload.Length];
Domain.CopyTo(data, 0);
payload.CopyTo(data.AsSpan(Domain.Length));
return HMACSHA256.HashData(_secret, data)[..MacLength];
}
private static bool AdminKeysHex(string keyId, out byte[] bytes)
{
bytes = Array.Empty<byte>();
if (!Contracts.Security.AdminKeys.IsValidKeyId(keyId)) return false;
bytes = Convert.FromHexString(keyId);
return true;
}
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Security;
/// <summary>
/// Contrôle, par le serveur, des signatures de contenu envoyées par le menu d'administration (phase 7). Le
/// serveur n'est pas l'ancre de confiance (ce sont les launchers des joueurs, avec le trousseau) : ce contrôle
/// évite seulement de mettre en ligne une signature que les launchers refuseraient, et garantit que la clé qui
/// signe est celle de la session (rien n'est signé au nom d'un autre).
/// </summary>
public static class ContentSignatures
{
/// <exception cref="ApiRejection">400 : signature d'une autre clé, date hors tolérance, ou signature invalide.</exception>
public static void RequireFromActor(ContentSignature signature, string kind, string id, string digest,
AuthenticatedAdmin actor, DateTimeOffset now)
{
if (!string.Equals(signature.KeyId, actor.Key.KeyId, StringComparison.Ordinal))
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid, "La signature du contenu doit être faite par la clé de cette session.");
if (signature.SignedAt < now - ContentSigning.MaxSignedAtSkew || signature.SignedAt > now + ContentSigning.MaxSignedAtSkew)
throw new ApiRejection(400, ApiErrorCodes.ClockSkew,
"Date de signature du contenu trop éloignée de l'heure du serveur : vérifiez l'heure de Windows, puis recommencez.");
if (!ContentSigning.VerifyWithKey(kind, id, digest, signature, actor.Key.PublicKeyBytes()))
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid,
"Signature du contenu invalide : le contenu signé ne correspond pas à celui envoyé.");
}
/// <summary>« Nicolas (PC fixe) » pour une clé connue du serveur, sinon null.</summary>
public static string? SignerName(AdminDatabase db, ContentSignature? signature)
{
if (signature is null || db.FindKey(signature.KeyId) is not { } found) return null;
return string.IsNullOrWhiteSpace(found.Key.Label) ? found.Admin.DisplayName : $"{found.Admin.DisplayName} ({found.Key.Label})";
}
/// <summary>Signature présente dans un manifest publié (null : absente ou illisible).</summary>
public static ContentSignature? ReadSignature(JsonObject manifest)
{
if (PublishedFiles.Property(manifest, "signature") is not JsonObject node) return null;
try
{
return node.Deserialize<ContentSignature>(ApiJson.Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,178 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>
/// Protections de la route ANONYME d'envoi des demandes des joueurs :
/// • défis anti-spam SANS ÉTAT (échéance, difficulté et nonce scellés par HMAC, secret en mémoire) : en demander ne
/// coûte rien au serveur ; seuls les défis UTILISÉS sont mémorisés jusqu'à leur échéance (refus du rejeu) ;
/// • limites par adresse IP (IPv6 : par /64) sur 10 minutes et sur 24 heures, comptées en mémoire uniquement :
/// aucune adresse n'est écrite sur le disque ni rattachée à une demande.
/// </summary>
public sealed class FeedbackGuard
{
private const int NonceLength = 16;
private const int MacLength = 16;
private const int PayloadLength = 8 + 1 + NonceLength; // échéance + difficulté + nonce
private const int MaxTracked = 100_000;
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Challenge/v1\n");
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
private readonly Dictionary<string, List<DateTimeOffset>> _accepted = new(StringComparer.Ordinal);
private readonly LauncherApiOptions.FeedbackSettings _settings;
private readonly TimeProvider _time;
public FeedbackGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_settings = options.Value.Feedback;
_time = time;
}
public FeedbackChallenge CreateChallenge()
{
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
var payload = new byte[PayloadLength];
BinaryPrimitives.WriteInt64BigEndian(payload, expires.ToUnixTimeSeconds());
payload[8] = (byte)_settings.ProofDifficulty;
RandomNumberGenerator.Fill(payload.AsSpan(9));
var id = new byte[PayloadLength + MacLength];
payload.CopyTo(id, 0);
Mac(payload).CopyTo(id, PayloadLength);
return new FeedbackChallenge
{
ChallengeId = Base64Url.Encode(id),
Nonce = Base64Url.Encode(payload.AsSpan(9)),
Difficulty = payload[8],
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
};
}
/// <summary>
/// Vérifie le défi (sceau, échéance), puis la solution, puis le marque comme utilisé. Faux : défi absent, forgé,
/// expiré, mal résolu ou déjà utilisé.
/// </summary>
public bool TryConsume(string? challengeId, string? solution)
{
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) ||
id.Length != PayloadLength + MacLength)
return false;
var payload = id.AsSpan(0, PayloadLength);
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
var now = _time.GetUtcNow();
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[..8]));
if (expires <= now) return false;
// Difficulté scellée à la création du défi (un défi obtenu juste avant un relèvement de la difficulté reste
// valable jusqu'à son échéance) ; jamais en dessous du minimum.
int difficulty = payload[8];
if (difficulty < FeedbackProof.MinDifficulty) return false;
if (!FeedbackProof.Verify(payload[9..], difficulty, solution)) return false;
if (_used.Count >= MaxTracked)
{
foreach (var (key, until) in _used)
if (until <= now) _used.TryRemove(key, out _);
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de demandes récentes.");
}
return _used.TryAdd(challengeId, expires);
}
/// <summary>
/// Compte une demande pour cette adresse (partition de limite de débit). Faux si la limite sur 10 minutes ou sur
/// 24 heures est atteinte (rien n'est compté alors). <paramref name="retryAfter"/> : délai avant la prochaine place.
/// </summary>
public bool TryCountSubmission(string partition, out TimeSpan retryAfter)
{
var now = _time.GetUtcNow();
retryAfter = TimeSpan.Zero;
lock (_accepted)
{
if (_accepted.Count >= MaxTracked)
{
foreach (var key in _accepted.Where(p => p.Value.All(t => now - t >= TimeSpan.FromDays(1))).Select(p => p.Key).ToList())
_accepted.Remove(key);
if (_accepted.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
}
if (!_accepted.TryGetValue(partition, out var times)) _accepted[partition] = times = new List<DateTimeOffset>();
times.RemoveAll(t => now - t >= TimeSpan.FromDays(1));
var recent = times.Where(t => now - t < TimeSpan.FromMinutes(10)).OrderBy(t => t).ToList();
if (recent.Count >= _settings.PerIpPer10Minutes)
{
retryAfter = recent[recent.Count - _settings.PerIpPer10Minutes] + TimeSpan.FromMinutes(10) - now;
return false;
}
if (times.Count >= _settings.PerIpPerDay)
{
retryAfter = times.Min() + TimeSpan.FromDays(1) - now;
return false;
}
times.Add(now);
return true;
}
}
// ------------------------------------------------------------------ Jetons d'envoi des captures
private sealed record PendingUpload(byte[] SecretHash, DateTimeOffset Expires, int Count);
private readonly ConcurrentDictionary<string, PendingUpload> _uploads = new(StringComparer.Ordinal);
/// <summary>
/// Jeton d'envoi des captures d'une demande qui vient d'être enregistrée : « &lt;id&gt;.&lt;secret 256 bits&gt; ». Seule
/// l'empreinte du secret est gardée, en mémoire (un redémarrage du service annule les envois en attente).
/// </summary>
public string CreateUploadToken(string id, int count)
{
var now = _time.GetUtcNow();
if (_uploads.Count >= MaxTracked)
{
foreach (var (key, pending) in _uploads)
if (pending.Expires <= now) _uploads.TryRemove(key, out _);
if (_uploads.Count >= MaxTracked) throw new StoreBusyException("Trop d'envois de captures en attente.");
}
var secret = RandomNumberGenerator.GetBytes(32);
_uploads[id] = new PendingUpload(SHA256.HashData(secret), now + TimeSpan.FromMinutes(FeedbackUpload.TokenLifetimeMinutes), count);
return id + "." + Base64Url.Encode(secret);
}
/// <summary>
/// Identifiant de la demande si le jeton est valable pour cette capture (numéro dans le nombre annoncé, délai non
/// dépassé), sinon null. Comparaison en temps constant.
/// </summary>
public string? CheckUpload(string? token, int index)
{
if (token is not { Length: > 33 and < 120 }) return null;
var dot = token.IndexOf('.');
if (dot != 32) return null;
var id = token[..dot];
if (!FeedbackRules.IsValidId(id) || !_uploads.TryGetValue(id, out var pending)) return null;
if (pending.Expires <= _time.GetUtcNow())
{
_uploads.TryRemove(id, out _);
return null;
}
if (!Base64Url.TryDecode(token[(dot + 1)..], out var secret) || secret.Length != 32 ||
!CryptographicOperations.FixedTimeEquals(SHA256.HashData(secret), pending.SecretHash))
return null;
return index >= 1 && index <= pending.Count ? id : null;
}
private byte[] Mac(ReadOnlySpan<byte> payload)
{
var data = new byte[Domain.Length + payload.Length];
Domain.CopyTo(data, 0);
payload.CopyTo(data.AsSpan(Domain.Length));
return HMACSHA256.HashData(_secret, data)[..MacLength];
}
}
@@ -0,0 +1,32 @@
using System.Collections.Concurrent;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Security;
/// <summary>Nonces des requêtes signées déjà vus (pendant la fenêtre de tolérance d'horloge) : refus des rejeux.</summary>
public sealed class ReplayCache
{
private const int Max = 100_000;
private readonly ConcurrentDictionary<string, DateTimeOffset> _seen = new(StringComparer.Ordinal);
private readonly TimeProvider _time;
public ReplayCache(TimeProvider time) => _time = time;
/// <summary>Faux si ce nonce a déjà été utilisé.</summary>
public bool TryRegister(string key, TimeSpan keepFor)
{
var now = _time.GetUtcNow();
if (_seen.Count >= Max)
{
foreach (var (k, expires) in _seen)
if (expires <= now) _seen.TryRemove(k, out _);
if (_seen.Count >= Max) throw new StoreBusyException("Trop de requêtes signées récentes.");
}
if (_seen.TryAdd(key, now + keepFor)) return true;
// Entrée expirée encore présente : elle peut être remplacée.
if (_seen.TryGetValue(key, out var existing) && existing <= now && _seen.TryUpdate(key, now + keepFor, existing))
return true;
return false;
}
}
@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Session d'un administrateur (en mémoire uniquement).</summary>
public sealed class AdminSessionState
{
/// <summary>Identifiant public de la session (inclus dans les requêtes signées).</summary>
public required string Id { get; init; }
/// <summary>SHA-256 du jeton : le jeton lui-même n'est jamais conservé.</summary>
public required string TokenHash { get; init; }
public required Guid AdminId { get; init; }
public required string KeyId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public string? Ip { get; init; }
public string? ClientName { get; init; }
private long _lastSeenTicks;
public DateTimeOffset LastSeenAt
{
get => new(Interlocked.Read(ref _lastSeenTicks), TimeSpan.Zero);
set => Interlocked.Exchange(ref _lastSeenTicks, value.UtcTicks);
}
}
/// <summary>
/// Sessions en mémoire : un redémarrage du service déconnecte tout le monde. Durée d'inactivité
/// et durée maximale bornées ; nombre de sessions borné par administrateur et au total.
/// </summary>
public sealed class SessionStore
{
private readonly ConcurrentDictionary<string, AdminSessionState> _byTokenHash = new(StringComparer.Ordinal);
private readonly object _createLock = new();
private readonly TimeProvider _time;
private readonly LauncherApiOptions.SessionSettings _settings;
public SessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_time = time;
_settings = options.Value.Sessions;
}
public TimeSpan IdleTimeout => TimeSpan.FromMinutes(_settings.IdleMinutes);
public int Count => _byTokenHash.Count;
/// <summary>Crée une session ; renvoie le jeton (à transmettre une seule fois au client).</summary>
public (string Token, AdminSessionState Session) Create(Guid adminId, string keyId, string? ip, string? clientName)
{
var now = _time.GetUtcNow();
var token = Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
var session = new AdminSessionState
{
Id = Base64Url.Encode(RandomNumberGenerator.GetBytes(16)),
TokenHash = Hash(token),
AdminId = adminId,
KeyId = keyId,
CreatedAt = now,
ExpiresAt = now + TimeSpan.FromHours(_settings.MaxHours),
Ip = ip,
ClientName = clientName,
LastSeenAt = now
};
lock (_createLock)
{
RemoveExpired();
// Au-delà du nombre autorisé pour cet administrateur : les plus anciennes sont fermées.
var own = _byTokenHash.Values.Where(s => s.AdminId == adminId).OrderBy(s => s.CreatedAt).ToList();
foreach (var old in own.Take(Math.Max(0, own.Count - _settings.MaxPerAdmin + 1)))
_byTokenHash.TryRemove(old.TokenHash, out _);
if (_byTokenHash.Count >= _settings.MaxTotal) throw new StoreBusyException("Trop de sessions ouvertes.");
_byTokenHash[session.TokenHash] = session;
}
return (token, session);
}
/// <summary>Session correspondant au jeton, si elle est encore valide ; l'activité est enregistrée.</summary>
public AdminSessionState? Find(string? token)
{
if (string.IsNullOrEmpty(token) || token.Length > 256) return null;
var hash = Hash(token);
if (!_byTokenHash.TryGetValue(hash, out var session)) return null;
var now = _time.GetUtcNow();
if (IsExpired(session, now))
{
_byTokenHash.TryRemove(hash, out _);
return null;
}
session.LastSeenAt = now;
return session;
}
public DateTimeOffset IdleExpiresAt(AdminSessionState session)
{
var idle = session.LastSeenAt + IdleTimeout;
return idle < session.ExpiresAt ? idle : session.ExpiresAt;
}
public void Remove(AdminSessionState session) => _byTokenHash.TryRemove(session.TokenHash, out _);
public int RemoveWhere(Func<AdminSessionState, bool> predicate)
{
var removed = 0;
foreach (var (hash, session) in _byTokenHash)
if (predicate(session) && _byTokenHash.TryRemove(hash, out _)) removed++;
return removed;
}
public void RemoveExpired()
{
var now = _time.GetUtcNow();
RemoveWhere(s => IsExpired(s, now));
}
private bool IsExpired(AdminSessionState session, DateTimeOffset now) =>
now >= session.ExpiresAt || now - session.LastSeenAt >= IdleTimeout;
private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
@@ -0,0 +1,127 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Security;
/// <summary>
/// Requêtes signées : toute route qui modifie quelque chose exige, en plus de la session, une
/// signature de la clé de l'administrateur sur la méthode, le chemin, le corps, l'heure et un nonce
/// (<see cref="SignedMessages.Request"/>). Un jeton de session volé ne permet donc aucune modification.
/// </summary>
public static class SignedRequests
{
private const string BodyKey = "GameLauncher.Body";
/// <summary>
/// Middleware : copie en mémoire le corps des requêtes qui modifient (taille bornée par Kestrel),
/// pour pouvoir le signer après la lecture JSON par la route.
/// </summary>
public static IApplicationBuilder UseRequestBodyCapture(this IApplicationBuilder app) => app.Use(async (context, next) =>
{
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
!HttpMethods.IsOptions(context.Request.Method))
{
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
feature.MaxRequestBodySize = limit.MaxBytes;
byte[] body;
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
{
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
body = new byte[length];
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
throw new BadHttpRequestException("Corps plus long que annoncé.");
}
else
{
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
body = buffer.ToArray();
}
context.Items[BodyKey] = body;
context.Request.Body = new MemoryStream(body, writable: false);
}
await next();
});
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
public static byte[] CapturedBody(this HttpContext context) =>
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
builder.WithMetadata(new RequestBodyLimit(maxBytes));
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.AddEndpointFilter(async (invocation, next) =>
{
var context = invocation.HttpContext;
var services = context.RequestServices;
var failure = Verify(context, services.GetRequiredService<ServerIdentity>(), services.GetRequiredService<ReplayCache>(),
services.GetRequiredService<TimeProvider>());
if (failure is null) return await next(invocation);
var admin = AdminSessionHandler.Get(context);
services.GetRequiredService<AuditLog>().Write("request.signature", AuditResults.Denied, admin?.Admin.Id,
admin?.Admin.DisplayName, admin?.Key.KeyId, context.ClientIp(), $"{context.Request.Method} {context.Request.Path}",
failure.Value.Message);
return ApiResults.Unauthorized(failure.Value.Message, failure.Value.Code);
});
private static (string Code, string Message)? Verify(HttpContext context, ServerIdentity server, ReplayCache replays, TimeProvider time)
{
var admin = AdminSessionHandler.Get(context);
if (admin is null) return (ApiErrorCodes.Unauthorized, "Authentification requise.");
var headers = context.Request.Headers;
var timestampText = headers[SignedRequestHeaders.Timestamp].ToString();
var nonce = headers[SignedRequestHeaders.Nonce].ToString();
var signature = AdminKeys.DecodeSignature(headers[SignedRequestHeaders.Signature].ToString());
if (string.IsNullOrEmpty(timestampText) || string.IsNullOrEmpty(nonce) || signature is null)
return (ApiErrorCodes.SignatureRequired, "Cette action doit être signée par votre clé.");
if (!long.TryParse(timestampText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var timestamp))
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
if (nonce.Length is < 16 or > 64 || !Base64Url.TryDecode(nonce, out _))
return (ApiErrorCodes.SignatureInvalid, "Nonce de signature invalide.");
var now = time.GetUtcNow();
var skew = SignedRequestHeaders.MaxClockSkew;
DateTimeOffset signedAt;
try
{
signedAt = DateTimeOffset.FromUnixTimeSeconds(timestamp);
}
catch (ArgumentOutOfRangeException)
{
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
}
if (signedAt < now - skew || signedAt > now + skew)
return (ApiErrorCodes.ClockSkew, "L'horloge de votre PC est décalée de plus de 2 minutes : corrigez l'heure de Windows.");
var body = context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
var request = context.Request;
var message = SignedMessages.Request(server.Origin, server.ServerId, admin.Session.Id, request.Method,
request.PathBase.Add(request.Path).ToUriComponent() + request.QueryString.ToUriComponent(), body, timestamp, nonce);
if (!AdminKeys.Verify(admin.Key.PublicKeyBytes(), message, signature))
return (ApiErrorCodes.SignatureInvalid, "Signature de la requête invalide.");
// Nonce enregistré seulement une fois la signature vérifiée.
if (!replays.TryRegister(admin.Session.Id + ":" + nonce, skew + skew + TimeSpan.FromMinutes(1)))
return (ApiErrorCodes.Replay, "Requête déjà reçue (rejeu refusé).");
return null;
}
}
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
public sealed record RequestBodyLimit(long MaxBytes);
@@ -0,0 +1,186 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Contenu de admins.json. Ne contient AUCUN secret : clés publiques et empreintes de codes
/// d'invitation uniquement.
/// Format 2 : groupes d'administrateurs et permissions personnalisées (un fichier au format 1 est migré à la
/// lecture : groupes par défaut avec les permissions des anciens rôles ; il est réécrit au format 2 à la
/// modification suivante). Un serveur plus ancien refuse de démarrer sur un fichier au format 2.
/// </summary>
public sealed class AdminDatabase
{
public const int CurrentFormat = 2;
public int FormatVersion { get; set; } = CurrentFormat;
public List<AdminRecord> Admins { get; set; } = new();
public List<InvitationRecord> Invitations { get; set; } = new();
/// <summary>Groupes (le groupe système « owner » existe toujours, voir <see cref="EnsureDefaults"/>).</summary>
public List<GroupRecord> Groups { get; set; } = new();
/// <summary>Permissions personnalisées (les permissions intégrées sont dans le code : <see cref="AdminPermissions.All"/>).</summary>
public List<PermissionRecord> CustomPermissions { get; set; } = new();
public AdminRecord? FindAdmin(Guid id) => Admins.FirstOrDefault(a => a.Id == id);
public GroupRecord? FindGroup(string? id) =>
id is null ? null : Groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.Ordinal));
/// <summary>Toutes les permissions connues : intégrées, puis personnalisées.</summary>
public IReadOnlyList<string> KnownPermissions() =>
AdminPermissions.All.Concat(CustomPermissions.Select(p => p.Name)).Distinct(StringComparer.Ordinal).ToList();
public bool IsKnownPermission(string permission) =>
AdminPermissions.IsBuiltIn(permission) || CustomPermissions.Any(p => string.Equals(p.Name, permission, StringComparison.Ordinal));
/// <summary>
/// Permissions effectives d'un groupe : toutes pour le groupe système, sinon celles du groupe qui existent
/// encore ; aucune pour un groupe inconnu (supprimé, ou fichier modifié à la main).
/// </summary>
public IReadOnlyList<string> PermissionsOf(string? groupId)
{
if (AdminGroups.IsSystem(groupId)) return KnownPermissions();
if (FindGroup(groupId) is not { } group) return Array.Empty<string>();
return group.Permissions.Where(IsKnownPermission).Distinct(StringComparer.Ordinal).ToList();
}
public string GroupName(string? groupId) =>
FindGroup(groupId)?.Name ?? (groupId is null ? "Inconnu" : AdminGroups.DefaultName(groupId) ?? $"Groupe supprimé ({groupId})");
/// <summary>
/// Groupes par défaut : le groupe système « Propriétaire » est (re)créé s'il manque ; au premier démarrage
/// (ou migration du format 1), les groupes « Administrateur » et « Éditeur » reçoivent les permissions des
/// anciens rôles. Faux si rien n'a changé.
/// </summary>
public bool EnsureDefaults(DateTimeOffset now)
{
var changed = false;
var firstTime = Groups.Count == 0;
foreach (var id in firstTime ? AdminRoles.All : new[] { AdminRoles.Owner })
{
if (FindGroup(id) is not null) continue;
Groups.Add(new GroupRecord
{
Id = id,
Name = AdminGroups.DefaultName(id)!,
Description = AdminGroups.DefaultDescription(id),
Permissions = AdminGroups.IsSystem(id) ? new() : AdminRoles.PermissionsOf(id).ToList(),
CreatedAt = now,
CreatedBy = "Valeurs par défaut",
Revision = 1
});
changed = true;
}
if (FindGroup(AdminRoles.Owner) is { } owner && owner.Permissions.Count > 0)
{
owner.Permissions.Clear(); // groupe système : toutes les permissions, jamais une liste
changed = true;
}
return changed;
}
/// <summary>Administrateur possédant cette clé (quel que soit l'état de la clé).</summary>
public (AdminRecord Admin, KeyRecord Key)? FindKey(string keyId)
{
foreach (var admin in Admins)
foreach (var key in admin.Keys)
if (string.Equals(key.KeyId, keyId, StringComparison.Ordinal)) return (admin, key);
return null;
}
/// <summary>Propriétaires actifs disposant d'au moins une clé active (ceux qui peuvent encore se connecter).</summary>
public IEnumerable<AdminRecord> UsableOwners() =>
Admins.Where(a => a.Role == AdminRoles.Owner && a.Status == AdminStatus.Active &&
a.Keys.Any(k => k.Status == AdminStatus.Active));
}
public sealed class AdminRecord
{
public Guid Id { get; set; }
public string DisplayName { get; set; } = "";
/// <summary>Identifiant du groupe (nom historique : « rôle »).</summary>
public string Role { get; set; } = AdminRoles.Editor;
public string Status { get; set; } = AdminStatus.Pending;
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public string? ApprovedBy { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public string? RevokedBy { get; set; }
public List<KeyRecord> Keys { get; set; } = new();
}
public sealed class KeyRecord
{
public string KeyId { get; set; } = "";
/// <summary>Clé publique : SPKI en Base64.</summary>
public string PublicKey { get; set; } = "";
public string? Label { get; set; }
public string Status { get; set; } = AdminStatus.Pending;
public DateTimeOffset AddedAt { get; set; }
public DateTimeOffset? LastUsedAt { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public string? ApprovedBy { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public string? RevokedBy { get; set; }
public byte[] PublicKeyBytes() => Convert.FromBase64String(PublicKey);
}
public sealed class InvitationRecord
{
public Guid Id { get; set; }
/// <summary>Empreinte du code (<see cref="SignedMessages.HashInvitationCode"/>) ; le code n'est jamais stocké.</summary>
public string CodeHash { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Role { get; set; } = AdminRoles.Editor;
/// <summary>Invitation « nouvel appareil » pour un administrateur existant.</summary>
public Guid? ForAdminId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public Guid? CreatedById { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}
/// <summary>Groupe d'administrateurs : nom, description, permissions.</summary>
public sealed class GroupRecord
{
/// <summary>Identifiant stable (<see cref="AdminGroups.IsValidGroupId"/>), référencé par les comptes et les invitations.</summary>
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string? Description { get; set; }
/// <summary>Permissions du groupe (vide pour le groupe système, qui les a toutes).</summary>
public List<string> Permissions { get; set; } = new();
/// <summary>Incrémentée à chaque modification (contrôle des modifications concurrentes).</summary>
public long Revision { get; set; } = 1;
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
/// <summary>Permission personnalisée (créée depuis le menu d'administration).</summary>
public sealed class PermissionRecord
{
/// <summary>« module.action » (<see cref="AdminGroups.IsValidPermissionName"/>).</summary>
public string Name { get; set; } = "";
public string Label { get; set; } = "";
public string? Description { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
}
@@ -0,0 +1,138 @@
using System.Text.Json;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Administrateurs et invitations (admins.json dans le dossier des données).
/// • Lecture : copie en cache, relue automatiquement si le fichier a changé (outil en ligne de
/// commande) — une révocation faite sur le VPS prend effet sans redémarrer le service.
/// • Écriture : <see cref="Update{T}"/> relit le fichier sous verrou inter-processus, applique la
/// modification sur une copie, puis remplace le fichier atomiquement (ancienne version en .bak).
/// • Fichier illisible : le serveur refuse tout (aucune reprise silencieuse d'une sauvegarde, qui
/// pourrait réactiver un administrateur révoqué).
/// </summary>
public sealed class AdminStore
{
public const string FileName = "admins.json";
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = true };
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly object _cacheLock = new(); // court : échange du cache
private readonly object _writeLock = new(); // un seul écrivain dans ce processus
private AdminDatabase? _cache;
private (DateTime WriteTime, long Length) _cacheStamp;
public AdminStore(IOptions<LauncherApiOptions> options) : this(options.Value.DataDirectoryFullPath) { }
public AdminStore(string dataDirectory)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
}
public string FilePath => _path;
/// <summary>
/// Instantané en lecture seule (ne pas le modifier : utiliser <see cref="Update{T}"/>).
/// </summary>
public AdminDatabase Read()
{
lock (_cacheLock)
{
var stamp = Stamp();
if (_cache is not null && stamp == _cacheStamp) return _cache;
_cache = Load();
_cacheStamp = stamp;
return _cache;
}
}
/// <summary>
/// Modification atomique. <paramref name="change"/> travaille sur une copie fraîchement relue ;
/// s'il lève une exception, rien n'est écrit.
/// </summary>
public T Update<T>(Func<AdminDatabase, T> change)
{
// Les lectures ne sont jamais bloquées par une écriture en attente du verrou fichier
// (le renommage atomique leur donne l'ancienne ou la nouvelle version, jamais un mélange).
lock (_writeLock)
{
using var fileLock = SecureFiles.AcquireLock(_lockPath, LockTimeout);
var database = Load();
var result = change(database);
SecureFiles.WriteAllTextAtomic(_path, JsonSerializer.Serialize(database, Json), keepBackup: true);
lock (_cacheLock)
{
_cache = database;
_cacheStamp = Stamp();
}
return result;
}
}
public void Update(Action<AdminDatabase> change) => Update(db =>
{
change(db);
return true;
});
private AdminDatabase Load()
{
if (!File.Exists(_path))
{
var empty = new AdminDatabase();
empty.EnsureDefaults(DateTimeOffset.UtcNow);
return empty;
}
try
{
var database = JsonSerializer.Deserialize<AdminDatabase>(File.ReadAllText(_path), Json)
?? throw new InvalidDataException("fichier vide");
if (database.FormatVersion is not (1 or AdminDatabase.CurrentFormat))
throw new InvalidDataException($"format {database.FormatVersion} inconnu");
database.Admins ??= new();
database.Invitations ??= new();
database.Groups ??= new();
database.CustomPermissions ??= new();
foreach (var admin in database.Admins) admin.Keys ??= new();
foreach (var group in database.Groups) group.Permissions ??= new();
CheckGroups(database);
// Format 1 (avant les groupes) : groupes par défaut créés avec les permissions des anciens rôles ;
// le fichier est réécrit au format 2 à la prochaine modification.
database.FormatVersion = AdminDatabase.CurrentFormat;
database.EnsureDefaults(DateTimeOffset.UtcNow);
return database;
}
catch (Exception ex) when (ex is JsonException or InvalidDataException)
{
throw new InvalidDataException($"{_path} est illisible ({ex.Message}). Restaurez {FileName}.bak APRÈS l'avoir vérifié : " +
"c'est la version précédant la dernière modification (une révocation récente y serait annulée).", ex);
}
}
/// <summary>Identifiants de groupes et de permissions : uniques et bien formés (sinon le fichier a été abîmé).</summary>
private static void CheckGroups(AdminDatabase database)
{
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var group in database.Groups)
if (!Contracts.Security.AdminGroups.IsValidGroupId(group.Id) || !ids.Add(group.Id))
throw new InvalidDataException($"groupe invalide ou en double : « {group.Id} »");
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var permission in database.CustomPermissions)
if (!Contracts.Security.AdminGroups.IsValidPermissionName(permission.Name) ||
Contracts.Security.AdminPermissions.IsBuiltIn(permission.Name) || !names.Add(permission.Name))
throw new InvalidDataException($"permission invalide ou en double : « {permission.Name} »");
}
private (DateTime, long) Stamp()
{
var info = new FileInfo(_path);
return info.Exists ? (info.LastWriteTimeUtc, info.Length) : (DateTime.MinValue, -1);
}
}
@@ -0,0 +1,294 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Journal d'audit : un fichier JSON Lines par mois (audit/audit-2026-10.jsonl), en ajout seul.
/// Chaque action d'administration y figure (réussie ou refusée), avec l'auteur, la clé et l'adresse IP.
/// Les entrées sont aussi écrites dans le journal du service (journalctl).
/// </summary>
public sealed class AuditLog
{
public const int MaxPageSize = 200;
private const int MaxFieldLength = 500;
/// <summary>
/// Échecs SANS authentification (connexion refusée, code d'invitation faux) détaillés au plus
/// ce nombre de fois par minute ; au-delà, ils sont comptés et résumés en une ligne : une
/// avalanche de tentatives ne peut ni noyer les vraies entrées ni remplir le disque.
/// </summary>
public const int MaxAnonymousFailuresPerMinute = 10;
private readonly object _throttleLock = new();
private DateTimeOffset _throttleWindow;
private int _throttleCount;
private int _suppressed;
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = false };
private readonly string _directory;
private readonly TimeProvider _time;
private readonly ILogger<AuditLog>? _logger;
private readonly object _writeLock = new();
public AuditLog(IOptions<LauncherApiOptions> options, TimeProvider time, ILogger<AuditLog> logger)
: this(options.Value.DataDirectoryFullPath, time, logger) { }
public AuditLog(string dataDirectory, TimeProvider time, ILogger<AuditLog>? logger = null)
{
_directory = Path.Combine(dataDirectory, "audit");
_time = time;
_logger = logger;
SecureFiles.EnsureDirectory(dataDirectory);
SecureFiles.EnsureDirectory(_directory);
}
public void Write(string action, string result, Guid? actorId = null, string? actorName = null, string? keyId = null,
string? ip = null, string? target = null, string? details = null)
{
var entry = new AuditEntry
{
Timestamp = _time.GetUtcNow(),
Action = Clean(action) ?? "?",
Result = Clean(result) ?? "?",
ActorId = actorId,
ActorName = Clean(actorName),
KeyId = Clean(keyId),
Ip = Clean(ip),
Target = Clean(target),
Details = Clean(details)
};
_logger?.LogInformation("AUDIT {Action} {Result} acteur={Actor} clé={KeyId} ip={Ip} cible={Target} {Details}",
entry.Action, entry.Result, entry.ActorName ?? entry.ActorId?.ToString(), entry.KeyId, entry.Ip, entry.Target, entry.Details);
var line = JsonSerializer.Serialize(entry, Json) + "\n";
var path = Path.Combine(_directory, $"audit-{entry.Timestamp.UtcDateTime:yyyy-MM}.jsonl");
try
{
lock (_writeLock)
{
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
using var stream = SecureFiles.OpenAppend(path);
stream.Write(Encoding.UTF8.GetBytes(line));
stream.Flush(flushToDisk: true);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or StoreBusyException)
{
// L'action elle-même n'est pas annulée, mais l'échec est visible dans journalctl.
if (_logger is not null) _logger.LogError(ex, "Écriture du journal d'audit impossible : {Line}", line.TrimEnd());
else Console.Error.WriteLine($"ATTENTION : journal d'audit non écrit ({ex.Message}).");
}
}
/// <summary>Échec d'une requête non authentifiée (voir <see cref="MaxAnonymousFailuresPerMinute"/>).</summary>
public void WriteAnonymousFailure(string action, string? keyId, string? ip, string? details)
{
int suppressed;
lock (_throttleLock)
{
var now = _time.GetUtcNow();
if (now - _throttleWindow >= TimeSpan.FromMinutes(1))
{
_throttleWindow = now;
_throttleCount = 0;
}
if (_throttleCount >= MaxAnonymousFailuresPerMinute)
{
if (_suppressed++ == 0)
_logger?.LogWarning("AUDIT afflux de tentatives refusées sans authentification (dernière : {Action} ip={Ip}) : " +
"détail suspendu, résumé à la minute suivante.", action, ip);
return;
}
_throttleCount++;
suppressed = _suppressed;
_suppressed = 0;
}
if (suppressed > 0)
Write("audit.suppressed", Contracts.Dtos.AuditResults.Denied,
details: $"{suppressed} tentative(s) refusée(s) sans authentification non détaillée(s) (afflux de requêtes)");
Write(action, Contracts.Dtos.AuditResults.Denied, keyId: keyId, ip: ip, details: details);
}
private readonly Dictionary<string, (DateTimeOffset Window, int Suppressed)> _perKey = new(StringComparer.Ordinal);
/// <summary>
/// Refus d'une requête correctement SIGNÉE (clé révoquée ou en attente, rejeu) : toujours
/// détaillé, au plus une ligne par clé et par minute (les suivantes sont comptées et signalées
/// sur la ligne suivante). Seules les clés enregistrées produisent des signatures valides : la
/// table reste petite.
/// </summary>
public void WriteSignedRefusal(string action, Guid? actorId, string? actorName, string keyId, string? ip, string details)
{
int suppressed;
lock (_throttleLock)
{
var now = _time.GetUtcNow();
if (_perKey.TryGetValue(keyId, out var state) && now - state.Window < TimeSpan.FromMinutes(1))
{
_perKey[keyId] = (state.Window, state.Suppressed + 1);
return;
}
suppressed = state.Suppressed;
_perKey[keyId] = (now, 0);
if (_perKey.Count > 10_000) _perKey.Clear();
}
Write(action, Contracts.Dtos.AuditResults.Denied, actorId, actorName, keyId, ip,
details: suppressed > 0 ? $"{details} (+{suppressed} refus identique(s) la minute précédente)" : details);
}
/// <summary>
/// Conservation : supprime les fichiers mensuels dont TOUS les jours sont antérieurs à <paramref name="months"/> mois
/// (le mois en cours et les précédents encore dans la fenêtre sont gardés en entier). Renvoie les mois supprimés.
/// </summary>
public IReadOnlyList<string> Purge(int months)
{
if (months < 1) throw new ArgumentOutOfRangeException(nameof(months));
var now = _time.GetUtcNow().UtcDateTime;
// Premier mois conservé : celui qui contient la date limite (ses derniers jours sont encore dans la fenêtre).
var cutoff = now.AddMonths(-months);
var firstKept = cutoff.ToString("yyyy-MM", CultureInfo.InvariantCulture);
var removed = new List<string>();
if (!Directory.Exists(_directory)) return removed;
lock (_writeLock)
{
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
foreach (var file in Directory.GetFiles(_directory, "audit-*.jsonl"))
{
var month = Path.GetFileName(file)["audit-".Length..^".jsonl".Length];
if (!DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) continue;
if (string.CompareOrdinal(month, firstKept) >= 0) continue;
File.Delete(file);
removed.Add(month);
}
}
removed.Sort(StringComparer.Ordinal);
return removed;
}
/// <summary>
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
/// précédente (« aaaa-MM:position »). <paramref name="before"/> : seulement les entrées antérieures
/// à cet instant (filtre « jusqu'au » du menu ; les mois plus récents ne sont pas lus). Les fichiers
/// sont lus depuis la fin, par blocs : la mémoire utilisée ne dépend pas de leur taille.
/// </summary>
public AuditPage Read(int limit, string? cursor, DateTimeOffset? before = null)
{
limit = Math.Clamp(limit, 1, MaxPageSize);
var months = Directory.Exists(_directory)
? Directory.GetFiles(_directory, "audit-*.jsonl").Select(Path.GetFileName).OfType<string>()
.Select(name => name["audit-".Length..^".jsonl".Length])
.Where(month => DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
.OrderByDescending(month => month, StringComparer.Ordinal).ToList()
: new List<string>();
string? startMonth = null;
var startOffset = long.MaxValue;
if (!string.IsNullOrEmpty(cursor))
{
var parts = cursor.Split(':');
if (parts.Length != 2 || !months.Contains(parts[0]) ||
!long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out startOffset))
return new AuditPage();
startMonth = parts[0];
}
// Mois postérieurs à « before » : sautés sans être lus (une entrée écrite au tout début d'un mois
// peut porter l'heure de la fin du mois précédent : le mois de « before » est donc lu en entier).
var lastMonth = before?.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture);
var entries = new List<AuditEntry>(limit);
for (var m = 0; m < months.Count; m++)
{
var month = months[m];
if (startMonth is not null && string.CompareOrdinal(month, startMonth) > 0) continue;
if (lastMonth is not null && string.CompareOrdinal(month, lastMonth) > 0) continue;
var end = month == startMonth ? startOffset : long.MaxValue;
// Lignes illisibles (écriture interrompue, modification manuelle) ignorées sans rien masquer :
// la lecture continue jusqu'à obtenir « limit » entrées lisibles.
foreach (var (start, line) in ReadBackward(Path.Combine(_directory, $"audit-{month}.jsonl"), end))
{
if (TryParse(line) is not { } entry) continue;
if (before is { } limitDate && entry.Timestamp >= limitDate) continue;
entries.Add(entry);
if (entries.Count == limit)
{
var more = start > 0 || m < months.Count - 1;
return new AuditPage { Entries = entries, NextCursor = more ? $"{month}:{start}" : null };
}
}
}
return new AuditPage { Entries = entries };
}
/// <summary>
/// Lignes situées avant <paramref name="end"/> (octets), de la dernière à la première, avec leur
/// position, lues par blocs depuis la fin. Une ligne démesurée (plus de 1 Mo) est sautée.
/// </summary>
private static IEnumerable<(long Start, string Line)> ReadBackward(string path, long end)
{
if (!File.Exists(path)) yield break;
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var position = Math.Min(end, stream.Length);
var carry = Array.Empty<byte>();
var skipping = false; // fin de ligne démesurée abandonnée : on cherche son début
const int blockSize = 64 * 1024;
const int maxLine = 1024 * 1024;
while (position > 0)
{
var size = (int)Math.Min(blockSize, position);
position -= size;
var data = new byte[size + carry.Length];
stream.Position = position;
stream.ReadExactly(data, 0, size);
carry.CopyTo(data, size);
var lineEnd = data.Length;
for (var i = data.Length - 1; i >= 0; i--)
{
if (data[i] != (byte)'\n') continue;
if (!skipping && lineEnd > i + 1) yield return (position + i + 1, Encoding.UTF8.GetString(data, i + 1, lineEnd - i - 1));
skipping = false;
lineEnd = i;
}
carry = data[..lineEnd];
if (carry.Length > maxLine)
{
carry = Array.Empty<byte>();
skipping = true;
}
}
if (!skipping && carry.Length > 0) yield return (0, Encoding.UTF8.GetString(carry));
}
private static AuditEntry? TryParse(string line)
{
try
{
return JsonSerializer.Deserialize<AuditEntry>(line, Json);
}
catch (JsonException)
{
return null;
}
}
private static string? Clean(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var sb = new StringBuilder(Math.Min(value.Length, MaxFieldLength));
foreach (var c in value)
{
if (sb.Length >= MaxFieldLength) break;
sb.Append(char.IsControl(c) ? ' ' : c);
}
return sb.ToString().Trim();
}
}
@@ -0,0 +1,121 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Images du catalogue envoyées depuis le menu d'administration (icône, image de fond), sans client FTP.
/// • Contrôle complet AVANT toute écriture (<see cref="CatalogImages.Check"/> : PNG/JPEG reconnu au contenu,
/// taille, dimensions, fichier complet sans données ajoutées).
/// • Rangées dans le dossier « images » du catalogue public (<c>…/catalog/images</c>, créé à l'installation :
/// LISEZMOI.md, étape 3 bis), sous le nom <c>&lt;sha256&gt;.png|.jpg</c> : un nom ne désigne jamais deux contenus
/// (les launchers gardent les images en cache sous leur adresse) et le launcher des joueurs vérifie l'empreinte.
/// • Écriture atomique (fichier temporaire caché du même dossier, puis renommage), lisible par nginx (0644).
/// • Nombre et taille totale des images bornés ; espace disque vérifié. Une image envoyée n'est pas publiée
/// tant que le catalogue qui la désigne ne l'est pas (page « Jeux » › « Publier ») ; les images inutilisées se
/// suppriment depuis la page « Fichiers du serveur ».
/// • Le dossier ne doit pas être un lien symbolique ; un fichier existant de même nom qui n'est pas un fichier
/// ordinaire est refusé.
/// </summary>
public sealed class CatalogImageStore
{
/// <summary>Marge d'espace disque laissée libre après l'écriture.</summary>
private const long FreeSpaceMargin = 64L * 1024 * 1024;
private readonly string? _directory;
private readonly Uri? _baseUrl;
private readonly object _writeLock = new();
public CatalogImageStore(IOptions<LauncherApiOptions> options)
: this(options.Value.CatalogPublicFileFullPath, options.Value.CatalogPublicUrl) { }
public CatalogImageStore(string? catalogPublicFile, string catalogPublicUrl)
{
if (catalogPublicFile is null) return;
_directory = Path.Combine(Path.GetDirectoryName(catalogPublicFile)!, CatalogImages.FolderName);
_baseUrl = new Uri(new Uri(catalogPublicUrl), CatalogImages.FolderName + "/");
}
public bool Enabled => _directory is not null;
/// <summary>Contrôle puis publie une image ; renvoie son adresse publique.</summary>
/// <exception cref="ApiRejection">Image refusée (400), non configuré ou dossier inutilisable (503), place (409/507).</exception>
/// <exception cref="IOException">Écriture impossible (droits du dossier) : rien n'a été publié.</exception>
public CatalogImageUploaded Save(string kind, byte[] content)
{
if (_directory is null || _baseUrl is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile) : impossible d'y envoyer une image.");
if (CatalogImages.Check(kind, content, out var info) is { } error) throw ApiRejection.BadRequest(error);
var sha256 = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
var name = $"{sha256}.{info!.Format}";
var path = Path.Combine(_directory, name);
lock (_writeLock)
{
EnsureDirectory();
var existing = new FileInfo(path);
if (existing.Exists || existing.LinkTarget is not null)
{
if (existing.LinkTarget is not null || existing.Attributes.HasFlag(FileAttributes.ReparsePoint))
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Une image de même nom existe sur le serveur mais n'est pas un fichier ordinaire : supprimez-la sur le VPS.");
if (existing.Length == content.Length && SameContent(path, sha256))
return Result(name, sha256, info, content.Length, alreadyPresent: true);
// Contenu différent sous un nom d'empreinte : fichier abîmé ou remplacé à la main, il est réécrit.
}
var files = new DirectoryInfo(_directory).EnumerateFiles().Where(f => !f.Name.StartsWith('.')).ToList();
if (files.Count >= CatalogImages.MaxStoredFiles || files.Sum(f => f.Length) + content.Length > CatalogImages.MaxStoredBytes)
throw ApiRejection.Conflict(
$"Trop d'images sur le serveur ({files.Count} fichiers, {files.Sum(f => f.Length) / 1024 / 1024} Mo ; au plus " +
$"{CatalogImages.MaxStoredFiles} fichiers et {CatalogImages.MaxStoredBytes / 1024 / 1024} Mo) : supprimez les images " +
"inutilisées depuis la page « Fichiers du serveur » (dossier catalog/images).");
try
{
if (new DriveInfo(_directory).AvailableFreeSpace < content.Length + FreeSpaceMargin)
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage, "Espace disque insuffisant sur le serveur.");
}
catch (ArgumentException)
{
// lecteur non identifiable (tests, système de fichiers particulier) : l'écriture échouera proprement si besoin
}
PublicFileWriter.Write(path, content, "fichier image");
return Result(name, sha256, info, content.Length, alreadyPresent: false);
}
}
private void EnsureDirectory()
{
var directory = new DirectoryInfo(_directory!);
if (!directory.Exists)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Le dossier des images du catalogue n'existe pas sur le serveur : créez « {CatalogImages.FolderName} » à côté de " +
"catalog.json (server/deploy/LISEZMOI.md, étape 3 bis).");
if (directory.LinkTarget is not null || directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Le dossier des images du catalogue est un lien symbolique : refusé (remplacez-le par un vrai dossier).");
}
private static bool SameContent(string path, string sha256)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return string.Equals(Convert.ToHexString(SHA256.HashData(stream)), sha256, StringComparison.OrdinalIgnoreCase);
}
private CatalogImageUploaded Result(string name, string sha256, CatalogImages.ImageInfo info, long bytes, bool alreadyPresent) => new()
{
Url = new Uri(_baseUrl!, name).AbsoluteUri,
FileName = name,
Sha256 = sha256,
Width = info.Width,
Height = info.Height,
Bytes = bytes,
AlreadyPresent = alreadyPresent
};
}
@@ -0,0 +1,149 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Catalogue des jeux (informations modifiables).
/// • Référence : catalog.json dans le dossier des données privées (+ historique des 20 dernières révisions).
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Catalog:PublicFile),
/// écrite atomiquement (fichier temporaire du même dossier puis renommage), lisible par tous (0644).
/// • Contrôle des modifications concurrentes : une publication précise la révision sur laquelle elle
/// a été préparée ; si quelqu'un a publié entre-temps, elle est refusée (409).
/// </summary>
public sealed class CatalogStore
{
public const string FileName = "catalog.json";
private const string HistoryFolder = "catalog-history";
private const int HistoryLength = 20;
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _writeLock = new();
public CatalogStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.CatalogPublicFileFullPath) { }
public CatalogStore(string dataDirectory, string? publicFile)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
}
public bool PublishingEnabled => _publicFile is not null;
/// <summary>Catalogue actuel (vide, révision 0, s'il n'a jamais été publié).</summary>
public CatalogDocument Read()
{
if (!File.Exists(_path)) return new CatalogDocument();
var text = File.ReadAllText(_path);
var document = JsonSerializer.Deserialize<CatalogDocument>(text, Json)
?? throw new InvalidDataException($"{FileName} est vide.");
var errors = new List<string>();
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
}
/// <summary>Catalogue publié tel qu'écrit (sans normalisation : signature comprise), ou null s'il n'a jamais été publié.</summary>
public CatalogDocument? ReadPublished()
{
if (!File.Exists(_path)) return null;
return JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(_path), Json)
?? throw new InvalidDataException($"{FileName} est vide.");
}
/// <summary>
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
/// fichier public, puis référence privée et historique.
/// </summary>
/// <returns>Le catalogue publié et le précédent.</returns>
public (CatalogDocument Published, CatalogDocument Previous) Publish(CatalogDocument catalog, long baseRevision, DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile).");
lock (_writeLock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var current = Read();
if (current.Revision != baseRevision)
throw ApiRejection.Conflict(
$"Le catalogue a été publié entre-temps (révision {current.Revision}) : rechargez-le, puis refaites vos modifications.",
ApiErrorCodes.RevisionConflict);
var published = catalog with
{
Format = CatalogDocument.FormatName,
Version = CatalogDocument.CurrentVersion,
Revision = current.Revision + 1,
UpdatedAt = now
};
var json = JsonSerializer.Serialize(published, Json);
if (Encoding.UTF8.GetByteCount(json) > CatalogRules.MaxFileBytes)
throw ApiRejection.BadRequest($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko).");
// 1. Référence privée (+ .bak) : c'est elle qui porte la révision.
var previousText = File.Exists(_path) ? File.ReadAllText(_path) : null;
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
// 2. Fichier public. En cas d'échec, la référence privée est remise dans son état précédent :
// les joueurs et le serveur restent sur la même révision (rien n'est publié à moitié).
try
{
WritePublic(json);
}
catch (IOException)
{
RestorePrivate(previousText);
throw;
}
// 3. Historique (aide uniquement).
WriteHistory(published.Revision, json);
return (published, current);
}
}
private void WritePublic(string json) => PublicFileWriter.Write(_publicFile!, json, "catalogue public");
private void RestorePrivate(string? previousText)
{
try
{
if (previousText is null) File.Delete(_path);
else SecureFiles.WriteAllTextAtomic(_path, previousText, keepBackup: false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Très improbable (le dossier privé vient d'être écrit) : la révision suivante sera alors
// refusée (409) jusqu'au rechargement de la page, sans rien écraser.
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"catalog-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "catalog-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,165 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Notes de version des jeux, dans le manifest.json PUBLIÉ de chaque jeu (LauncherApi:Games:&lt;id&gt;:ManifestFile).
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
/// </summary>
public sealed class ChangelogStore
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = PublishedFiles.MaxManifestBytes;
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly object _writeLock = new();
public ChangelogStore(IOptions<LauncherApiOptions> options)
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
{
_manifests = manifests;
_dataDirectory = dataDirectory;
}
/// <summary>Notes de version publiées du jeu.</summary>
public ChangelogState Read(string gameId)
{
var (path, key) = Locate(gameId);
var bytes = ReadManifest(path);
var root = Parse(bytes, key);
return State(key, root, bytes);
}
/// <summary>
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
/// </summary>
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
{
var (path, key) = Locate(gameId);
lock (_writeLock)
using (PublishedFiles.LockGame(_dataDirectory, key))
{
var bytes = ReadManifest(path);
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
throw Conflict();
var root = Parse(bytes, key);
var changelog = ChangelogArray(root)
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
var index = -1;
for (var i = 0; i < changelog.Count; i++)
if (changelog[i] is JsonObject candidate && string.Equals(PublishedFiles.StringValue(candidate, "version"), version, StringComparison.Ordinal))
{
index = i;
break;
}
if (index < 0)
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
var old = (JsonObject)changelog[index]!;
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
// anciens launchers ; numéro et date de la version repris du manifest.
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
var replacement = old.DeepClone().AsObject();
var edited = JsonSerializer.SerializeToNode(clean, PublishedFiles.ManifestJson)!.AsObject();
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.DeepClone());
changelog[index] = replacement;
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
if (total > ChangelogLimits.MaxTotalItems)
throw ApiRejection.BadRequest(
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
var newBytes = PublishedFiles.Serialize(root);
PublishedFiles.Backup(_dataDirectory, key, bytes);
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
return (State(key, root, newBytes), previous);
}
}
// ------------------------------------------------------------------ Lecture
private (string Path, string GameId) Locate(string gameId)
{
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw PublishedFiles.NotConfigured(gameId, "Modification des notes de version");
}
private static byte[] ReadManifest(string path) =>
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
internal static JsonObject Parse(byte[] bytes, string gameId)
{
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
var manifestGameId = PublishedFiles.StringValue(root, "gameId");
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
return root;
}
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
{
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
return new ChangelogState
{
GameId = gameId,
ManifestVersion = PublishedFiles.StringValue(root, "version") ?? "",
ManifestSha256 = Sha256(bytes),
Entries = entries
};
}
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
{
try
{
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.ManifestJson) ?? new List<ChangelogEntry?>();
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
}
}
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
private static ApiRejection Conflict() => ApiRejection.Conflict(
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
ApiErrorCodes.RevisionConflict);
}
@@ -0,0 +1,226 @@
using System.Text.Json;
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Server.Storage;
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
/// <param name="Details">Détail de chaque changement, en clair.</param>
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
/// <summary>
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
/// numérotées (1/2, 2/2…) plutôt que tronqué.
/// </summary>
public static class FeedbackFormsAudit
{
public const string Published = "feedback.forms";
public const string Created = "feedback.form_create";
public const string Updated = "feedback.form_update";
public const string Deleted = "feedback.form_delete";
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
internal const int MaxDetailsLength = 480;
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
private const int MaxShownValueLength = 80;
private const string Separator = " ; ";
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<FeedbackFormChange>();
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
}
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
changes.Add(new FeedbackFormChange(Deleted, form.Label,
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
return changes;
}
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
{
var parts = new List<string>();
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) parts.Add("ordre des types modifié");
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
}
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
{
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
}
/// <summary>
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
/// </summary>
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
{
// Place réservée au numéro « (12/12) ».
var room = Math.Max(20, maxLength - 8);
var lines = new List<string>();
var current = "";
foreach (var detail in details.SelectMany(d => Split(d, room)))
{
if (current.Length == 0) current = detail;
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
else
{
lines.Add(current);
current = detail;
}
}
if (current.Length > 0) lines.Add(current);
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
}
private static IEnumerable<string> Split(string text, int room)
{
for (var i = 0; i < text.Length; i += room)
yield return text.Substring(i, Math.Min(room, text.Length - i));
}
// ------------------------------------------------------------------ Types de demande
private static List<string> CreatedDetails(FeedbackForm form)
{
var details = new List<string>();
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
details.Add($"{form.Fields.Count} champ(s)");
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
return details;
}
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
{
var details = new List<string>();
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
if (old.TechByDefault != form.TechByDefault)
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
if (old.CustomIcon != form.CustomIcon)
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
if (old.AllowAttachments != form.AllowAttachments)
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
details.Add($"champ supprimé : « {field.Label} »");
foreach (var field in form.Fields)
{
if (old.Field(field.Id) is not { } before)
{
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
continue;
}
var parts = FieldDetails(before, field);
if (parts.Count == 0) continue;
var prefix = $"champ « {field.Label} » modifié : ";
var combined = prefix + string.Join(", ", parts);
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
else details.AddRange(parts.Select(p => prefix + p));
}
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
details.Add("ordre des champs modifié");
return details;
}
// ------------------------------------------------------------------ Champs
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
{
var parts = new List<string>();
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
if ((old.Hint ?? "") != (field.Hint ?? ""))
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
if (old.ShowInList != field.ShowInList)
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
parts.AddRange(OptionDetails(old.Choices, field.Choices));
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
return parts;
}
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
{
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
foreach (var option in now)
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
yield return "ordre des choix modifié";
}
private static string FieldSummary(FeedbackField field)
{
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
if (field.ShowInList) parts.Add("en aperçu dans la liste");
return string.Join(", ", parts);
}
private static string KindText(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "zone de texte",
FeedbackFieldKind.Text => "texte court",
FeedbackFieldKind.Choice => "liste de choix",
FeedbackFieldKind.Checkboxes => "cases à cocher",
FeedbackFieldKind.Rating => "note en étoiles",
FeedbackFieldKind.YesNo => "case oui/non",
_ => kind.ToString()
};
// ------------------------------------------------------------------ Texte
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
private static string ValueChange(string name, bool feminine, string? before, string? after)
{
var e = feminine ? "e" : "";
before = OneLine(before);
after = OneLine(after);
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
if (after.Length == 0) return $"{name} retiré{e}";
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
? $"{name} : {Quote(before)} → {Quote(after)}"
: $"{name} modifié{e}";
}
private static string OneLine(string? value) =>
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
private static string Quote(string? value) => $"« {OneLine(value)} »";
}
@@ -0,0 +1,145 @@
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Formulaires des demandes des joueurs (communs à tous les jeux), publiés depuis le menu d'administration.
/// • Référence : feedback-forms.json dans les données privées (+ historique des 20 dernières révisions) ;
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
/// l'adresse), écrite atomiquement, lisible par tous ;
/// • Toujours signés (autorisation « forms » du trousseau) : le serveur vérifie que la signature est celle de la
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
/// Rien de publié : les formulaires d'origine (révision 0) s'appliquent.
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
/// </summary>
public sealed class FeedbackFormsStore
{
public const string FileName = "feedback-forms.json";
private const string HistoryFolder = "feedback-forms-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _lock = new();
private FeedbackFormsDocument? _current;
public FeedbackFormsStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.FeedbackFormsPublicFileFullPath, options.Value.FeedbackFormsPublicUrl) { }
public FeedbackFormsStore(string dataDirectory, string? publicFile, string? publicUrl = null)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
PublicUrl = publicUrl;
}
public bool PublishingEnabled => _publicFile is not null;
public string? PublicUrl { get; }
/// <summary>Formulaires en vigueur : ceux publiés, sinon ceux d'origine (révision 0).</summary>
public FeedbackFormsDocument Current
{
get
{
lock (_lock) return _current ??= Read() ?? FeedbackFormRules.Defaults();
}
}
/// <summary>
/// Formulaire à utiliser pour valider une demande, ou la raison du refus. Révision 0 (formulaires d'origine) :
/// toujours acceptée (launchers 3.0 / 3.1, formulaires publiés illisibles chez le joueur), sauf pour un type
/// masqué ou supprimé depuis. Autre révision : seule celle en vigueur est acceptée (409 : le launcher recharge les
/// formulaires).
/// </summary>
/// <returns>Le formulaire du type, et s'il faut le conserver avec la demande (formulaires publiés).</returns>
public (FeedbackForm Form, bool Snapshot) Resolve(long revision, string? kind)
{
var current = Current;
if (current.Find(kind) is { Hidden: true } ||
(current.Revision > 0 && current.Find(kind) is null && FeedbackForms.Find(kind) is not null))
throw ApiRejection.BadRequest("Ce type de demande n'est plus proposé.");
if (revision == 0)
return (FeedbackForms.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), false);
if (revision != current.Revision)
throw new ApiRejection(409, ApiErrorCodes.FeedbackFormChanged,
"Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis envoyez de nouveau.");
return (current.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), current.Revision > 0);
}
/// <summary>Formulaires publiés (null : aucun).</summary>
private FeedbackFormsDocument? Read()
{
if (!File.Exists(_path)) return null;
return FeedbackFormRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
/// <summary>
/// Publie des formulaires signés (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue
/// (409 si une autre publication a eu lieu), révision suivante obligatoire.
/// </summary>
/// <returns>Formulaires publiés (avec la date) et ceux d'avant.</returns>
public (FeedbackFormsDocument Published, FeedbackFormsDocument Previous) Publish(FeedbackFormsDocument document, long baseRevision,
DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication des formulaires non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
"LauncherApi:Catalog:PublicFile) est nécessaire, les formulaires sont publiés à côté.");
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Formulaires non signés.");
var errors = new List<string>();
if (!FeedbackFormRules.Validate(document, errors))
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
lock (_lock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var previous = Read() ?? FeedbackFormRules.Defaults();
if (baseRevision != previous.Revision)
throw ApiRejection.Conflict(
$"Les formulaires ont été publiés entre-temps (révision {previous.Revision}) : rechargez la page, puis refaites vos modifications.",
ApiErrorCodes.RevisionConflict);
if (document.Revision != previous.Revision + 1)
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
var json = FeedbackFormRules.Serialize(published);
if (Encoding.UTF8.GetByteCount(json) > FeedbackFormRules.MaxBytes)
throw ApiRejection.BadRequest($"Formulaires trop volumineux (au plus {FeedbackFormRules.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "formulaires des demandes");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(published.Revision, json);
_current = published;
return (published, previous);
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"feedback-forms-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "feedback-forms-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
.Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,534 @@
using System.Globalization;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>Demande enregistrée (un fichier JSON par demande, dans les données privées).</summary>
public sealed record FeedbackRecord
{
public const string FormatName = "gamelauncher-feedback";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
public string Id { get; init; } = "";
public string Reference { get; init; } = "";
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public int FormVersion { get; init; }
/// <summary>Révision des formulaires utilisés (0 : formulaires d'origine).</summary>
public long FormRevision { get; init; }
/// <summary>Formulaire tel qu'à l'envoi (formulaires publiés depuis le menu) ; null : formulaires d'origine.</summary>
public FeedbackForm? Form { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public string Title { get; init; } = "";
public Dictionary<string, string> Fields { get; init; } = new();
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures annoncées à l'envoi (celles réellement reçues : <see cref="Attachments"/>).</summary>
public int AttachmentCount { get; init; }
/// <summary>Captures reçues, nettoyées (fichiers <c>&lt;id&gt;-&lt;n&gt;.png|jpg</c> à côté de la demande).</summary>
public List<FeedbackAttachmentInfo> Attachments { get; init; } = new();
public string Status { get; init; } = FeedbackStatuses.New;
public long Revision { get; init; } = 1;
public string? AdminNote { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
public string? StatusChangedBy { get; init; }
public FeedbackSummary ToSummary() => new()
{
Id = Id,
Reference = Reference,
GameId = GameId,
Kind = Kind,
Title = Title,
Status = Status,
ReceivedAt = ReceivedAt,
StatusChangedAt = StatusChangedAt,
Badge = (Form ?? FeedbackForms.Find(Kind))?.Badge(Fields),
HasNote = !string.IsNullOrEmpty(AdminNote),
HasLinks = FeedbackLinks.Contains(Title + "\n" + string.Join("\n", Fields.Values)),
AttachmentCount = Attachments.Count,
Revision = Revision
};
public FeedbackDetail ToDetail() => new()
{
Summary = ToSummary(),
FormVersion = FormVersion,
FormRevision = FormRevision,
Form = Form,
Fields = Fields,
Tech = Tech,
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
AdminNote = AdminNote,
StatusChangedBy = StatusChangedBy
};
}
/// <summary>
/// Demandes des joueurs : <c>feedback/&lt;jeu&gt;/&lt;AAAA-MM&gt;/&lt;id&gt;.json</c> dans le dossier des données privées
/// (0700, fichiers 0600, jamais servi par nginx). Un fichier par demande : écriture atomique (fichier temporaire puis
/// renommage) d'une seule demande à la fois, suppression et purge par simple suppression de fichiers. Un index en
/// mémoire (résumés) est construit au premier accès : les listes ne relisent pas le disque. Seul le service écrit
/// ici (l'outil en ligne de commande n'y touche pas) : un verrou en mémoire suffit.
///
/// Choix (au lieu de SQLite) : volumes bornés (<see cref="LauncherApiOptions.FeedbackSettings.MaxPerGame"/>), aucune
/// relation entre données, pas de dépendance native à maintenir sur le VPS, sauvegarde par simple copie de dossier,
/// cohérent avec les autres stores du serveur. À réévaluer avec le futur module « comptes + échanges ».
/// </summary>
public sealed class FeedbackStore
{
private static readonly JsonSerializerOptions Json = new(ApiJson.Options) { WriteIndented = true };
private readonly string _root;
private readonly LauncherApiOptions.FeedbackSettings _settings;
private readonly IReadOnlySet<string> _games;
private readonly ILogger<FeedbackStore>? _logger;
private readonly object _lock = new();
private Dictionary<string, Dictionary<string, FeedbackRecord>>? _index;
private long _totalBytes;
public FeedbackStore(IOptions<LauncherApiOptions> options, ILogger<FeedbackStore> logger)
: this(options.Value.DataDirectoryFullPath, options.Value.Feedback, options.Value.FeedbackGameIds, logger) { }
public FeedbackStore(string dataDirectory, LauncherApiOptions.FeedbackSettings settings, IReadOnlySet<string> games,
ILogger<FeedbackStore>? logger = null)
{
_root = Path.Combine(dataDirectory, "feedback");
_settings = settings;
_games = games;
_logger = logger;
}
public bool Enabled => _settings.Enabled;
/// <summary>
/// Identifiant de jeu tel que configuré (casse du réglage), ou null si ce jeu n'accepte pas de demandes.
/// Seul ce résultat sert à construire un chemin (jamais la valeur reçue).
/// </summary>
public string? ResolveGame(string? gameId)
{
if (string.IsNullOrEmpty(gameId) || gameId.StartsWith('.')) return null;
return _games.FirstOrDefault(g => string.Equals(g, gameId, StringComparison.OrdinalIgnoreCase) && !g.StartsWith('.'));
}
public IReadOnlyList<string> Games => _games.Where(g => !g.StartsWith('.')).Order(StringComparer.OrdinalIgnoreCase).ToList();
// ------------------------------------------------------------------ Envoi
/// <summary>Raison d'un refus d'enregistrement pour cause de plafond (null : place disponible).</summary>
public string? FullReason(string gameId, DateTimeOffset now, long extraBytes = 0)
{
lock (_lock)
{
var index = Index();
var game = index.TryGetValue(gameId, out var records) ? records : null;
if (game is not null)
{
if (game.Count >= _settings.MaxPerGame) return $"{game.Count} demandes conservées pour ce jeu (plafond {_settings.MaxPerGame})";
var lastDay = game.Values.Count(r => r.ReceivedAt > now - TimeSpan.FromDays(1));
if (lastDay >= _settings.MaxPerGamePerDay) return $"{lastDay} demandes reçues en 24 h pour ce jeu (plafond {_settings.MaxPerGamePerDay})";
}
if (_totalBytes + extraBytes >= (long)_settings.MaxTotalMegabytes * 1024 * 1024)
return $"taille totale des demandes {_totalBytes / (1024 * 1024)} Mo (plafond {_settings.MaxTotalMegabytes} Mo)";
if (FreeDiskBytes() is { } free && free < (long)_settings.MinFreeDiskMegabytes * 1024 * 1024)
return $"espace disque libre {free / (1024 * 1024)} Mo (minimum {_settings.MinFreeDiskMegabytes} Mo)";
return null;
}
}
/// <summary>Enregistre une demande déjà validée (<see cref="FeedbackRules.Validate"/>). Null : plafond atteint.</summary>
/// <param name="form">Formulaire utilisé, conservé avec la demande (null : formulaires d'origine).</param>
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now, FeedbackForm? form = null)
{
lock (_lock)
{
if (FullReason(gameId, now) is not null) return null;
var index = Index();
if (!index.TryGetValue(gameId, out var game)) index[gameId] = game = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
var existingReferences = index.Values.SelectMany(g => g.Values).Select(r => r.Reference).ToHashSet(StringComparer.Ordinal);
string reference;
do reference = FeedbackRules.NewReference(); while (existingReferences.Contains(reference));
var record = new FeedbackRecord
{
Id = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
Reference = reference,
GameId = gameId,
Kind = submission.Kind,
FormVersion = submission.FormVersion,
FormRevision = form is null ? 0 : submission.FormRevision,
Form = form,
ReceivedAt = now,
Title = submission.Title,
Fields = new Dictionary<string, string>(submission.Fields, StringComparer.Ordinal),
Tech = submission.Tech,
AttachmentCount = submission.AttachmentCount,
Status = FeedbackStatuses.New,
Revision = 1
};
Write(record);
game[record.Id] = record;
return record;
}
}
// ------------------------------------------------------------------ Captures d'écran
/// <summary>Demande (tous jeux confondus) d'après son identifiant interne.</summary>
public FeedbackRecord? FindById(string id)
{
if (!FeedbackRules.IsValidId(id)) return null;
lock (_lock)
foreach (var game in Index().Values)
if (game.TryGetValue(id, out var record)) return record;
return null;
}
/// <summary>
/// Enregistre une capture DÉJÀ contrôlée et nettoyée (<see cref="FeedbackImages.Sanitize"/>). Null : plafond atteint.
/// Une capture de même numéro est remplacée (nouvel essai après une coupure).
/// </summary>
public FeedbackRecord? AddAttachment(string id, int index, byte[] clean, FeedbackImages.ImageInfo info, DateTimeOffset now)
{
lock (_lock)
{
var record = FindById(id) ?? throw ApiRejection.NotFound("Demande introuvable.");
if (index < 1 || index > record.AttachmentCount) throw ApiRejection.BadRequest("Numéro de capture invalide.");
var previous = record.Attachments.FirstOrDefault(a => a.Index == index);
if (FullReason(record.GameId, now, clean.Length - (previous?.Bytes ?? 0)) is not null) return null;
var path = AttachmentPath(record, index, info.Format);
if (previous is not null && previous.Format != info.Format) DeleteAttachmentFile(record, previous);
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
SecureFiles.WriteAllBytesAtomic(path, clean);
_totalBytes += clean.Length - oldSize;
var attachments = record.Attachments.Where(a => a.Index != index)
.Append(new FeedbackAttachmentInfo { Index = index, Format = info.Format, Width = info.Width, Height = info.Height, Bytes = clean.Length })
.OrderBy(a => a.Index).ToList();
var updated = record with { Attachments = attachments };
Write(updated);
Index()[record.GameId][id] = updated;
return updated;
}
}
/// <summary>Contenu d'une capture (null : absente).</summary>
public byte[]? ReadAttachment(FeedbackRecord record, int index)
{
var info = record.Attachments.FirstOrDefault(a => a.Index == index);
if (info is null) return null;
var path = AttachmentPath(record, index, info.Format);
if (!File.Exists(path) || IsLink(path) || new FileInfo(path).Length > FeedbackImages.MaxBytes) return null;
return File.ReadAllBytes(path);
}
// ------------------------------------------------------------------ Consultation
public IReadOnlyList<FeedbackGameCounts> Counts()
{
lock (_lock)
{
var index = Index();
return Games.Select(id =>
{
var records = index.TryGetValue(id, out var game) ? game.Values : (IEnumerable<FeedbackRecord>)Array.Empty<FeedbackRecord>();
var list = records.ToList();
return new FeedbackGameCounts
{
GameId = id,
New = list.Count(r => r.Status == FeedbackStatuses.New),
Open = list.Count(r => !FeedbackStatuses.IsClosed(r.Status)),
Total = list.Count
};
}).ToList();
}
}
/// <summary>
/// Demandes d'un jeu, les plus récentes d'abord. <paramref name="cursor"/> : « ticks-id » de la dernière demande
/// de la page précédente (stable même si des demandes arrivent entre deux pages).
/// </summary>
public FeedbackPage List(string gameId, string? kind, string? status, string? cursor, int limit)
{
lock (_lock)
{
var records = Index().TryGetValue(gameId, out var game) ? game.Values : Enumerable.Empty<FeedbackRecord>();
var filtered = records.Where(r => (kind is null || r.Kind == kind) && (status is null || MatchesStatus(r.Status, status)))
.OrderByDescending(r => r.ReceivedAt).ThenByDescending(r => r.Id, StringComparer.Ordinal)
.ToList();
var start = 0;
if (TryParseCursor(cursor, out var ticks, out var id))
{
start = filtered.FindIndex(r => r.ReceivedAt.UtcTicks < ticks ||
(r.ReceivedAt.UtcTicks == ticks && string.CompareOrdinal(r.Id, id) < 0));
if (start < 0) start = filtered.Count;
}
var page = filtered.Skip(start).Take(limit).ToList();
var more = start + page.Count < filtered.Count;
return new FeedbackPage
{
Items = page.Select(r => r.ToSummary()).ToList(),
NextCursor = more && page.Count > 0 ? $"{page[^1].ReceivedAt.UtcTicks}-{page[^1].Id}" : null,
Total = filtered.Count
};
}
}
/// <summary>Filtre de statut : un statut précis, ou « open » (non close) / « closed ».</summary>
public const string OpenFilter = "open";
public const string ClosedFilter = "closed";
public static bool IsValidStatusFilter(string? status) => status is null or OpenFilter or ClosedFilter || FeedbackStatuses.IsValid(status);
private static bool MatchesStatus(string status, string filter) => filter switch
{
OpenFilter => !FeedbackStatuses.IsClosed(status),
ClosedFilter => FeedbackStatuses.IsClosed(status),
_ => status == filter
};
private static bool TryParseCursor(string? cursor, out long ticks, out string id)
{
ticks = 0;
id = "";
if (string.IsNullOrEmpty(cursor) || cursor.Length > 60) return false;
var dash = cursor.IndexOf('-');
if (dash <= 0 || !long.TryParse(cursor[..dash], NumberStyles.None, CultureInfo.InvariantCulture, out ticks)) return false;
id = cursor[(dash + 1)..];
return FeedbackRules.IsValidId(id);
}
public FeedbackRecord? Find(string gameId, string id)
{
if (!FeedbackRules.IsValidId(id)) return null;
lock (_lock)
return Index().TryGetValue(gameId, out var game) && game.TryGetValue(id, out var record) ? record : null;
}
// ------------------------------------------------------------------ Traitement
/// <summary>Statut et note interne ; révision attendue (409 sinon), refus si la demande n'existe plus (404).</summary>
public FeedbackRecord Update(string gameId, string id, long revision, string status, string? note, string actorName, DateTimeOffset now,
out FeedbackRecord previous)
{
lock (_lock)
{
previous = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (supprimée entre-temps ?).");
if (previous.Revision != revision)
throw ApiRejection.Conflict("La demande a été modifiée par un autre administrateur : rechargez-la.", ApiErrorCodes.RevisionConflict);
var updated = previous with
{
Status = status,
AdminNote = note,
Revision = previous.Revision + 1,
StatusChangedAt = now,
StatusChangedBy = actorName
};
Write(updated);
Index()[gameId][id] = updated;
return updated;
}
}
public FeedbackRecord Delete(string gameId, string id)
{
lock (_lock)
{
var record = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (déjà supprimée ?).");
DeleteFile(record);
Index()[gameId].Remove(id);
return record;
}
}
/// <summary>
/// Purge (conservation RGPD) : demandes closes depuis plus de <c>RetentionClosedMonths</c>, et toutes celles reçues
/// depuis plus de <c>RetentionMaxMonths</c>. Renvoie le nombre de demandes supprimées.
/// </summary>
public int Purge(DateTimeOffset now)
{
var closedLimit = now.AddMonths(-_settings.RetentionClosedMonths);
var maxLimit = now.AddMonths(-_settings.RetentionMaxMonths);
var removed = 0;
lock (_lock)
{
foreach (var game in Index().Values)
{
var expired = game.Values.Where(r => r.ReceivedAt < maxLimit ||
(FeedbackStatuses.IsClosed(r.Status) && (r.StatusChangedAt ?? r.ReceivedAt) < closedLimit))
.ToList();
foreach (var record in expired)
{
try
{
DeleteFile(record);
game.Remove(record.Id);
removed++;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger?.LogError(ex, "Purge de la demande {Reference} impossible", record.Reference);
}
}
}
RemoveEmptyFolders();
}
return removed;
}
// ------------------------------------------------------------------ Fichiers
private string GameFolder(string gameId) => Path.Combine(_root, gameId);
private string PathOf(FeedbackRecord record) =>
Path.Combine(GameFolder(record.GameId), record.ReceivedAt.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture), record.Id + ".json");
private void Write(FeedbackRecord record)
{
if (ResolveGame(record.GameId) != record.GameId || !FeedbackRules.IsValidId(record.Id))
throw new InvalidOperationException("Demande invalide : écriture refusée.");
var path = PathOf(record);
SecureFiles.EnsureDirectory(_root);
SecureFiles.EnsureDirectory(GameFolder(record.GameId));
SecureFiles.EnsureDirectory(Path.GetDirectoryName(path)!);
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
SecureFiles.WriteAllTextAtomic(path, JsonSerializer.Serialize(record, Json), keepBackup: false);
_totalBytes += new FileInfo(path).Length - oldSize;
}
private string AttachmentPath(FeedbackRecord record, int index, string format)
{
if (format is not (FeedbackImages.Png or FeedbackImages.Jpeg) || index is < 1 or > FeedbackImages.MaxAttachments)
throw new InvalidOperationException("Capture invalide.");
return Path.Combine(Path.GetDirectoryName(PathOf(record))!, $"{record.Id}-{index}.{format}");
}
private void DeleteAttachmentFile(FeedbackRecord record, FeedbackAttachmentInfo attachment)
{
var path = AttachmentPath(record, attachment.Index, attachment.Format);
if (!File.Exists(path)) return;
var size = new FileInfo(path).Length;
File.Delete(path);
_totalBytes -= size;
}
private void DeleteFile(FeedbackRecord record)
{
foreach (var attachment in record.Attachments) DeleteAttachmentFile(record, attachment);
var path = PathOf(record);
if (!File.Exists(path)) return;
var size = new FileInfo(path).Length;
File.Delete(path);
_totalBytes -= size;
}
private void RemoveEmptyFolders()
{
if (!Directory.Exists(_root)) return;
foreach (var game in Directory.GetDirectories(_root))
foreach (var month in Directory.GetDirectories(game))
{
try
{
if (!Directory.EnumerateFileSystemEntries(month).Any()) Directory.Delete(month);
}
catch (IOException)
{
// dossier en cours d'utilisation : nettoyé à la purge suivante
}
}
}
private long? FreeDiskBytes()
{
try
{
var folder = Directory.Exists(_root) ? _root : Path.GetDirectoryName(_root)!;
return new DriveInfo(Path.GetFullPath(folder)).AvailableFreeSpace; // même usage que les autres stores
}
catch (Exception ex) when (ex is IOException or ArgumentException or UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// Index construit au premier accès : lecture de tous les fichiers des jeux configurés (dossiers et fichiers aux noms
/// attendus uniquement, liens symboliques ignorés). Un fichier illisible est signalé et ignoré : il n'empêche ni le
/// démarrage ni la réception.
/// </summary>
private Dictionary<string, Dictionary<string, FeedbackRecord>> Index()
{
if (_index is not null) return _index;
var index = new Dictionary<string, Dictionary<string, FeedbackRecord>>(StringComparer.Ordinal);
long total = 0;
foreach (var gameId in Games)
{
var records = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
index[gameId] = records;
var folder = GameFolder(gameId);
if (!Directory.Exists(folder) || IsLink(folder)) continue;
foreach (var month in Directory.GetDirectories(folder))
{
if (IsLink(month) || !DateTime.TryParseExact(Path.GetFileName(month), "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
continue;
foreach (var file in Directory.GetFiles(month, "*.json"))
{
var id = Path.GetFileNameWithoutExtension(file);
if (!FeedbackRules.IsValidId(id) || IsLink(file)) continue;
try
{
var info = new FileInfo(file);
if (info.Length > 256 * 1024) throw new InvalidDataException("fichier trop volumineux");
var record = JsonSerializer.Deserialize<FeedbackRecord>(File.ReadAllText(file), Json)
?? throw new InvalidDataException("fichier vide");
if (record.Format != FeedbackRecord.FormatName || record.Id != id ||
!string.Equals(record.GameId, gameId, StringComparison.Ordinal))
throw new InvalidDataException("contenu inattendu");
var attachments = (record.Attachments ?? new List<FeedbackAttachmentInfo>())
.Where(a => a.Format is FeedbackImages.Png or FeedbackImages.Jpeg && a.Index is >= 1 and <= FeedbackImages.MaxAttachments)
.ToList();
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>(), Attachments = attachments };
total += info.Length;
foreach (var attachment in attachments)
{
var attachmentPath = Path.Combine(month, $"{id}-{attachment.Index}.{attachment.Format}");
if (File.Exists(attachmentPath)) total += new FileInfo(attachmentPath).Length;
}
}
catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or UnauthorizedAccessException)
{
_logger?.LogWarning("Demande ignorée (fichier illisible) : {File} ({Message})", file, ex.Message);
}
}
}
}
_totalBytes = total;
return _index = index;
}
private static bool IsLink(string path)
{
try
{
return new FileInfo(path).LinkTarget is not null;
}
catch (IOException)
{
return true;
}
}
}
@@ -0,0 +1,98 @@
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Trousseau publié (phase 7).
/// • Référence : keyring.json dans le dossier des données privées (+ historique).
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Keyring:PublicFile, par défaut à côté
/// du catalogue), écrite atomiquement, lisible par tous.
/// • Le serveur ne détient pas la clé racine et ne vérifie que la structure et le numéro (toujours croissant) :
/// c'est le launcher qui vérifie la signature racine (avant l'envoi dans le menu d'administration, puis chez
/// chaque joueur). Un trousseau falsifié serait refusé par tous les launchers.
/// </summary>
public sealed class KeyringStore
{
public const string FileName = "keyring.json";
private const string HistoryFolder = "keyring-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _writeLock = new();
public KeyringStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.KeyringPublicFileFullPath) { }
public KeyringStore(string dataDirectory, string? publicFile)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
}
public bool PublishingEnabled => _publicFile is not null;
/// <summary>Trousseau publié, ou null.</summary>
public KeyringDocument? Read()
{
if (!File.Exists(_path)) return null;
return Keyring.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
/// <summary>Publie un trousseau signé (structure contrôlée, numéro supérieur au trousseau actuel).</summary>
/// <returns>Le trousseau précédent (null : premier).</returns>
public KeyringDocument? Publish(KeyringDocument keyring)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou LauncherApi:Catalog:PublicFile).");
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors)) throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
if (keyring.Signature is null || string.IsNullOrEmpty(keyring.Signature.Value))
throw ApiRejection.BadRequest("Trousseau non signé : signez-le d'abord avec la clé racine (tools\\sign-keyring.bat).");
lock (_writeLock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var current = Read();
if (current is not null && keyring.Sequence <= current.Sequence)
throw ApiRejection.Conflict(
$"Trousseau n° {keyring.Sequence} refusé : le trousseau publié porte déjà le n° {current.Sequence} " +
"(exportez une nouvelle demande, qui prend le numéro suivant).", ApiErrorCodes.RevisionConflict);
var json = Keyring.Serialize(keyring);
if (System.Text.Encoding.UTF8.GetByteCount(json) > Keyring.MaxBytes)
throw ApiRejection.BadRequest($"Trousseau trop volumineux (au plus {Keyring.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte le numéro) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "trousseau public");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(keyring.Sequence, json);
return current;
}
}
private void WriteHistory(long sequence, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"keyring-{sequence:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "keyring-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,133 @@
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Mode maintenance du launcher (commun à tous les jeux), activé ou désactivé depuis le menu d'administration.
/// • Référence : maintenance.json dans les données privées (+ historique des 20 dernières révisions) ;
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
/// l'adresse), écrite atomiquement, lisible par tous ;
/// • Toujours signé (autorisation « maintenance » du trousseau) : le serveur vérifie que la signature est celle de la
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
/// Rien de publié : maintenance inactive (révision 0).
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
/// </summary>
public sealed class MaintenanceStore
{
public const string FileName = "maintenance.json";
private const string HistoryFolder = "maintenance-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _lock = new();
private MaintenanceDocument? _current;
public MaintenanceStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.MaintenancePublicFileFullPath, options.Value.MaintenancePublicUrl) { }
public MaintenanceStore(string dataDirectory, string? publicFile, string? publicUrl = null)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
PublicUrl = publicUrl;
}
public bool PublishingEnabled => _publicFile is not null;
public string? PublicUrl { get; }
/// <summary>État en vigueur : celui publié, sinon maintenance inactive (révision 0).</summary>
/// <exception cref="InvalidDataException">Fichier de référence illisible.</exception>
public MaintenanceDocument Current
{
get
{
lock (_lock) return _current ??= Read() ?? MaintenanceRules.Defaults();
}
}
private MaintenanceDocument? Read()
{
if (!File.Exists(_path)) return null;
try
{
return MaintenanceRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
catch (System.Text.Json.JsonException ex)
{
throw new InvalidDataException($"{FileName} illisible : {ex.Message}", ex);
}
}
/// <summary>
/// Publie un document signé (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue (409 si
/// une autre publication a eu lieu), révision suivante obligatoire. Pendant une maintenance, le début annoncé ne
/// change pas (modification du message seulement).
/// </summary>
/// <returns>Document publié (avec la date) et celui d'avant.</returns>
public (MaintenanceDocument Published, MaintenanceDocument Previous) Publish(MaintenanceDocument document, long baseRevision, DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du mode maintenance non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
"LauncherApi:Catalog:PublicFile) est nécessaire, le mode maintenance est publié à côté.");
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Mode maintenance non signé.");
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors, now))
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
lock (_lock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var previous = Read() ?? MaintenanceRules.Defaults();
if (baseRevision != previous.Revision)
throw ApiRejection.Conflict(
$"Le mode maintenance a été modifié entre-temps (révision {previous.Revision}) : rechargez la page, puis recommencez.",
ApiErrorCodes.RevisionConflict);
if (document.Revision != previous.Revision + 1)
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
if (document.Enabled && previous.Enabled && document.Since != previous.Since)
throw ApiRejection.BadRequest("Maintenance déjà en cours : sa date de début ne change pas (seul le message peut être modifié).");
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
var json = MaintenanceRules.Serialize(published);
if (Encoding.UTF8.GetByteCount(json) > MaintenanceRules.MaxBytes)
throw ApiRejection.BadRequest($"Mode maintenance trop volumineux (au plus {MaintenanceRules.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "fichier public du mode maintenance");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(published.Revision, json);
_current = published;
return (published, previous);
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"maintenance-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "maintenance-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
.Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,68 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Signature du manifest déjà en ligne d'un jeu (version publiée avant la phase 7, ou avec WinSCP) : le launcher
/// envoie la signature du contenu de SON manifest.json (dossier de publication) ; le serveur ne l'ajoute que si la
/// version en ligne a exactement ce contenu, puis réécrit le manifest atomiquement (mêmes règles que les notes).
/// </summary>
public static class ManifestSignatures
{
public static ManifestSignatureState Read(string gameId, string manifestPath, AdminDatabase admins)
{
if (PublishedFiles.ReadManifest(manifestPath) is not { } bytes) return new ManifestSignatureState { GameId = gameId };
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
var signature = ContentSignatures.ReadSignature(root);
return new ManifestSignatureState
{
GameId = gameId,
Version = PublishedFiles.StringValue(root, "version") ?? "",
Digest = Digest(bytes),
Signature = signature,
SignerName = ContentSignatures.SignerName(admins, signature)
};
}
/// <param name="verify">Contrôle de la signature sur le condensé de la version en ligne (lève une ApiRejection).</param>
public static ManifestSignatureState Sign(string gameId, string manifestPath, string dataDirectory, ManifestSignRequest request,
Action<string> verify, AdminDatabase admins)
{
using (PublishedFiles.LockGame(dataDirectory, gameId))
{
var bytes = PublishedFiles.ReadManifest(manifestPath)
?? throw ApiRejection.NotFound("Aucune version en ligne pour ce jeu.");
var digest = Digest(bytes);
if (!string.Equals(digest, request.Digest, StringComparison.OrdinalIgnoreCase))
throw ApiRejection.Conflict(
"La version en ligne ne correspond pas au manifest.json choisi (fichiers, archive ou exécutable différents) : " +
"choisissez le dossier de publication de la version en ligne.", ApiErrorCodes.RevisionConflict);
verify(digest);
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
PublishedFiles.SetProperty(root, "signature", JsonSerializer.SerializeToNode(request.Signature, ApiJson.Options));
var newBytes = PublishedFiles.Serialize(root);
PublishedFiles.Backup(dataDirectory, gameId, bytes);
PublishedFiles.WriteManifest(manifestPath, newBytes, PublishedFiles.Sha256(bytes),
() => ApiRejection.Conflict("Le manifest a été modifié pendant la signature : recommencez.", ApiErrorCodes.RevisionConflict));
return Read(gameId, manifestPath, admins);
}
}
private static string Digest(byte[] bytes)
{
try
{
return ContentSigning.ManifestDigest(PublishedFiles.StripBom(bytes));
}
catch (JsonException ex)
{
throw new InvalidDataException($"Manifest en ligne illisible : {ex.Message}", ex);
}
}
}
@@ -0,0 +1,52 @@
using System.Text;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichier PUBLIC servi par nginx (catalogue, trousseau, images du catalogue) : écrit atomiquement (fichier temporaire caché du
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
/// </summary>
internal static class PublicFileWriter
{
public const UnixFileMode Mode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
public static void Write(string path, string text, string what) =>
Write(path, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text), what);
/// <summary>Contenu binaire (image du catalogue) : mêmes garanties que pour un texte.</summary>
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
public static void Write(string path, byte[] content, string what)
{
var directory = Path.GetDirectoryName(path)!;
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
using (var stream = new FileStream(temp, options))
{
stream.Write(content);
stream.Flush(flushToDisk: true);
}
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, Mode);
File.Move(temp, path, overwrite: true); // renommage atomique dans le même dossier
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique)
}
throw new IOException($"Écriture du {what} impossible ({path}) : {ex.Message}", ex);
}
}
}
@@ -0,0 +1,850 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5).
/// • Dépôt : dossier CACHÉ « .publish-&lt;id&gt; » dans le dossier du jeu (même disque : la mise en ligne
/// finale n'est qu'une suite de renommages ; nginx refuse les chemins commençant par un point), droits
/// 0700 ; description (version, plan) dans les données privées.
/// • Envoi : manifest, puis objets absents du serveur (par lots, ou par morceaux pour les gros), puis
/// archive par morceaux ; chaque fichier est reçu à la suite (position vérifiée : reprise possible
/// après une coupure) puis vérifié (SHA-256) avant d'être accepté.
/// • Mise en ligne (commit) : tout est revérifié AVANT de déplacer quoi que ce soit ; objets puis archive
/// installés, manifest en DERNIER (comme avec WinSCP), historique des notes du serveur conservé ; si
/// l'écriture du manifest échoue, l'archive remplacée est remise en place. Jamais si les fichiers en
/// ligne ont changé pendant l'envoi (autre publication, WinSCP).
/// • Une publication par jeu ; abandonnée au bout de 48 h sans envoi ; résultat conservé 24 h (une
/// réponse perdue n'empêche pas de constater que la version est en ligne).
/// </summary>
public sealed class PublicationStore
{
private const string RecordsFolder = "publications";
private const string StagingPrefix = ".publish-";
private const string DoneSuffix = ".done";
private const long FreeSpaceMargin = 256L * 1024 * 1024;
private static readonly TimeSpan DoneLifetime = TimeSpan.FromHours(24);
private static readonly TimeSpan CleanUpEvery = TimeSpan.FromMinutes(10);
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly string _recordsDirectory;
private readonly TimeProvider _time;
private readonly object _startLock = new();
private readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, HashProgress> _hashes = new(StringComparer.Ordinal);
private DateTimeOffset _lastCleanUp = DateTimeOffset.MinValue;
public PublicationStore(IOptions<LauncherApiOptions> options, TimeProvider time)
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath, time) { }
public PublicationStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory, TimeProvider time)
{
_manifests = manifests;
_dataDirectory = dataDirectory;
_recordsDirectory = Path.Combine(dataDirectory, RecordsFolder);
_time = time;
}
// ================================================================== Début, état, abandon
/// <summary>
/// Début d'une publication, ou reprise si le même manifest était en cours d'envoi. Une publication
/// d'un autre administrateur, active depuis moins de 30 min, n'est pas remplacée (409).
/// </summary>
/// <returns>L'état, et la publication remplacée (audit) le cas échéant.</returns>
public (PublicationState State, PublicationRecord? Replaced) Start(string gameId, PublicationStartRequest request, Guid adminId, string adminName)
{
var (manifestPath, key) = Locate(gameId);
if (!ManifestCheck.IsValidVersion(request.Version)) throw ApiRejection.BadRequest("Numéro de version invalide.");
if (!PublishingLimits.IsValidSha256(request.ManifestSha256)) throw ApiRejection.BadRequest("Empreinte du manifest invalide.");
if (request.ManifestSize is <= 0 or > PublishingLimits.MaxManifestBytes) throw ApiRejection.BadRequest("Taille du manifest invalide.");
lock (_startLock)
{
CleanUp(force: true);
PublicationRecord? replaced = null;
foreach (var existing in Records().Where(r => string.Equals(r.GameId, key, StringComparison.OrdinalIgnoreCase)).ToList())
{
if (existing.AdminId == adminId && existing.Version == request.Version &&
string.Equals(existing.ManifestSha256, request.ManifestSha256, StringComparison.OrdinalIgnoreCase))
{
Touch(existing.Id);
return (Lock(existing.Id, () => State(Current(existing.Id), resumed: true)), null);
}
var idle = _time.GetUtcNow() - LastActivity(existing.Id);
if (existing.AdminId != adminId && idle < PublishingLimits.TakeoverAfter)
throw ApiRejection.Conflict(
$"{existing.AdminName} publie la version {existing.Version} de ce jeu (dernier envoi il y a " +
$"{Math.Max(1, (int)idle.TotalMinutes)} min). Réessayez plus tard.", ApiErrorCodes.PublicationInProgress);
Lock(existing.Id, () =>
{
Delete(existing);
return 0;
});
replaced = existing;
}
var current = PublishedFiles.ReadManifest(manifestPath);
var currentRoot = current is null ? null : ChangelogStore.Parse(current, key);
var record = new PublicationRecord
{
Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
GameId = key,
Version = request.Version,
ManifestSha256 = request.ManifestSha256.ToLowerInvariant(),
ManifestSize = request.ManifestSize,
AdminId = adminId,
AdminName = adminName,
CreatedAt = _time.GetUtcNow(),
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
PublishedContentSha256 = OnlineContentHash(currentRoot)
};
CreateStaging(record);
Save(record);
return (Lock(record.Id, () => State(record, resumed: false)), replaced);
}
}
public PublicationState GetState(string id, Guid adminId)
{
Find(id, adminId);
return Lock(id, () =>
{
EnsurePlan(Current(id));
return State(Current(id), resumed: false);
});
}
/// <summary>Abandon : dépôt supprimé, rien n'est publié.</summary>
public PublicationRecord Cancel(string id, Guid adminId)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
Delete(record);
return record;
});
}
// ================================================================== Envoi
public ChunkResult AppendManifest(string id, Guid adminId, long offset, byte[] data)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
var staging = Staging(record);
var result = Append(Path.Combine(staging, "manifest.part"), Path.Combine(staging, "manifest.json"), offset, data,
record.ManifestSize, record.ManifestSha256, "manifest");
Touch(id);
if (result.Complete) EnsurePlan(record);
return result;
});
}
public ChunkResult AppendArchive(string id, Guid adminId, long offset, byte[] data)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
if (!plan.ArchiveNeeded) return new ChunkResult { Received = plan.ArchiveSize, Complete = true };
var staging = Staging(record);
var result = Append(Path.Combine(staging, "archive.part"), Path.Combine(staging, "archive.zip"), offset, data,
plan.ArchiveSize, plan.ArchiveSha256, "archive");
Touch(id);
return result;
});
}
/// <summary>Gros objet, par morceaux.</summary>
public ChunkResult AppendObject(string id, Guid adminId, string sha256, long offset, byte[] data)
{
Find(id, adminId);
var sha = sha256.ToLowerInvariant();
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
if (!plan.Pending.TryGetValue(sha, out var size))
throw ApiRejection.BadRequest("Cet objet n'est pas attendu par cette publication.");
var objects = StagingObjects(record);
var result = Append(Path.Combine(objects, sha + ".part"), Path.Combine(objects, sha + ".bin"), offset, data, size, sha, "objet");
Touch(id);
return result;
});
}
/// <summary>Lot de petits objets (chacun vérifié) ; renvoie le nombre d'objets enregistrés.</summary>
public ChunkResult AddObjects(string id, Guid adminId, byte[] body)
{
Find(id, adminId);
List<(string Sha256, ReadOnlyMemory<byte> Data)> batch;
try
{
batch = ObjectBatch.Read(body, PublishingLimits.MaxObjectsPerBatch);
}
catch (InvalidDataException ex)
{
throw ApiRejection.BadRequest(ex.Message);
}
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
var objects = StagingObjects(record);
var saved = 0;
foreach (var (sha, data) in batch)
{
if (!plan.Pending.TryGetValue(sha, out var size))
throw ApiRejection.BadRequest("Un objet du lot n'est pas attendu par cette publication.");
if (data.Length != size) throw ApiRejection.BadRequest($"Objet {sha[..12]}… : taille différente de celle du manifest.");
if (PublishedFiles.Sha256(data.Span) != sha)
throw ApiRejection.BadRequest($"Objet {sha[..12]}… : contenu différent de son empreinte (fichier modifié ou abîmé).");
var done = Path.Combine(objects, sha + ".bin");
if (!File.Exists(done))
{
var temp = Path.Combine(objects, $"{sha}.{Guid.NewGuid():N}.tmp");
PublishedFiles.WriteNew(temp, data.Span, PrivateFile);
File.Move(temp, done, overwrite: true);
}
saved++;
}
Touch(id);
return new ChunkResult { Received = saved, Complete = true };
});
}
// ================================================================== Mise en ligne
/// <summary>
/// Met la version en ligne : objets, archive, puis manifest (historique des notes du serveur conservé).
/// Déjà faite (réponse précédente perdue) : le même résultat est renvoyé.
/// </summary>
/// <returns>Le résultat, et l'enregistrement (null si la mise en ligne avait déjà eu lieu).</returns>
/// <param name="signature">Signature du contenu du manifest par l'administrateur (phase 7), ou null.</param>
/// <param name="verifySignature">Contrôle de la signature sur le condensé du manifest reçu (lève une ApiRejection).</param>
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId, ContentSignature? signature = null,
Action<string, string>? verifySignature = null)
{
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
if (Load(id) is null && LoadDone(id) is { } done && done.AdminId == adminId) return (done.Result, null);
Find(id, adminId);
return Lock(id, () =>
{
if (Load(id) is null && LoadDone(id) is { } finished && finished.AdminId == adminId) return (finished.Result, (PublicationRecord?)null);
var record = Current(id);
var (manifestPath, gameId) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var plan = RequirePlan(record);
var state = State(record, resumed: false);
if (state.PendingObjects.Count > 0 || !state.ArchiveComplete)
throw ApiRejection.Conflict(
$"Envoi incomplet : {state.PendingObjects.Count} fichier(s) et {(state.ArchiveComplete ? "aucune archive" : "l'archive")} restent à envoyer.");
var staging = Staging(record);
var manifestBytes = File.ReadAllBytes(Path.Combine(staging, "manifest.json"));
var errors = new List<string>();
var summary = ManifestCheck.Check(manifestBytes, gameId, record.Version, errors)
?? throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
using (PublishedFiles.LockGame(_dataDirectory, gameId))
{
var current = PublishedFiles.ReadManifest(manifestPath);
var currentRoot = current is null ? null : ChangelogStore.Parse(current, gameId);
var currentVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "";
// Fichiers en ligne changés pendant l'envoi (autre publication, WinSCP) : jamais écrasés. Une
// correction des notes (page Notes de version) ne change que « changelog » : elle est permise.
if (OnlineContentHash(currentRoot) != record.PublishedContentSha256)
throw ApiRejection.Conflict(
$"La version en ligne a changé pendant l'envoi ({Display(record.PublishedVersion)} → {Display(currentVersion)}) : " +
"vérifiez avant de publier la vôtre, puis recommencez.", ApiErrorCodes.RevisionConflict);
// Signature (phase 7) : sur le contenu reçu (fichiers, archive, exécutable…), que la fusion des notes ne touche pas.
if (signature is not null)
verifySignature?.Invoke(gameId, ContentSigning.ManifestDigest(PublishedFiles.StripBom(manifestBytes)));
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne) et la
// signature de cette mise en ligne (une signature éventuellement présente dans le fichier reçu est retirée).
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
PublishedFiles.SetProperty(root, "signature",
signature is null ? null : JsonSerializer.SerializeToNode(signature, Contracts.ApiJson.Options));
var newBytes = PublishedFiles.Serialize(root);
// 0. Contrôles AVANT tout déplacement : droits, liens symboliques, dossiers objects/xx.
if (new FileInfo(manifestPath).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
var archiveTarget = Path.Combine(gameDirectory, summary.ArchiveName);
if (new FileInfo(archiveTarget).LinkTarget is not null)
throw new ManifestWriteRefusedException("l'archive publiée est un lien symbolique : la remplacer romprait le lien.");
var mode = PublishedFiles.PublicMode(gameDirectory, current is null ? null : manifestPath);
var objectsDirectory = Path.Combine(gameDirectory, "objects");
PublishedFiles.EnsurePublicDirectory(objectsDirectory, gameDirectory, keepSetGid: true);
foreach (var prefix in plan.Pending.Keys.Select(sha => sha[..2]).Distinct())
PublishedFiles.EnsurePublicDirectory(Path.Combine(objectsDirectory, prefix), gameDirectory, keepSetGid: false);
// 1. Objets (contenu adressé par l'empreinte : un objet présent est le bon s'il a la bonne taille).
var added = 0;
foreach (var (sha, size) in plan.Pending)
{
var staged = Path.Combine(StagingObjects(record), sha + ".bin");
var target = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha));
if (ObjectPresent(target, size))
{
PublishedFiles.TryDelete(staged);
continue;
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, mode);
File.Move(staged, target, overwrite: true);
added++;
}
foreach (var (sha, size) in summary.Objects)
if (!ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
throw ApiRejection.Conflict(
$"Le fichier {ManifestCheck.ObjectPath(sha)} a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
// 2. Archive : l'ancienne (même nom : republication) est gardée de côté jusqu'à l'écriture du manifest.
var stagedArchive = Path.Combine(staging, "archive.zip");
string? previousArchive = null;
var archiveAdded = false;
if (File.Exists(stagedArchive))
{
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(stagedArchive, mode);
if (File.Exists(archiveTarget))
{
previousArchive = PublishedFiles.TempPath(archiveTarget);
File.Move(archiveTarget, previousArchive);
}
File.Move(stagedArchive, archiveTarget);
archiveAdded = true;
}
else if (!File.Exists(archiveTarget) || new FileInfo(archiveTarget).Length != summary.ArchiveSize)
{
throw ApiRejection.Conflict("L'archive a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
}
// 3. Manifest en dernier : les launchers ne voient jamais une version incomplète.
try
{
if (current is not null) PublishedFiles.Backup(_dataDirectory, gameId, current);
PublishedFiles.WriteManifest(manifestPath, newBytes, current is null ? null : PublishedFiles.Sha256(current),
() => ApiRejection.Conflict("Le manifest a été modifié pendant la mise en ligne : recommencez.", ApiErrorCodes.RevisionConflict));
}
catch
{
// Archive remise dans le dépôt (pas besoin de la renvoyer), l'ancienne remise en ligne.
if (archiveAdded)
{
File.Move(archiveTarget, stagedArchive, overwrite: true);
if (previousArchive is not null) File.Move(previousArchive, archiveTarget);
}
throw;
}
if (previousArchive is not null) PublishedFiles.TryDelete(previousArchive);
var result = new PublicationResult
{
GameId = gameId,
Version = record.Version,
PreviousVersion = currentVersion,
ManifestSha256 = PublishedFiles.Sha256(newBytes),
ObjectsAdded = added,
ArchiveAdded = archiveAdded
};
SaveDone(new PublicationDone { Id = id, AdminId = record.AdminId, Result = result, At = _time.GetUtcNow() });
Delete(record);
return (result, (PublicationRecord?)record);
}
});
}
/// <summary>
/// Notes : celles de la version publiée (du manifest reçu ; à défaut, celles déjà en ligne), puis
/// l'historique du SERVEUR (qui contient les corrections faites en ligne), dans les bornes du launcher
/// (30 versions ; les plus anciennes retirées si l'ensemble est trop long).
/// Sans manifest en ligne : les notes du manifest reçu.
/// </summary>
private static JsonArray? MergeChangelog(JsonObject uploaded, JsonObject? current, string version)
{
var received = ChangelogStore.ChangelogArray(uploaded);
if (current is null) return received?.DeepClone().AsArray();
static bool Is(JsonNode? node, string version) =>
node is JsonObject entry && PublishedFiles.StringValue(entry, "version") == version;
var history = ChangelogStore.ChangelogArray(current) ?? new JsonArray();
var merged = new JsonArray();
var head = received?.FirstOrDefault(n => Is(n, version)) ?? history.FirstOrDefault(n => Is(n, version));
if (head is not null) merged.Add(head.DeepClone());
foreach (var entry in history)
{
if (merged.Count >= ChangelogLimits.MaxEntries) break;
if (entry is JsonObject && !Is(entry, version)) merged.Add(entry.DeepClone());
}
// Budget de tout l'historique (contrôle de lecture en même temps : une entrée illisible empêche la publication).
while (merged.Count > 1 &&
ChangelogStore.Entries(merged).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems) > ChangelogLimits.MaxTotalItems)
merged.RemoveAt(merged.Count - 1);
return merged;
}
/// <summary>Empreinte de la version en ligne hors notes et hors signature (une correction de notes ou une signature reste permise pendant l'envoi) ; vide si elle n'existe pas.</summary>
private static string OnlineContentHash(JsonObject? root)
{
if (root is null) return "";
var copy = root.DeepClone().AsObject();
PublishedFiles.SetProperty(copy, "changelog", null);
PublishedFiles.SetProperty(copy, "signature", null);
return PublishedFiles.Sha256(System.Text.Encoding.UTF8.GetBytes(copy.ToJsonString()));
}
// ================================================================== Plan (après réception du manifest)
/// <summary>Manifest complet : contrôle, puis liste des objets et de l'archive à envoyer.</summary>
private void EnsurePlan(PublicationRecord record)
{
if (record.Plan is not null) return;
var staging = Staging(record);
var manifest = Path.Combine(staging, "manifest.json");
if (!File.Exists(manifest)) return;
var (manifestPath, gameId) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var errors = new List<string>();
var summary = ManifestCheck.Check(File.ReadAllBytes(manifest), gameId, record.Version, errors);
if (summary is null)
{
Delete(record); // manifest refusé : la publication est abandonnée (à refaire avec un manifest corrigé)
throw ApiRejection.BadRequest("Manifest refusé : " + string.Join(" ", errors.Take(5)) +
(errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
}
var pending = summary.Objects
.Where(o => !ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(o.Key)), o.Value))
.ToDictionary(o => o.Key, o => o.Value, StringComparer.Ordinal);
// Archive déjà en ligne (même nom, même taille, même empreinte DANS le manifest en ligne) : pas renvoyée.
// Aucune lecture du fichier (plusieurs Go) pendant la requête.
var archiveTarget = new FileInfo(Path.Combine(gameDirectory, summary.ArchiveName));
var archiveNeeded = !(archiveTarget.Exists && archiveTarget.LinkTarget is null && archiveTarget.Length == summary.ArchiveSize &&
PublishedArchive(manifestPath, gameId) is { } online &&
string.Equals(online.Name, summary.ArchiveName, StringComparison.Ordinal) && online.Sha256 == summary.ArchiveSha256);
var needed = pending.Values.Sum() + (archiveNeeded ? summary.ArchiveSize : 0);
var free = new DriveInfo(gameDirectory).AvailableFreeSpace;
if (free < needed + FreeSpaceMargin)
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage,
$"Espace disque insuffisant sur le serveur : {needed / (1024 * 1024)} Mo à recevoir, {free / (1024 * 1024)} Mo libres.");
Save(record with
{
Plan = new PublicationPlan
{
ArchiveName = summary.ArchiveName,
ArchiveSize = summary.ArchiveSize,
ArchiveSha256 = summary.ArchiveSha256,
ArchiveNeeded = archiveNeeded,
Pending = pending,
ObjectCount = summary.Objects.Count,
ObjectsOnServer = summary.Objects.Count - pending.Count
}
});
}
/// <summary>Archive référencée par le manifest en ligne (nom, empreinte), sans la lire.</summary>
private static (string Name, string Sha256)? PublishedArchive(string manifestPath, string gameId)
{
var bytes = PublishedFiles.ReadManifest(manifestPath);
if (bytes is null) return null;
var root = ChangelogStore.Parse(bytes, gameId);
if (PublishedFiles.Property(root, "archive") is not JsonObject archive) return null;
var name = PublishedFiles.StringValue(archive, "url");
var sha = PublishedFiles.StringValue(archive, "sha256");
return name is null || sha is null ? null : (name, sha.ToLowerInvariant());
}
private PublicationPlan RequirePlan(PublicationRecord record)
{
EnsurePlan(record);
return Current(record.Id).Plan ?? throw ApiRejection.Conflict("Envoyez d'abord le manifest.");
}
private PublicationState State(PublicationRecord record, bool resumed)
{
var staging = Staging(record);
var manifestDone = File.Exists(Path.Combine(staging, "manifest.json"));
var plan = record.Plan;
var pending = new List<string>();
var partial = new Dictionary<string, long>(StringComparer.Ordinal);
long archiveReceived = 0;
var archiveComplete = false;
if (plan is not null)
{
var (manifestPath, _) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var objects = Path.Combine(staging, "objects");
foreach (var (sha, size) in plan.Pending)
{
if (File.Exists(Path.Combine(objects, sha + ".bin")) ||
ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
continue;
pending.Add(sha);
var part = new FileInfo(Path.Combine(objects, sha + ".part"));
if (part.Exists && part.Length > 0) partial[sha] = part.Length;
}
archiveComplete = !plan.ArchiveNeeded || File.Exists(Path.Combine(staging, "archive.zip"));
archiveReceived = archiveComplete ? plan.ArchiveSize : Length(Path.Combine(staging, "archive.part"));
}
return new PublicationState
{
PublicationId = record.Id,
GameId = record.GameId,
Version = record.Version,
PublishedVersion = record.PublishedVersion,
Resumed = resumed,
ManifestSize = record.ManifestSize,
ManifestReceived = manifestDone ? record.ManifestSize : Length(Path.Combine(staging, "manifest.part")),
ManifestAccepted = plan is not null,
ObjectCount = plan?.ObjectCount ?? 0,
ObjectsOnServer = plan?.ObjectsOnServer ?? 0,
PendingObjects = pending,
PartialObjects = partial,
ArchiveName = plan?.ArchiveName,
ArchiveSize = plan?.ArchiveSize ?? 0,
ArchiveReceived = archiveReceived,
ArchiveComplete = archiveComplete,
ExpiresAt = LastActivity(record.Id) + PublishingLimits.IdleExpiry
};
}
// ================================================================== Réception d'un fichier
/// <summary>
/// Ajoute un morceau à la suite de ce qui a été reçu (position vérifiée), puis vérifie l'empreinte
/// quand le fichier est complet (calculée au fil des morceaux ; recalculée après un redémarrage).
/// </summary>
private ChunkResult Append(string partPath, string donePath, long offset, byte[] data, long size, string sha256, string what)
{
if (File.Exists(donePath)) return new ChunkResult { Received = size, Complete = true };
var received = Length(partPath);
if (offset != received)
throw ApiRejection.Conflict($"Position d'envoi {offset} inattendue ({what} : {received} octets déjà reçus).", ApiErrorCodes.OffsetMismatch);
if (received + data.LongLength > size) throw ApiRejection.BadRequest($"Données en trop ({what} : {size} octets attendus).");
if (data.Length == 0 && size > 0) throw ApiRejection.BadRequest("Morceau vide.");
var options = new FileStreamOptions { Mode = FileMode.Append, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
using (var stream = new FileStream(partPath, options))
{
stream.Write(data);
stream.Flush(flushToDisk: true);
}
HashProgress? progress;
if (received == 0)
{
RemoveHash(partPath);
progress = _hashes[partPath] = new HashProgress();
}
else if (!_hashes.TryGetValue(partPath, out progress) || progress.Offset != received)
{
RemoveHash(partPath);
progress = null;
}
progress?.Add(data);
received += data.Length;
if (received < size) return new ChunkResult { Received = received };
var actual = progress is not null && progress.Offset == size ? progress.Finish() : HashFile(partPath);
RemoveHash(partPath);
if (actual != sha256)
{
PublishedFiles.TryDelete(partPath);
throw ApiRejection.BadRequest($"Empreinte différente pour {what} : fichier abîmé ou modifié pendant l'envoi ; il sera renvoyé.");
}
File.Move(partPath, donePath, overwrite: true);
return new ChunkResult { Received = size, Complete = true };
}
private void RemoveHash(string key)
{
if (_hashes.TryRemove(key, out var progress)) progress.Dispose();
}
private sealed class HashProgress : IDisposable
{
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
public long Offset { get; private set; }
public void Add(byte[] data)
{
_hash.AppendData(data);
Offset += data.Length;
}
public string Finish() => Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
public void Dispose() => _hash.Dispose();
}
// ================================================================== Enregistrements, dépôt
public sealed record PublicationRecord
{
public string Id { get; init; } = "";
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string ManifestSha256 { get; init; } = "";
public long ManifestSize { get; init; }
public Guid AdminId { get; init; }
public string AdminName { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Version en ligne au début (vide : aucune).</summary>
public string PublishedVersion { get; init; } = "";
/// <summary>Empreinte du manifest en ligne SANS les notes, au début : la mise en ligne est refusée si elle a changé.</summary>
public string PublishedContentSha256 { get; init; } = "";
public PublicationPlan? Plan { get; init; }
}
public sealed record PublicationPlan
{
public string ArchiveName { get; init; } = "";
public long ArchiveSize { get; init; }
public string ArchiveSha256 { get; init; } = "";
public bool ArchiveNeeded { get; init; }
/// <summary>Objets absents du serveur quand le manifest a été reçu (empreinte → taille).</summary>
public Dictionary<string, long> Pending { get; init; } = new(StringComparer.Ordinal);
public int ObjectCount { get; init; }
public int ObjectsOnServer { get; init; }
}
/// <summary>Publication terminée : résultat gardé 24 h (réponse perdue, nouvel essai).</summary>
public sealed record PublicationDone
{
public string Id { get; init; } = "";
public Guid AdminId { get; init; }
public PublicationResult Result { get; init; } = new();
public DateTimeOffset At { get; init; }
}
private (string Path, string GameId) Locate(string gameId)
{
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw PublishedFiles.NotConfigured(gameId, "Publication");
}
/// <summary>Publication de cet administrateur, encore valable (sinon 404/403).</summary>
private PublicationRecord Find(string id, Guid adminId)
{
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
CleanUp(force: false);
var record = Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
if (record.AdminId != adminId)
throw new ApiRejection(403, ApiErrorCodes.Forbidden, $"Cette publication a été lancée par {record.AdminName}.");
return record;
}
/// <summary>Relue sous le verrou : abandonnée entre-temps → 404.</summary>
private PublicationRecord Current(string id) =>
Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
private string RecordPath(string id) => Path.Combine(_recordsDirectory, id + ".json");
private string DonePath(string id) => Path.Combine(_recordsDirectory, id + DoneSuffix + ".json");
private PublicationRecord? Load(string id) => Read<PublicationRecord>(RecordPath(id));
private PublicationDone? LoadDone(string id) => Read<PublicationDone>(DonePath(id));
private static T? Read<T>(string path) where T : class
{
if (!File.Exists(path)) return null;
try
{
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), ApiJson.Options);
}
catch (Exception ex) when (ex is JsonException or IOException)
{
return null;
}
}
private IEnumerable<PublicationRecord> Records()
{
if (!Directory.Exists(_recordsDirectory)) yield break;
foreach (var file in Directory.GetFiles(_recordsDirectory, "*.json"))
{
var id = Path.GetFileNameWithoutExtension(file);
if (PublishingLimits.IsValidPublicationId(id) && Load(id) is { } record) yield return record;
}
}
private void Save(PublicationRecord record)
{
SecureFiles.EnsureDirectory(_recordsDirectory);
SecureFiles.WriteAllTextAtomic(RecordPath(record.Id), JsonSerializer.Serialize(record, ApiJson.Options), keepBackup: false);
}
private void SaveDone(PublicationDone done)
{
SecureFiles.EnsureDirectory(_recordsDirectory);
SecureFiles.WriteAllTextAtomic(DonePath(done.Id), JsonSerializer.Serialize(done, ApiJson.Options), keepBackup: false);
}
/// <summary>Dernier envoi : date de modification de l'enregistrement (mise à jour à chaque morceau).</summary>
private DateTimeOffset LastActivity(string id)
{
var path = RecordPath(id);
return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero) : DateTimeOffset.MinValue;
}
private void Touch(string id)
{
try
{
File.SetLastWriteTimeUtc(RecordPath(id), _time.GetUtcNow().UtcDateTime);
}
catch (IOException)
{
// simple indication d'activité
}
}
private string Staging(PublicationRecord record) =>
Path.Combine(Path.GetDirectoryName(Locate(record.GameId).Path)!, StagingPrefix + record.Id);
private string StagingObjects(PublicationRecord record)
{
var objects = Path.Combine(Staging(record), "objects");
if (!Directory.Exists(objects)) CreatePrivateDirectory(objects);
return objects;
}
private void CreateStaging(PublicationRecord record)
{
var staging = Staging(record);
if (new DirectoryInfo(Path.GetDirectoryName(staging)!).LinkTarget is not null)
throw new ManifestWriteRefusedException("le dossier du jeu est un lien symbolique.");
CreatePrivateDirectory(staging);
}
private static void CreatePrivateDirectory(string path)
{
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
else Directory.CreateDirectory(path, PrivateDirectory);
}
private void Delete(PublicationRecord record)
{
foreach (var key in _hashes.Keys.Where(k => k.Contains(StagingPrefix + record.Id, StringComparison.Ordinal)).ToList())
RemoveHash(key);
try
{
var staging = Staging(record);
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
// dépôt laissé : supprimé au prochain nettoyage
}
PublishedFiles.TryDelete(RecordPath(record.Id));
_locks.TryRemove(record.Id, out _);
}
/// <summary>
/// Publications sans envoi depuis 48 h, résultats de plus de 24 h et dépôts orphelins : supprimés
/// (au début d'une publication, et au plus toutes les 10 minutes lors des envois).
/// </summary>
private void CleanUp(bool force)
{
var now = _time.GetUtcNow();
lock (_locks)
{
if (!force && now - _lastCleanUp < CleanUpEvery) return;
_lastCleanUp = now;
}
var known = new HashSet<string>(StringComparer.Ordinal);
foreach (var record in Records().ToList())
{
if (now - LastActivity(record.Id) > PublishingLimits.IdleExpiry)
Lock(record.Id, () =>
{
Delete(record);
return 0;
});
else known.Add(record.Id);
}
if (Directory.Exists(_recordsDirectory))
foreach (var done in Directory.GetFiles(_recordsDirectory, "*" + DoneSuffix + ".json"))
if (now - new DateTimeOffset(File.GetLastWriteTimeUtc(done), TimeSpan.Zero) > DoneLifetime)
PublishedFiles.TryDelete(done);
foreach (var manifest in _manifests.Values)
{
var directory = Path.GetDirectoryName(manifest)!;
if (!Directory.Exists(directory)) continue;
foreach (var staging in Directory.GetDirectories(directory, StagingPrefix + "*"))
{
var id = Path.GetFileName(staging)[StagingPrefix.Length..];
if (known.Contains(id) || now - Directory.GetLastWriteTimeUtc(staging) <= PublishingLimits.IdleExpiry) continue;
try
{
Directory.Delete(staging, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// nouvel essai au prochain nettoyage
}
}
}
}
private T Lock<T>(string id, Func<T> action)
{
lock (_locks.GetOrAdd(id, _ => new object())) return action();
}
private static bool ObjectPresent(string path, long size)
{
var info = new FileInfo(path);
return info.Exists && info.LinkTarget is null && info.Length == size;
}
private static long Length(string path)
{
var info = new FileInfo(path);
return info.Exists ? info.Length : 0;
}
private static string HashFile(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,281 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichiers PUBLICS d'un jeu (dossier servi par nginx : manifest.json, archive, objects/), communs aux
/// notes de version (phase 4) et à la publication d'une version (phase 5) :
/// • même verrou par jeu (une seule écriture du manifest à la fois, service et outils confondus) ;
/// • même présentation JSON que ManifestBuilder ;
/// • écriture atomique : fichier temporaire caché (refusé par nginx) dans le même dossier, puis renommage ;
/// • droits : ceux du manifest existant (sinon 0640 avec le groupe du dossier setgid, ou 0644) ;
/// refus si le dossier n'a pas le bit setgid alors que nginx lit les fichiers par leur groupe,
/// ou si le manifest est un lien symbolique.
/// </summary>
internal static class PublishedFiles
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = 64L * 1024 * 1024;
public static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
private const string HistoryFolder = "manifest-history";
private const int HistoryLength = 20;
private const UnixFileMode GroupReadable = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
private const UnixFileMode AllReadable = GroupReadable | UnixFileMode.OtherRead;
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
public static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
MaxDepth = 64
};
public static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
/// <summary>Verrou d'écriture du manifest d'un jeu (fichier dans les données privées).</summary>
public static FileStream LockGame(string dataDirectory, string gameId) =>
SecureFiles.AcquireLock(Path.Combine(dataDirectory, $"manifest-{gameId.ToLowerInvariant()}.lock"), LockTimeout);
public static string Sha256(ReadOnlySpan<byte> bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
public static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
/// <summary>Manifest publié (null s'il n'existe pas encore).</summary>
public static byte[]? ReadManifest(string path)
{
var info = new FileInfo(path);
if (!info.Exists) return null;
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
return File.ReadAllBytes(path);
}
public static JsonObject ParseObject(ReadOnlySpan<byte> bytes, string what)
{
try
{
return JsonNode.Parse(bytes, documentOptions: ReadOptions) as JsonObject
?? throw new InvalidDataException($"{what} : objet JSON attendu.");
}
catch (Exception ex) when (ex is JsonException or ArgumentException)
{
throw new InvalidDataException($"{what} illisible : {ex.Message}", ex);
}
}
/// <summary>Propriété d'un objet JSON, nom insensible à la casse (comme pour le launcher).</summary>
public static JsonNode? Property(JsonObject node, string name)
{
foreach (var (key, value) in node)
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
return value;
return null;
}
public static string? StringValue(JsonObject node, string name) =>
Property(node, name) is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
/// <summary>Remplace une propriété (toutes casses confondues) ; null la retire.</summary>
public static void SetProperty(JsonObject node, string name, JsonNode? value)
{
foreach (var existing in node.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
node.Remove(existing);
if (value is not null) node[name] = value;
}
public static byte[] Serialize(JsonObject root)
{
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(root.ToJsonString(ManifestJson));
if (bytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
return bytes;
}
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération ; 20 dernières).</summary>
public static void Backup(string dataDirectory, string gameId, byte[] bytes)
{
try
{
var directory = Path.Combine(dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
SecureFiles.EnsureDirectory(Path.Combine(dataDirectory, HistoryFolder));
SecureFiles.EnsureDirectory(directory);
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// La copie est une aide : son échec n'empêche pas l'écriture.
}
}
/// <summary>
/// Droits d'un nouveau fichier public du dossier : ceux du manifest existant, sinon lisible par le
/// groupe du dossier (setgid) ou par tous. Refus si nginx risque de perdre l'accès.
/// </summary>
public static UnixFileMode PublicMode(string directory, string? referenceFile)
{
if (OperatingSystem.IsWindows()) return AllReadable;
var directoryMode = File.GetUnixFileMode(directory);
var setgid = directoryMode.HasFlag(UnixFileMode.SetGroup);
var mode = referenceFile is not null && File.Exists(referenceFile)
? File.GetUnixFileMode(referenceFile)
: setgid ? GroupReadable : AllReadable;
// Le fichier créé appartient au compte du service, avec le groupe du dossier SEULEMENT si celui-ci
// a le bit setgid : sinon nginx (groupe www-data) ne pourrait plus lire un fichier lisible par le groupe.
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) && !setgid)
throw new ManifestWriteRefusedException(
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : les fichiers écrits ne seraient plus lisibles par nginx.");
// Fichier existant : ses droits sont repris tels quels (rw des comptes compris) ; nouveau fichier : lecture.
return mode & (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
}
/// <summary>
/// Sous-dossier public (objects, objects/ab) : droits du dossier du jeu, fixés explicitement (le masque
/// du service, UMask=0077, retirerait l'accès de nginx). Refus s'il s'agit d'un lien symbolique.
/// <paramref name="keepSetGid"/> : dossier qui en contiendra d'autres (objects) ; son bit setgid doit être
/// conservé pour que les sous-dossiers créés ensuite aient le groupe lu par nginx. (Un dossier
/// « feuille » garde de toute façon le groupe hérité à sa création, comme les fichiers qui y sont déplacés.)
/// </summary>
public static void EnsurePublicDirectory(string path, string gameDirectory, bool keepSetGid)
{
var info = new DirectoryInfo(path);
if (info.LinkTarget is not null)
throw new ManifestWriteRefusedException($"« {Path.GetFileName(path)} » est un lien symbolique dans le dossier du jeu.");
if (info.Exists) return;
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(path);
return;
}
var gameMode = File.GetUnixFileMode(gameDirectory);
var permissions = gameMode & PermissionBits;
var needSetGid = keepSetGid && gameMode.HasFlag(UnixFileMode.SetGroup);
// Jamais de bit spécial demandé : systemd l'interdit au service (RestrictSUIDSGID=yes, refus EPERM).
// Dans un dossier setgid, le noyau transmet de lui-même le bit setgid et le groupe (www-data).
Directory.CreateDirectory(path, permissions);
if ((File.GetUnixFileMode(path) & PermissionBits) != permissions) // réduits par le masque du service (UMask=0077)
{
try
{
// Fixer les droits retire le setgid hérité : sans conséquence pour un dossier « feuille » (son
// groupe reste celui hérité) ; « objects » doit le redemander, ce que systemd peut refuser.
File.SetUnixFileMode(path, needSetGid ? permissions | UnixFileMode.SetGroup : permissions);
}
catch (UnauthorizedAccessException) when (needSetGid)
{
// traité ci-dessous
}
}
// Sans setgid (ou si le compte du service n'appartient pas au groupe du dossier, Linux le retire), les
// sous-dossiers créés ensuite n'auraient plus le groupe lu par nginx.
if (needSetGid && File.GetUnixFileMode(path) != (permissions | UnixFileMode.SetGroup))
{
TryDeleteDirectory(path);
throw new ManifestWriteRefusedException(
"le dossier « objects » n'existe pas encore et le service ne peut pas le créer avec le bit setgid et l'accès de nginx : " +
"créez-le sur le VPS (server/deploy/LISEZMOI.md, étape 3 quater).");
}
}
/// <summary>Droits rwx des trois classes, sans setuid, setgid ni sticky.</summary>
private const UnixFileMode PermissionBits =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
private static void TryDeleteDirectory(string path)
{
try
{
Directory.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé : vide, sans conséquence
}
}
/// <summary>
/// Remplace (ou crée) le manifest publié. <paramref name="expectedSha256"/> : empreinte du manifest
/// sur lequel l'écriture a été préparée (null : il ne devait pas exister) ; revérifiée juste avant le
/// renommage, pour ne jamais écraser un envoi (WinSCP…) fait entre-temps.
/// </summary>
public static void WriteManifest(string path, byte[] bytes, string? expectedSha256, Func<ApiRejection> conflict)
{
var directory = Path.GetDirectoryName(path)!;
if (new FileInfo(path).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
var mode = PublicMode(directory, path);
var temp = TempPath(path);
try
{
WriteNew(temp, bytes, mode);
var current = ReadManifest(path);
if (current is null ? expectedSha256 is not null : !string.Equals(Sha256(current), expectedSha256, StringComparison.OrdinalIgnoreCase))
throw conflict();
File.Move(temp, path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
TryDelete(temp);
if (ex is ApiRejection or ManifestWriteRefusedException) throw;
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
}
}
/// <summary>Fichier temporaire caché du même dossier (nginx refuse les noms commençant par un point).</summary>
public static string TempPath(string path) =>
Path.Combine(Path.GetDirectoryName(path)!, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
public static void WriteNew(string path, ReadOnlySpan<byte> bytes, UnixFileMode mode)
{
using (var stream = new FileStream(path, new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, mode);
}
public static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé en place : sans conséquence (nom unique, caché)
}
}
public static ApiRejection NotConfigured(string gameId, string what) => new(503, ApiErrorCodes.NotConfigured,
$"{what} impossible pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
}
/// <summary>Écriture refusée par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
public sealed class ManifestWriteRefusedException : IOException
{
public ManifestWriteRefusedException(string message) : base(message) { }
}
@@ -0,0 +1,112 @@
using System.Text;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichiers privés du serveur : dossier 0700 et fichiers 0600 sous Linux (le service tourne sous un
/// compte dédié), écriture atomique (fichier temporaire puis renommage) et verrou inter-processus
/// (le service et l'outil en ligne de commande peuvent écrire en même temps).
/// </summary>
internal static class SecureFiles
{
private const UnixFileMode DirectoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
private const UnixFileMode FileMode600 = UnixFileMode.UserRead | UnixFileMode.UserWrite;
public static void EnsureDirectory(string path)
{
if (Directory.Exists(path)) return;
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
else Directory.CreateDirectory(path, DirectoryMode);
}
/// <summary>Remplace le contenu d'un fichier de façon atomique ; l'ancienne version est copiée en « .bak ».</summary>
public static void WriteAllTextAtomic(string path, string content, bool keepBackup)
{
var temp = path + ".tmp";
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
if (keepBackup && File.Exists(path)) File.Copy(path, path + ".bak", overwrite: true);
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
}
/// <summary>Écrit un fichier binaire de façon atomique (fichier temporaire 0600 puis renommage).</summary>
public static void WriteAllBytesAtomic(string path, ReadOnlySpan<byte> bytes)
{
var temp = path + ".tmp";
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
File.Move(temp, path, overwrite: true);
}
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
public static bool TryCreateNew(string path, string content)
{
try
{
using var stream = new FileStream(path, CreateOptions(System.IO.FileMode.CreateNew));
stream.Write(Encoding.UTF8.GetBytes(content));
stream.Flush(flushToDisk: true);
return true;
}
catch (IOException) when (File.Exists(path))
{
return false;
}
}
/// <summary>Ouvre un fichier en ajout (journal d'audit), créé en 0600.</summary>
public static FileStream OpenAppend(string path) =>
new(path, Private(new FileStreamOptions { Mode = System.IO.FileMode.Append, Access = FileAccess.Write, Share = FileShare.Read }));
/// <summary>
/// Verrou exclusif inter-processus (fichier « .lock »). Réessaie pendant <paramref name="timeout"/>,
/// puis lève <see cref="StoreBusyException"/>.
/// </summary>
public static FileStream AcquireLock(string lockPath, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (true)
{
try
{
return new FileStream(lockPath, Private(new FileStreamOptions
{
Mode = System.IO.FileMode.OpenOrCreate,
Access = FileAccess.ReadWrite,
Share = FileShare.None
}));
}
catch (IOException) when (DateTime.UtcNow < deadline)
{
Thread.Sleep(25);
}
catch (IOException ex)
{
throw new StoreBusyException($"Fichier verrouillé : {Path.GetFileName(lockPath)}", ex);
}
}
}
private static FileStreamOptions CreateOptions(System.IO.FileMode mode) =>
Private(new FileStreamOptions { Mode = mode, Access = FileAccess.Write, Share = FileShare.None });
/// <summary>Fichier créé en 0600 sous Linux (lecture/écriture pour le compte du service uniquement).</summary>
private static FileStreamOptions Private(FileStreamOptions options)
{
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = FileMode600;
return options;
}
}
/// <summary>Données momentanément inaccessibles (verrou tenu par un autre processus) : réponse 503.</summary>
public sealed class StoreBusyException : Exception
{
public StoreBusyException(string message, Exception? inner = null) : base(message, inner) { }
}
@@ -0,0 +1,765 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Page « Fichiers du serveur » : consultation du site public (racine LauncherApi:Files:Root), suppression d'un
/// fichier inutilisé et nettoyage du dossier « objects » d'un jeu.
///
/// Règles (le serveur ne fait confiance qu'à ce qu'il lit lui-même, au moment de supprimer) :
/// • chemins relatifs à la racine uniquement : ni « .. », ni chemin absolu, ni élément caché (« .publish-… »),
/// ni lien symbolique sur le chemin ; un dossier n'est jamais supprimé ;
/// • fichiers .json (manifest, catalogue, trousseau, mise à jour du launcher) : JAMAIS supprimés ;
/// • un fichier référencé par un fichier de description d'un dossier parent (archive et fichiers d'un
/// manifest.json, exécutable de launcher-update.json, images du catalogue) n'est pas supprimable ;
/// un fichier de description illisible rend tout son dossier non supprimable (impossible de vérifier) ;
/// • vérification refaite sous le verrou d'écriture du manifest du jeu (celui de la publication et des notes),
/// et refus pendant une publication de ce jeu (dossier « .publish-… » présent) ;
/// • la suppression n'est possible que là où systemd autorise l'écriture (ReadWritePaths).
/// </summary>
public sealed partial class ServerFilesStore
{
private const string ManifestName = "manifest.json";
private const string LauncherUpdateName = "launcher-update.json";
private const string StagingPrefix = ".publish-";
/// <summary>Fichier de description lu au plus (même borne que le manifest).</summary>
private const long MaxDescriptionBytes = PublishedFiles.MaxManifestBytes;
private static readonly StringComparison PathComparison =
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
private static readonly StringComparer PathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
[GeneratedRegex(@"\A[0-9a-f]{2}\z")]
private static partial Regex PrefixRegex();
[GeneratedRegex(@"\A[0-9a-f]{64}\.bin\z")]
private static partial Regex ObjectNameRegex();
/// <summary>Les dépôts de publication (« .publish-… ») sont « cachés » sous Linux : énumération explicite.</summary>
private static readonly EnumerationOptions IncludingHidden = new() { AttributesToSkip = 0, IgnoreInaccessible = true };
private readonly string? _root;
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly string? _catalogFile;
private readonly string _catalogUrl;
public ServerFilesStore(IOptions<LauncherApiOptions> options)
: this(options.Value.FilesRootFullPath, options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath,
options.Value.CatalogPublicFileFullPath, options.Value.CatalogPublicUrl) { }
public ServerFilesStore(string? root, IReadOnlyDictionary<string, string> manifests, string dataDirectory,
string? catalogFile, string catalogUrl)
{
_root = root is null ? null : Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
_manifests = manifests;
_dataDirectory = dataDirectory;
_catalogFile = catalogFile is null ? null : Path.GetFullPath(catalogFile);
_catalogUrl = catalogUrl;
}
public bool Enabled => _root is not null;
// ================================================================== Consultation
public ServerFolder List(string? relativePath)
{
var root = RequireRoot();
var (directory, relative) = Resolve(relativePath ?? "");
if (!Directory.Exists(directory)) throw ApiRejection.NotFound("Dossier introuvable (supprimé ou renommé entre-temps ?).");
var references = ScanReferences();
var infos = new List<FileSystemInfo>();
var truncated = false;
foreach (var info in new DirectoryInfo(directory).EnumerateFileSystemInfos("*", new EnumerationOptions
{
AttributesToSkip = 0,
IgnoreInaccessible = true,
RecurseSubdirectories = false
}))
{
if (info.Name.StartsWith('.')) continue; // dépôts de publication, fichiers cachés : jamais affichés
if (infos.Count >= ServerFilesLimits.MaxEntries)
{
truncated = true;
break;
}
infos.Add(info);
}
var budget = new ScanBudget(ServerFilesLimits.MaxScannedFiles);
var entries = infos
.OrderBy(i => i is DirectoryInfo ? 0 : 1)
.ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.Select(info => Describe(info, references, budget))
.ToList();
long free = -1, total = -1;
try
{
var drive = new DriveInfo(root);
free = drive.AvailableFreeSpace;
total = drive.TotalSize;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
// information facultative
}
return new ServerFolder
{
Path = relative,
RootName = Path.GetFileName(root) is { Length: > 0 } name ? name : root,
Entries = entries,
Truncated = truncated,
FreeBytes = free,
TotalBytes = total,
PrunableGames = _manifests.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList()
};
}
private ServerFileEntry Describe(FileSystemInfo info, References references, ScanBudget budget)
{
var modified = new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero);
if (info.LinkTarget is not null)
return new ServerFileEntry
{
Name = info.Name,
IsDirectory = info is DirectoryInfo,
Size = -1,
FileCount = -1,
Modified = modified,
Status = ServerFileStatus.Link,
Reason = "Lien symbolique : ni suivi ni supprimé depuis le menu."
};
if (info is DirectoryInfo directory)
{
var (size, count) = budget.Measure(directory);
return new ServerFileEntry
{
Name = info.Name,
IsDirectory = true,
Size = size,
FileCount = count,
Modified = modified,
Status = ServerFileStatus.Directory
};
}
var (status, reason) = references.StatusOf(info.FullName);
return new ServerFileEntry
{
Name = info.Name,
Size = ((FileInfo)info).Length,
FileCount = 1,
Modified = modified,
Status = status,
Reason = reason
};
}
/// <summary>Taille des sous-dossiers, dans la limite d'un nombre de fichiers parcourus par requête.</summary>
private sealed class ScanBudget
{
private long _remaining;
public ScanBudget(long files) => _remaining = files;
public (long Size, long Count) Measure(DirectoryInfo directory)
{
if (_remaining <= 0) return (-1, -1);
long size = 0, count = 0;
try
{
// Fichiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
foreach (var file in directory.EnumerateFiles("*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
}))
{
if (--_remaining < 0) return (-1, -1);
size += file.Length;
count++;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return (-1, -1);
}
return (size, count);
}
}
// ================================================================== Suppression d'un fichier
public ServerFileDeleteResult Delete(string relativePath, long expectedSize)
{
RequireRoot();
var (path, relative) = Resolve(relativePath);
if (relative.Length == 0 || Directory.Exists(path))
throw ApiRejection.BadRequest("Seul un fichier peut être supprimé (jamais un dossier).");
var file = new FileInfo(path);
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
// Même verrou que la publication et les notes de version pour chaque jeu dont le dossier contient ce fichier :
// le manifest ne peut pas changer entre la vérification et la suppression.
var locks = new List<FileStream>();
try
{
foreach (var gameId in GamesContaining(path)) locks.Add(PublishedFiles.LockGame(_dataDirectory, gameId));
file.Refresh();
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
if (file.LinkTarget is not null) throw ApiRejection.Conflict("Lien symbolique : jamais supprimé depuis le menu.");
if (file.Length != expectedSize)
throw ApiRejection.Conflict("Le fichier a changé depuis l'affichage de la liste : actualisez avant de le supprimer.",
ApiErrorCodes.RevisionConflict);
if (PublicationFolderInProgress(path) is { } game)
throw ApiRejection.Conflict(
$"Une publication est en cours dans « {RelativeDisplay(game)} » : réessayez une fois la version en ligne ou la publication abandonnée.",
ApiErrorCodes.PublicationInProgress);
// Vérification refaite maintenant (manifests relus) : seul un fichier inutilisé est supprimé.
var (status, reason) = ScanReferences().StatusOf(path);
if (status != ServerFileStatus.Unused)
throw ApiRejection.Conflict(status == ServerFileStatus.Protected || status == ServerFileStatus.InUse
? $"Suppression refusée : {reason}"
: $"Suppression refusée : {reason ?? "impossible de vérifier que ce fichier est inutilisé."}");
var size = file.Length;
File.Delete(path);
return new ServerFileDeleteResult { Path = relative, FreedBytes = size };
}
finally
{
foreach (var handle in locks) handle.Dispose();
}
}
// ================================================================== Nettoyage du dossier « objects » d'un jeu
public ObjectsPrunePreview PreviewPrune(string gameId)
{
var (manifestPath, key) = Locate(gameId);
return Analyze(manifestPath, key).Preview;
}
public ObjectsPruneResult Prune(string gameId, string manifestSha256, Action<string, Exception> logFailure)
{
var (manifestPath, key) = Locate(gameId);
using (PublishedFiles.LockGame(_dataDirectory, key))
{
var analysis = Analyze(manifestPath, key);
if (!string.Equals(analysis.Preview.ManifestSha256, manifestSha256, StringComparison.OrdinalIgnoreCase))
throw ApiRejection.Conflict("Le manifest en ligne a changé depuis l'analyse : relancez l'analyse avant de nettoyer.",
ApiErrorCodes.RevisionConflict);
if (analysis.Preview.PublicationInProgress)
throw ApiRejection.Conflict(
"Une publication de ce jeu est en cours : nettoyez une fois la version en ligne (ou la publication abandonnée).",
ApiErrorCodes.PublicationInProgress);
int deleted = 0, failed = 0;
long freed = 0;
foreach (var orphan in analysis.Orphans)
{
try
{
var info = new FileInfo(orphan);
if (!info.Exists || info.LinkTarget is not null) continue;
var size = info.Length;
File.Delete(orphan);
deleted++;
freed += size;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
failed++;
logFailure(orphan, ex);
}
}
return new ObjectsPruneResult { GameId = key, Deleted = deleted, FreedBytes = freed, Failed = failed };
}
}
private sealed record PruneAnalysis(ObjectsPrunePreview Preview, IReadOnlyList<string> Orphans);
private PruneAnalysis Analyze(string manifestPath, string gameId)
{
var bytes = PublishedFiles.ReadManifest(manifestPath)
?? throw ApiRejection.NotFound($"Aucun manifest en ligne pour « {gameId} » : rien à nettoyer.");
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var manifest = ManifestReferences.Parse(bytes);
if (!manifest.LocalObjects)
throw ApiRejection.Conflict("Le manifest en ligne sert ses fichiers depuis une autre adresse (« objectsBaseUrl ») : " +
"nettoyage impossible depuis le menu.");
var objectsDirectory = Path.Combine(gameDirectory, "objects");
if (new DirectoryInfo(gameDirectory).LinkTarget is not null || new DirectoryInfo(objectsDirectory).LinkTarget is not null)
throw ApiRejection.Conflict("Le dossier du jeu ou son dossier « objects » est un lien symbolique : nettoyage impossible depuis le menu.");
// Autres fichiers de description du site (un autre manifest peut désigner ces objets par une adresse) :
// un objet qu'ils utilisent, ou qu'ils rendent invérifiable, est conservé.
References? site = null;
if (_root is not null && IsInside(gameDirectory, _root))
{
site = ScanReferences();
if (site.SiteWide is { } reason) throw ApiRejection.Conflict($"Nettoyage impossible : {reason}");
}
var referenced = new HashSet<string>(manifest.Objects, StringComparer.Ordinal);
var present = new HashSet<string>(StringComparer.Ordinal);
var orphans = new List<string>();
long orphanBytes = 0;
if (Directory.Exists(objectsDirectory))
{
foreach (var prefix in new DirectoryInfo(objectsDirectory).EnumerateDirectories())
{
if (!PrefixRegex().IsMatch(prefix.Name) || prefix.LinkTarget is not null) continue;
foreach (var file in prefix.EnumerateFiles())
{
// Seuls les objets au nom exact (« <empreinte>.bin » dans le bon sous-dossier) sont concernés :
// tout autre fichier est laissé tel quel.
if (!ObjectNameRegex().IsMatch(file.Name) || !file.Name.StartsWith(prefix.Name, StringComparison.Ordinal) ||
file.LinkTarget is not null)
continue;
var sha = file.Name[..64];
if (referenced.Contains(sha))
{
present.Add(sha);
continue;
}
if (site is not null && site.StatusOf(file.FullName).Status != ServerFileStatus.Unused) continue;
orphans.Add(file.FullName);
orphanBytes += file.Length;
}
}
}
var inProgress = Directory.Exists(gameDirectory) &&
new DirectoryInfo(gameDirectory).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any();
orphans.Sort(StringComparer.Ordinal);
return new PruneAnalysis(new ObjectsPrunePreview
{
GameId = gameId,
Version = manifest.Version ?? "",
ManifestSha256 = PublishedFiles.Sha256(bytes),
ReferencedCount = referenced.Count,
MissingCount = referenced.Count - present.Count,
OrphanCount = orphans.Count,
OrphanBytes = orphanBytes,
Examples = orphans.Take(10).Select(o => Path.GetRelativePath(gameDirectory, o).Replace('\\', '/')).ToList(),
PublicationInProgress = inProgress
}, orphans);
}
// ================================================================== Références (fichiers de description)
/// <summary>Dossiers parcourus au plus pour trouver les fichiers de description du site.</summary>
private const int MaxScannedFolders = 50_000;
/// <summary>
/// Fichiers désignés par TOUS les fichiers de description du site (manifest.json et launcher-update.json de
/// n'importe quel dossier, catalogue configuré), relus à chaque requête. Les adresses sont résolues comme le fait
/// le launcher (relatives au fichier de description, « .. » compris ; adresses absolues du même serveur rattachées
/// aux fichiers du site). Ce qui ne peut pas être vérifié n'est jamais supprimable.
/// </summary>
private sealed class References
{
public Dictionary<string, string> Files { get; } = new(PathComparer);
/// <summary>Dossiers dont le contenu ne peut pas être vérifié (fichier de description illisible…).</summary>
public List<(string Folder, string Reason)> UnknownFolders { get; } = new();
/// <summary>Vérification impossible pour tout le site (adresse du même serveur non rattachable, site trop grand…).</summary>
public string? SiteWide { get; set; }
public void Add(string path, string reason) => Files.TryAdd(path, reason);
public (string Status, string? Reason) StatusOf(string path)
{
var name = Path.GetFileName(path);
if (name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return (ServerFileStatus.Protected, name.Equals(ManifestName, StringComparison.OrdinalIgnoreCase)
? "manifest d'un jeu : jamais supprimé."
: "fichier de description (.json : catalogue, trousseau, mise à jour du launcher…) : jamais supprimé depuis le menu.");
if (Files.TryGetValue(path, out var reason)) return (ServerFileStatus.InUse, reason);
if (SiteWide is not null) return (ServerFileStatus.Unknown, SiteWide);
foreach (var (folder, why) in UnknownFolders)
if (IsInside(path, folder))
return (ServerFileStatus.Unknown, why);
return (ServerFileStatus.Unused, null);
}
}
private References ScanReferences()
{
var root = RequireRoot();
var references = new References();
var site = new SiteAddresses(root, _catalogFile, _catalogUrl);
var folders = new List<string> { root };
try
{
// Dossiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
foreach (var directory in new DirectoryInfo(root).EnumerateDirectories("*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
}))
{
if (folders.Count >= MaxScannedFolders)
{
references.SiteWide = $"impossible de vérifier : le site contient plus de {MaxScannedFolders} dossiers.";
return references;
}
folders.Add(directory.FullName);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
references.SiteWide = "impossible de vérifier : le site n'a pas pu être parcouru entièrement.";
return references;
}
foreach (var folder in folders) ReadFolderReferences(folder, site, references);
if (_catalogFile is not null && IsInside(_catalogFile, root) && File.Exists(_catalogFile)) ReadCatalogReferences(site, references);
return references;
}
private void ReadFolderReferences(string folder, SiteAddresses site, References references)
{
var display = RelativeDisplay(folder);
var problems = new List<string>();
var manifest = Path.Combine(folder, ManifestName);
if (File.Exists(manifest))
{
try
{
var parsed = ManifestReferences.Parse(ReadDescription(manifest));
var version = parsed.Version is { Length: > 0 } v ? $"version {v}" : "version en ligne";
var reason = $"manifest de {display}";
if (parsed.Archive is { } archive &&
Resolve(site, folder, archive, $"archive « {archive} » du {reason}", problems, references) is { } archivePath)
references.Add(archivePath, $"archive de la {version} ({reason}).");
var objectsBase = string.IsNullOrEmpty(parsed.ObjectsBase) ? "objects/" : parsed.ObjectsBase;
if (!objectsBase.EndsWith('/')) objectsBase += "/";
if (parsed.Objects.Count > 0 &&
Resolve(site, folder, objectsBase, $"dossier des fichiers « {objectsBase} » du {reason}", problems, references) is { } objects)
foreach (var sha in parsed.Objects)
references.Add(Path.Combine(objects, sha[..2], sha + ".bin"), $"fichier de la {version} ({reason}).");
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
problems.Add($"manifest de {display} illisible ({ex.Message})");
}
}
var update = Path.Combine(folder, LauncherUpdateName);
if (File.Exists(update))
{
try
{
var (version, executable) = LauncherUpdateReference(ReadDescription(update));
if (Resolve(site, folder, executable, $"exécutable « {executable} » de launcher-update.json de {display}", problems, references) is { } target)
references.Add(target, $"exécutable du launcher {version} (launcher-update.json de {display}).");
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
problems.Add($"launcher-update.json de {display} illisible ({ex.Message})");
}
}
if (problems.Count > 0)
references.UnknownFolders.Add((folder,
$"impossible de vérifier : {string.Join(" ; ", problems)}. Rien n'est supprimé dans {display} tant que ce n'est pas corrigé."));
}
private void ReadCatalogReferences(SiteAddresses site, References references)
{
var folder = Path.GetDirectoryName(_catalogFile!)!;
try
{
foreach (var (gameId, kind, url) in CatalogImages(ReadDescription(_catalogFile!)))
{
switch (site.ResolveCatalogImage(url))
{
case { Path: { } path }:
references.Add(path, $"{kind} du jeu {gameId} dans le catalogue.");
break;
case { Unmappable: true }:
references.SiteWide ??= $"impossible de vérifier : {kind} du jeu {gameId} du catalogue (« {url} ») non rattachée aux fichiers du site.";
break;
}
}
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
references.UnknownFolders.Add((folder, $"impossible de vérifier : catalogue illisible ({ex.Message})."));
}
}
/// <summary>
/// Fichier (ou dossier) du site désigné par une adresse d'un fichier de description de <paramref name="folder"/>.
/// Null : hors du site (autre serveur, en dehors de la racine), ou non vérifiable (problème noté).
/// </summary>
private static string? Resolve(SiteAddresses site, string folder, string address, string what, List<string> problems, References references)
{
var resolved = site.Resolve(folder, address);
if (resolved.Invalid) problems.Add($"{what} : adresse non reconnue");
else if (resolved.Unmappable)
references.SiteWide ??= $"impossible de vérifier : {what} désigne ce serveur par une adresse complète qui n'a pas pu être rattachée aux fichiers du site.";
return resolved.Path;
}
/// <summary>Adresse résolue : chemin du site, adresse extérieure (rien), invalide, ou du même serveur mais non rattachable.</summary>
private readonly record struct ResolvedAddress(string? Path, bool Invalid = false, bool Unmappable = false);
/// <summary>
/// Correspondance entre adresses et fichiers du site. Hôte du site : celui du catalogue ; préfixe des adresses :
/// déduit de l'adresse publique et de l'emplacement du fichier du catalogue (« /catalog/catalog.json » ↔
/// « www/catalog/catalog.json » : la racine du site est servie sous « / »).
/// </summary>
private sealed class SiteAddresses
{
private const string LocalHost = "site.invalid";
private readonly string _root;
private readonly Uri? _catalogUri;
private readonly string? _catalogFile;
private readonly string? _sitePrefix;
public SiteAddresses(string root, string? catalogFile, string catalogUrl)
{
_root = root;
_catalogFile = catalogFile;
if (Uri.TryCreate(catalogUrl, UriKind.Absolute, out var uri)) _catalogUri = uri;
if (_catalogUri is not null && catalogFile is not null && IsInside(catalogFile, root))
{
var relative = "/" + System.IO.Path.GetRelativePath(root, catalogFile).Replace('\\', '/');
var path = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
if (path.EndsWith(relative, StringComparison.Ordinal)) _sitePrefix = path[..^(relative.Length - 1)]; // se termine par « / »
}
}
/// <summary>Résolution comme le launcher : adresse relative au fichier de description (adresse fictive du dossier).</summary>
public ResolvedAddress Resolve(string folder, string address)
{
if (string.IsNullOrWhiteSpace(address) || address.Any(char.IsControl) || address.Contains('\\')) return new(null, Invalid: true);
var relativeFolder = System.IO.Path.GetRelativePath(_root, folder).Replace('\\', '/');
var baseUri = new Uri($"http://{LocalHost}/" + (relativeFolder == "." ? "" : string.Join('/', relativeFolder.Split('/').Select(Uri.EscapeDataString)) + "/"));
if (!Uri.TryCreate(baseUri, address, out var target) || target.Scheme is not ("http" or "https")) return new(null, Invalid: true);
if (target.Host == LocalHost) return Map(Uri.UnescapeDataString(target.AbsolutePath));
return Absolute(target);
}
/// <summary>Image du catalogue : relative à l'adresse du catalogue.</summary>
public ResolvedAddress ResolveCatalogImage(string url)
{
if (_catalogUri is null || _catalogFile is null) return new(null, Unmappable: true);
if (!Uri.TryCreate(_catalogUri, url, out var image) || image.Scheme is not ("http" or "https")) return new(null, Invalid: true);
if (!string.Equals(image.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase)) return new(null);
if (_sitePrefix is not null) return Absolute(image);
// Préfixe inconnu : chemin relatif au dossier du catalogue.
var catalogPath = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
var directory = catalogPath[..(catalogPath.LastIndexOf('/') + 1)];
var imagePath = Uri.UnescapeDataString(image.AbsolutePath);
if (!imagePath.StartsWith(directory, StringComparison.Ordinal)) return new(null, Unmappable: true);
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(_catalogFile)!,
imagePath[directory.Length..].Replace('/', System.IO.Path.DirectorySeparatorChar)));
return IsInside(full, _root) ? new(full) : new(null);
}
private ResolvedAddress Absolute(Uri target)
{
if (_catalogUri is not null && !string.Equals(target.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase))
return new(null); // autre serveur : rien à protéger ici
if (_sitePrefix is null) return new(null, Unmappable: true); // même serveur (ou inconnu) : impossible de rattacher
var path = Uri.UnescapeDataString(target.AbsolutePath);
return path.StartsWith(_sitePrefix, StringComparison.Ordinal) ? Map("/" + path[_sitePrefix.Length..]) : new(null);
}
/// <summary>Chemin d'adresse (« /jeu/objects/ab/… ») → chemin du site (null : en dehors de la racine).</summary>
private ResolvedAddress Map(string urlPath)
{
var relative = urlPath.TrimStart('/');
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(_root, relative.Replace('/', System.IO.Path.DirectorySeparatorChar)));
if (urlPath.EndsWith('/')) full = full.TrimEnd(System.IO.Path.DirectorySeparatorChar);
return IsInside(full, _root) ? new(full) : new(null);
}
}
private static byte[] ReadDescription(string path)
{
var info = new FileInfo(path);
if (info.Length > MaxDescriptionBytes) throw new InvalidDataException("fichier trop volumineux");
return File.ReadAllBytes(path);
}
private static (string Version, string Executable) LauncherUpdateReference(byte[] bytes)
{
var envelope = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "launcher-update.json");
var payload = PublishedFiles.StringValue(envelope, "payload") ?? throw new InvalidDataException("contenu signé absent");
byte[] decoded;
try
{
decoded = Convert.FromBase64String(payload);
}
catch (FormatException)
{
throw new InvalidDataException("contenu signé illisible");
}
var release = PublishedFiles.ParseObject(decoded, "Description du launcher");
var file = PublishedFiles.StringValue(release, "file");
if (string.IsNullOrWhiteSpace(file)) throw new InvalidDataException("exécutable absent");
return (PublishedFiles.StringValue(release, "version") ?? "", file);
}
private static IEnumerable<(string GameId, string Kind, string Url)> CatalogImages(byte[] bytes)
{
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Catalogue");
if (PublishedFiles.Property(root, "games") is not JsonObject games) yield break;
foreach (var (gameId, node) in games)
{
if (node is not JsonObject game) continue;
if (PublishedFiles.StringValue(game, "icon") is { Length: > 0 } icon) yield return (gameId, "icône", icon);
if (PublishedFiles.StringValue(game, "background") is { Length: > 0 } background) yield return (gameId, "image de fond", background);
}
}
// ================================================================== Chemins
private string RequireRoot() => _root ?? throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Racine du site non configurée sur le serveur (LauncherApi:Files:Root, ou un jeu / le catalogue configuré).");
/// <summary>
/// Chemin relatif (« / ») → chemin complet sous la racine. Refus : « .. », chemin absolu, « \ », « : »,
/// élément caché, caractère de contrôle, lien symbolique sur le chemin.
/// </summary>
private (string FullPath, string Relative) Resolve(string relativePath)
{
var root = RequireRoot();
var relative = relativePath.Trim().Trim('/');
if (relative.Length == 0) return (root, "");
if (relative.Length > ServerFilesLimits.MaxPathLength || relative.Any(char.IsControl) || relative.Contains('\\') ||
relative.Contains(':'))
throw ApiRejection.BadRequest("Chemin invalide.");
var segments = relative.Split('/');
if (segments.Any(s => s.Length == 0 || s == "." || s == ".." || s.StartsWith('.')))
throw ApiRejection.BadRequest("Chemin invalide (élément vide, caché ou « .. »).");
var full = Path.GetFullPath(Path.Combine(new[] { root }.Concat(segments).ToArray()));
if (!IsInside(full, root)) throw ApiRejection.BadRequest("Chemin en dehors du site.");
// Aucun lien symbolique entre la racine et l'élément (il pourrait mener en dehors du site).
var current = root;
foreach (var segment in segments)
{
current = Path.Combine(current, segment);
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
if (info.Exists && info.LinkTarget is not null)
throw ApiRejection.BadRequest("Chemin refusé : il passe par un lien symbolique.");
}
return (full, string.Join('/', segments));
}
/// <summary>Dossiers de la racine jusqu'à <paramref name="folder"/> inclus (fichiers de description pris en compte).</summary>
private IEnumerable<string> FoldersFromRootTo(string folder)
{
var root = _root;
var chain = new List<string>();
for (var current = Path.GetFullPath(folder).TrimEnd(Path.DirectorySeparatorChar);
current is not null && (root is null || IsInside(current, root));
current = Path.GetDirectoryName(current))
chain.Add(current);
chain.Reverse();
return chain;
}
/// <summary>Jeux configurés dont le dossier contient ce chemin (verrous à prendre avant de supprimer).</summary>
private IEnumerable<string> GamesContaining(string path) =>
_manifests.Where(m => IsInside(path, Path.GetDirectoryName(m.Value)!))
.Select(m => m.Key).OrderBy(k => k, StringComparer.Ordinal).ToList();
/// <summary>Dossier d'un jeu (contenant un manifest.json) où une publication est en cours, parmi les parents du chemin.</summary>
private string? PublicationFolderInProgress(string path)
{
foreach (var folder in FoldersFromRootTo(Path.GetDirectoryName(path)!))
if (File.Exists(Path.Combine(folder, ManifestName)) || _manifests.Values.Any(m => string.Equals(Path.GetDirectoryName(m), folder, PathComparison)))
if (new DirectoryInfo(folder).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any())
return folder;
return null;
}
private (string Path, string GameId) Locate(string gameId)
{
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw PublishedFiles.NotConfigured(gameId, "Nettoyage des fichiers");
}
private string RelativeDisplay(string folder)
{
if (_root is null) return Path.GetFileName(folder) + "/";
var relative = Path.GetRelativePath(_root, folder).Replace('\\', '/');
return relative == "." ? "la racine du site" : relative + "/";
}
private static bool IsInside(string path, string parent)
{
var p = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar);
var r = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(p, r, PathComparison) || p.StartsWith(r + Path.DirectorySeparatorChar, PathComparison);
}
}
/// <summary>
/// Ce qu'un manifest.json désigne sur le disque : nom de l'archive, empreintes des fichiers. Lecture tolérante (tout
/// manifest du site, pas seulement ceux des jeux configurés) mais stricte sur ce qui sert à décider : toute empreinte
/// invalide rend le manifest « illisible », donc rien n'est supprimé à côté.
/// </summary>
internal sealed record ManifestReferences(string? Version, string? Archive, string? ObjectsBase, bool LocalObjects, IReadOnlyCollection<string> Objects)
{
public static ManifestReferences Parse(byte[] bytes)
{
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
var version = PublishedFiles.StringValue(root, "version");
string? archive = null;
if (PublishedFiles.Property(root, "archive") is JsonObject archiveNode)
archive = PublishedFiles.StringValue(archiveNode, "url") is { Length: > 0 } url ? url : throw new InvalidDataException("archive sans adresse");
var objectsBase = PublishedFiles.StringValue(root, "objectsBaseUrl");
var local = string.IsNullOrEmpty(objectsBase) || objectsBase is "objects/" or "objects";
var objects = new HashSet<string>(StringComparer.Ordinal);
if (PublishedFiles.Property(root, "files") is JsonArray files)
{
foreach (var node in files)
{
if (node is not JsonObject file) throw new InvalidDataException("fichier invalide dans la liste");
var sha = PublishedFiles.StringValue(file, "sha256")?.ToLowerInvariant();
if (!PublishingLimits.IsValidSha256(sha)) throw new InvalidDataException("empreinte de fichier invalide");
objects.Add(sha!);
}
}
else if (PublishedFiles.Property(root, "files") is not null)
{
throw new InvalidDataException("liste des fichiers invalide");
}
return new ManifestReferences(version, archive, objectsBase, local, objects);
}
}
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Identifiant aléatoire et permanent de ce serveur (fichier « server-id »), inclus dans les messages
/// signés : une signature destinée à un autre serveur (test, ancien VPS…) est refusée.
/// </summary>
public sealed class ServerIdentity
{
public const string FileName = "server-id";
public ServerIdentity(IOptions<LauncherApiOptions> options) : this(options.Value) { }
public ServerIdentity(LauncherApiOptions options)
{
Origin = options.NormalizedOrigin;
ServerId = LoadOrCreate(options.DataDirectoryFullPath);
}
public string ServerId { get; }
/// <summary>Adresse publique de l'API (« https://hote »).</summary>
public string Origin { get; }
public static string LoadOrCreate(string dataDirectory)
{
SecureFiles.EnsureDirectory(dataDirectory);
var path = Path.Combine(dataDirectory, FileName);
if (!File.Exists(path))
SecureFiles.TryCreateNew(path, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant());
var id = File.ReadAllText(path).Trim();
if (id.Length != 32 || !id.All(Uri.IsHexDigit))
throw new InvalidDataException($"{path} : identifiant de serveur invalide.");
return id;
}
}
@@ -0,0 +1,8 @@
{
// Développement (F5 dans Visual Studio) : HTTP local, données dans le dossier de compilation.
"LauncherApi": {
"PublicOrigin": "http://127.0.0.1:5080",
"DataDirectory": "data-dev",
"RequireHttps": false
}
}
@@ -0,0 +1,85 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
// Écoute locale uniquement : seul nginx (même machine) joint l'API.
"Urls": "http://127.0.0.1:5080",
"LauncherApi": {
// Adresse publique de l'API, SANS chemin : elle est incluse dans les messages signés.
"PublicOrigin": "https://launcher.lanisys.fr",
// Données privées (administrateurs, audit) : JAMAIS dans le dossier servi par nginx.
"DataDirectory": "/var/lib/launcher-api",
"RequireHttps": true,
"TrustedProxies": [ "127.0.0.1", "::1" ],
"Sessions": {
"IdleMinutes": 20,
"MaxHours": 8,
"MaxPerAdmin": 5
},
"ChallengeLifetimeSeconds": 60,
"InvitationLifetimeHours": 24,
// Catalogue des jeux (phase 3) : fichier public écrit par le service, servi par nginx.
// Vide : publication désactivée. À renseigner dans appsettings.Production.json sur le VPS
// (voir server/deploy/LISEZMOI.md), ex. « /srv/exovival/www/catalog/catalog.json ».
"Catalog": {
"PublicFile": "",
"PublicUrl": ""
},
// Trousseau (phase 7) : vide = « keyring.json » à côté du catalogue (même dossier, déjà autorisé en écriture).
"Keyring": {
"PublicFile": "",
"PublicUrl": ""
},
// Signature du contenu : true = catalogue et versions refusés s'ils ne sont pas signés (une fois les
// launchers en mode « require », voir server/deploy/LISEZMOI.md, étape 3 quinquies).
"ContentSigning": {
"Require": false
},
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
"Games": {},
// Page « Fichiers du serveur » : racine du site public consultable depuis le menu. Vide = dossier parent
// commun des jeux et du catalogue (ex. « /srv/exovival/www »). La suppression n'est possible que dans les
// dossiers autorisés en écriture par systemd (ReadWritePaths : jeux, catalogue). Voir server/deploy/LISEZMOI.md.
"Files": {
"Root": ""
},
// Demandes des joueurs (bouton « Aide » du launcher) : désactivé par défaut. Activer dans appsettings.Production.json
// (voir server/deploy/LISEZMOI.md, étape 3 octies). Games vide = jeux de « Games » ci-dessus.
// Aucune adresse IP n'est enregistrée avec une demande ; les limites par adresse sont comptées en mémoire.
"Feedback": {
"Enabled": false,
"Games": [],
"ProofDifficulty": 18,
"PerIpPer10Minutes": 3,
"PerIpPerDay": 10,
"RequestsPerMinute": 10,
"MaxPerGamePerDay": 300,
"MaxPerGame": 5000,
"MaxTotalMegabytes": 1024,
"MinFreeDiskMegabytes": 500,
"RetentionClosedMonths": 12,
"RetentionMaxMonths": 24
},
// Journal d'audit : mois conservés (purge automatique au démarrage puis chaque jour ; CNIL : 6 mois à 1 an).
"Audit": {
"RetentionMonths": 12
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
"PublicationUploadsPerMinute": 1500,
"AuthPerMinute": 20,
"EnrollmentsPerHour": 10,
"MaxActiveInvitations": 20,
"MaxRequestBodyBytes": 65536
}
}
}
+646
View File
@@ -0,0 +1,646 @@
# Launcher API — installation sur le VPS
La Launcher API est le serveur d'administration du launcher (voir `docs/ADMIN-ARCHITECTURE.md`).
Elle tourne sur le VPS de téléchargement, derrière nginx, à l'adresse
`https://launcher.lanisys.fr/api/`. Ce guide suppose le VPS déjà préparé avec
« Serveur de téléchargement Exovival – VPS Debian 13 » (compte `admin` en SSH, compte `deploy`,
nginx + HTTPS).
Compter 20 minutes la première fois. Les commandes `PS>` se tapent dans PowerShell sur ton PC,
les autres sur le VPS.
## 1. Publier le serveur (PC)
Double-clique `tools\publish-server.bat`. Il produit dans `Publish\Server\` :
* `launcher-api.tar.gz` : le serveur pour Linux x64, **autonome** (aucun .NET à installer sur le VPS) ;
* `installer-api.sh` : le script d'installation ;
* `launcher-api.tar.gz.sha256` : l'empreinte de l'archive, aussi affichée à la fin du script.
## 2. Envoyer et installer (PC puis VPS)
```
PS> scp "Publish\Server\launcher-api.tar.gz" "Publish\Server\installer-api.sh" admin@launcher.lanisys.fr:/tmp/
```
```
sudo sh /tmp/installer-api.sh /tmp/launcher-api.tar.gz <empreinte affichée par publish-server.bat>
rm /tmp/launcher-api.tar.gz /tmp/installer-api.sh
```
L'empreinte garantit que l'archive installée (en root) est exactement celle produite sur ton PC :
une archive altérée pendant le transfert ou dans `/tmp` est refusée sans rien modifier.
Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), le service systemd
`launcher-api` (compte `deploy`, durci, aucune connexion sortante), le démarre et vérifie qu'il répond. Il doit finir par
`{"status":"ok"}`. Les données privées sont dans `/var/lib/launcher-api` (droits 700).
> Réglages propres au VPS (autre domaine, durées de session…) : crée
> `/opt/launcher-api/appsettings.Production.json` avec uniquement les valeurs à changer, par ex.
> `{ "LauncherApi": { "PublicOrigin": "https://dl.autredomaine.fr" } }`, puis
> `sudo systemctl restart launcher-api`. Ce fichier est conservé lors des mises à jour.
## 3. Brancher nginx (VPS)
1. Limite de requêtes : ajoute cette ligne dans `/etc/nginx/conf.d/securite.conf`
(à côté de `limit_conn_zone`) :
```
limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
```
2. Ouvre `/etc/nginx/sites-available/dl-exovival` et colle le bloc `location ^~ /api/ { … }` de
`server/deploy/nginx-api.conf` dans le bloc **HTTPS** (`listen 443 ssl`), avant `location / {`.
3. Vérifie et recharge :
```
sudo nginx -t && sudo systemctl reload nginx
```
4. Teste depuis ton PC :
```
PS> curl.exe -s https://launcher.lanisys.fr/api/v1/info
```
Réponse attendue : `{"serverId":"…","origin":"https://launcher.lanisys.fr",…}`. Si `origin` est
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
signatures seront refusées.
## 3 bis. Catalogue des jeux (VPS)
La page **Jeux** du menu d'administration publie `catalog.json` (nom affiché, accroche, description,
liens, thème, images, ordre des jeux), que les launchers des joueurs lisent au démarrage à l'adresse
`catalogUrl` de `launcher.json`. Le serveur l'écrit dans **un seul** dossier, servi par nginx, séparé
de ses données privées. Exemple ci-dessous avec le site de « Serveur de téléchargement Exovival »
(`/srv/exovival/www`) : adapte le chemin à la racine réelle de ton site.
1. Crée le dossier public (propriétaire `deploy`, groupe `www-data` pour nginx) :
```
sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog /srv/exovival/www/catalog/images
```
2. Indique le fichier au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages) :
```
{ "LauncherApi": { "Catalog": { "PublicFile": "/srv/exovival/www/catalog/catalog.json" } } }
```
L'adresse publique est déduite de `PublicOrigin` (`https://launcher.lanisys.fr/catalog/catalog.json`) ;
sinon, ajoute `"PublicUrl"`. Elle doit être **identique** à `catalogUrl` dans `launcher.json`.
Le serveur refuse de démarrer si le dossier n'existe pas, ou si le dossier public et
`/var/lib/launcher-api` sont l'un dans l'autre.
3. Autorise le service à écrire dans ce dossier, et seulement lui (le reste du système reste en
lecture seule) :
```
sudo mkdir -p /etc/systemd/system/launcher-api.service.d
sudo nano /etc/systemd/system/launcher-api.service.d/catalog.conf
```
Colle le contenu de `server/deploy/launcher-api-catalog.conf` (ligne `ReadWritePaths=` adaptée),
puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. nginx : colle le bloc `location = /catalog/catalog.json { … }` de `server/deploy/nginx-catalog.conf`
dans le bloc HTTPS du site, avant `location / {`, puis `sudo nginx -t && sudo systemctl reload nginx`.
5. Publie depuis la page **Jeux**, puis vérifie depuis ton PC :
```
PS> curl.exe -sI https://launcher.lanisys.fr/catalog/catalog.json
```
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
**Images** (icône, fond) : page **Jeux** › « Parcourir… » à côté du champ (explorateur Windows). L'image
est contrôlée sur le PC puis par le serveur (PNG ou JPEG de 4 Mo au plus, dimensions bornées, fichier
complet), rangée dans `www/catalog/images/` sous le nom `<empreinte SHA-256>.png|.jpg`, et son adresse
remplit le champ ; « Publier » l'applique aux joueurs. Prérequis : étape 3 septies (bloc nginx). L'envoi
en SFTP (compte `deploy`) reste possible : saisis alors l'adresse à la main, par ex.
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
en cache : pour changer une image déposée à la main, envoie-la sous un **nouveau nom** (automatique
avec « Parcourir… »). Les images d'un autre serveur sont refusées.
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
## 3 ter. Notes de version modifiables en ligne (VPS)
La page **Notes de version** du menu d'administration corrige les notes des versions déjà publiées,
directement dans le `manifest.json` du jeu (rien d'autre n'y est modifié). Exemple ci-dessous avec le
dossier du jeu de « Serveur de téléchargement Exovival » (`/srv/exovival/www/exovivalzombie`) : adapte
le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
> **Compromis de sécurité** : le service pourra écrire dans le dossier du jeu (il remplace le manifest
> par renommage dans ce dossier). Un service compromis pourrait donc modifier les fichiers du jeu de ce
> dossier. L'autorisation se limite à ce seul dossier ; sans cette étape, rien n'est modifiable en ligne.
1. Vérifie que le dossier du jeu a le **bit setgid** (le `s` de `drwxr-s---`), pour que le manifest
remplacé garde le groupe `www-data` lu par nginx :
```
stat -c '%A %U:%G' /srv/exovival/www/exovivalzombie
sudo chmod g+s /srv/exovival/www/exovivalzombie # seulement s'il manque
```
Sans lui, le serveur refuse la modification plutôt que de rendre le manifest illisible.
2. Indique le manifest au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages ; la clé est le `gameId` de `launcher.json`) :
```
{ "LauncherApi": { "Games": { "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } } } }
```
3. Autorise le service à écrire dans ce dossier, et seulement lui :
```
sudo nano /etc/systemd/system/launcher-api.service.d/games.conf
```
Colle le contenu de `server/deploy/launcher-api-games.conf` (ligne `ReadWritePaths=` adaptée,
une par jeu), puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. Sur ton PC, ajoute `--remote-manifest <adresse du manifest>` à ton script de publication
(déjà dans `tools\publish-example.bat`) : sans lui, la publication suivante écraserait les notes
corrigées en ligne avec celles du `manifest.json` local.
5. Teste : menu d'administration › **Notes de version**, corrige une faute, **Enregistrer**. L'ancien
manifest est copié dans `/var/lib/launcher-api/manifest-history/`.
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
## 3 quater. Publier une version depuis le launcher (VPS)
La page **Publier une version** du menu d'administration remplace l'envoi avec WinSCP : seuls les
fichiers absents du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est mis
en ligne en dernier. Prérequis : l'étape 3 ter (dossier du jeu déclaré dans `LauncherApi:Games`,
complément `games.conf`, bit setgid).
1. nginx : ajoute la zone de limite des envois dans `/etc/nginx/conf.d/securite.conf`, à côté de
`api_par_ip` (un envoi enchaîne plusieurs morceaux de 4 Mo par seconde ; avec la limite générale de
l'API, il serait bridé et ferait des pauses) :
```
limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
```
Puis colle le bloc `location ^~ /api/v1/publications/ { … }` de `server/deploy/nginx-publications.conf`
dans le bloc HTTPS du site, à côté du bloc `/api/`, et recharge :
```
sudo nginx -t && sudo systemctl reload nginx
```
Seules ces routes acceptent des envois de 8 Mo ; le reste de l'API reste limité à 64 Ko. Vérifie
aussi que le site contient toujours la règle `location ~ /\. { deny all; }` : les fichiers en cours
d'envoi sont rangés dans un dossier caché (`.publish-…`) du dossier du jeu.
2. Crée le dossier `objects` du jeu **et ses 256 sous-dossiers** (`00` à `ff`), avec le groupe et le bit
setgid du dossier du jeu (sans risque s'ils existent déjà : leurs droits sont simplement remis en état) :
```
JEU=/srv/exovival/www/exovivalzombie
sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects"
for i in $(seq 0 255); do sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects/$(printf %02x $i)"; done
```
Pourquoi : systemd interdit au service de demander le bit setgid (`RestrictSUIDSGID=yes`, gardé). Un
sous-dossier qui manquerait serait créé par le service en `drwxr-x---` (groupe `www-data`, lisible par
nginx) mais **sans setgid** : un fichier déposé ensuite dedans **avec WinSCP** prendrait le groupe
`deploy` et ne serait plus lisible par nginx. Relance la boucle si `stat` montre un `drwxr-x---`.
3. Espace disque : l'envoi est stocké sur le même disque que le jeu jusqu'à la mise en ligne. Prévois la
taille de l'archive et des nouveaux fichiers (le serveur refuse l'envoi s'il manque de place).
4. Teste : lance le .bat de publication (ManifestBuilder) sur ton PC, puis menu d'administration ›
**Publier une version** › « Choisir le manifest.json… » (dossier `--output`) › **Publier**.
> **Déjà fait avant la correction des pauses ?** Ajoute la ligne `limit_req_zone … publication_par_ip …`
> ci-dessus, remplace dans le bloc `/api/v1/publications/` du site `limit_req zone=api_par_ip burst=20 nodelay;`
> par `limit_req zone=publication_par_ip burst=50 nodelay;`, recharge nginx, puis mets à jour le serveur
> (étape 8) : il a désormais sa propre limite pour les envois (`LauncherApi:Limits:PublicationUploadsPerMinute`,
> 1500 par minute par défaut, rien à ajouter dans `appsettings.Production.json`).
Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cliques de nouveau sur
« Publier » avec le même dossier ; sans reprise, elle est supprimée au bout de 48 h. WinSCP reste
possible, mais n'envoie pas une version avec les deux en même temps : le serveur refuse alors de
mettre la sienne en ligne plutôt que d'écraser l'autre.
## 3 quinquies. Signer ce que reçoivent les joueurs (PC et VPS)
Sans signature, quelqu'un qui prendrait le contrôle du VPS pourrait distribuer un faux jeu. Avec la
signature, chaque version mise en ligne (fichiers, archive, exécutable) et le catalogue portent la
signature de l'administrateur qui les a publiés. Les launchers ne font confiance qu'aux clés du
**trousseau**, lui-même signé **hors ligne** par la clé racine du launcher (`launcher-signing.key`, celle des
mises à jour du launcher). Le VPS ne détient aucune de ces clés privées.
Prérequis : étapes 3 bis (catalogue : le trousseau est écrit dans le même dossier) et 3 quater.
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le trousseau
est écrit à côté du catalogue (`/srv/exovival/www/catalog/keyring.json`).
2. **nginx** : ajoute le bloc `location = /catalog/keyring.json { … }` de `server/deploy/nginx-catalog.conf` à côté
de celui du catalogue, puis `sudo nginx -t && sudo systemctl reload nginx`.
3. **Trousseau** (PC) :
1. menu d'administration › **Signature** › « Exporter la demande… » : enregistre `keyring-request-1.json`
(les appareils actifs des administrateurs autorisés à publier) ;
2. glisse ce fichier sur `tools\sign-keyring.bat` : la demande est comparée au trousseau signé précédent
(`keyring-N.json` du même dossier) et chaque **NOUVELLE CLÉ** (ou autorisation élargie) est signalée.
**Vérifie ces empreintes hors du serveur** : la demande vient du VPS, un serveur compromis pourrait y glisser
sa propre clé. Fais lire à chaque personne l'empreinte de son appareil (page **Mes appareils**, calculée sur
son PC) de vive voix ou par téléphone, et compare. Puis la phrase de passe de la clé racine → `keyring-N.json`
(garde ce fichier dans le même dossier : il servira de référence la fois suivante) ;
3. page **Signature** › « Publier le trousseau signé… » : le launcher vérifie la signature racine avant de l'envoyer.
Les noms des appareils (« Nicolas — PC fixe ») sont **publics** : le trousseau est lisible par tous.
4. **Contenu déjà en ligne** :
* version du jeu : page **Publier une version** › « Choisir le manifest.json… » du dossier de **la version
en ligne** › « Signer la version en ligne » (le serveur vérifie que les fichiers sont identiques) ;
* catalogue : page **Jeux** › « Publier » (il est signé à chaque publication).
La page **Signature** doit alors tout indiquer « valide pour les launchers ».
5. **Launcher** : dans `src\GameLauncher\launcher.json`, décommente `"keyringUrl"` (adresse affichée par la page
Signature) et `"contentSigning": "warn"`, puis publie le launcher (mise à jour signée habituelle). En « warn »,
les problèmes sont seulement inscrits au journal du launcher (`%AppData%\<éditeur>\GameLauncher\logs\launcher.log`) : vérifie qu'il
indique « Manifest de EZR … signé par … » et « Trousseau n° 1 vérifié ».
6. **Protection complète** : passe à `"contentSigning": "require"` et publie le launcher : un contenu non signé,
mal signé ou plus ancien que le dernier vu est alors refusé (le jeu installé reste jouable). Optionnel, sur le
VPS : `"ContentSigning": { "Require": true }` dans la section `LauncherApi` d'`appsettings.Production.json`
(puis `sudo systemctl restart launcher-api`) pour que le serveur refuse toute publication non signée.
Ensuite, les nouvelles versions et le catalogue sont signés automatiquement à leur publication. **À refaire
(étape 3)** : à chaque nouvel administrateur ou nouvel appareil qui doit publier (avant sa première publication),
et à chaque révocation (la page Signature signale le contenu signé par une clé qui va disparaître : re-signe-le
d'abord). Une version envoyée avec WinSCP doit être signée ensuite (étape 4).
**Clé compromise** (appareil volé…) : révoque l'appareil, signe et publie un nouveau trousseau (étape 3), puis
mets son numéro dans `"keyringMinSequence"` de `launcher.json` et publie le launcher. Sans cela, un launcher qui
n'a encore jamais vu le nouveau trousseau (installation neuve, cache effacé) accepterait encore l'ancien,
toujours signé par la clé racine, si un serveur compromis le lui présentait.
Limites :
* les images du catalogue ne sont pas signées (un serveur compromis pourrait les remplacer, pas les liens ni les
textes) ;
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
et au moins celui du dernier trousseau qu'il a vu ;
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
fois par minute) ;
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
à la fois les mises à jour du launcher et le trousseau.
## 3 sexies. Fichiers du serveur : libérer de la place depuis le launcher (VPS)
La page **« Fichiers du serveur »** du menu d'administration (propriétaires et administrateurs, permission
`files.manage`) affiche le contenu du site public et permet de supprimer ce qui ne sert plus : anciennes archives
`.zip`, anciens exécutables du launcher, images retirées du catalogue, et les fichiers du dossier `objects` d'un jeu
que la version en ligne n'utilise plus (bouton **« Nettoyer le dossier objects »**, avec analyse préalable).
1. Racine du site : par défaut, le dossier parent commun des jeux et du catalogue configurés (ici
`/srv/exovival/www`). Pour un autre dossier, dans `appsettings.Production.json` :
```json
{ "LauncherApi": { "Files": { "Root": "/srv/exovival/www" } } }
```
Le dossier doit être séparé des données privées (`/var/lib/launcher-api`). Les éléments cachés (`.publish-…`)
et les liens symboliques ne sont ni affichés comme fichiers ordinaires, ni suivis, ni supprimés.
2. Écriture : rien à ajouter pour les dossiers des jeux et du catalogue (déjà dans `games.conf` et `catalog.conf`).
Pour supprimer aussi dans un autre dossier (ex. `/srv/exovival/www/launcher`, exécutables du launcher), ajoute
une ligne `ReadWritePaths=` à un complément systemd, puis `sudo systemctl daemon-reload && sudo systemctl restart launcher-api`.
Sans cela, la page affiche ces fichiers mais la suppression est refusée (« le serveur n'a pas le droit de modifier ce dossier »).
Règles appliquées par le serveur (le launcher ne fait qu'afficher) :
* les fichiers `.json` (manifest, catalogue, trousseau, `launcher-update.json`) ne sont **jamais** supprimés ;
* avant chaque suppression, le serveur relit **tous** les fichiers de description du site (chaque `manifest.json`
et `launcher-update.json`, le catalogue) : un fichier encore désigné (archive, fichier d'une version, exécutable,
image) est refusé, y compris par une adresse relative (`../`) ou complète vers ce serveur ;
* un fichier de description illisible rend son dossier non supprimable ; une adresse de ce serveur impossible à
rattacher aux fichiers du site bloque toute suppression (impossible de vérifier) ;
* vérification refaite sous le verrou d'écriture du manifest du jeu, et refus pendant une publication de ce jeu ;
* le nettoyage du dossier `objects` ne touche que les fichiers `<empreinte>.bin` que plus aucun manifest du site
n'utilise, et seulement si le manifest analysé n'a pas changé entre l'analyse et le nettoyage ;
* chaque suppression et chaque nettoyage sont inscrits au journal d'audit (`files.delete`, `files.prune`).
À savoir : après un nettoyage, une version précédente ne peut plus être republiée sans renvoyer ses fichiers
(la publication les renvoie automatiquement). Un joueur en train de télécharger l'ancienne version reprend
avec la nouvelle à sa prochaine vérification.
## 3 septies. Groupes d'administrateurs et envoi d'images depuis le launcher (PC et VPS)
**Groupes et permissions** : les rôles fixes (Propriétaire, Administrateur, Éditeur) deviennent des **groupes**
modifiables depuis la page **« Groupes et permissions »** du menu d'administration (permission `admins.manage`).
Au premier démarrage, le serveur crée les groupes par défaut avec les droits des anciens rôles : rien ne change
pour les administrateurs existants. Le groupe **Propriétaire** a toujours toutes les permissions et ne peut être
ni modifié ni supprimé.
**Envoi des images du catalogue** sans client FTP : page **Jeux** › « Parcourir… ».
1. **Avant** la mise à jour, garde une copie de la liste des administrateurs au format actuel (le serveur la
réécrit au format 2 à la première modification ; une ancienne version du serveur refuserait de démarrer avec) :
```
sudo -u deploy cp /var/lib/launcher-api/admins.json /var/lib/launcher-api/admins.format1.json
```
2. Mets le serveur à jour (étape 8), puis vérifie : `sudo -u deploy /opt/launcher-api/GameLauncher.Server list`
affiche maintenant aussi les groupes et leurs permissions.
3. **nginx** : ajoute le second bloc de `server/deploy/nginx-api.conf` (`location ^~ /api/v1/catalog/images/ { … }`)
à côté du bloc `/api/`, puis `sudo nginx -t && sudo systemctl reload nginx`. Sans lui, nginx refuse les images
de plus de 64 Ko (« Requête trop volumineuse »).
4. Le dossier des images doit exister (étape 3 bis, il appartient à `deploy`, groupe `www-data`) :
```
ls -ld /srv/exovival/www/catalog/images
```
Sinon : `sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog/images`. Le service ne le crée
jamais lui-même ; il n'écrit que dans ce dossier (déjà couvert par `catalog.conf`), en `-rw-r--r--`.
5. Test : page **Jeux** › un jeu › Icône › « Parcourir… » › une image PNG : l'adresse
`https://launcher.lanisys.fr/catalog/images/<empreinte>.png` remplit le champ ; « Publier ».
Règles appliquées par le serveur : image reconnue à son **contenu** (PNG/JPEG), fichier complet sans données
ajoutées après l'image, icône de 32 à 2048 px, fond de 640 × 360 à 7680 × 4320 px, 300 images et 300 Mo au plus
dans le dossier (les images inutilisées se suppriment dans « Fichiers du serveur »), requête signée, permission
`games.edit`, audit `catalog.image_upload`. Le launcher des joueurs vérifie que l'image téléchargée a bien
l'empreinte de son nom : l'adresse figurant dans le catalogue signé, l'image est couverte par cette signature.
## 3 octies. Demandes des joueurs (bouton « Aide ») et purge automatique (VPS)
Le bouton **Aide** du launcher permet aux joueurs d'envoyer, **sans compte**, un avis, une demande d'évolution, un
bug ou autre chose, rattaché au jeu sélectionné. Les demandes se lisent dans le menu d'administration, page
**« Demandes des joueurs »** (permissions `feedback.read` et `feedback.manage`). C'est la **première route
d'écriture ouverte à tous** : elle est désactivée tant que tu ne l'actives pas.
Protections en place : défi anti-spam à résoudre par le launcher (preuve de calcul, une fraction de seconde), 3
demandes par 10 minutes et 10 par jour par adresse IP (comptées **en mémoire seulement**), 300 demandes par jour et
par jeu, 5 000 demandes et 1 Go au plus (captures comprises), 500 Mo d'espace disque libre minimum, corps de 16 Ko,
contrôle complet du contenu (caractères invisibles refusés). **Captures d'écran** : 3 par demande, PNG ou JPEG de
4 Mo au plus, envoyées avec un jeton remis au seul launcher qui a envoyé la demande (15 min) ; le serveur vérifie
que ce sont de vraies images (structure complète) et **retire leurs métadonnées** (EXIF, position GPS…). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
1. Active la réception dans `/opt/launcher-api/appsettings.Production.json` (section `LauncherApi`) :
```json
"Feedback": {
"Enabled": true,
"Games": [ "EZR" ]
}
```
`Games` : identifiants (`gameId` de `launcher.json`) des jeux qui acceptent des demandes ; vide = les jeux de la
section `Games`. Les autres réglages (limites, plafonds, durées de conservation) sont décrits dans
`appsettings.json`. En cas d'afflux, relève `ProofDifficulty` (18 par défaut, 26 au plus).
2. **nginx** : ajoute dans `/etc/nginx/conf.d/securite.conf` (à côté de `api_par_ip`) :
```
limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m;
```
puis colle les **trois** blocs `location … /api/v1/feedback…` de `server/deploy/nginx-api.conf` (défi, envoi,
captures d'écran à 5 Mo) à côté du bloc `/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
3. Redémarre le service : `sudo systemctl restart launcher-api`.
4. **Droits des administrateurs existants** : le groupe **Propriétaire** reçoit automatiquement les nouvelles
permissions. Pour un serveur déjà installé, les autres groupes ne changent pas tout seuls (aucune élévation de droits
sans action d'un propriétaire) : page **« Groupes et permissions »** › groupe « Administrateur » › coche
« Demandes des joueurs (lecture) » et « (traitement) ». Le groupe « Éditeur » n'y a pas accès par défaut (le texte
libre peut contenir des données personnelles).
5. Test : launcher › bouton **Aide** (point d'interrogation, en haut à droite) › Bug › envoyer ; la référence
`FB-XXXX-XXXX` s'affiche. Menu d'administration › « Demandes des joueurs » › le jeu › la demande est là.
**Conservation (RGPD) — purge automatique**, au démarrage du service puis toutes les 24 heures :
| Données | Durée (réglage) | Journal |
|---|---|---|
| Journal d'audit `audit-AAAA-MM.jsonl` | 12 mois (`Audit:RetentionMonths`, 1 à 120) | `audit.purge` |
| Demande close (traitée, rejetée, doublon) | 12 mois après la clôture (`Feedback:RetentionClosedMonths`) | `feedback.purge` |
| Toute demande | 24 mois après réception (`Feedback:RetentionMaxMonths`) | `feedback.purge` |
Un fichier d'audit mensuel est supprimé quand tous ses jours dépassent la durée (le mois le plus ancien gardé peut
donc compter quelques semaines de plus). Pense aussi à borner les journaux du système (voir la fiche RGPD) :
`journald` (`MaxRetentionSec=1year` dans `/etc/systemd/journald.conf`) et nginx (`logrotate`).
## 3 nonies. Formulaires des demandes modifiables depuis le menu (PC et VPS)
Les types de demande du bouton **Aide** (avis, demande d'évolution, bug, autre au départ ; 1 à 8) se gèrent depuis le
menu d'administration : ajout, suppression, ordre d'affichage, nom, description, icône (l'une des quatre d'origine ou
un fichier **.ico** contenant une image 64 × 64), captures d'écran autorisées ou non, champs (zone de texte, texte
court, liste déroulante, cases à cocher, case oui/non, étoiles), types masqués. Les formulaires sont **communs à tous les jeux** et **signés** : un serveur
compromis ne peut pas faire afficher aux joueurs un faux formulaire (« entrez votre mot de passe »). Les libellés et
aides ne peuvent contenir **aucun lien**.
Prérequis : étapes 3 bis (catalogue), 3 quinquies (trousseau) et 3 octies (demandes).
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : les formulaires sont
écrits à côté du trousseau (`/srv/exovival/www/catalog/feedback-forms.json`, 644).
2. **nginx** : ajoute
- le bloc `location = /catalog/feedback-forms.json { … }` de `server/deploy/nginx-catalog.conf`, à côté de celui
du trousseau (lecture par les launchers) ;
- le bloc `location = /api/v1/feedback/forms { … }` de `server/deploy/nginx-api.conf` (publication depuis le
menu : formulaires et icônes importées, 300 Ko au plus ; sans lui, une publication de plus de 64 Ko est refusée) ;
puis `sudo nginx -t && sudo systemctl reload nginx`.
3. **Launcher 3.2** : publie-le (mise à jour signée habituelle) **avant** l'étape 4, et laisse aux joueurs le temps de
le recevoir. Les launchers plus anciens ne connaissent pas la nouvelle autorisation « formulaires » du trousseau :
ils refusent le nouveau trousseau et gardent le précédent (aucune gêne pour jouer, mais un appareil ajouté au
trousseau en même temps ne serait pas reconnu par eux).
4. **Permission et trousseau** :
1. page **Groupes et permissions** : donne `feedback.forms` (« Formulaires des demandes ») au groupe qui doit modifier
les formulaires (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« formulaires des demandes »** des
appareils concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
5. **Publier** depuis le menu, page **« Formulaires des demandes »** (modifier, masquer, aperçu, « Publier »). Les launchers lisent le fichier à l'ouverture de
la fenêtre « Aide » et vérifient la signature comme pour le catalogue (`contentSigning`) ; s'ils ne peuvent pas
(hors ligne, signature refusée), ils proposent les formulaires d'origine, toujours acceptés par le serveur.
Compatibilité : chaque demande annonce la révision des formulaires utilisés. Les launchers 3.0 / 3.1 (révision 0,
formulaires d'origine) restent acceptés, sauf pour un type masqué. Si les formulaires sont republiés pendant qu'un
joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formulaires et garde la saisie des champs
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
## 3 decies. Mode maintenance du launcher (PC et VPS)
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
(`catalog.conf`, étape 3 bis).
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
4. **Permission et trousseau** :
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
l'activer (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« mode maintenance »** des appareils
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
Comportement des launchers :
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
du jeu doivent refuser les connexions, c'est à eux de le faire.
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
## 4. Créer ta clé d'administrateur (PC)
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
| Fichier | Contenu |
|---|---|
| `%USERPROFILE%\.launcher-keys\admin.key` | Clé **privée**, chiffrée par ta phrase de passe. **Sauvegarde-la** (clé USB, gestionnaire de mots de passe) ; jamais dans Git, jamais sur le serveur. |
| `%USERPROFILE%\.launcher-keys\admin.pub` | Clé publique : c'est elle qu'on donne au serveur. |
Note l'**empreinte** affichée (`AB12 CD34 EF56 7890 1A2B`).
> Depuis la phase 2, le launcher sait aussi créer la clé : menu d'administration (Ctrl+Maj+Alt+A,
> ou 5 clics rapides sur le logo) › « J'ai une invitation », **sans code** → clé seule, même dossier,
> même format. Une clé créée par `admin-key.bat` est reconnue par le launcher, et inversement.
> Le launcher peut aussi créer la clé **dans la puce de sécurité (TPM) du PC** (non copiable,
> code PIN) : il écrit alors `admin.tpmkey` (référence, sans secret) et `admin.pub` ; c'est ce
> `.pub` qu'on déclare ci-dessous. Une clé TPM ne fonctionne que dans le launcher, sur ce PC
> (`admin-key.bat` n'utilise que des clés fichier) : garde une clé fichier de secours (§ 9, `add-key`).
## 5. Te déclarer propriétaire (PC puis VPS, une seule fois)
```
PS> scp "$env:USERPROFILE\.launcher-keys\admin.pub" admin@launcher.lanisys.fr:/tmp/owner.pub
```
```
sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub --label "PC bureau"
rm /tmp/owner.pub
```
**Vérifie que l'empreinte affichée est celle de l'étape 4.** L'accès SSH administrateur est la
preuve d'identité : c'est pour cela que le premier propriétaire se crée sur le VPS.
## 6. Tester la connexion (PC)
```
PS> tools\admin-key.bat login-test --api https://launcher.lanisys.fr/api/
```
Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste des permissions.
## 7. Ajouter un administrateur
Tout se fait dans le launcher (menu d'administration), sans ligne de commande :
1. **Toi** : page **Administrateurs** › « Inviter un administrateur » (nom, rôle : propriétaire,
administrateur ou éditeur, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable
24 h, usage unique, affiché une seule fois).
2. **Paul** : launcher › menu d'administration › « J'ai une invitation », code collé ; il te communique
**par téléphone ou message** l'empreinte affichée.
3. **Toi** : page **Administrateurs** › « Actualiser » : le compte de Paul apparaît en premier, en
attente ; saisis l'empreinte dictée sous son appareil, puis « Approuver ».
En ligne de commande, `tools\admin-key.bat` fait la même chose : `invite --api https://launcher.lanisys.fr/api/
--name "Paul" --role editor`, puis `admins --api …` et `approve --api … --admin <id> --key-id <clé>
--fingerprint "<empreinte>"`.
Le serveur n'affiche pas l'empreinte des clés en attente : l'approbation n'est possible qu'avec
celle que Paul te donne. Si elle ne correspond pas, **n'insiste pas** : quelqu'un d'autre a pu
utiliser le code. Révoque ce compte (page Administrateurs, ou `revoke-admin`) et recommence.
Nouveau PC pour un administrateur existant : **lui-même**, depuis un PC déjà enregistré, page **Mes
appareils** › « Ajouter un appareil » ; sur le nouveau PC, « J'ai une invitation » ; puis, de retour sur
l'ancien, approbation de l'appareil en attente avec l'empreinte affichée par le nouveau (en ligne de
commande : `invite --for <son id>`, `enroll`, `approve`). Personne d'autre ne peut approuver une clé à
son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
## 8. Mettre à jour le serveur
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
conservés, ainsi que les compléments systemd (`launcher-api.service.d/catalog.conf` et `games.conf`) ;
les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
pas, le script remet la précédente en place.
## 9. Récupération (VPS)
| Situation | Commande |
|---|---|
| Voir les administrateurs, clés et empreintes | `sudo -u deploy /opt/launcher-api/GameLauncher.Server list` |
| PC perdu ou volé | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-key --key <identifiant>` |
| Clé TPM supprimée dans le launcher (« Supprimer cette clé de la puce ») | Même commande `revoke-key`, avec l'identifiant affiché par le launcher |
| Retirer un administrateur | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-admin --id <identifiant>` |
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
| Plus personne ne peut gérer les groupes | Tout propriétaire le peut toujours (groupe système, toutes les permissions) ; sinon `add-owner` (étape 5) |
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
mêmes réglages que le service (`appsettings.json` puis `appsettings.Production.json`).
`admins.json` illisible : le service refuse de démarrer. `admins.json.bak` est la version qui
précède la **dernière** modification : vérifie-la avant de la restaurer (si cette dernière
modification était une révocation, la restaurer réactiverait la clé révoquée ; refais alors la
révocation).
Le service n'a pas besoin d'être redémarré : une révocation ferme immédiatement les sessions concernées.
## 10. Surveillance
| Pour… | Commande |
|---|---|
| Journal du service (connexions, refus, erreurs) | `journalctl -u launcher-api -f` |
| Journal d'audit (une ligne JSON par action) | `sudo tail -n 20 /var/lib/launcher-api/audit/audit-$(date +%Y-%m).jsonl` |
| Demandes des joueurs reçues (nombre par jeu) | `sudo find /var/lib/launcher-api/feedback -name '*.json' \| cut -d/ -f6 \| sort \| uniq -c` |
| Niveau de durcissement du service | `systemd-analyze security launcher-api` |
| État | `systemctl status launcher-api` |
**Sauvegarde** : `/var/lib/launcher-api/admins.json` (une copie `.bak` de la version précédente est
gardée à côté). Il ne contient aucun secret (clés publiques uniquement), mais sa perte oblige à
recréer les administrateurs.
## Fichiers sur le VPS
```
/opt/launcher-api/ binaires (root, lecture seule pour le service)
├─ GameLauncher.Server serveur + outil en ligne de commande
├─ appsettings.json réglages livrés (remplacés à chaque mise à jour)
└─ appsettings.Production.json TES réglages (facultatif, conservé)
/var/lib/launcher-api/ données privées (deploy, 700)
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
├─ server-id identifiant aléatoire du serveur
├─ audit/audit-AAAA-MM.jsonl journal d'audit (12 mois, purge automatique)
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
├─ catalog-history/ 20 dernières versions publiées
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
├─ publications/ publications en cours (version, fichiers restant à envoyer)
├─ feedback/<jeu>/AAAA-MM/<id>.json demandes des joueurs (une par fichier, 600 ; purge automatique)
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
├─ feedback-forms-history/ 20 dernières révisions publiées
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
├─ maintenance-history/ 20 dernières révisions publiées
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
├─ keyring.json trousseau publié (écrit par le service, 644)
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
├─ maintenance.json mode maintenance signé (écrit par le service, 644)
└─ images/ images du catalogue (envoyées en SFTP)
/etc/systemd/system/launcher-api.service
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
```
+93
View File
@@ -0,0 +1,93 @@
#!/bin/sh
# =============================================================================
# Installe ou met à jour la Launcher API sur le VPS (compte admin, avec sudo) :
# sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>
# L'empreinte est affichée par tools\publish-server.bat (fichier launcher-api.tar.gz.sha256) :
# une archive modifiée pendant le transfert ou dans /tmp est refusée.
#
# • binaires dans /opt/launcher-api (propriétaire root : le service ne peut pas les modifier) ;
# • service systemd « launcher-api » (compte deploy, voir launcher-api.service) ;
# • appsettings.Production.json (réglages propres au VPS) conservé d'une version à l'autre ;
# • en cas d'échec du démarrage, la version précédente est remise en place.
# =============================================================================
set -eu
ARCHIVE="${1:-}"
EXPECTED_SHA256=$(printf '%s' "${2:-}" | tr 'A-F' 'a-f')
INSTALL_DIR=/opt/launcher-api
SERVICE=launcher-api
SERVICE_USER=deploy
fail() { echo "ERREUR : $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || fail "lancez ce script avec sudo."
[ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ] || fail "utilisation : sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>"
printf '%s' "$EXPECTED_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "empreinte SHA-256 manquante ou invalide (voir launcher-api.tar.gz.sha256)."
id "$SERVICE_USER" >/dev/null 2>&1 || fail "le compte $SERVICE_USER n'existe pas (guide du VPS, étape 9)."
command -v curl >/dev/null 2>&1 || fail "curl est nécessaire (sudo apt install -y curl)."
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
# Copie privée de l'archive (dossier 700), PUIS vérification : plus rien ne peut la modifier entre-temps.
cp "$ARCHIVE" "$TMPROOT/archive.tar.gz"
ACTUAL_SHA256=$(sha256sum "$TMPROOT/archive.tar.gz" | cut -d' ' -f1)
[ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ] || fail "empreinte différente : archive altérée ou incomplète. Rien n'a été modifié."
WORK="$TMPROOT/files"
mkdir "$WORK"
tar -xzf "$TMPROOT/archive.tar.gz" -C "$WORK" --no-same-owner
[ -f "$WORK/GameLauncher.Server" ] || fail "archive invalide : GameLauncher.Server absent."
[ -f "$WORK/deploy/launcher-api.service" ] || fail "archive invalide : deploy/launcher-api.service absent."
echo "== Préparation de la nouvelle version"
rm -rf "$INSTALL_DIR.new"
mkdir -p "$INSTALL_DIR.new"
cp -a "$WORK/." "$INSTALL_DIR.new/"
rm -rf "$INSTALL_DIR.new/deploy"
if [ -f "$INSTALL_DIR/appsettings.Production.json" ]; then
cp -a "$INSTALL_DIR/appsettings.Production.json" "$INSTALL_DIR.new/"
fi
chown -R root:root "$INSTALL_DIR.new"
find "$INSTALL_DIR.new" -type d -exec chmod 755 {} +
find "$INSTALL_DIR.new" -type f -exec chmod 644 {} +
chmod 755 "$INSTALL_DIR.new/GameLauncher.Server"
echo "== Installation du service"
UNIT=/etc/systemd/system/$SERVICE.service
rm -f "$UNIT.old"
[ -f "$UNIT" ] && cp -a "$UNIT" "$UNIT.old"
systemctl stop $SERVICE 2>/dev/null || true
install -m 644 -o root -g root "$WORK/deploy/launcher-api.service" "$UNIT"
systemctl daemon-reload
rm -rf "$INSTALL_DIR.old"
[ -d "$INSTALL_DIR" ] && mv "$INSTALL_DIR" "$INSTALL_DIR.old"
mv "$INSTALL_DIR.new" "$INSTALL_DIR"
systemctl enable --now $SERVICE
echo "== Vérification"
OK=0
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS http://127.0.0.1:5080/api/v1/health >/dev/null 2>&1; then OK=1; break; fi
sleep 1
done
if [ "$OK" -ne 1 ]; then
echo "La nouvelle version ne répond pas. Dernières lignes du journal :" >&2
journalctl -u $SERVICE -n 30 --no-pager >&2 || true
if [ -d "$INSTALL_DIR.old" ]; then
echo "Retour à la version précédente." >&2
systemctl stop $SERVICE || true
rm -rf "$INSTALL_DIR.failed"
mv "$INSTALL_DIR" "$INSTALL_DIR.failed"
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
[ -f "$UNIT.old" ] && cp -a "$UNIT.old" "$UNIT"
systemctl daemon-reload
systemctl start $SERVICE || true
fi
exit 1
fi
echo "Launcher API opérationnelle :"
curl -fsS http://127.0.0.1:5080/api/v1/health; echo
echo "Journal : journalctl -u $SERVICE -f"
echo "Premier propriétaire (une seule fois) : voir LISEZMOI.md, étape 5."
+14
View File
@@ -0,0 +1,14 @@
# =============================================================================
# Launcher API : autorisation d'écrire le catalogue des jeux (LISEZMOI.md, étape 3 bis).
# /etc/systemd/system/launcher-api.service.d/catalog.conf
#
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
# écrire dans UN SEUL dossier, celui du fichier public du catalogue (LauncherApi:Catalog:PublicFile).
# Fichier séparé : il est conservé lors des mises à jour du serveur (installer-api.sh remplace
# launcher-api.service, pas ce dossier).
#
# Adapter le chemin au dossier réellement servi par nginx (exemple de la documentation ci-dessous),
# puis : sudo systemctl daemon-reload && sudo systemctl restart launcher-api
# =============================================================================
[Service]
ReadWritePaths=/srv/exovival/www/catalog
+17
View File
@@ -0,0 +1,17 @@
# =============================================================================
# Launcher API : autorisation de modifier le manifest.json des jeux (LISEZMOI.md, étape 3 ter).
# /etc/systemd/system/launcher-api.service.d/games.conf
#
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
# écrire dans le dossier de chaque jeu configuré (LauncherApi:Games:<id>:ManifestFile), et
# seulement là. Le remplacement du manifest se fait par renommage dans ce dossier : c'est le dossier
# entier qui doit être autorisé (pas le seul fichier).
# Compromis : un service compromis pourrait alors modifier les fichiers du jeu de ce dossier.
# Sans ce complément (et sans LauncherApi:Games), les notes ne sont pas modifiables en ligne.
#
# Fichier séparé : conservé lors des mises à jour du serveur. Adapter le chemin au dossier du jeu
# réellement servi par nginx (exemple de la documentation ci-dessous ; une ligne par jeu), puis :
# sudo systemctl daemon-reload && sudo systemctl restart launcher-api
# =============================================================================
[Service]
ReadWritePaths=/srv/exovival/www/exovivalzombie
+76
View File
@@ -0,0 +1,76 @@
# =============================================================================
# Launcher API : service systemd (installé par installer-api.sh).
# /etc/systemd/system/launcher-api.service
#
# Le service tourne sous le compte « deploy » (déjà propriétaire des fichiers du jeu, sans
# terminal), enfermé par systemd : système en lecture seule, pas d'accès à /home, aucun privilège.
# Ses binaires (/opt/launcher-api) appartiennent à root : il ne peut pas se modifier lui-même.
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
#
# Vérifier le durcissement : systemd-analyze security launcher-api
# Journal : journalctl -u launcher-api -f
# =============================================================================
[Unit]
Description=Launcher API (administration du launcher)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/launcher-api
ExecStart=/opt/launcher-api/GameLauncher.Server
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
Environment=DOTNET_NOLOGO=1
# Données privées : /var/lib/launcher-api (administrateurs, audit, identifiant du serveur)
StateDirectory=launcher-api
StateDirectoryMode=0700
UMask=0077
# --- Durcissement ------------------------------------------------------------
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Réseau : uniquement la machine elle-même (nginx). Aucune connexion sortante possible, même en cas de compromission.
IPAddressDeny=any
IPAddressAllow=localhost
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RemoveIPC=yes
SystemCallArchitectures=native
# MemoryDenyWriteExecute n'est PAS activé : le compilateur JIT de .NET a besoin de mémoire exécutable.
# SystemCallFilter n'est pas activé : .NET utilise des appels système variés selon les versions
# (un filtre trop strict provoquerait des arrêts difficiles à diagnostiquer).
# --- Ressources (petit VPS) ------------------------------------------------------
MemoryMax=512M
TasksMax=256
LimitNOFILE=8192
[Install]
WantedBy=multi-user.target
+143
View File
@@ -0,0 +1,143 @@
# =============================================================================
# Launcher API derrière nginx — DEUX ajouts (voir LISEZMOI.md, étape 3) :
#
# 1) Dans /etc/nginx/conf.d/securite.conf (contexte « http », à côté de limit_conn_zone) :
#
# limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
#
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
# (les blocs ci-dessous ; le deuxième, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies ;
# les suivants, demandes des joueurs, LISEZMOI.md étape 3 octies ; le dernier, formulaires des demandes,
# LISEZMOI.md étape 3 nonies)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
# =============================================================================
# « ^~ » : aucune autre règle (manifest, objects, .zip…) ne s'applique sous /api/.
location ^~ /api/ {
limit_req zone=api_par_ip burst=20 nodelay;
limit_req_status 429;
# Requêtes d'administration : petites. (La publication de versions, en phase 5, aura sa propre règle.)
client_max_body_size 64k;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# REMPLACE l'en-tête (au lieu d'ajouter à celui du client) : l'adresse IP transmise ne peut pas être falsifiée.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# Envoi d'une image du catalogue (icône, fond) depuis la page « Jeux » : seule route du catalogue qui accepte un
# corps de plus de 64 Ko (image de 4 Mo au plus). Le préfixe le plus long l'emporte sur « /api/ ». Mêmes limites
# de débit ; la route exige une session d'administrateur, la permission games.edit et une requête signée.
location ^~ /api/v1/catalog/images/ {
limit_req zone=api_par_ip burst=20 nodelay;
limit_req_status 429;
client_max_body_size 5m;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Demandes des joueurs (bouton « Aide » du launcher) : seules routes d'ÉCRITURE ouvertes à tous (sans compte).
# • corps de 16 Ko au plus ; limite de débit propre, plus stricte (zone feedback_par_ip, à ajouter dans
# /etc/nginx/conf.d/securite.conf : « limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m; ») ;
# • access_log off : l'heure d'envoi d'une demande ne peut pas être rapprochée d'une adresse IP dans les journaux
# nginx (la demande est anonyme ; le serveur ne garde les adresses qu'en mémoire, pour ses limites).
# Correspondance EXACTE (« = ») : prioritaire sur « ^~ /api/ », et limitée aux deux routes des joueurs (les routes
# des administrateurs, /api/v1/feedback/overview et /api/v1/games/…/feedback, restent sous le bloc « /api/ »).
location = /api/v1/feedback/challenge {
limit_req zone=feedback_par_ip burst=6 nodelay;
limit_req_status 429;
access_log off;
client_max_body_size 16k;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
location = /api/v1/feedback {
limit_req zone=feedback_par_ip burst=6 nodelay;
limit_req_status 429;
access_log off;
client_max_body_size 16k;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Captures d'écran d'une demande (jeton d'envoi remis au seul launcher qui a envoyé la demande) : 4 Mo par image.
location ^~ /api/v1/feedback/attachments/ {
limit_req zone=feedback_par_ip burst=6 nodelay;
limit_req_status 429;
access_log off;
client_max_body_size 5m;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Publication des formulaires des demandes (page « Formulaires des demandes », administrateurs) : formulaires signés
# avec leurs icônes importées (256 Ko au plus). Correspondance EXACTE : la lecture (GET) passe aussi par ce bloc,
# avec les limites de débit des administrateurs ; la route exige une session, feedback.forms et une requête signée.
location = /api/v1/feedback/forms {
limit_req zone=api_par_ip burst=20 nodelay;
limit_req_status 429;
client_max_body_size 300k;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
+47
View File
@@ -0,0 +1,47 @@
# =============================================================================
# Catalogue des jeux, trousseau, formulaires des demandes et mode maintenance (écrits par la Launcher API) —
# voir LISEZMOI.md, étapes 3 bis, 3 quinquies, 3 nonies et 3 decies :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
#
# Les images du catalogue (catalog/images/*.png|jpg) sont servies par « location / » ; les
# fichiers temporaires de la Launcher API (.catalog.json.….tmp) par la règle des fichiers cachés
# (refusés).
# =============================================================================
# Catalogue : toujours relu sur le serveur (les launchers le lisent au démarrage).
location = /catalog/catalog.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Trousseau (phase 7) : clés autorisées à signer versions et catalogue, signé par la clé racine du launcher.
location = /catalog/keyring.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Formulaires des demandes (bouton « Aide ») : publiés depuis le menu, signés (autorisation « forms » du trousseau),
# lus par les launchers à l'ouverture de la fenêtre « Aide ».
location = /catalog/feedback-forms.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Mode maintenance (launcher 3.3) : activé depuis le menu, signé (autorisation « maintenance » du trousseau), relu par les
# launchers au démarrage puis toutes les 1 à 2 minutes. Jamais mis en cache : la fin de la maintenance doit être vue tout de suite.
location = /catalog/maintenance.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
+39
View File
@@ -0,0 +1,39 @@
# =============================================================================
# Publication d'une version depuis le launcher — deux ajouts (voir LISEZMOI.md, étape 3 quater) :
#
# 1) Dans /etc/nginx/conf.d/securite.conf, à côté de la zone « api_par_ip » :
#
# limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
#
# Un envoi enchaîne plusieurs morceaux de 4 Mo par seconde : la zone « api_par_ip » (5 par seconde)
# briderait l'envoi vers 20 Mo/s et provoquerait des pauses. 25 par seconde ≈ 100 Mo/s ; ces routes
# exigent une session d'administrateur et le droit de publier.
#
# 2) Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival, à côté du bloc
# « location ^~ /api/ { … } » (nginx-api.conf) : (le bloc ci-dessous)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
#
# Seules ces routes acceptent de gros envois (morceaux de 4 Mo, 8 Mo au plus) ; toutes les autres
# routes de l'API restent limitées à 64 Ko. Le préfixe le plus long l'emporte sur « /api/ ».
# Les fichiers en cours d'envoi sont rangés dans un dossier caché (.publish-…) du dossier du jeu :
# la règle « location ~ /\. { deny all; } » du site doit être présente (elle l'est dans le guide).
# =============================================================================
location ^~ /api/v1/publications/ {
limit_req zone=publication_par_ip burst=50 nodelay;
limit_req_status 429;
client_max_body_size 9m;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 180s;
proxy_read_timeout 180s;
}
@@ -0,0 +1,94 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Security;
namespace GameLauncher.ApiClient;
/// <summary>
/// Clé d'administrateur dans un fichier chiffré par une phrase de passe : PKCS#8 chiffré (AES-256,
/// PBKDF2-SHA256, 600 000 itérations), comme la clé de signature des mises à jour du launcher.
/// Le fichier seul ne sert à rien sans la phrase de passe.
/// </summary>
public static class AdminKeyFile
{
public const int MinPassphraseLength = 12;
public const string PrivateKeyExtension = ".key";
public const string PublicKeyExtension = ".pub";
private static readonly PbeParameters Encryption = new(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 600_000);
public static ECDsa Generate() => ECDsa.Create(ECCurve.NamedCurves.nistP256);
/// <summary>
/// Enregistre la clé chiffrée (refuse d'écraser un fichier existant) et sa clé publique à côté
/// (même nom, extension .pub). Renvoie le chemin de la clé publique.
/// </summary>
public static string Save(ECDsa key, string path, ReadOnlySpan<char> passphrase)
{
if (passphrase.Length < MinPassphraseLength)
throw new ArgumentException($"La phrase de passe doit contenir au moins {MinPassphraseLength} caractères.");
if (File.Exists(path)) throw new IOException($"Le fichier existe déjà : {path}");
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var pem = key.ExportEncryptedPkcs8PrivateKeyPem(passphrase, Encryption);
using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
using (var writer = new StreamWriter(stream))
writer.Write(pem);
// Clé publique : jamais écrasée non plus ; en cas d'échec, la clé privée qui vient d'être créée est retirée.
var publicPath = Path.ChangeExtension(path, PublicKeyExtension);
try
{
using var stream = new FileStream(publicPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
using var writer = new StreamWriter(stream);
writer.Write(AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
}
catch
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// clé chiffrée laissée en place : sans danger
}
throw;
}
return publicPath;
}
/// <summary>Déchiffre la clé. <see cref="CryptographicException"/> si la phrase de passe est fausse.</summary>
public static ECDsa Load(string path, ReadOnlySpan<char> passphrase)
{
var info = new FileInfo(path);
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
if (info.Length > 16 * 1024) throw new CryptographicException("Fichier de clé inattendu (trop volumineux).");
var pem = File.ReadAllText(path);
if (!pem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal))
throw new CryptographicException("Ce fichier n'est pas une clé privée chiffrée.");
var key = ECDsa.Create();
try
{
key.ImportFromEncryptedPem(pem, passphrase);
}
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
{
key.Dispose();
throw new CryptographicException("Phrase de passe incorrecte (ou fichier de clé endommagé).", ex);
}
try
{
AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // clé P-256 uniquement
return key;
}
catch
{
key.Dispose();
throw;
}
}
}
+414
View File
@@ -0,0 +1,414 @@
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.ApiClient;
/// <summary>
/// Session d'administration ouverte. Le jeton reste en mémoire (jamais écrit sur le disque) ;
/// chaque requête qui modifie quelque chose est signée par la clé (<see cref="SignedMessages.Request"/>).
/// Les modules du launcher ajoutent leurs propres appels avec <see cref="GetAsync{T}"/> et
/// <see cref="SendSignedAsync{T}"/>.
/// </summary>
public sealed class AdminSession : IAsyncDisposable
{
private readonly LauncherApiClient _client;
private readonly IAdminSigner _signer;
private readonly TimeSpan _clockOffset;
private string? _token;
internal AdminSession(LauncherApiClient client, IAdminSigner signer, string serverId, LoginResponse login, TimeSpan clockOffset)
{
_client = client;
_signer = signer;
_clockOffset = clockOffset;
_token = login.Token;
ServerId = serverId;
SessionId = login.SessionId;
Profile = login.Admin;
ExpiresAt = login.ExpiresAt;
IdleExpiresAt = login.IdleExpiresAt;
}
public string ServerId { get; }
/// <summary>Écart entre l'horloge du serveur et celle du PC (heure serveur = heure du PC + écart), mesuré à la connexion.</summary>
public TimeSpan ClockOffset => _clockOffset;
public string SessionId { get; }
public AdminProfile Profile { get; private set; }
public DateTimeOffset ExpiresAt { get; }
public DateTimeOffset IdleExpiresAt { get; private set; }
public bool IsOpen => _token is not null;
/// <summary>Clé publique de CET appareil (SPKI), lue sur le PC : son empreinte ne dépend pas du serveur.</summary>
public byte[] PublicKey => _signer.PublicKey;
/// <summary>Empreinte de la clé de cet appareil, calculée sur le PC (à dicter pour vérifier le trousseau).</summary>
public string LocalFingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
// ------------------------------------------------------------------ Session
/// <summary>Relit la session (droits à jour) et la prolonge.</summary>
public async Task<SessionInfo> RefreshAsync(CancellationToken ct = default)
{
var info = await GetAsync<SessionInfo>(ApiRoutes.AuthMe, ct).ConfigureAwait(false);
Profile = info.Admin;
IdleExpiresAt = info.IdleExpiresAt;
return info;
}
/// <summary>Ferme la session sur le serveur (sans erreur si elle avait déjà expiré).</summary>
public async Task LogoutAsync(CancellationToken ct = default)
{
if (_token is null) return;
try
{
await SendCoreAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuthLogout, null, signed: false, ct).ConfigureAwait(false);
}
catch (ApiException)
{
// session déjà expirée, serveur injoignable : le jeton est oublié de toute façon
}
finally
{
_token = null;
}
}
public async ValueTask DisposeAsync() => await LogoutAsync().ConfigureAwait(false);
// ------------------------------------------------------------------ Administrateurs
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
public Task<MyAccount> GetMyAccountAsync(CancellationToken ct = default) => GetAsync<MyAccount>(ApiRoutes.MyAccount, ct);
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
public Task<InvitationCreated> CreateInvitationAsync(CreateInvitationRequest request, CancellationToken ct = default) =>
SendSignedAsync<InvitationCreated>(HttpMethod.Post, ApiRoutes.Invitations, request, ct);
public Task DeleteInvitationAsync(Guid invitationId, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Invitation(invitationId), (object?)null, ct);
public Task<AdminSummary> ApproveKeyAsync(Guid adminId, string keyId, string fingerprint, CancellationToken ct = default) =>
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyApprove(adminId, keyId), new ApproveKeyRequest { Fingerprint = fingerprint }, ct);
public Task<AdminSummary> RevokeKeyAsync(Guid adminId, string keyId, CancellationToken ct = default) =>
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyRevoke(adminId, keyId), (object?)null, ct);
public Task<AdminSummary> ChangeRoleAsync(Guid adminId, string role, CancellationToken ct = default) =>
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRole(adminId), new ChangeRoleRequest { Role = role }, ct);
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
// ------------------------------------------------------------------ Groupes et permissions (admins.manage)
/// <summary>Groupes (permissions, membres) et permissions connues du serveur.</summary>
public Task<GroupsState> GetGroupsAsync(CancellationToken ct = default) => GetAsync<GroupsState>(ApiRoutes.Groups, ct);
public Task<GroupsState> CreateGroupAsync(GroupRequest request, CancellationToken ct = default) =>
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Groups, request, ct);
/// <summary>Modifie un groupe (409 « revision_conflict » s'il a changé depuis <see cref="GroupRequest.Revision"/>).</summary>
public Task<GroupsState> UpdateGroupAsync(string groupId, GroupRequest request, CancellationToken ct = default) =>
SendSignedAsync<GroupsState>(HttpMethod.Put, ApiRoutes.Group(CheckGroupId(groupId)), request, ct);
/// <summary>Supprime un groupe sans membre ni invitation en cours.</summary>
public Task<GroupsState> DeleteGroupAsync(string groupId, CancellationToken ct = default) =>
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Group(CheckGroupId(groupId)), (object?)null, ct);
public Task<GroupsState> CreatePermissionAsync(CreatePermissionRequest request, CancellationToken ct = default) =>
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Permissions, request, ct);
/// <summary>Supprime une permission personnalisée (elle est retirée de tous les groupes).</summary>
public Task<GroupsState> DeletePermissionAsync(string name, CancellationToken ct = default) =>
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Permission(
AdminGroups.IsValidPermissionName(name) ? name : throw new ArgumentException($"Permission invalide : « {name} ».", nameof(name))),
(object?)null, ct);
private static string CheckGroupId(string groupId) =>
AdminGroups.IsValidGroupId(groupId) ? groupId : throw new ArgumentException($"Identifiant de groupe invalide : « {groupId} ».", nameof(groupId));
/// <param name="before">Facultatif : seulement les entrées antérieures à cet instant (filtre « jusqu'au »).</param>
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, DateTimeOffset? before = null, CancellationToken ct = default) =>
GetAsync<AuditPage>(ApiRoutes.AuditPage(limit, cursor, before), ct);
/// <summary>Inscrit un export CSV du journal au journal (à faire AVANT d'écrire le fichier).</summary>
public Task RecordAuditExportAsync(AuditExportRequest request, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuditExport, request, ct);
// ------------------------------------------------------------------ Fichiers du site public (www)
/// <summary>Contenu d'un dossier du site (chemin relatif à sa racine, « / » comme séparateur ; « » : racine).</summary>
public Task<ServerFolder> GetServerFolderAsync(string path, CancellationToken ct = default) =>
GetAsync<ServerFolder>(ApiRoutes.FilesFolder(path), ct);
/// <summary>Supprime un fichier que le serveur juge inutilisé (refus s'il est référencé par un manifest).</summary>
public Task<ServerFileDeleteResult> DeleteServerFileAsync(string path, long size, CancellationToken ct = default) =>
SendSignedAsync<ServerFileDeleteResult>(HttpMethod.Post, ApiRoutes.FilesDelete, new ServerFileDeleteRequest { Path = path, Size = size }, ct);
public Task<ObjectsPrunePreview> GetObjectsPruneAsync(string gameId, CancellationToken ct = default) =>
GetAsync<ObjectsPrunePreview>(ApiRoutes.ObjectsPrune(CheckGameId(gameId)), ct);
/// <summary>Supprime les fichiers d'« objects » que le manifest analysé n'utilise plus (refus s'il a changé).</summary>
public Task<ObjectsPruneResult> PruneObjectsAsync(string gameId, string manifestSha256, CancellationToken ct = default) =>
SendSignedAsync<ObjectsPruneResult>(HttpMethod.Post, ApiRoutes.ObjectsPrune(CheckGameId(gameId)),
new ObjectsPruneRequest { ManifestSha256 = manifestSha256 }, ct);
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
public Task<CatalogState> GetCatalogAsync(CancellationToken ct = default) =>
GetAsync<CatalogState>(ApiRoutes.Catalog, ct);
/// <summary>Publie le catalogue (409 « revision_conflict » si quelqu'un a publié depuis <paramref name="baseRevision"/>).</summary>
public Task<CatalogState> PublishCatalogAsync(CatalogDocument catalog, long baseRevision, CancellationToken ct = default) =>
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
/// <summary>
/// Envoie une image du catalogue (icône ou fond, contrôlée avant l'envoi avec <see cref="CatalogImages.Check"/>) :
/// requête signée (la signature couvre l'empreinte du contenu). Renvoie l'adresse à mettre dans le catalogue.
/// </summary>
public Task<CatalogImageUploaded> UploadCatalogImageAsync(string kind, byte[] content, CancellationToken ct = default) =>
SendSignedBytesAsync<CatalogImageUploaded>(HttpMethod.Put,
ApiRoutes.CatalogImage(CatalogImages.IsValidKind(kind) ? kind : throw new ArgumentException($"Type d'image inconnu : « {kind} ».", nameof(kind))),
content, CatalogImages.ContentType, UploadTimeout, ct);
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
public Task<ChangelogState> GetChangelogAsync(string gameId, CancellationToken ct = default) =>
GetAsync<ChangelogState>(ApiRoutes.GameChangelog(CheckGameId(gameId)), ct);
/// <summary>
/// Remplace les notes d'UNE version existante (requête signée). <paramref name="baseSha256"/> : empreinte
/// du manifest sur lequel la modification a été préparée (409 revision_conflict s'il a changé).
/// </summary>
public Task<ChangelogState> SaveChangelogEntryAsync(string gameId, ChangelogEntry entry, string baseSha256, CancellationToken ct = default)
{
if (!ChangelogLimits.IsEditableVersion(entry.Version))
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
}
// ------------------------------------------------------------------ Publication d'une version
/// <summary>Délai d'une requête d'envoi de fichier (morceau de 4 Mo sur une connexion lente).</summary>
public static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(3);
public Task<PublicationState> StartPublicationAsync(string gameId, PublicationStartRequest request, CancellationToken ct = default) =>
SendSignedAsync<PublicationState>(HttpMethod.Post, ApiRoutes.PublicationStart(CheckGameId(gameId)), request, ct);
public Task<PublicationState> GetPublicationAsync(string id, CancellationToken ct = default) =>
GetAsync<PublicationState>(ApiRoutes.Publication(CheckPublicationId(id)), ct);
public Task CancelPublicationAsync(string id, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Publication(CheckPublicationId(id)), null, ct);
public Task<ChunkResult> UploadManifestAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationManifest(CheckPublicationId(id), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadArchiveAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationArchive(CheckPublicationId(id), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadObjectAsync(string id, string sha256, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put,
ApiRoutes.PublicationObject(CheckPublicationId(id), PublishingLimits.IsValidSha256(sha256) ? sha256.ToLowerInvariant()
: throw new ArgumentException("Empreinte invalide.", nameof(sha256)), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadObjectBatchAsync(string id, byte[] batch, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Post, ApiRoutes.PublicationObjects(CheckPublicationId(id)), batch,
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
/// <param name="signature">Signature du contenu du manifest (phase 7), ou null (version non signée).</param>
public Task<PublicationResult> CommitPublicationAsync(string id, ContentSignature? signature = null, CancellationToken ct = default) =>
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)),
signature is null ? null : JsonSerializer.SerializeToUtf8Bytes(new PublicationCommitRequest { Signature = signature }, ApiJson.Options),
signed: true, ct, timeout: UploadTimeout);
// ------------------------------------------------------------------ Signature du contenu (phase 7)
/// <summary>
/// Signe un contenu servi aux joueurs avec la clé de cette session (date : heure du serveur, à la seconde).
/// La clé privée ne quitte jamais le PC (ni la puce TPM) : seul le condensé calculé ici est signé.
/// </summary>
public async Task<ContentSignature> SignContentAsync(string kind, string id, string digest, CancellationToken ct = default)
{
var signedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset);
var message = ContentSigning.Message(kind, id, signedAt, _signer.KeyId, digest);
var signature = await _signer.SignAsync(message, ct).ConfigureAwait(false);
return new ContentSignature { KeyId = _signer.KeyId, SignedAt = signedAt, Value = Convert.ToBase64String(signature) };
}
public Task<SigningState> GetSigningStateAsync(CancellationToken ct = default) => GetAsync<SigningState>(ApiRoutes.Signing, ct);
/// <summary>Publie un trousseau signé par la clé racine (le serveur contrôle la structure et le numéro).</summary>
public Task<KeyringDocument> PublishKeyringAsync(KeyringDocument keyring, CancellationToken ct = default) =>
SendSignedAsync<KeyringDocument>(HttpMethod.Put, ApiRoutes.Keyring, keyring, ct);
public Task<ManifestSignatureState> GetManifestSignatureAsync(string gameId, CancellationToken ct = default) =>
GetAsync<ManifestSignatureState>(ApiRoutes.ManifestSignature(CheckGameId(gameId)), ct);
/// <summary>Signe la version en ligne (le serveur vérifie qu'elle a exactement le contenu signé).</summary>
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
// ------------------------------------------------------------------ Demandes des joueurs
public Task<FeedbackOverview> GetFeedbackOverviewAsync(CancellationToken ct = default) =>
GetAsync<FeedbackOverview>(ApiRoutes.FeedbackOverview, ct);
/// <param name="status">Statut précis, « open » (non closes) ou « closed » ; null : toutes.</param>
public Task<FeedbackPage> GetFeedbackPageAsync(string gameId, string? kind = null, string? status = null, string? cursor = null,
int limit = 100, CancellationToken ct = default) =>
GetAsync<FeedbackPage>(ApiRoutes.GameFeedback(CheckGameId(gameId), kind, status, cursor, limit), ct);
public Task<FeedbackDetail> GetFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
GetAsync<FeedbackDetail>(ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), ct);
/// <summary>Statut et note interne (409 « revision_conflict » si la demande a changé depuis <paramref name="revision"/>).</summary>
public Task<FeedbackDetail> UpdateFeedbackAsync(string gameId, string id, long revision, string status, string? adminNote,
CancellationToken ct = default) =>
SendSignedAsync<FeedbackDetail>(HttpMethod.Put, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)),
new FeedbackUpdateRequest { Revision = revision, Status = status, AdminNote = adminNote }, ct);
public Task DeleteFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), null, ct);
/// <summary>Formulaires des demandes en vigueur (communs à tous les jeux ; révision 0 : formulaires d'origine).</summary>
public Task<FeedbackFormsState> GetFeedbackFormsAsync(CancellationToken ct = default) =>
GetAsync<FeedbackFormsState>(ApiRoutes.FeedbackForms, ct);
/// <summary>
/// Publie les formulaires : révision <paramref name="baseRevision"/> + 1, signés par la clé de cette session (autorisation
/// « forms » du trousseau requise chez les joueurs), puis envoyés en requête signée.
/// </summary>
public async Task<FeedbackFormsState> PublishFeedbackFormsAsync(IReadOnlyList<FeedbackForm> forms, long baseRevision,
CancellationToken ct = default)
{
var document = new FeedbackFormsDocument { Revision = baseRevision + 1, Forms = forms.ToList() };
var errors = new List<string>();
if (!FeedbackFormRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(forms));
var signature = await SignContentAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(document), ct)
.ConfigureAwait(false);
return await SendSignedAsync<FeedbackFormsState>(HttpMethod.Put, ApiRoutes.FeedbackForms,
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
/// <summary>Mode maintenance en vigueur (révision 0 : jamais publié, inactif).</summary>
public Task<MaintenanceState> GetMaintenanceAsync(CancellationToken ct = default) =>
GetAsync<MaintenanceState>(ApiRoutes.Maintenance, ct);
/// <summary>
/// Active, désactive ou modifie le mode maintenance : révision <paramref name="baseRevision"/> + 1, signé par la clé de
/// cette session (autorisation « maintenance » du trousseau requise chez les joueurs), puis envoyé en requête signée.
/// </summary>
/// <param name="message">Précision affichée aux joueurs, déjà nettoyée (<see cref="MaintenanceRules.CleanMessage"/>) ; ignorée à la désactivation.</param>
/// <param name="since">
/// Début de la maintenance : celui de la maintenance en cours pour un nouveau message, null pour en commencer une
/// (l'heure du serveur, à la seconde) ; ignoré à la désactivation.
/// </param>
public async Task<MaintenanceState> PublishMaintenanceAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision,
CancellationToken ct = default)
{
var document = new MaintenanceDocument
{
Revision = baseRevision + 1,
Enabled = enabled,
Message = enabled ? message : null,
Since = enabled ? since ?? ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset) : null
};
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(message));
var signature = await SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), ct)
.ConfigureAwait(false);
return await SendSignedAsync<MaintenanceState>(HttpMethod.Put, ApiRoutes.Maintenance,
new MaintenancePublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
signed: false, ct, maxResponseBytes: FeedbackImages.MaxBytes);
private static string CheckFeedbackId(string id) =>
FeedbackRules.IsValidId(id) ? id : throw new ArgumentException("Identifiant de demande invalide.", nameof(id));
private static string CheckPublicationId(string id) =>
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
private static string CheckGameId(string gameId) =>
CatalogRules.IsValidGameId(gameId) ? gameId : throw new ArgumentException($"Identifiant de jeu invalide : « {gameId} ».", nameof(gameId));
// ------------------------------------------------------------------ Requêtes génériques (modules)
public Task<T> GetAsync<T>(string route, CancellationToken ct = default) =>
SendCoreAsync<T>(HttpMethod.Get, route, null, signed: false, ct);
/// <summary>Requête qui modifie : corps JSON (ou aucun corps si null), signée par la clé.</summary>
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
/// <summary>Requête qui modifie avec un corps binaire (morceau de fichier), signée par la clé.</summary>
public Task<T> SendSignedBytesAsync<T>(HttpMethod method, string route, byte[] body, string contentType, TimeSpan timeout,
CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null,
long maxResponseBytes = ApiJson.MaxResponseBytes)
{
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
(long Timestamp, string Nonce, byte[] Signature)? signature = null;
if (signed)
{
var timestamp = (DateTimeOffset.UtcNow + _clockOffset).ToUnixTimeSeconds();
var nonce = Base64Url.Encode(RandomNumberGenerator.GetBytes(16));
var path = _client.Resolve(route).PathAndQuery;
var message = SignedMessages.Request(_client.Origin, ServerId, SessionId, method.Method, path,
body ?? Array.Empty<byte>(), timestamp, nonce);
signature = (timestamp, nonce, await _signer.SignAsync(message, ct).ConfigureAwait(false));
}
try
{
return await _client.SendAsync<T>(method, route, body, request =>
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
if (signature is { } s)
{
request.Headers.Add(SignedRequestHeaders.Timestamp, s.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture));
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
}
}, ct, contentType, timeout, maxResponseBytes).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
_token = null; // session expirée ou révoquée : il faudra se reconnecter
throw;
}
}
}
@@ -0,0 +1,24 @@
using GameLauncher.Contracts.Dtos;
namespace GameLauncher.ApiClient;
/// <summary>Erreur renvoyée par la Launcher API (ou problème de communication).</summary>
public sealed class ApiException : Exception
{
public ApiException(int status, string code, string message, Exception? inner = null) : base(message, inner)
{
Status = status;
Code = code;
}
/// <summary>Code HTTP (0 : serveur injoignable).</summary>
public int Status { get; }
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
public string Code { get; }
/// <summary>Délai annoncé par le serveur avant un nouvel essai (en-tête Retry-After), s'il y en a un.</summary>
public TimeSpan? RetryAfter { get; init; }
public bool IsUnauthorized => Status == 401;
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Client de la Launcher API (sans WPF) : connexion par défi signé, requêtes signées, appels typés,
fichier de clé d'administrateur chiffré. Utilisé par le launcher (module Administration),
par tools\AdminKeyTool et par les tests. Voir docs\ADMIN-ARCHITECTURE.md.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.ApiClient</RootNamespace>
<AssemblyName>GameLauncher.ApiClient</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Security;
namespace GameLauncher.ApiClient;
/// <summary>
/// Clé privée d'un administrateur, vue par le client : elle signe, elle ne sort jamais.
/// Implémentations : <see cref="EcdsaAdminSigner"/> (clé en mémoire, déverrouillée depuis un fichier
/// chiffré) ; clé de la puce TPM dans le launcher (Platform\TpmKeyStore, phase 2b).
/// </summary>
public interface IAdminSigner
{
/// <summary>Clé publique (SPKI DER).</summary>
byte[] PublicKey { get; }
string KeyId { get; }
/// <summary>Signature ECDSA P-256/SHA-256 (IEEE P1363, 64 octets). Peut demander une confirmation à l'utilisateur.</summary>
ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default);
}
/// <summary>Clé ECDSA P-256 en mémoire.</summary>
public sealed class EcdsaAdminSigner : IAdminSigner, IDisposable
{
private readonly ECDsa _key;
/// <param name="key">Clé privée P-256 ; elle appartient désormais à ce signataire (libérée par <see cref="Dispose"/>).</param>
public EcdsaAdminSigner(ECDsa key)
{
_key = key;
PublicKey = AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // vérifie P-256
KeyId = AdminKeys.ComputeKeyId(PublicKey);
}
public byte[] PublicKey { get; }
public string KeyId { get; }
public string Fingerprint => AdminKeys.ComputeFingerprint(PublicKey);
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) =>
ValueTask.FromResult(AdminKeys.Sign(_key, message));
public void Dispose() => _key.Dispose();
}
@@ -0,0 +1,330 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
namespace GameLauncher.ApiClient;
/// <summary>
/// Client de la Launcher API. L'adresse vient de la configuration INTÉGRÉE à l'exécutable (jamais
/// d'un fichier modifiable) ; HTTPS obligatoire (HTTP accepté uniquement en local, pour le
/// développement). Les redirections ne sont jamais suivies (le jeton ne part que vers cette adresse).
/// </summary>
public sealed class LauncherApiClient : IDisposable
{
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30);
private readonly HttpClient _http;
private readonly bool _ownsHttp;
/// <param name="apiBaseUrl">Adresse de base, ex. « https://launcher.lanisys.fr/api/ ».</param>
/// <param name="handler">Pour les tests ; par défaut un gestionnaire sans redirection automatique.</param>
public LauncherApiClient(Uri apiBaseUrl, string userAgent, HttpMessageHandler? handler = null)
{
if (!apiBaseUrl.IsAbsoluteUri ||
!(apiBaseUrl.Scheme == Uri.UriSchemeHttps || (apiBaseUrl.Scheme == Uri.UriSchemeHttp && apiBaseUrl.IsLoopback)))
throw new ArgumentException("L'adresse de l'API doit être en https://.", nameof(apiBaseUrl));
if (!string.IsNullOrEmpty(apiBaseUrl.Query) || !string.IsNullOrEmpty(apiBaseUrl.Fragment))
throw new ArgumentException("L'adresse de l'API ne doit contenir ni paramètres ni fragment.", nameof(apiBaseUrl));
BaseUrl = apiBaseUrl.AbsoluteUri.EndsWith('/') ? apiBaseUrl : new Uri(apiBaseUrl.AbsoluteUri + "/");
Origin = SignedMessages.NormalizeOrigin(BaseUrl);
_ownsHttp = true;
_http = new HttpClient(handler ?? new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(15),
AutomaticDecompression = DecompressionMethods.None,
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
}, disposeHandler: handler is null)
{
// Délai propre à chaque requête (SendAsync) : 30 s par défaut, plus pour l'envoi de fichiers.
Timeout = Timeout.InfiniteTimeSpan
};
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public Uri BaseUrl { get; }
/// <summary>Origine (« https://hote ») incluse dans les messages signés : celle de l'adresse configurée.</summary>
public string Origin { get; }
public Task<ServerInfo> GetInfoAsync(CancellationToken ct = default) =>
SendAsync<ServerInfo>(HttpMethod.Get, ApiRoutes.Info, null, null, ct);
/// <summary>
/// Connexion : défi, signature par la clé, ouverture de la session. L'heure du serveur est
/// relevée au passage (les requêtes signées restent valides même si l'heure du PC est décalée).
/// </summary>
public async Task<AdminSession> LoginAsync(IAdminSigner signer, string? clientName = null, CancellationToken ct = default)
{
var challenge = await SendAsync<ChallengeResponse>(HttpMethod.Post, ApiRoutes.AuthChallenge,
Serialize(new ChallengeRequest { KeyId = signer.KeyId }), null, ct).ConfigureAwait(false);
var clockOffset = challenge.ServerTime == default ? TimeSpan.Zero : challenge.ServerTime - DateTimeOffset.UtcNow;
if (clockOffset.Duration() > MaxClockOffset)
throw new ApiException(0, "clock_skew",
$"L'horloge de votre PC diffère de plus d'une heure de celle du serveur ({clockOffset.TotalHours:+0.0;-0.0} h) : corrigez la date et l'heure de Windows.");
EnsureSameOrigin(challenge.Origin);
var message = SignedMessages.Login(Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, signer.KeyId);
var signature = await signer.SignAsync(message, ct).ConfigureAwait(false);
var login = await SendAsync<LoginResponse>(HttpMethod.Post, ApiRoutes.AuthLogin, Serialize(new LoginRequest
{
ChallengeId = challenge.ChallengeId,
KeyId = signer.KeyId,
Signature = Convert.ToBase64String(signature),
ClientName = clientName
}), null, ct).ConfigureAwait(false);
return new AdminSession(this, signer, challenge.ServerId, login, clockOffset);
}
/// <summary>
/// Enrôle la clé avec un code d'invitation. Renvoie l'empreinte à communiquer au propriétaire,
/// qui doit la saisir pour approuver la clé.
/// </summary>
public async Task<EnrollmentResponse> EnrollAsync(string invitationCode, IAdminSigner signer, string? keyLabel,
CancellationToken ct = default)
{
var code = InvitationCodes.Normalize(invitationCode)
?? throw new ApiException(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation mal saisi.");
var info = await GetInfoAsync(ct).ConfigureAwait(false);
EnsureSameOrigin(info.Origin);
var proof = await signer.SignAsync(
SignedMessages.Enrollment(Origin, info.ServerId, SignedMessages.HashInvitationCode(code), signer.KeyId), ct).ConfigureAwait(false);
return await SendAsync<EnrollmentResponse>(HttpMethod.Post, ApiRoutes.Enrollment, Serialize(new EnrollmentRequest
{
InvitationCode = code,
PublicKey = Convert.ToBase64String(signer.PublicKey),
KeyLabel = keyLabel,
Proof = Convert.ToBase64String(proof)
}), null, ct).ConfigureAwait(false);
}
// ------------------------------------------------------------------ Demandes des joueurs (routes anonymes)
/// <summary>
/// Envoie une demande de joueur (bouton « Aide ») : défi anti-spam demandé au serveur, résolu sur ce PC (une fraction de
/// seconde, hors du fil de l'interface), puis envoi. Aucune session, aucune clé, aucun identifiant du PC.
/// </summary>
public async Task<FeedbackSubmitted> SendFeedbackAsync(FeedbackSubmission submission, CancellationToken ct = default) =>
(await SendFeedbackAsync(submission, Array.Empty<byte[]>(), ct).ConfigureAwait(false)).Submitted;
/// <summary>
/// Envoie une demande puis ses captures d'écran (déjà nettoyées par <see cref="FeedbackImages.Sanitize"/>), une par
/// une, avec le jeton remis par le serveur. Une capture refusée ou perdue n'annule pas la demande : le résultat
/// indique combien ont été reçues et pourquoi les autres ne l'ont pas été.
/// </summary>
public async Task<FeedbackSendResult> SendFeedbackAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments,
CancellationToken ct = default)
{
if (attachments.Count > FeedbackImages.MaxAttachments) throw new ArgumentException("Trop de captures.", nameof(attachments));
var submitted = await SendSubmissionAsync(submission with { AttachmentCount = attachments.Count }, ct).ConfigureAwait(false);
var sent = 0;
string? error = null;
for (var i = 0; i < attachments.Count && submitted.UploadToken is { } token; i++)
{
var index = i + 1;
for (var attempt = 1; ; attempt++)
{
try
{
await SendAsync<FeedbackAttachmentStored>(HttpMethod.Put, ApiRoutes.FeedbackAttachment(index), attachments[i],
request => request.Headers.Add(FeedbackUpload.TokenHeader, token), ct, FeedbackUpload.ContentType,
TimeSpan.FromMinutes(2)).ConfigureAwait(false);
sent++;
break;
}
catch (ApiException ex) when (ex.Status == 0 && attempt < 2)
{
// coupure : un nouvel essai (même numéro : la capture est remplacée si elle était arrivée)
}
catch (ApiException ex)
{
error = ex.Message;
break;
}
}
}
return new FeedbackSendResult(submitted, sent, error);
}
private async Task<FeedbackSubmitted> SendSubmissionAsync(FeedbackSubmission submission, CancellationToken ct)
{
var challenge = await SendAsync<FeedbackChallenge>(HttpMethod.Post, ApiRoutes.FeedbackChallenge, null, null, ct).ConfigureAwait(false);
if (challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty || !Base64Url.TryDecode(challenge.Nonce, out var nonce) ||
nonce.Length is 0 or > 64)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
var solution = await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
return await SendAsync<FeedbackSubmitted>(HttpMethod.Post, ApiRoutes.Feedback,
Serialize(submission with { ChallengeId = challenge.ChallengeId, Solution = solution }), null, ct).ConfigureAwait(false);
}
// ------------------------------------------------------------------ Transport
internal static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options);
/// <summary>
/// Adresse d'une route, OBLIGATOIREMENT sous l'adresse de base : une route absolue ou
/// « //autre-hote/… » (construite par erreur à partir d'une donnée reçue) ne doit jamais
/// emporter le jeton ailleurs.
/// </summary>
internal Uri Resolve(string route)
{
if (string.IsNullOrEmpty(route) || route.StartsWith('/') || route.StartsWith('\\') || route.Contains("://", StringComparison.Ordinal))
throw new ArgumentException($"Route invalide : « {route} ».", nameof(route));
var uri = new Uri(BaseUrl, route);
if (uri.Scheme != BaseUrl.Scheme || uri.Host != BaseUrl.Host || uri.Port != BaseUrl.Port ||
!uri.AbsolutePath.StartsWith(BaseUrl.AbsolutePath, StringComparison.Ordinal))
throw new ArgumentException($"Route hors de l'API : « {route} ».", nameof(route));
return uri;
}
/// <summary>Écart d'horloge maximal accepté avec le serveur (au-delà : l'heure du PC est à corriger).</summary>
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
Action<HttpRequestMessage>? prepare, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null,
long maxResponseBytes = ApiJson.MaxResponseBytes)
{
using var request = new HttpRequestMessage(method, Resolve(route));
if (body is not null)
{
request.Content = new ByteArrayContent(body);
request.Content.Headers.ContentType = contentType == "application/json"
? new MediaTypeHeaderValue(contentType) { CharSet = "utf-8" }
: new MediaTypeHeaderValue(contentType);
}
prepare?.Invoke(request);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout ?? RequestTimeout);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
using (response)
{
byte[] bytes;
try
{
bytes = await ReadBoundedAsync(response, deadline.Token, maxResponseBytes).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Connexion au serveur d'administration interrompue ({ex.Message}).", ex);
}
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
if (typeof(T) == typeof(byte[])) return (T)(object)bytes; // contenu binaire (capture d'écran…)
try
{
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
?? throw new JsonException("réponse vide");
return value;
}
catch (JsonException ex)
{
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur illisible.", ex);
}
}
}
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct, long maxBytes)
{
if (response.Content.Headers.ContentLength > maxBytes)
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
using var buffer = new MemoryStream();
var chunk = new byte[16 * 1024];
int read;
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > maxBytes)
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
private static ApiException ToException(HttpResponseMessage response, byte[] body)
{
var status = (int)response.StatusCode;
var retryAfter = RetryAfterOf(response);
try
{
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
return new ApiException(status, error.Code, error.Message) { RetryAfter = retryAfter };
}
catch (JsonException)
{
// réponse d'erreur non JSON (nginx, proxy…)
}
var (code, message) = status switch
{
401 => (ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
403 => (ApiErrorCodes.Forbidden, "Action non autorisée."),
404 => (ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
413 => (ApiErrorCodes.InvalidRequest,
"Requête trop volumineuse pour le serveur (envoi de fichiers : vérifiez nginx-publications.conf sur le VPS)."),
429 => (ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
>= 500 => (ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
_ => (ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
};
return new ApiException(status, code, message) { RetryAfter = retryAfter };
}
/// <summary>En-tête Retry-After (en secondes ou en date) ; absent ou incohérent : null.</summary>
private static TimeSpan? RetryAfterOf(HttpResponseMessage response)
{
var header = response.Headers.RetryAfter;
var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null);
return delay is { } value && value >= TimeSpan.Zero ? value : null;
}
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
private void EnsureSameOrigin(string serverOrigin)
{
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
throw new ApiException(0, "origin_mismatch",
$"Le serveur se présente comme « {serverOrigin} » au lieu de « {Origin} » : configuration à vérifier (PublicOrigin).");
}
public void Dispose()
{
if (_ownsHttp) _http.Dispose();
}
}
/// <summary>Réponse sans contenu (204).</summary>
public sealed class NoContent
{
public static readonly NoContent Value = new();
private NoContent() { }
}
/// <summary>Résultat de l'envoi d'une demande et de ses captures d'écran.</summary>
/// <param name="AttachmentsSent">Captures reçues par le serveur.</param>
/// <param name="AttachmentError">Raison de l'échec de la première capture non reçue (null : toutes reçues).</param>
public sealed record FeedbackSendResult(FeedbackSubmitted Submitted, int AttachmentsSent, string? AttachmentError);
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GameLauncher.Contracts;
/// <summary>Options JSON communes au launcher et au serveur (strictes : ni commentaires, ni virgules finales).</summary>
public static class ApiJson
{
public static readonly JsonSerializerOptions Options = Create();
/// <summary>Taille maximale d'une réponse JSON lue par le client (hors transferts de fichiers).</summary>
public const int MaxResponseBytes = 1024 * 1024;
public static JsonSerializerOptions Create() => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
ReadCommentHandling = JsonCommentHandling.Disallow,
AllowTrailingCommas = false,
MaxDepth = 32,
NumberHandling = JsonNumberHandling.Strict
};
}
+173
View File
@@ -0,0 +1,173 @@
namespace GameLauncher.Contracts;
/// <summary>
/// Routes de la Launcher API, relatives à l'adresse de base de l'API
/// (ex. « https://launcher.lanisys.fr/api/ »). Le serveur les publie sous « /api/ ».
/// </summary>
public static class ApiRoutes
{
/// <summary>Préfixe des routes côté serveur (nginx transmet « /api/… » sans le modifier).</summary>
public const string Prefix = "/api";
public const string Version = "v1";
// ------------------------------------------------------------------ Système
public const string Info = "v1/info";
public const string Health = "v1/health";
// ------------------------------------------------------------------ Authentification
public const string AuthChallenge = "v1/auth/challenge";
public const string AuthLogin = "v1/auth/login";
public const string AuthLogout = "v1/auth/logout";
public const string AuthMe = "v1/auth/me";
/// <summary>Enrôlement d'une clé avec un code d'invitation (sans session).</summary>
public const string Enrollment = "v1/enrollment";
// ------------------------------------------------------------------ Administrateurs
public const string Admins = "v1/admins";
public const string Invitations = "v1/admins/invitations";
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
public const string MyAccount = "v1/admins/me";
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
public static string KeyApprove(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/approve";
public static string KeyRevoke(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/revoke";
// ------------------------------------------------------------------ Groupes et permissions (admins.manage)
public const string Groups = "v1/groups";
public const string Permissions = "v1/permissions";
/// <summary>Un groupe (identifiant : minuscules, chiffres, tirets — rien à encoder).</summary>
public static string Group(string groupId) => $"v1/groups/{groupId}";
/// <summary>Une permission personnalisée (« module.action » : rien à encoder).</summary>
public static string Permission(string name) => $"v1/permissions/{name}";
// ------------------------------------------------------------------ Audit
public const string Audit = "v1/audit";
/// <summary>Annonce d'un export CSV du journal (requête signée, inscrite au journal avant l'écriture du fichier).</summary>
public const string AuditExport = "v1/audit/export";
/// <summary>
/// Page du journal : <paramref name="before"/> (facultatif) = entrées antérieures à cet instant (filtre « jusqu'au »),
/// <paramref name="cursor"/> = suite de la page précédente.
/// </summary>
public static string AuditPage(int limit, string? cursor, DateTimeOffset? before)
{
var route = $"{Audit}?limit={limit}";
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
if (before is { } date)
route += $"&before={Uri.EscapeDataString(date.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture))}";
return route;
}
// ------------------------------------------------------------------ Fichiers du site public (www)
public const string Files = "v1/files";
public const string FilesDelete = "v1/files/delete";
public const string ObjectsPrunePattern = "v1/games/{gameId}/objects/prune";
/// <summary>Contenu d'un dossier (chemin relatif à la racine du site, « » : racine).</summary>
public static string FilesFolder(string path) =>
string.IsNullOrEmpty(path) ? Files : $"{Files}?path={Uri.EscapeDataString(path)}";
public static string ObjectsPrune(string gameId) => $"v1/games/{gameId}/objects/prune";
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
public const string Catalog = "v1/catalog";
/// <summary>Envoi d'une image du catalogue (icône ou fond) : corps binaire, requête signée, games.edit.</summary>
public const string CatalogImagePattern = "v1/catalog/images/{kind}";
/// <summary>Préfixe des routes d'envoi d'image (limite de taille propre, voir nginx-api.conf).</summary>
public const string CatalogImagesPrefix = "v1/catalog/images/";
public static string CatalogImage(string kind) => $"v1/catalog/images/{kind}";
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
// ------------------------------------------------------------------ Signature du contenu (phase 7)
/// <summary>État des signatures (trousseau publié, manifests, catalogue) : propriétaires.</summary>
public const string Signing = "v1/signing";
/// <summary>Publication d'un trousseau signé par la clé racine : propriétaires.</summary>
public const string Keyring = "v1/keyring";
public const string ManifestSignaturePattern = "v1/games/{gameId}/signature";
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
// ------------------------------------------------------------------ Demandes des joueurs (aide, avis, bugs…)
/// <summary>Défi anti-spam (anonyme) à résoudre avant l'envoi d'une demande.</summary>
public const string FeedbackChallenge = "v1/feedback/challenge";
/// <summary>Envoi d'une demande par un joueur (anonyme, corps de 16 Ko au plus).</summary>
public const string Feedback = "v1/feedback";
/// <summary>Capture d'écran d'une demande (anonyme, jeton d'envoi ; corps : l'image, 4 Mo au plus).</summary>
public const string FeedbackAttachmentPattern = "v1/feedback/attachments/{index:int}";
public static string FeedbackAttachment(int index) => $"v1/feedback/attachments/{index}";
/// <summary>Compteurs par jeu (feedback.read).</summary>
public const string FeedbackOverview = "v1/feedback/overview";
/// <summary>Formulaires des demandes : lecture (feedback.forms ou feedback.read), publication signée (PUT, feedback.forms).</summary>
public const string FeedbackForms = "v1/feedback/forms";
public const string GameFeedbackPattern = "v1/games/{gameId}/feedback";
public const string GameFeedbackItemPattern = "v1/games/{gameId}/feedback/{id}";
/// <summary>Demandes d'un jeu (identifiant : caractères sûrs ; filtres et curseur encodés).</summary>
public static string GameFeedback(string gameId, string? kind = null, string? status = null, string? cursor = null, int limit = 100)
{
var route = $"v1/games/{gameId}/feedback?limit={limit}";
if (!string.IsNullOrEmpty(kind)) route += $"&kind={Uri.EscapeDataString(kind)}";
if (!string.IsNullOrEmpty(status)) route += $"&status={Uri.EscapeDataString(status)}";
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
return route;
}
/// <summary>Une demande (identifiant : 32 caractères hexadécimaux, rien à encoder).</summary>
public static string GameFeedbackItem(string gameId, string id) => $"v1/games/{gameId}/feedback/{id}";
public const string GameFeedbackAttachmentPattern = "v1/games/{gameId}/feedback/{id}/attachments/{index:int}";
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
// ------------------------------------------------------------------ Mode maintenance du launcher
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
public const string Maintenance = "v1/maintenance";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
public const string PublicationManifestPattern = "v1/publications/{id}/manifest";
public const string PublicationArchivePattern = "v1/publications/{id}/archive";
public const string PublicationObjectsPattern = "v1/publications/{id}/objects";
public const string PublicationObjectPattern = "v1/publications/{id}/objects/{sha256}";
public const string PublicationCommitPattern = "v1/publications/{id}/commit";
/// <summary>Préfixe des routes d'envoi (limite de taille propre, voir nginx-publications.conf).</summary>
public const string PublicationsPrefix = "v1/publications/";
public static string PublicationStart(string gameId) => $"v1/games/{gameId}/publications";
public static string Publication(string id) => $"v1/publications/{id}";
public static string PublicationManifest(string id, long offset) => $"v1/publications/{id}/manifest?offset={offset}";
public static string PublicationArchive(string id, long offset) => $"v1/publications/{id}/archive?offset={offset}";
public static string PublicationObjects(string id) => $"v1/publications/{id}/objects";
public static string PublicationObject(string id, string sha256, long offset) => $"v1/publications/{id}/objects/{sha256}?offset={offset}";
public static string PublicationCommit(string id) => $"v1/publications/{id}/commit";
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text;
namespace GameLauncher.Contracts;
/// <summary>Contrôle des textes courts saisis dans le menu d'administration (noms, libellés).</summary>
public static class ApiText
{
public const int MaxDisplayNameLength = 64;
public const int MaxKeyLabelLength = 64;
public const int MaxClientNameLength = 96;
/// <summary>
/// Texte d'une ligne : espaces normalisés, sans caractère de contrôle ni de mise en forme
/// bidirectionnelle. Null si vide ou trop long (après nettoyage).
/// </summary>
public static string? CleanLine(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var sb = new StringBuilder(value.Length);
var previousSpace = false;
foreach (var c in value.Trim())
{
if (char.IsWhiteSpace(c))
{
if (!previousSpace) sb.Append(' ');
previousSpace = true;
continue;
}
if (char.IsControl(c) || IsFormatting(c)) return null;
sb.Append(c);
previousSpace = false;
}
var text = sb.ToString();
return text.Length == 0 || text.Length > maxLength ? null : text;
}
/// <summary>Caractères invisibles de mise en forme (bidirectionnels, largeur nulle…).</summary>
private static bool IsFormatting(char c) =>
char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format;
}
+33
View File
@@ -0,0 +1,33 @@
namespace GameLauncher.Contracts;
/// <summary>Base64 « URL » sans remplissage (nonces, jetons).</summary>
public static class Base64Url
{
public static string Encode(ReadOnlySpan<byte> data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static bool TryDecode(string? text, out byte[] data)
{
data = Array.Empty<byte>();
if (string.IsNullOrEmpty(text) || text.Length > 4096) return false;
foreach (var c in text)
if (!(c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_')) return false;
var s = text.Replace('-', '+').Replace('_', '/');
switch (s.Length % 4)
{
case 2: s += "=="; break;
case 3: s += "="; break;
case 1: return false;
}
try
{
data = Convert.FromBase64String(s);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -0,0 +1,134 @@
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Informations MODIFIABLES des jeux (catalog.json, publié par le serveur et lu par les launchers).
/// Les jeux restent déclarés dans launcher.json (intégré à l'exe) : une entrée pour un jeu non déclaré
/// est ignorée par le launcher. Toute valeur absente (null) = valeur de launcher.json.
/// Ce fichier est PUBLIC : il ne contient que de l'affichage (aucun nom d'administrateur).
/// </summary>
public sealed record CatalogDocument
{
public const string FormatName = "gamelauncher-catalog";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Numéro de version du contenu, augmenté à chaque publication (contrôle des modifications concurrentes).</summary>
public long Revision { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
public CatalogLauncher? Launcher { get; init; }
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Signature de l'administrateur qui a publié (phase 7) : couvre tout sauf la révision et la date
/// (<see cref="ContentSigning.CatalogDigest"/>). Ignorée par les launchers antérieurs.
/// </summary>
public ContentSignature? Signature { get; init; }
}
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
public sealed record CatalogLauncher
{
public string? LauncherName { get; init; }
/// <summary>Jeu affiché au premier lancement.</summary>
public string? DefaultGame { get; init; }
/// <summary>Ordre des jeux dans la liste (les jeux non cités suivent, dans l'ordre de launcher.json).</summary>
public List<string>? GameOrder { get; init; }
}
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
public sealed record CatalogGame
{
/// <summary>Nom AFFICHÉ, repris par le raccourci du Bureau (le nom de launcher.json reste celui du registre et des dossiers).</summary>
public string? GameName { get; init; }
public string? Tagline { get; init; }
/// <summary>Texte de présentation (plusieurs lignes, texte brut).</summary>
public string? Description { get; init; }
public string? WebsiteUrl { get; init; }
public List<CatalogLink>? Links { get; init; }
/// <summary>Thème INTÉGRÉ au launcher (« default », « exovival »…), jamais un fichier.</summary>
public string? Theme { get; init; }
/// <summary>Image https (PNG ou JPEG) du même serveur que le catalogue.</summary>
public string? Icon { get; init; }
/// <summary>Image de fond https (PNG ou JPEG) du même serveur que le catalogue.</summary>
public string? Background { get; init; }
/// <summary>0 (désactivé) ou 5 à 1440 minutes.</summary>
public int? UpdateCheckIntervalMinutes { get; init; }
/// <summary>Masqué de la liste (un jeu installé sur le PC reste toujours affiché).</summary>
public bool? Hidden { get; init; }
}
/// <summary>Lien affiché sous la présentation du jeu (Discord, YouTube…).</summary>
public sealed record CatalogLink
{
/// <summary>Voir <see cref="CatalogLinkKinds"/> (choisit l'icône intégrée).</summary>
public string Kind { get; init; } = CatalogLinkKinds.Website;
public string? Label { get; init; }
public string Url { get; init; } = "";
}
public static class CatalogLinkKinds
{
public const string Website = "website";
public const string Discord = "discord";
public const string YouTube = "youtube";
public const string X = "x";
public const string Twitch = "twitch";
public const string Steam = "steam";
public const string Other = "other";
public static readonly IReadOnlyList<string> All = new[] { Website, Discord, YouTube, X, Twitch, Steam, Other };
/// <summary>Libellé par défaut d'un lien.</summary>
public static string DefaultLabel(string kind) => kind switch
{
Website => "Site web",
Discord => "Discord",
YouTube => "YouTube",
X => "X (Twitter)",
Twitch => "Twitch",
Steam => "Steam",
_ => "Lien"
};
}
/// <summary>Publication du catalogue depuis le menu d'administration (requête signée, permission games.edit).</summary>
public sealed record CatalogUpdateRequest
{
/// <summary>Révision sur laquelle les modifications ont été faites (refus si quelqu'un a publié entre-temps).</summary>
public long BaseRevision { get; init; }
public CatalogDocument Catalog { get; init; } = new();
}
/// <summary>Catalogue tel que connu du serveur, pour le menu d'administration.</summary>
public sealed record CatalogState
{
public CatalogDocument Catalog { get; init; } = new();
/// <summary>Faux : la publication n'est pas configurée sur le serveur (LauncherApi:Catalog:PublicFile).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du catalogue (pour information).</summary>
public string? PublicUrl { get; init; }
}
@@ -0,0 +1,175 @@
using System.Buffers.Binary;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Images du catalogue (icône, image de fond) envoyées depuis le menu d'administration : contrôles communs
/// au launcher (avant l'envoi) et au serveur (refus de tout fichier non conforme).
/// • PNG ou JPEG uniquement, reconnus à leur contenu (pas à leur extension), <see cref="MaxBytes"/> au plus
/// (la limite de téléchargement des launchers) ;
/// • dimensions lues dans l'en-tête et bornées (pas d'image démesurée à décoder chez les joueurs) ;
/// • fichier complet, sans données ajoutées après la fin de l'image (PNG : bloc IEND ; JPEG : marqueur EOI) ;
/// • nom du fichier publié = empreinte SHA-256 du contenu (<c>&lt;64 hex&gt;.png|.jpg</c>) : une image publiée ne
/// change jamais de contenu (les launchers la gardent en cache sous son adresse), et le launcher des joueurs
/// vérifie que l'image téléchargée a bien cette empreinte. Comme l'adresse figure dans le catalogue signé,
/// l'image est ainsi protégée par la même signature.
/// </summary>
public static partial class CatalogImages
{
/// <summary>Taille maximale d'une image (celle acceptée par les launchers des joueurs).</summary>
public const long MaxBytes = 4 * 1024 * 1024;
/// <summary>Taille maximale d'une requête d'envoi (image + marge) ; voir nginx-api.conf.</summary>
public const long MaxRequestBytes = MaxBytes + 64 * 1024;
public const string Icon = "icon";
public const string Background = "background";
public const string ContentType = "application/octet-stream";
/// <summary>Dossier des images, à côté du fichier public du catalogue.</summary>
public const string FolderName = "images";
/// <summary>Nombre et taille totale des images du dossier (les images inutilisées se suppriment dans « Fichiers du serveur »).</summary>
public const int MaxStoredFiles = 300;
public const long MaxStoredBytes = 300L * 1024 * 1024;
/// <summary>Nombre maximal de pixels (≈ 8K) : borne la mémoire nécessaire au décodage chez les joueurs.</summary>
public const long MaxPixels = 40_000_000;
public static bool IsValidKind(string? kind) => kind is Icon or Background;
public static string KindLabel(string kind) => kind == Icon ? "icône" : "image de fond";
/// <summary>Dimensions acceptées (largeur et hauteur, en pixels) selon le type d'image.</summary>
public static (int MinWidth, int MinHeight, int MaxWidth, int MaxHeight) Bounds(string kind) =>
kind == Icon ? (32, 32, 2048, 2048) : (640, 360, 7680, 4320);
[GeneratedRegex(@"\A[0-9a-f]{64}\.(png|jpg)\z")]
private static partial Regex StoredNameRegex();
/// <summary>Nom d'une image publiée par le serveur (empreinte SHA-256 du contenu + extension).</summary>
public static bool IsStoredName(string? name) => name is not null && StoredNameRegex().IsMatch(name);
/// <summary>
/// Empreinte attendue d'une image dont l'adresse se termine par un nom publié par le serveur
/// (« …/catalog/images/&lt;sha256&gt;.png ») ; null pour une autre adresse (image déposée à la main).
/// </summary>
public static string? ExpectedSha256(Uri url)
{
var name = url.Segments.Length == 0 ? "" : Uri.UnescapeDataString(url.Segments[^1]);
return IsStoredName(name) ? name[..64] : null;
}
/// <summary>Format et dimensions lus dans le contenu.</summary>
/// <param name="Format">« png » ou « jpg » (extension du fichier publié).</param>
public sealed record ImageInfo(string Format, int Width, int Height);
/// <summary>
/// Contrôle complet d'une image pour un type donné : null si elle est acceptable, sinon la raison du refus
/// (en français, affichable). <paramref name="info"/> est renseigné dès que le format est reconnu.
/// </summary>
public static string? Check(string kind, ReadOnlySpan<byte> data, out ImageInfo? info)
{
info = null;
if (!IsValidKind(kind)) return "Type d'image inconnu.";
if (data.Length == 0) return "Fichier vide.";
if (data.Length > MaxBytes) return $"Image trop volumineuse ({data.Length / 1024 / 1024.0:0.0} Mo) : {MaxBytes / 1024 / 1024} Mo au plus.";
string? error;
if (data.StartsWith(PngSignature)) info = ReadPng(data, out error);
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) info = ReadJpeg(data, out error);
else return "Ce fichier n'est pas une image PNG ou JPEG.";
if (info is null) return error;
var (minW, minH, maxW, maxH) = Bounds(kind);
if (info.Width < minW || info.Height < minH || info.Width > maxW || info.Height > maxH ||
(long)info.Width * info.Height > MaxPixels)
return $"Dimensions refusées pour une {KindLabel(kind)} : {info.Width} × {info.Height} px " +
$"(de {minW} × {minH} à {maxW} × {maxH} px).";
return null;
}
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
/// <summary>Dernier bloc d'un PNG : longueur 0, « IEND », CRC fixe.</summary>
private static ReadOnlySpan<byte> PngEnd => new byte[] { 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 };
private static ImageInfo? ReadPng(ReadOnlySpan<byte> data, out string? error)
{
error = "Image PNG invalide ou incomplète.";
// Signature (8) + bloc IHDR : longueur (4) = 13, type (4), largeur (4), hauteur (4)…
if (data.Length < 8 + 8 + 13 + 4 + PngEnd.Length) return null;
if (BinaryPrimitives.ReadUInt32BigEndian(data[8..]) != 13 || !data.Slice(12, 4).SequenceEqual("IHDR"u8)) return null;
var width = BinaryPrimitives.ReadUInt32BigEndian(data[16..]);
var height = BinaryPrimitives.ReadUInt32BigEndian(data[20..]);
if (width is 0 or > int.MaxValue || height is 0 or > int.MaxValue) return null;
if (!data[^PngEnd.Length..].SequenceEqual(PngEnd))
{
error = "Image PNG incomplète, ou suivie de données étrangères à l'image.";
return null;
}
error = null;
return new ImageInfo("png", (int)width, (int)height);
}
private static ImageInfo? ReadJpeg(ReadOnlySpan<byte> data, out string? error)
{
error = "Image JPEG invalide ou incomplète.";
if (data.Length < 4 || data[^2] != 0xFF || data[^1] != 0xD9)
{
error = "Image JPEG incomplète, ou suivie de données étrangères à l'image.";
return null;
}
var i = 2;
while (i + 4 <= data.Length)
{
if (data[i] != 0xFF) return null;
var marker = data[i + 1];
if (marker == 0xFF)
{
i++; // octet de remplissage
continue;
}
if (marker is 0x01 or (>= 0xD0 and <= 0xD7))
{
i += 2; // marqueur sans longueur
continue;
}
if (marker is 0xD8 or 0xD9 or 0xDA) return null; // début d'image, fin ou données avant les dimensions
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 2)..]);
if (length < 2 || i + 2 + length > data.Length) return null;
// SOF0 à SOF15, sauf DHT (C4), JPG (C8) et DAC (CC) : précision (1), hauteur (2), largeur (2).
if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC)
{
if (length < 7) return null;
var height = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 5)..]);
var width = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 7)..]);
if (width == 0 || height == 0) return null;
error = null;
return new ImageInfo("jpg", width, height);
}
i += 2 + length;
}
return null;
}
}
/// <summary>Image envoyée (PUT v1/catalog/images/{kind}) : adresse publique à mettre dans le catalogue.</summary>
public sealed record CatalogImageUploaded
{
/// <summary>Adresse https de l'image, sur le serveur du catalogue.</summary>
public string Url { get; init; } = "";
/// <summary>Nom du fichier publié (empreinte SHA-256 du contenu + extension).</summary>
public string FileName { get; init; } = "";
public string Sha256 { get; init; } = "";
public int Width { get; init; }
public int Height { get; init; }
public long Bytes { get; init; }
/// <summary>Le serveur avait déjà cette image (même contenu) : rien n'a été écrit.</summary>
public bool AlreadyPresent { get; init; }
}
@@ -0,0 +1,274 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Contrôle et nettoyage du catalogue, communs au serveur (refus de toute valeur invalide) et au
/// launcher des joueurs (valeur invalide ignorée : la valeur de launcher.json s'applique).
/// Tout est borné : textes, nombre de jeux et de liens, adresses (https uniquement), images (même
/// serveur que le catalogue, PNG ou JPEG), thème (identifiant d'un thème intégré, jamais un fichier).
/// </summary>
public static partial class CatalogRules
{
/// <summary>
/// Taille maximale de catalog.json. Inférieure à la taille maximale d'une requête de l'API (64 Ko :
/// Limits:MaxRequestBodyBytes et client_max_body_size de nginx), qui n'est donc pas à augmenter.
/// </summary>
public const int MaxFileBytes = 48 * 1024;
/// <summary>Format du fichier publié (JSON indenté, écrit par le serveur).</summary>
public static readonly JsonSerializerOptions PublishedJson = new(ApiJson.Options) { WriteIndented = true };
/// <summary>Taille (octets) du fichier publié pour ce catalogue.</summary>
public static int PublishedSize(CatalogDocument catalog) =>
Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(catalog, PublishedJson));
public const int MaxGames = 50;
public const int MaxGameNameLength = 80;
public const int MaxLauncherNameLength = 80;
public const int MaxTaglineLength = 200;
public const int MaxDescriptionLength = 2000;
public const int MaxDescriptionLines = 40;
public const int MaxLinks = 8;
public const int MaxLinkLabelLength = 40;
public const int MaxUrlLength = 500;
public const int MinUpdateInterval = 5;
public const int MaxUpdateInterval = 1440;
[GeneratedRegex(@"\A[A-Za-z0-9_.\-]{1,64}\z")]
private static partial Regex GameIdRegex();
[GeneratedRegex(@"\A[a-z0-9][a-z0-9\-]{0,31}\z")]
private static partial Regex ThemeRegex();
public static bool IsValidGameId(string? id) => id is not null && GameIdRegex().IsMatch(id);
public static bool IsValidTheme(string? theme) => theme is not null && ThemeRegex().IsMatch(theme);
/// <summary>
/// Nettoie le catalogue. Chaque valeur invalide est retirée (null) et décrite dans <paramref name="errors"/> :
/// le serveur refuse la publication s'il y en a, le launcher ignore simplement ces valeurs.
/// </summary>
/// <param name="imageHost">Hôte autorisé pour les images (celui du catalogue) ; null : https suffit.</param>
public static CatalogDocument Normalize(CatalogDocument input, string? imageHost, List<string> errors)
{
if (input.Format != CatalogDocument.FormatName) errors.Add("Format de catalogue inconnu.");
if (input.Version != CatalogDocument.CurrentVersion) errors.Add($"Version de catalogue non prise en charge ({input.Version}).");
if (input.Revision < 0) errors.Add("Révision invalide.");
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
foreach (var (id, game) in input.Games ?? new Dictionary<string, CatalogGame>())
{
if (!IsValidGameId(id))
{
errors.Add($"Identifiant de jeu invalide : « {Truncate(id)} ».");
continue;
}
if (games.ContainsKey(id))
{
errors.Add($"Jeu « {id} » présent plusieurs fois.");
continue;
}
if (games.Count >= MaxGames)
{
errors.Add($"Trop de jeux (au plus {MaxGames}).");
break;
}
if (game is null) continue;
var cleaned = NormalizeGame(id, game, imageHost, errors);
if (!IsEmpty(cleaned)) games[id] = cleaned;
}
return new CatalogDocument
{
Revision = Math.Max(0, input.Revision),
UpdatedAt = input.UpdatedAt,
Launcher = NormalizeLauncher(input.Launcher, errors),
Games = games
};
}
private static CatalogLauncher? NormalizeLauncher(CatalogLauncher? launcher, List<string> errors)
{
if (launcher is null) return null;
var name = Line(launcher.LauncherName, MaxLauncherNameLength, "Nom du launcher", errors);
string? defaultGame = null;
if (!string.IsNullOrWhiteSpace(launcher.DefaultGame))
{
if (IsValidGameId(launcher.DefaultGame.Trim())) defaultGame = launcher.DefaultGame.Trim();
else errors.Add("Jeu par défaut : identifiant invalide.");
}
List<string>? order = null;
if (launcher.GameOrder is { Count: > 0 })
{
order = new List<string>();
foreach (var id in launcher.GameOrder)
{
if (!IsValidGameId(id))
{
errors.Add($"Ordre des jeux : identifiant invalide « {Truncate(id)} ».");
continue;
}
if (order.Count >= MaxGames)
{
errors.Add($"Ordre des jeux : au plus {MaxGames} jeux.");
break;
}
if (!order.Contains(id, StringComparer.OrdinalIgnoreCase)) order.Add(id);
}
if (order.Count == 0) order = null;
}
return name is null && defaultGame is null && order is null
? null
: new CatalogLauncher { LauncherName = name, DefaultGame = defaultGame, GameOrder = order };
}
private static CatalogGame NormalizeGame(string id, CatalogGame game, string? imageHost, List<string> errors)
{
var prefix = $"Jeu « {id} »";
int? interval = null;
if (game.UpdateCheckIntervalMinutes is { } minutes)
{
if (minutes == 0 || minutes is >= MinUpdateInterval and <= MaxUpdateInterval) interval = minutes;
else errors.Add($"{prefix} : intervalle de recherche des mises à jour invalide (0, ou {MinUpdateInterval} à {MaxUpdateInterval} minutes).");
}
string? theme = null;
if (!string.IsNullOrWhiteSpace(game.Theme))
{
var value = game.Theme.Trim();
if (IsValidTheme(value)) theme = value;
else errors.Add($"{prefix} : thème invalide (identifiant d'un thème intégré au launcher).");
}
return new CatalogGame
{
GameName = Line(game.GameName, MaxGameNameLength, $"{prefix} : nom", errors),
Tagline = Line(game.Tagline, MaxTaglineLength, $"{prefix} : accroche", errors),
Description = Text(game.Description, $"{prefix} : présentation", errors),
WebsiteUrl = Url(game.WebsiteUrl, $"{prefix} : site web", errors),
Links = Links(game.Links, prefix, errors),
Theme = theme,
Icon = Image(game.Icon, imageHost, $"{prefix} : icône", errors),
Background = Image(game.Background, imageHost, $"{prefix} : image de fond", errors),
UpdateCheckIntervalMinutes = interval,
Hidden = game.Hidden == true ? true : null
};
}
private static List<CatalogLink>? Links(List<CatalogLink>? links, string prefix, List<string> errors)
{
if (links is not { Count: > 0 }) return null;
var result = new List<CatalogLink>();
foreach (var link in links)
{
if (link is null) continue;
if (result.Count >= MaxLinks)
{
errors.Add($"{prefix} : au plus {MaxLinks} liens.");
break;
}
var kind = CatalogLinkKinds.All.Contains(link.Kind) ? link.Kind : null;
if (kind is null)
{
errors.Add($"{prefix} : type de lien inconnu « {Truncate(link.Kind)} ».");
continue;
}
var url = Url(link.Url, $"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)}", errors);
if (url is null)
{
if (string.IsNullOrWhiteSpace(link.Url)) errors.Add($"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)} sans adresse.");
continue;
}
var label = Line(link.Label, MaxLinkLabelLength, $"{prefix} : libellé de lien", errors);
result.Add(new CatalogLink { Kind = kind, Label = label, Url = url });
}
return result.Count == 0 ? null : result;
}
// ------------------------------------------------------------------ Valeurs
private static string? Line(string? value, int maxLength, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var cleaned = ApiText.CleanLine(value, maxLength);
if (cleaned is null) errors.Add($"{field} : {maxLength} caractères au plus, sans caractère spécial invisible.");
return cleaned;
}
/// <summary>Texte de plusieurs lignes : fins de ligne normalisées, sans caractère de contrôle ou invisible.</summary>
private static string? Text(string? value, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var lines = value.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var sb = new StringBuilder(value.Length);
for (var i = 0; i < lines.Length; i++)
{
if (i > 0) sb.Append('\n');
var line = lines[i].TrimEnd();
if (line.Length == 0) continue;
// Indentation conservée (espaces de tête), le reste nettoyé comme une ligne simple.
var indent = line.Length - line.TrimStart(' ').Length;
var cleaned = ApiText.CleanLine(line, MaxDescriptionLength);
if (cleaned is null)
{
errors.Add($"{field} : caractère spécial invisible ou de contrôle interdit.");
return null;
}
sb.Append(' ', Math.Min(indent, 8)).Append(cleaned);
}
var text = sb.ToString().Trim('\n');
if (text.Length > MaxDescriptionLength || text.Count(c => c == '\n') + 1 > MaxDescriptionLines)
{
errors.Add($"{field} : {MaxDescriptionLength} caractères et {MaxDescriptionLines} lignes au plus.");
return null;
}
return text.Length == 0 ? null : text;
}
/// <summary>Adresse https absolue, sans identifiants, bornée.</summary>
public static string? Url(string? value, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var text = value.Trim();
if (text.Length > MaxUrlLength || !Uri.TryCreate(text, UriKind.Absolute, out var uri) ||
uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrEmpty(uri.Host) ||
text.Any(char.IsWhiteSpace) || text.Any(char.IsControl))
{
errors.Add($"{field} : adresse https:// complète attendue ({MaxUrlLength} caractères au plus).");
return null;
}
return uri.AbsoluteUri;
}
/// <summary>Image https PNG ou JPEG, sur l'hôte du catalogue.</summary>
private static string? Image(string? value, string? imageHost, string field, List<string> errors)
{
var url = Url(value, field, errors);
if (url is null) return null;
var uri = new Uri(url);
var path = uri.AbsolutePath;
if (!(path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) || !string.IsNullOrEmpty(uri.Query))
{
errors.Add($"{field} : image .png ou .jpg attendue (sans paramètres).");
return null;
}
if (imageHost is not null && !string.Equals(uri.Host, imageHost, StringComparison.OrdinalIgnoreCase))
{
errors.Add($"{field} : l'image doit être sur le serveur du catalogue ({imageHost}).");
return null;
}
return url;
}
private static bool IsEmpty(CatalogGame game) =>
game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null };
private static string Truncate(string? value) =>
value is null ? "" : value.Length <= 40 ? value : value[..40] + "…";
}
@@ -1,11 +1,14 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using GameLauncher.Core.Utils;
using System.Text.RegularExpressions;
namespace GameLauncher.Core.Models;
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes d'une version du jeu (manifest.json → "changelog").
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
///
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
public List<string> Changes { get; set; } = new();
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
[JsonIgnore]
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
[JsonIgnore]
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
@@ -142,6 +147,69 @@ public static class ChangelogLimits
public const int MaxTitleLength = 200;
public const int MaxTagLength = 24;
public const int MaxTextLength = 2000;
public const int MaxVersionLength = 64;
/// <summary>
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
/// </summary>
public const int MaxEditRequestBytes = 60 * 1024;
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
/// <summary>
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
/// </summary>
public static List<string> Check(ChangelogEntry entry)
{
var errors = new List<string>();
void Length(string? value, int max, string what)
{
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
}
Length(entry.Title, MaxTitleLength, "Titre");
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
if (introduction.Count > MaxIntroductionParagraphs)
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
var items = 0;
for (var s = 0; s < sections.Count; s++)
{
var section = sections[s];
var where = $"Section {s + 1}";
Length(section.Title, MaxTitleLength, $"{where} (titre)");
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
items += sectionItems.Count;
for (var i = 0; i < sectionItems.Count; i++)
{
var item = sectionItems[i];
var at = $"{where}, élément {i + 1}";
Length(item.Text, MaxTextLength, at);
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
Length(item.Note, MaxTextLength, $"{at} (note)");
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
}
}
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
errors.Add($"{MaxFlatChanges} modifications au plus.");
return errors.Distinct().ToList();
}
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
{
@@ -229,7 +297,8 @@ public static class ChangelogLimits
var text = sb.ToString().Trim();
if (text.Length == 0) return null;
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
return text;
}
@@ -0,0 +1,34 @@
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
/// l'éditeur du menu d'administration.
/// </summary>
public sealed record ChangelogState
{
public string GameId { get; init; } = "";
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
public string ManifestVersion { get; init; } = "";
/// <summary>
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// </summary>
public string ManifestSha256 { get; init; } = "";
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
public List<ChangelogEntry> Entries { get; init; } = new();
}
/// <summary>
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
/// </summary>
public sealed record ChangelogUpdateRequest
{
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
public string BaseSha256 { get; init; } = "";
public ChangelogEntry? Entry { get; init; }
}
@@ -0,0 +1,99 @@
namespace GameLauncher.Contracts.Dtos;
public sealed record AdminKeySummary
{
public string KeyId { get; init; } = "";
public string? Label { get; init; }
public string Status { get; init; } = "";
/// <summary>
/// Empreinte des clés actives ou révoquées. Volontairement absente pour une clé EN ATTENTE :
/// l'approbateur doit saisir celle que lui communique le nouvel administrateur.
/// </summary>
public string? Fingerprint { get; init; }
/// <summary>
/// Clé publique (SPKI, Base64) des clés ACTIVES seulement (trousseau de la phase 7). Absente pour une clé en
/// attente (on pourrait en déduire l'empreinte) ou révoquée.
/// </summary>
public string? PublicKey { get; init; }
public DateTimeOffset AddedAt { get; init; }
public DateTimeOffset? LastUsedAt { get; init; }
}
public sealed record AdminSummary
{
public Guid Id { get; init; }
public string DisplayName { get; init; } = "";
/// <summary>Identifiant du groupe de l'administrateur (historiquement « rôle »).</summary>
public string Role { get; init; } = "";
/// <summary>Nom affiché du groupe (null : ancien serveur, ou groupe supprimé d'un compte révoqué).</summary>
public string? RoleName { get; init; }
/// <summary>Permissions effectives (celles du groupe, relues à chaque requête par le serveur).</summary>
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
public string Status { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
}
/// <summary>Compte de l'administrateur connecté : ses appareils (clés) et les invitations « nouvel appareil » en cours.</summary>
public sealed record MyAccount
{
public AdminSummary Admin { get; init; } = new();
public IReadOnlyList<InvitationSummary> Invitations { get; init; } = Array.Empty<InvitationSummary>();
}
public sealed record CreateInvitationRequest
{
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
public string? DisplayName { get; init; }
/// <summary>Groupe du nouvel administrateur (identifiant ; ignoré pour un nouvel appareil).</summary>
public string? Role { get; init; }
/// <summary>Renseigné : invitation « nouvel appareil » pour cet administrateur existant.</summary>
public Guid? ForAdminId { get; init; }
}
public sealed record InvitationSummary
{
public Guid Id { get; init; }
public string DisplayName { get; init; } = "";
public string Role { get; init; } = "";
/// <summary>Nom affiché du groupe (null : ancien serveur).</summary>
public string? RoleName { get; init; }
public Guid? ForAdminId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
/// <summary>Administrateur qui a créé l'invitation (null : auteur inconnu).</summary>
public Guid? CreatedById { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
public sealed record InvitationCreated
{
public InvitationSummary Invitation { get; init; } = new();
/// <summary>Code à transmettre au futur administrateur. Affiché UNE SEULE FOIS (le serveur n'en garde que l'empreinte).</summary>
public string Code { get; init; } = "";
}
public sealed record ApproveKeyRequest
{
/// <summary>Empreinte communiquée par le nouvel administrateur (« AB12 CD34 EF56 7890 1A2B »).</summary>
public string Fingerprint { get; init; } = "";
}
public sealed record ChangeRoleRequest
{
/// <summary>Identifiant du nouveau groupe.</summary>
public string Role { get; init; } = "";
}
@@ -0,0 +1,66 @@
namespace GameLauncher.Contracts.Dtos;
public sealed record AuditEntry
{
public DateTimeOffset Timestamp { get; init; }
/// <summary>Action, ex. « auth.login », « admins.invite ».</summary>
public string Action { get; init; } = "";
/// <summary>« ok », « denied » ou « failed ».</summary>
public string Result { get; init; } = "";
public Guid? ActorId { get; init; }
public string? ActorName { get; init; }
public string? KeyId { get; init; }
/// <summary>
/// Nom de l'appareil de la clé <see cref="KeyId"/>, tel que défini à l'enregistrement de la clé (invitation).
/// Jamais écrit dans les fichiers du journal : ajouté par le serveur à la lecture (null : clé inconnue, ou serveur
/// plus ancien).
/// </summary>
public string? KeyLabel { get; init; }
public string? Ip { get; init; }
public string? Target { get; init; }
public string? Details { get; init; }
}
public sealed record AuditPage
{
public IReadOnlyList<AuditEntry> Entries { get; init; } = Array.Empty<AuditEntry>();
/// <summary>Curseur à passer dans « cursor » pour la page suivante (entrées plus anciennes), ou null.</summary>
public string? NextCursor { get; init; }
}
/// <summary>
/// Export CSV du journal par le menu d'administration (requête signée) : le launcher l'annonce au serveur AVANT
/// d'écrire le fichier, et n'écrit rien si le serveur ne l'a pas inscrit au journal (action « audit.export »).
/// </summary>
public sealed record AuditExportRequest
{
/// <summary>Nombre d'entrées exportées.</summary>
public int Count { get; init; }
/// <summary>Période exportée (jours inclus, heure du PC), null si non bornée.</summary>
public DateOnly? From { get; init; }
public DateOnly? To { get; init; }
/// <summary>Autres filtres appliqués (catégorie, résultat, recherche), en clair ; null si aucun.</summary>
public string? Filters { get; init; }
}
public static class AuditExportLimits
{
public const int MaxCount = 1_000_000;
public const int MaxFiltersLength = 300;
}
public static class AuditResults
{
public const string Ok = "ok";
public const string Denied = "denied";
public const string Failed = "failed";
}
+100
View File
@@ -0,0 +1,100 @@
namespace GameLauncher.Contracts.Dtos;
public sealed record ChallengeRequest
{
public string KeyId { get; init; } = "";
}
public sealed record ChallengeResponse
{
public string ChallengeId { get; init; } = "";
public string Nonce { get; init; } = "";
public string ServerId { get; init; } = "";
public string Origin { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
/// <summary>Heure du serveur : le launcher s'y cale pour horodater ses requêtes signées (PC mal réglé).</summary>
public DateTimeOffset ServerTime { get; init; }
}
public sealed record LoginRequest
{
public string ChallengeId { get; init; } = "";
public string KeyId { get; init; } = "";
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Login"/>.</summary>
public string Signature { get; init; } = "";
/// <summary>Description du poste, pour le journal (ex. « GameLauncher 1.4.0 »). Facultatif.</summary>
public string? ClientName { get; init; }
}
/// <summary>Administrateur connecté et ses droits.</summary>
public sealed record AdminProfile
{
public Guid Id { get; init; }
public string DisplayName { get; init; } = "";
/// <summary>Identifiant du groupe (historiquement « rôle »).</summary>
public string Role { get; init; } = "";
/// <summary>Nom affiché du groupe (null : ancien serveur).</summary>
public string? RoleName { get; init; }
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
public string KeyId { get; init; } = "";
public string? KeyLabel { get; init; }
}
public sealed record LoginResponse
{
/// <summary>Jeton de session (en-tête « Authorization: Bearer … »). À garder en mémoire uniquement.</summary>
public string Token { get; init; } = "";
/// <summary>Identifiant public de la session, inclus dans les requêtes signées.</summary>
public string SessionId { get; init; } = "";
/// <summary>Fin de session au plus tard (durée maximale).</summary>
public DateTimeOffset ExpiresAt { get; init; }
/// <summary>Fin de session sans activité.</summary>
public DateTimeOffset IdleExpiresAt { get; init; }
public AdminProfile Admin { get; init; } = new();
}
/// <summary>Session en cours (GET v1/auth/me) : prolonge la session (activité).</summary>
public sealed record SessionInfo
{
public string SessionId { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset IdleExpiresAt { get; init; }
public AdminProfile Admin { get; init; } = new();
}
/// <summary>Enrôlement d'une clé avec un code d'invitation.</summary>
public sealed record EnrollmentRequest
{
public string InvitationCode { get; init; } = "";
/// <summary>Clé publique (SPKI en Base64).</summary>
public string PublicKey { get; init; } = "";
/// <summary>Nom du poste (ex. « PC bureau »).</summary>
public string? KeyLabel { get; init; }
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Enrollment"/> : preuve de possession.</summary>
public string Proof { get; init; } = "";
}
public sealed record EnrollmentResponse
{
public Guid AdminId { get; init; }
public string DisplayName { get; init; } = "";
public string KeyId { get; init; } = "";
/// <summary>Empreinte à communiquer au propriétaire, qui la saisit pour approuver.</summary>
public string Fingerprint { get; init; } = "";
public string Status { get; init; } = "";
}
@@ -0,0 +1,76 @@
namespace GameLauncher.Contracts.Dtos;
/// <summary>Groupe d'administrateurs (page « Groupes et permissions »).</summary>
public sealed record GroupSummary
{
/// <summary>Identifiant stable (« owner », « admin », « editor », « moderateurs »…), utilisé par les comptes et les invitations.</summary>
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string? Description { get; init; }
/// <summary>Permissions effectives (groupe système : toutes, y compris les permissions personnalisées).</summary>
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
/// <summary>Groupe système (« Propriétaire ») : toutes les permissions, ni modifiable ni supprimable.</summary>
public bool System { get; init; }
/// <summary>Révision : une modification préparée sur une révision dépassée est refusée (409 revision_conflict).</summary>
public long Revision { get; init; }
/// <summary>Comptes actifs ou en attente du groupe (les comptes révoqués ne comptent pas).</summary>
public int MemberCount { get; init; }
/// <summary>Invitations en cours vers ce groupe.</summary>
public int InvitationCount { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
public string? UpdatedBy { get; init; }
}
/// <summary>Permission connue du serveur : intégrée (vérifiée par le code) ou personnalisée.</summary>
public sealed record PermissionSummary
{
/// <summary>« module.action ».</summary>
public string Name { get; init; } = "";
public string Label { get; init; } = "";
public string? Description { get; init; }
/// <summary>Intégrée : vérifiée par le serveur et le launcher, jamais supprimable.</summary>
public bool BuiltIn { get; init; }
/// <summary>Groupes qui ont cette permission (le groupe système compte).</summary>
public int GroupCount { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}
/// <summary>GET v1/groups : groupes et permissions connues.</summary>
public sealed record GroupsState
{
public IReadOnlyList<GroupSummary> Groups { get; init; } = Array.Empty<GroupSummary>();
public IReadOnlyList<PermissionSummary> Permissions { get; init; } = Array.Empty<PermissionSummary>();
}
/// <summary>Création (POST v1/groups) ou modification (PUT v1/groups/{id}) d'un groupe.</summary>
public sealed record GroupRequest
{
public string Name { get; init; } = "";
public string? Description { get; init; }
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
/// <summary>Modification : révision sur laquelle elle a été préparée (ignorée à la création).</summary>
public long Revision { get; init; }
}
/// <summary>Création d'une permission personnalisée (POST v1/permissions).</summary>
public sealed record CreatePermissionRequest
{
/// <summary>« module.action », ex. « news.edit ».</summary>
public string Name { get; init; } = "";
public string Label { get; init; } = "";
public string? Description { get; init; }
}
@@ -0,0 +1,80 @@
namespace GameLauncher.Contracts.Dtos;
/// <summary>Informations publiques du serveur (GET v1/info).</summary>
public sealed record ServerInfo
{
/// <summary>Identifiant aléatoire propre à ce serveur, inclus dans les messages signés.</summary>
public string ServerId { get; init; } = "";
/// <summary>Adresse publique de l'API (« https://hote »), incluse dans les messages signés.</summary>
public string Origin { get; init; } = "";
public string ApiVersion { get; init; } = ApiRoutes.Version;
public string ServerVersion { get; init; } = "";
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
}
public sealed record HealthStatus
{
public string Status { get; init; } = "ok";
}
/// <summary>Erreur renvoyée par l'API (corps des réponses 4xx/5xx).</summary>
public sealed record ApiError
{
public int Status { get; init; }
/// <summary>Code stable, voir <see cref="ApiErrorCodes"/>.</summary>
public string Code { get; init; } = ApiErrorCodes.Error;
/// <summary>Message destiné à l'administrateur (français).</summary>
public string Message { get; init; } = "";
}
public static class ApiErrorCodes
{
public const string Error = "error";
public const string InvalidRequest = "invalid_request";
public const string Unauthorized = "unauthorized";
public const string Forbidden = "forbidden";
public const string NotFound = "not_found";
public const string Conflict = "conflict";
public const string RateLimited = "rate_limited";
public const string SignatureRequired = "signature_required";
public const string SignatureInvalid = "signature_invalid";
public const string ClockSkew = "clock_skew";
public const string Replay = "replay";
public const string InvitationInvalid = "invitation_invalid";
public const string FingerprintMismatch = "fingerprint_mismatch";
public const string LastOwner = "last_owner";
public const string SelfAction = "self_action";
public const string Busy = "busy";
public const string HttpsRequired = "https_required";
/// <summary>Quelqu'un a modifié la même donnée entre-temps (révision dépassée) : recharger avant de recommencer.</summary>
public const string RevisionConflict = "revision_conflict";
/// <summary>Fonction non configurée sur le serveur.</summary>
public const string NotConfigured = "not_configured";
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
public const string OffsetMismatch = "offset_mismatch";
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
public const string PublicationInProgress = "publication_in_progress";
/// <summary>Espace disque insuffisant sur le serveur.</summary>
public const string InsufficientStorage = "insufficient_storage";
/// <summary>Demandes des joueurs : plafond atteint (nombre, taille, espace disque), envoi refusé pour l'instant.</summary>
public const string FeedbackFull = "feedback_full";
/// <summary>Les formulaires des demandes ont été republiés pendant la saisie : le launcher les recharge.</summary>
public const string FeedbackFormChanged = "feedback_form_changed";
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
public const string ProofInvalid = "proof_invalid";
}
@@ -0,0 +1,237 @@
namespace GameLauncher.Contracts.Feedback;
// ------------------------------------------------------------------ Joueur (routes anonymes)
/// <summary>Défi anti-spam (POST v1/feedback/challenge) : à résoudre avant l'envoi, voir <see cref="FeedbackProof"/>.</summary>
public sealed record FeedbackChallenge
{
/// <summary>Identifiant scellé par le serveur (sans état), à renvoyer avec la demande.</summary>
public string ChallengeId { get; init; } = "";
/// <summary>Valeur aléatoire (base64url) à combiner avec la solution.</summary>
public string Nonce { get; init; } = "";
/// <summary>Nombre de bits à zéro exigés en tête de l'empreinte.</summary>
public int Difficulty { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Informations techniques jointes à une demande (facultatives, choisies par le joueur).</summary>
public sealed record FeedbackTechInfo
{
public string? LauncherVersion { get; init; }
/// <summary>Version du jeu installée (absente si le jeu n'est pas installé).</summary>
public string? GameVersion { get; init; }
/// <summary>« Windows 11 64 bits (10.0.26100) ».</summary>
public string? Os { get; init; }
/// <summary>Langue de Windows (« fr-FR »).</summary>
public string? Language { get; init; }
}
/// <summary>
/// Demande envoyée par un joueur (POST v1/feedback), SANS compte : aucun identifiant du joueur ni de son PC.
/// Le serveur ne fait confiance à rien : il revalide tout avec <see cref="FeedbackRules"/> et pose lui-même la date,
/// l'identifiant, la référence et le statut.
/// </summary>
public sealed record FeedbackSubmission
{
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public int FormVersion { get; init; } = FeedbackForms.CurrentVersion;
/// <summary>
/// Révision des formulaires utilisés (<see cref="FeedbackFormsDocument.Revision"/>) ; 0 : formulaires d'origine
/// (launchers 3.0 / 3.1, ou formulaires publiés illisibles). Une autre révision que celle en vigueur : 409.
/// </summary>
public long FormRevision { get; init; }
public string Title { get; init; } = "";
/// <summary>Valeurs des champs du formulaire (identifiant du champ → valeur).</summary>
public Dictionary<string, string> Fields { get; init; } = new();
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures d'écran annoncées (0 à <see cref="FeedbackImages.MaxAttachments"/>), envoyées ensuite une par une.</summary>
public int AttachmentCount { get; init; }
/// <summary>Défi résolu (<see cref="FeedbackChallenge.ChallengeId"/>) et sa solution.</summary>
public string ChallengeId { get; init; } = "";
public string Solution { get; init; } = "";
}
/// <summary>Demande enregistrée : référence à citer (Discord, e-mail…).</summary>
public sealed record FeedbackSubmitted
{
public string Reference { get; init; } = "";
/// <summary>
/// Jeton d'envoi des captures annoncées (null sans capture) : à joindre à chaque envoi (en-tête
/// <see cref="FeedbackUpload.TokenHeader"/>). Valable <see cref="FeedbackUpload.TokenLifetimeMinutes"/> minutes,
/// seulement pour cette demande et le nombre de captures annoncé ; connu du seul launcher qui a envoyé la demande.
/// </summary>
public string? UploadToken { get; init; }
}
/// <summary>Envoi des captures d'écran d'une demande (PUT v1/feedback/attachments/{index}, corps : l'image).</summary>
public static class FeedbackUpload
{
public const string TokenHeader = "X-GL-Feedback-Upload";
public const int TokenLifetimeMinutes = 15;
public const string ContentType = "application/octet-stream";
}
/// <summary>Capture d'écran enregistrée (nettoyée de ses métadonnées par le serveur).</summary>
public sealed record FeedbackAttachmentInfo
{
/// <summary>Numéro (1 à <see cref="FeedbackImages.MaxAttachments"/>).</summary>
public int Index { get; init; }
/// <summary>« png » ou « jpg ».</summary>
public string Format { get; init; } = "";
public int Width { get; init; }
public int Height { get; init; }
public long Bytes { get; init; }
}
/// <summary>Capture reçue (réponse du serveur).</summary>
public sealed record FeedbackAttachmentStored
{
public int Index { get; init; }
}
// ------------------------------------------------------------------ Menu d'administration
/// <summary>Statuts d'une demande (traitement par les administrateurs).</summary>
public static class FeedbackStatuses
{
public const string New = "new";
public const string InProgress = "in_progress";
public const string Done = "done";
public const string Rejected = "rejected";
public const string Duplicate = "duplicate";
public static readonly IReadOnlyList<string> All = new[] { New, InProgress, Done, Rejected, Duplicate };
public static bool IsValid(string? status) => status is not null && All.Contains(status, StringComparer.Ordinal);
/// <summary>Demande close (traitée, rejetée, doublon) : comptée pour la durée de conservation après clôture.</summary>
public static bool IsClosed(string? status) => status is Done or Rejected or Duplicate;
public static string Label(string? status) => status switch
{
New => "Nouvelle",
InProgress => "En cours",
Done => "Traitée",
Rejected => "Rejetée",
Duplicate => "Doublon",
_ => status ?? "?"
};
}
/// <summary>Résumé d'une demande (listes).</summary>
public sealed record FeedbackSummary
{
public string Id { get; init; } = "";
public string Reference { get; init; } = "";
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public string Title { get; init; } = "";
public string Status { get; init; } = FeedbackStatuses.New;
public DateTimeOffset ReceivedAt { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
/// <summary>Note (avis) ou gravité (bug), pour l'aperçu de la liste.</summary>
public string? Badge { get; init; }
public bool HasNote { get; init; }
/// <summary>Le titre ou un champ contient un lien ou une adresse (<see cref="FeedbackLinks"/>) : prudence.</summary>
public bool HasLinks { get; init; }
/// <summary>Captures d'écran reçues.</summary>
public int AttachmentCount { get; init; }
public long Revision { get; init; }
}
/// <summary>Page de demandes d'un jeu (GET v1/games/{gameId}/feedback), les plus récentes d'abord.</summary>
public sealed record FeedbackPage
{
public IReadOnlyList<FeedbackSummary> Items { get; init; } = Array.Empty<FeedbackSummary>();
/// <summary>Suite de la liste (null : fin).</summary>
public string? NextCursor { get; init; }
/// <summary>Nombre de demandes correspondant aux filtres.</summary>
public int Total { get; init; }
}
/// <summary>Demande complète (GET v1/games/{gameId}/feedback/{id}).</summary>
public sealed record FeedbackDetail
{
public FeedbackSummary Summary { get; init; } = new();
public int FormVersion { get; init; }
/// <summary>Révision des formulaires avec laquelle la demande a été envoyée (0 : formulaires d'origine).</summary>
public long FormRevision { get; init; }
/// <summary>
/// Formulaire tel qu'il était à l'envoi (libellés, types, choix) : une demande reste lisible même si le formulaire a
/// changé depuis. Null : demande reçue avant la 3.2 (formulaires d'origine).
/// </summary>
public FeedbackForm? Form { get; init; }
public Dictionary<string, string> Fields { get; init; } = new();
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures d'écran reçues (contenu : GET …/attachments/{index}).</summary>
public IReadOnlyList<FeedbackAttachmentInfo> Attachments { get; init; } = Array.Empty<FeedbackAttachmentInfo>();
/// <summary>Note interne des administrateurs (jamais visible du joueur).</summary>
public string? AdminNote { get; init; }
/// <summary>Nom de l'administrateur qui a changé le statut ou la note en dernier.</summary>
public string? StatusChangedBy { get; init; }
}
/// <summary>Changement de statut et/ou de note interne (PUT, requête signée).</summary>
public sealed record FeedbackUpdateRequest
{
/// <summary>Révision lue : 409 « revision_conflict » si la demande a changé entre-temps.</summary>
public long Revision { get; init; }
public string Status { get; init; } = FeedbackStatuses.New;
public string? AdminNote { get; init; }
}
/// <summary>Compteurs d'un jeu (tableau de bord, tuiles du choix du jeu).</summary>
public sealed record FeedbackGameCounts
{
public string GameId { get; init; } = "";
public int New { get; init; }
public int Open { get; init; }
public int Total { get; init; }
}
/// <summary>Vue d'ensemble (GET v1/feedback/overview).</summary>
public sealed record FeedbackOverview
{
/// <summary>Réception des demandes activée sur le serveur (LauncherApi:Feedback:Enabled).</summary>
public bool Enabled { get; init; }
/// <summary>Plafond atteint (nombre, taille ou espace disque) : les nouvelles demandes sont refusées.</summary>
public bool StorageFull { get; init; }
public IReadOnlyList<FeedbackGameCounts> Games { get; init; } = Array.Empty<FeedbackGameCounts>();
/// <summary>Durées de conservation appliquées (mois).</summary>
public int RetentionClosedMonths { get; init; }
public int RetentionMaxMonths { get; init; }
}
@@ -0,0 +1,199 @@
using System.Text.Json.Serialization;
namespace GameLauncher.Contracts.Feedback;
/// <summary>Type d'un champ de formulaire (écrit en toutes lettres dans le fichier des formulaires).</summary>
[JsonConverter(typeof(JsonStringEnumConverter<FeedbackFieldKind>))]
public enum FeedbackFieldKind
{
/// <summary>Zone de texte sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
Multiline,
/// <summary>Liste déroulante : un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
Choice,
/// <summary>Note en étoiles, de 1 à <see cref="FeedbackField.RatingMax"/> (valeur : « 1 », « 2 »…).</summary>
Rating,
/// <summary>Texte court sur une ligne (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
Text,
/// <summary>Cases à cocher : plusieurs choix (valeur : identifiants des options cochées, séparés par des virgules).</summary>
Checkboxes,
/// <summary>Case oui/non (valeur : « yes » ou « no » ; obligatoire = doit être cochée).</summary>
YesNo
}
/// <summary>Option d'un champ à choix : identifiant stable (envoyé au serveur) et libellé affiché.</summary>
public sealed record FeedbackOption(string Id, string Label);
/// <summary>Champ prédéfini d'un formulaire.</summary>
/// <param name="Id">Identifiant stable (clé dans <see cref="FeedbackSubmission.Fields"/>).</param>
/// <param name="Label">Libellé affiché (joueur et menu d'administration).</param>
/// <param name="Hint">Aide sous le champ, ou texte indicatif dans la zone de saisie.</param>
/// <param name="RatingMax">Nombre d'étoiles d'une note (3, 5 ou 10).</param>
/// <param name="ShowInList">Valeur affichée en aperçu dans la liste des demandes du menu (un champ par formulaire au plus).</param>
public sealed record FeedbackField(
string Id,
string Label,
FeedbackFieldKind Kind,
bool Required,
string? Hint = null,
int MaxLength = FeedbackRules.MaxMultilineLength,
IReadOnlyList<FeedbackOption>? Options = null,
int RatingMax = 5,
bool ShowInList = false)
{
[JsonIgnore]
public IReadOnlyList<FeedbackOption> Choices => Options ?? Array.Empty<FeedbackOption>();
/// <summary>Champ à saisir au clavier (zone de texte ou texte court).</summary>
[JsonIgnore]
public bool IsText => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
/// <summary>Libellé d'une valeur (option(s) choisie(s), « 4/5 » pour une note, « Oui »/« Non », texte sinon).</summary>
public string DisplayValue(string? value) => Kind switch
{
_ when string.IsNullOrEmpty(value) => "",
FeedbackFieldKind.Choice => Choices.FirstOrDefault(o => o.Id == value)?.Label ?? value!,
FeedbackFieldKind.Checkboxes => string.Join(", ", value!.Split(',').Select(id => Choices.FirstOrDefault(o => o.Id == id)?.Label ?? id)),
FeedbackFieldKind.Rating => $"{value}/{RatingMax}",
FeedbackFieldKind.YesNo => value == FeedbackRules.Yes ? "Oui" : "Non",
_ => value!
};
}
/// <summary>Formulaire d'un type de demande.</summary>
/// <param name="Kind">Identifiant stable (<see cref="FeedbackKinds"/>).</param>
/// <param name="IconKey">Icône du launcher (ressource « Icon.&lt;clé&gt; »).</param>
/// <param name="TitleHint">Texte indicatif du titre de la demande.</param>
/// <param name="TechByDefault">« Joindre les informations techniques » coché à l'ouverture du formulaire.</param>
/// <param name="Hidden">Type masqué : n'est plus proposé aux joueurs (les demandes déjà reçues restent consultables).</param>
/// <param name="AllowAttachments">Captures d'écran proposées pour ce type (3 au plus, contrôlées et nettoyées).</param>
/// <param name="CustomIcon">Icône importée (PNG 64 × 64 en base64, <see cref="FeedbackIcons"/>) ; null : <paramref name="IconKey"/>.</param>
public sealed record FeedbackForm(
string Kind,
string Label,
string Description,
string IconKey,
string TitleHint,
bool TechByDefault,
IReadOnlyList<FeedbackField> Fields,
bool Hidden = false,
bool AllowAttachments = true,
string? CustomIcon = null)
{
/// <summary>Octets de l'icône importée (null : icône d'origine <see cref="IconKey"/>).</summary>
public byte[]? CustomIconBytes() => FeedbackIcons.Decode(CustomIcon);
public FeedbackField? Field(string id) => (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.Id == id);
/// <summary>Valeur montrée en aperçu dans la liste du menu (champ « afficher en aperçu »), ou null.</summary>
public string? Badge(IReadOnlyDictionary<string, string>? values)
{
var field = (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.ShowInList);
return field is not null && values is not null && values.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value)
? field.DisplayValue(value)
: null;
}
}
/// <summary>Types de demande (identifiants stables, jamais traduits).</summary>
public static class FeedbackKinds
{
public const string Review = "review";
public const string Feature = "feature";
public const string Bug = "bug";
public const string Other = "other";
}
/// <summary>
/// Formulaires D'ORIGINE des demandes des joueurs (révision 0). Ils servent tant qu'aucun formulaire n'a été publié
/// depuis le menu d'administration (<see cref="FeedbackFormsDocument"/>), aux launchers qui n'ont pas pu lire les
/// formulaires publiés, aux launchers 3.0 / 3.1, et à l'affichage des demandes reçues avant la 3.2.
/// Ne pas les modifier : les formulaires se changent désormais depuis le menu (page « Formulaires des demandes »).
/// </summary>
public static class FeedbackForms
{
/// <summary>Version du format des demandes (enregistrée avec chaque demande).</summary>
public const int CurrentVersion = 1;
private static FeedbackOption O(string id, string label) => new(id, label);
public static readonly IReadOnlyList<FeedbackForm> All = new[]
{
new FeedbackForm(FeedbackKinds.Review, "Avis", "Dites-nous ce que vous pensez du jeu.", "Star",
"Votre avis en une phrase", TechByDefault: false, new[]
{
new FeedbackField("rating", "Note", FeedbackFieldKind.Rating, Required: true, ShowInList: true),
new FeedbackField("topic", "Sujet", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("overall", "Le jeu dans son ensemble"), O("gameplay", "Gameplay"), O("graphics", "Graphismes"),
O("sound", "Son et musique"), O("performance", "Performances"), O("story", "Histoire et ambiance"),
O("launcher", "Launcher"), O("other", "Autre")
}),
new FeedbackField("liked", "Ce que vous aimez", FeedbackFieldKind.Multiline, Required: false,
"Ce qui vous plaît le plus.", MaxLength: 1500),
new FeedbackField("improve", "Ce qui pourrait être amélioré", FeedbackFieldKind.Multiline, Required: false,
"Ce qui vous gêne ou vous manque.", MaxLength: 1500),
new FeedbackField("recommend", "Recommanderiez-vous le jeu ?", FeedbackFieldKind.Choice, Required: false, Options: new[]
{
O("yes", "Oui"), O("maybe", "Peut-être"), O("no", "Non")
})
}),
new FeedbackForm(FeedbackKinds.Feature, "Demande d'évolution", "Proposez une idée ou une amélioration.", "Lightbulb",
"Votre idée en une phrase", TechByDefault: false, new[]
{
new FeedbackField("area", "Domaine", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("gameplay", "Gameplay"), O("content", "Contenu (niveaux, objets, ennemis…)"), O("interface", "Interface et menus"),
O("accessibility", "Accessibilité"), O("launcher", "Launcher"), O("other", "Autre")
}),
new FeedbackField("idea", "Description de l'idée", FeedbackFieldKind.Multiline, Required: true,
"Décrivez ce que vous aimeriez voir dans le jeu."),
new FeedbackField("problem", "Quel problème cela résoudrait-il ?", FeedbackFieldKind.Multiline, Required: false,
"Facultatif : pourquoi cette idée vous semble utile.", MaxLength: 1500),
new FeedbackField("importance", "Importance pour vous", FeedbackFieldKind.Choice, Required: false, Options: new[]
{
O("bonus", "Un petit plus"), O("useful", "Utile"), O("important", "Importante")
})
}),
new FeedbackForm(FeedbackKinds.Bug, "Bug", "Signalez un problème rencontré dans le jeu ou le launcher.", "Bug",
"Le problème en une phrase", TechByDefault: true, new[]
{
new FeedbackField("category", "Catégorie", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("crash", "Plantage ou fermeture du jeu"), O("graphics", "Graphismes"), O("sound", "Son"), O("gameplay", "Gameplay"),
O("performance", "Performances (lenteurs, saccades)"), O("install", "Installation ou mise à jour"),
O("launcher", "Launcher"), O("other", "Autre")
}),
new FeedbackField("severity", "Gravité", FeedbackFieldKind.Choice, Required: true, ShowInList: true, Options: new[]
{
O("minor", "Mineur"), O("annoying", "Gênant"), O("blocking", "Bloquant"), O("crash", "Plantage")
}),
new FeedbackField("frequency", "Fréquence", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("always", "À chaque fois"), O("often", "Souvent"), O("sometimes", "Parfois"), O("once", "Une seule fois")
}),
new FeedbackField("steps", "Étapes pour reproduire", FeedbackFieldKind.Multiline, Required: true,
"1. Je lance une partie…\n2. J'ouvre l'inventaire…"),
new FeedbackField("expected", "Résultat attendu", FeedbackFieldKind.Multiline, Required: false,
"Ce qui aurait dû se passer.", MaxLength: 1000),
new FeedbackField("actual", "Résultat obtenu", FeedbackFieldKind.Multiline, Required: true,
"Ce qui s'est passé à la place (message d'erreur…).", MaxLength: 1500)
}),
new FeedbackForm(FeedbackKinds.Other, "Autre", "Une question, une remarque, autre chose.", "Forum",
"Le sujet de votre message", TechByDefault: false, new[]
{
new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true, "Votre message.")
})
};
public static FeedbackForm? Find(string? kind) => All.FirstOrDefault(f => f.Kind == kind);
public static string KindLabel(string? kind) => Find(kind)?.Label ?? kind ?? "?";
}
@@ -0,0 +1,283 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, communs à tous les jeux).
/// Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée « forms » dans le
/// trousseau : le launcher des joueurs le vérifie comme le catalogue avant d'afficher quoi que ce soit.
/// Révision 0 = formulaires d'origine (<see cref="FeedbackForms"/>), jamais publiés.
/// </summary>
public sealed record FeedbackFormsDocument
{
public const string FormatName = "gamelauncher-feedback-forms";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Révision : +1 à chaque publication (couverte par la signature). Envoyée avec chaque demande.</summary>
public long Revision { get; init; }
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>Types de demande (1 à 8), dans l'ordre d'affichage aux joueurs, masqués ou non.</summary>
public List<FeedbackForm> Forms { get; init; } = new();
public ContentSignature? Signature { get; init; }
public FeedbackForm? Find(string? kind) => (Forms ?? new List<FeedbackForm>()).FirstOrDefault(f => f.Kind == kind);
/// <summary>Types proposés aux joueurs (non masqués), dans l'ordre du document.</summary>
[JsonIgnore]
public IEnumerable<FeedbackForm> Visible => (Forms ?? new List<FeedbackForm>()).Where(f => f is not null && !f.Hidden);
}
/// <summary>État des formulaires pour le menu d'administration (GET v1/feedback/forms).</summary>
public sealed record FeedbackFormsState
{
/// <summary>Formulaires en vigueur (ceux d'origine, révision 0, si rien n'a été publié).</summary>
public FeedbackFormsDocument Document { get; init; } = new();
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
public string? PublicUrl { get; init; }
public string? UpdatedBy { get; init; }
}
/// <summary>Publication des formulaires (PUT v1/feedback/forms, requête signée).</summary>
public sealed record FeedbackFormsPublishRequest
{
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
public long BaseRevision { get; init; }
/// <summary>Formulaires signés, révision <see cref="BaseRevision"/> + 1.</summary>
public FeedbackFormsDocument Document { get; init; } = new();
}
/// <summary>
/// Règles des formulaires publiés, communes au menu (contrôle avant de signer), au serveur (refus avant publication)
/// et au launcher (refus d'un fichier hors limites, même signé). Les textes saisis par l'administrateur doivent être
/// déjà propres (une ligne, sans caractère invisible) et ne contiennent AUCUN lien : rien ne doit pouvoir renvoyer le
/// joueur vers un site depuis un formulaire.
/// </summary>
public static partial class FeedbackFormRules
{
/// <summary>Taille du fichier (icônes importées comprises). Publication : route dédiée de nginx et de l'API.</summary>
public const int MaxBytes = 256 * 1024;
/// <summary>Corps de la requête de publication (formulaires + révision de base).</summary>
public const int MaxRequestBytes = MaxBytes + 8 * 1024;
/// <summary>Nombre de types de demande (au moins un proposé aux joueurs).</summary>
public const int MaxForms = 8;
public const int MaxFields = 15;
public const int MinOptions = 2;
public const int MaxOptions = 20;
public const int MaxFormLabelLength = 40;
public const int MaxDescriptionLength = 120;
public const int MaxTitleHintLength = 80;
public const int MaxFieldLabelLength = 80;
public const int MaxHintLength = 200;
public const int MaxHintLines = 4;
public const int MaxOptionLabelLength = 60;
public const int MaxTextLength = 200;
public const int DefaultTextLength = 120;
public const int MinMultilineLength = 20;
/// <summary>Nombres d'étoiles proposés pour une note.</summary>
public static readonly IReadOnlyList<int> RatingSizes = new[] { 3, 5, 10 };
/// <summary>Icônes proposées pour un type de demande : les quatre d'origine (sinon icône importée, <see cref="FeedbackIcons"/>).</summary>
public static IReadOnlyList<string> Icons => FeedbackIcons.DefaultIcons;
/// <summary>Identifiant d'un champ ou d'un choix : stable, jamais affiché (clé des valeurs envoyées).</summary>
[GeneratedRegex(@"\A[a-z][a-z0-9_]{0,31}\z")]
private static partial Regex IdRegex();
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
/// <summary>Formulaires d'origine (révision 0).</summary>
public static FeedbackFormsDocument Defaults() => new() { Revision = 0, Forms = FeedbackForms.All.ToList() };
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Lit feedback-forms.json (null : vide).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static FeedbackFormsDocument? Parse(ReadOnlySpan<byte> json) => JsonSerializer.Deserialize<FeedbackFormsDocument>(json, ApiJson.Options);
public static string Serialize(FeedbackFormsDocument document) => JsonSerializer.Serialize(document, FileOptions);
/// <summary>
/// Condensé signé : format, version, révision et formulaires (tels quels, dans l'ordre ; valeurs vides ramenées à
/// une forme unique). La date de publication et la signature n'en font pas partie.
/// </summary>
public static string Digest(FeedbackFormsDocument document)
{
var forms = (document.Forms ?? new List<FeedbackForm>()).Where(f => f is not null).Select(f => f with
{
CustomIcon = string.IsNullOrEmpty(f.CustomIcon) ? null : f.CustomIcon,
Fields = (f.Fields ?? Array.Empty<FeedbackField>()).Where(x => x is not null).Select(x => x with
{
Hint = string.IsNullOrEmpty(x.Hint) ? null : x.Hint,
Options = x.Options is { Count: > 0 } options ? options.ToList() : null
}).ToList()
}).ToList();
var canonical = new CanonicalForms(document.Format ?? "", document.Version, document.Revision, forms);
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
}
private sealed record CanonicalForms(string Format, int Version, long Revision, List<FeedbackForm> Forms);
/// <summary>
/// Contrôle complet d'un document publié (révision 1 au moins). Les erreurs, en français, désignent le type et le
/// champ concernés ; elles sont ajoutées à <paramref name="errors"/>.
/// </summary>
public static bool Validate(FeedbackFormsDocument? document, List<string> errors)
{
var count = errors.Count;
if (document is null)
{
errors.Add("Formulaires absents.");
return false;
}
if (document.Format != FeedbackFormsDocument.FormatName || document.Version != FeedbackFormsDocument.CurrentVersion)
errors.Add($"Format de formulaires non pris en charge ({document.Format} v{document.Version}).");
if (document.Revision < 1) errors.Add("Révision des formulaires invalide (1 au moins).");
var forms = document.Forms ?? new List<FeedbackForm>();
if (forms.Count is 0 or > MaxForms || forms.Any(f => f is null))
{
errors.Add($"Les formulaires doivent comprendre de 1 à {MaxForms} types de demande.");
return false;
}
var kinds = new HashSet<string>(StringComparer.Ordinal);
foreach (var form in forms)
{
if (!IsValidId(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant invalide (minuscules, chiffres, « _ »).");
else if (!kinds.Add(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant « {form.Kind} » en double.");
}
if (forms.All(f => f.Hidden)) errors.Add("Au moins un type de demande doit rester proposé aux joueurs.");
foreach (var form in forms) ValidateForm(form, errors);
if (errors.Count == count && Encoding.UTF8.GetByteCount(Serialize(document)) > MaxBytes)
errors.Add($"Formulaires trop volumineux (au plus {MaxBytes / 1024} Ko).");
return errors.Count == count;
}
private static void ValidateForm(FeedbackForm form, List<string> errors)
{
var where = $"Type « {form.Label} »";
CheckLine(form.Label, MaxFormLabelLength, required: true, $"{where} : nom", errors);
CheckLine(form.Description, MaxDescriptionLength, required: true, $"{where} : description", errors);
CheckLine(form.TitleHint, MaxTitleHintLength, required: false, $"{where} : exemple de titre", errors);
if (!Icons.Contains(form.IconKey, StringComparer.Ordinal)) errors.Add($"{where} : icône inconnue.");
if (form.CustomIcon is not null && FeedbackIcons.CheckCustom(form.CustomIcon) is { } iconError) errors.Add($"{where} : {iconError}");
var fields = form.Fields ?? Array.Empty<FeedbackField>();
if (fields.Count > MaxFields) errors.Add($"{where} : {MaxFields} champs au plus.");
if (fields.Any(f => f is null))
{
errors.Add($"{where} : champ vide.");
return;
}
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in fields)
{
var label = $"{where}, champ « {field.Label} »";
if (!IsValidId(field.Id)) errors.Add($"{label} : identifiant invalide (minuscules, chiffres, « _ », 32 caractères au plus).");
else if (!ids.Add(field.Id)) errors.Add($"{label} : identifiant « {field.Id} » en double.");
CheckLine(field.Label, MaxFieldLabelLength, required: true, $"{label} : libellé", errors);
CheckHint(field.Hint, $"{label} : aide", errors);
if (!Enum.IsDefined(field.Kind)) errors.Add($"{label} : type de champ inconnu.");
var minLength = field.Kind == FeedbackFieldKind.Multiline ? MinMultilineLength : 1;
var maxLength = field.Kind switch
{
FeedbackFieldKind.Multiline => FeedbackRules.MaxMultilineLength,
FeedbackFieldKind.Text => MaxTextLength,
_ => FeedbackRules.MaxMultilineLength
};
if (field.MaxLength < minLength || field.MaxLength > maxLength)
errors.Add($"{label} : longueur maximale de {minLength} à {maxLength} caractères.");
var options = field.Options ?? Array.Empty<FeedbackOption>();
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes)
{
if (options.Count < MinOptions || options.Count > MaxOptions)
errors.Add($"{label} : {MinOptions} à {MaxOptions} choix.");
var optionIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var option in options)
{
if (option is null || !IsValidId(option.Id) || !optionIds.Add(option.Id))
{
errors.Add($"{label} : identifiant de choix invalide ou en double.");
continue;
}
CheckLine(option.Label, MaxOptionLabelLength, required: true, $"{label} : choix « {option.Label} »", errors);
}
}
else if (options.Count > 0)
{
errors.Add($"{label} : seules les listes et les cases à cocher ont des choix.");
}
if (field.Kind == FeedbackFieldKind.Rating && !RatingSizes.Contains(field.RatingMax))
errors.Add($"{label} : nombre d'étoiles invalide ({string.Join(", ", RatingSizes)}).");
if (field.ShowInList && field.Kind is not (FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo))
errors.Add($"{label} : seules une liste, une note ou une case oui/non peuvent être affichées en aperçu.");
}
if (fields.Count(f => f.ShowInList) > 1) errors.Add($"{where} : un seul champ peut être affiché en aperçu dans la liste.");
}
/// <summary>Texte d'une ligne déjà propre (tel qu'il sera affiché), sans lien.</summary>
private static void CheckLine(string? value, int maxLength, bool required, string what, List<string> errors)
{
if (string.IsNullOrEmpty(value))
{
if (required) errors.Add($"{what} : obligatoire.");
return;
}
if (ApiText.CleanLine(value, maxLength) != value)
errors.Add($"{what} : {maxLength} caractères au plus, sur une ligne, sans espace en trop ni caractère spécial invisible.");
else if (FeedbackLinks.Contains(value))
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
}
/// <summary>Aide d'un champ : quelques lignes déjà propres, sans lien.</summary>
private static void CheckHint(string? value, string what, List<string> errors)
{
if (string.IsNullOrEmpty(value)) return;
var clean = FeedbackRules.CleanMultiline(value, MaxHintLength, out var problem);
if (problem is not null || clean != value || value.Count(c => c == '\n') + 1 > MaxHintLines)
errors.Add($"{what} : {MaxHintLength} caractères et {MaxHintLines} lignes au plus, sans caractère spécial invisible.");
else if (FeedbackLinks.Contains(value))
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
}
/// <summary>
/// Mots qui laissent penser qu'un champ demande des données personnelles ou secrètes : AVERTISSEMENT seulement
/// dans le menu (pas un refus). Les demandes sont anonymes : ne rien demander qui identifie le joueur.
/// </summary>
public static IReadOnlyList<string> PersonalDataWords { get; } = new[]
{
"mot de passe", "password", "e-mail", "email", "courriel", "adresse", "téléphone", "telephone", "nom complet",
"date de naissance", "carte", "iban", "identifiant de connexion", "pseudo steam", "compte"
};
}
@@ -0,0 +1,241 @@
using System.Buffers.Binary;
using System.IO.Compression;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Icônes des types de demande : l'une des quatre icônes d'origine, ou une icône importée depuis un fichier .ico
/// (OBLIGATOIREMENT) contenant une image de 64 × 64 pixels.
/// • L'import (menu d'administration) lit le fichier .ico sans bibliothèque : en-tête et répertoire contrôlés, image
/// 64 × 64 au format PNG (revérifiée) ou bitmap 32 ou 24 bits (avec son masque de transparence) ; tout le reste est
/// refusé. L'image est réécrite en PNG 64 × 64 neuf, sans aucune métadonnée.
/// • Le PNG obtenu est intégré (base64) aux formulaires SIGNÉS : le serveur et les launchers le revérifient
/// (<see cref="CheckCustom"/> : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé).
/// </summary>
public static class FeedbackIcons
{
/// <summary>Côté de l'image attendue dans le fichier .ico et de l'icône publiée.</summary>
public const int Size = 64;
/// <summary>Taille d'un fichier .ico accepté à l'import.</summary>
public const int MaxIcoBytes = 1024 * 1024;
/// <summary>Taille de l'icône publiée (PNG).</summary>
public const int MaxPngBytes = 20 * 1024;
/// <summary>Les quatre icônes d'origine des types de demande (ressources « Icon.&lt;clé&gt; » du launcher).</summary>
public static readonly IReadOnlyList<string> DefaultIcons = new[] { "Star", "Lightbulb", "Bug", "Forum" };
public static string DefaultIconLabel(string? key) => key switch
{
"Star" => "Étoile",
"Lightbulb" => "Ampoule",
"Bug" => "Insecte (bug)",
"Forum" => "Bulles de discussion",
_ => key ?? ""
};
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
/// <summary>
/// Convertit un fichier .ico en icône publiable (PNG 64 × 64 sans métadonnées). Renvoie null si tout va bien, sinon
/// la raison du refus (française, lisible).
/// </summary>
public static string? FromIco(ReadOnlySpan<byte> ico, out byte[]? png)
{
png = null;
if (ico.Length > MaxIcoBytes) return $"Fichier trop volumineux (au plus {MaxIcoBytes / 1024} Ko).";
// ICONDIR : réservé = 0, type = 1 (icône), nombre d'images.
if (ico.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(ico) != 0 || BinaryPrimitives.ReadUInt16LittleEndian(ico[2..]) != 1)
return "Ce fichier n'est pas une icône .ico.";
var count = BinaryPrimitives.ReadUInt16LittleEndian(ico[4..]);
if (count is 0 or > 64 || ico.Length < 6 + count * 16) return "Icône .ico endommagée (répertoire des images).";
var sizes = new List<string>();
for (var i = 0; i < count; i++)
{
var entry = ico.Slice(6 + i * 16, 16);
var width = entry[0] == 0 ? 256 : entry[0];
var height = entry[1] == 0 ? 256 : entry[1];
sizes.Add($"{width} × {height}");
if (width != Size || height != Size) continue;
var length = BinaryPrimitives.ReadUInt32LittleEndian(entry[8..]);
var offset = BinaryPrimitives.ReadUInt32LittleEndian(entry[12..]);
if (length == 0 || offset < 6 + (uint)count * 16 || (ulong)offset + length > (ulong)ico.Length)
return "Icône .ico endommagée (image 64 × 64 hors du fichier).";
var image = ico.Slice((int)offset, (int)length);
return image.StartsWith(PngSignature) ? FromPng(image, out png) : FromBitmap(image, out png);
}
return $"Le fichier .ico doit contenir une image de {Size} × {Size} pixels (images trouvées : {string.Join(", ", sizes.Distinct())}).";
}
/// <summary>
/// Icône publiée (base64) contrôlée : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé (aucune
/// métadonnée). Null si elle est acceptable, sinon la raison du refus.
/// </summary>
public static string? CheckCustom(string? base64)
{
var bytes = Decode(base64);
if (bytes is null) return "Icône importée illisible.";
if (bytes.Length > MaxPngBytes) return $"Icône importée trop volumineuse (au plus {MaxPngBytes / 1024} Ko).";
if (FeedbackImages.Sanitize(bytes, out var info, out var clean) is { } error) return $"Icône importée refusée : {error}";
if (info!.Format != FeedbackImages.Png || info.Width != Size || info.Height != Size)
return $"L'icône importée doit être une image PNG de {Size} × {Size} pixels.";
if (!clean!.AsSpan().SequenceEqual(bytes)) return "Icône importée non nettoyée (métadonnées).";
return null;
}
/// <summary>Octets d'une icône publiée (null : absente ou base64 invalide).</summary>
public static byte[]? Decode(string? base64)
{
if (string.IsNullOrEmpty(base64) || base64.Length > MaxPngBytes * 2) return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
return null;
}
}
// ------------------------------------------------------------------ Images de l'icône
/// <summary>Image PNG de l'icône : revérifiée et nettoyée (métadonnées retirées), 64 × 64 exigé.</summary>
private static string? FromPng(ReadOnlySpan<byte> image, out byte[]? png)
{
png = null;
if (FeedbackImages.Sanitize(image, out var info, out var clean) is { } error) return $"Image 64 × 64 de l'icône refusée : {error}";
if (info!.Width != Size || info.Height != Size) return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
if (clean!.Length > MaxPngBytes) return $"Image de l'icône trop volumineuse (au plus {MaxPngBytes / 1024} Ko une fois nettoyée).";
png = clean;
return null;
}
/// <summary>
/// Image bitmap de l'icône (BITMAPINFOHEADER, sans compression, 32 ou 24 bits) : pixels lus ligne par ligne (de bas
/// en haut), transparence prise du canal alpha (32 bits) ou du masque 1 bit, puis réécrits en PNG neuf.
/// </summary>
private static string? FromBitmap(ReadOnlySpan<byte> image, out byte[]? png)
{
png = null;
if (image.Length < 40 || BinaryPrimitives.ReadUInt32LittleEndian(image) != 40)
return "Image de l'icône dans un format non pris en charge (PNG ou bitmap 24/32 bits seulement).";
var width = BinaryPrimitives.ReadInt32LittleEndian(image[4..]);
var height = BinaryPrimitives.ReadInt32LittleEndian(image[8..]);
var planes = BinaryPrimitives.ReadUInt16LittleEndian(image[12..]);
var bits = BinaryPrimitives.ReadUInt16LittleEndian(image[14..]);
var compression = BinaryPrimitives.ReadUInt32LittleEndian(image[16..]);
if (width != Size || height != Size * 2 || planes != 1)
return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
if (compression != 0 || bits is not (32 or 24))
return "Image de l'icône en 256 couleurs ou moins : enregistrez l'icône en couleurs 24 ou 32 bits (ou PNG).";
var stride = (Size * bits / 8 + 3) & ~3;
var maskStride = (Size + 31) / 32 * 4;
var pixels = image[40..];
if (pixels.Length < stride * Size) return "Icône .ico endommagée (image incomplète).";
var mask = pixels.Length >= stride * Size + maskStride * Size ? pixels.Slice(stride * Size, maskStride * Size) : ReadOnlySpan<byte>.Empty;
var rgba = new byte[Size * Size * 4];
var hasAlpha = false;
for (var y = 0; y < Size; y++)
{
var row = pixels.Slice((Size - 1 - y) * stride, stride); // lignes de bas en haut
for (var x = 0; x < Size; x++)
{
var source = x * (bits / 8);
var target = (y * Size + x) * 4;
rgba[target] = row[source + 2];
rgba[target + 1] = row[source + 1];
rgba[target + 2] = row[source];
rgba[target + 3] = bits == 32 ? row[source + 3] : (byte)255;
if (bits == 32 && row[source + 3] != 0) hasAlpha = true;
}
}
// Pas de canal alpha utilisable : transparence donnée par le masque (bit à 1 = transparent).
if (!hasAlpha && !mask.IsEmpty)
{
for (var y = 0; y < Size; y++)
{
var row = mask.Slice((Size - 1 - y) * maskStride, maskStride);
for (var x = 0; x < Size; x++)
rgba[(y * Size + x) * 4 + 3] = (row[x / 8] & (0x80 >> (x % 8))) != 0 ? (byte)0 : (byte)255;
}
}
else if (!hasAlpha)
{
for (var i = 3; i < rgba.Length; i += 4) rgba[i] = 255;
}
var encoded = EncodePng(rgba);
// Contrôle final avec les règles des images (comme toute icône publiée).
if (FeedbackImages.Sanitize(encoded, out _, out var clean) is { } error) return $"Conversion de l'icône impossible : {error}";
if (clean!.Length > MaxPngBytes) return $"Icône trop détaillée (au plus {MaxPngBytes / 1024} Ko une fois convertie en PNG).";
png = clean;
return null;
}
/// <summary>PNG 64 × 64 RVBA (8 bits par canal, sans entrelacement, filtre « Sub »), sans aucun bloc facultatif.</summary>
private static byte[] EncodePng(byte[] rgba)
{
var raw = new byte[Size * (1 + Size * 4)];
for (var y = 0; y < Size; y++)
{
var rowStart = y * (1 + Size * 4);
raw[rowStart] = 1; // Sub : meilleure compression des aplats
for (var i = 0; i < Size * 4; i++)
{
var value = rgba[y * Size * 4 + i];
var left = i >= 4 ? rgba[y * Size * 4 + i - 4] : (byte)0;
raw[rowStart + 1 + i] = unchecked((byte)(value - left));
}
}
byte[] compressed;
using (var buffer = new MemoryStream())
{
using (var zlib = new ZLibStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true)) zlib.Write(raw);
compressed = buffer.ToArray();
}
using var png = new MemoryStream();
png.Write(PngSignature);
var header = new byte[13];
BinaryPrimitives.WriteInt32BigEndian(header, Size);
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), Size);
header[8] = 8; // bits par canal
header[9] = 6; // RVBA
WriteChunk(png, "IHDR", header);
WriteChunk(png, "IDAT", compressed);
WriteChunk(png, "IEND", Array.Empty<byte>());
return png.ToArray();
}
private static void WriteChunk(Stream stream, string type, byte[] body)
{
Span<byte> number = stackalloc byte[4];
BinaryPrimitives.WriteInt32BigEndian(number, body.Length);
stream.Write(number);
var typeBytes = System.Text.Encoding.ASCII.GetBytes(type);
stream.Write(typeBytes);
stream.Write(body);
BinaryPrimitives.WriteUInt32BigEndian(number, Crc32(typeBytes, body));
stream.Write(number);
}
private static readonly uint[] CrcTable = Enumerable.Range(0, 256).Select(n =>
{
var c = (uint)n;
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
return c;
}).ToArray();
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
{
var c = 0xFFFFFFFFu;
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
}
@@ -0,0 +1,333 @@
using System.Buffers.Binary;
using System.IO.Compression;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Captures d'écran jointes à une demande : contrôle ET nettoyage, mêmes règles au launcher (avant l'envoi), au serveur
/// (refus de tout fichier non conforme) et dans le menu d'administration (avant l'affichage).
/// • <b>PNG ou JPEG uniquement</b>, reconnus à leur contenu, jamais à l'extension. Le launcher convertit les autres
/// formats courants (BMP, GIF, WebP) en PNG avant l'envoi ;
/// • <b>vraie image</b> : structure complète vérifiée. PNG : blocs et CRC, en-tête cohérent, palette si nécessaire,
/// données décompressées de la taille exacte de l'image, filtres de lignes valides. JPEG : segments, tables,
/// une trame baseline ou progressive, scans et fin d'image ;
/// • dimensions bornées (<see cref="MaxSide"/>, <see cref="MaxPixels"/>), taille bornée (<see cref="MaxBytes"/>),
/// aucune donnée après la fin de l'image, décompression interrompue au-delà de la taille attendue (bombe zlib) ;
/// • <b>métadonnées retirées</b> : PNG : blocs texte, eXIf, date… (seuls IHDR, PLTE, tRNS, IDAT, IEND sont
/// gardés) ; JPEG : EXIF (position GPS, appareil…), XMP, commentaires… (seuls JFIF et Adobe sont gardés).
/// </summary>
public static class FeedbackImages
{
public const int MaxAttachments = 3;
public const int MaxBytes = 4 * 1024 * 1024;
/// <summary>Corps d'une requête d'envoi d'une capture (image + marge) ; voir nginx-api.conf.</summary>
public const int MaxRequestBytes = MaxBytes + 16 * 1024;
public const int MinSide = 16;
public const int MaxSide = 4096;
public const long MaxPixels = 4096L * 2304;
/// <summary>Le launcher réduit les captures plus grandes à cette taille (côté le plus long) avant l'envoi.</summary>
public const int TargetMaxSide = 3840;
public const string Png = "png";
public const string Jpeg = "jpg";
/// <summary>Formats acceptés en entrée par le launcher (convertis en PNG/JPEG avant l'envoi).</summary>
public static readonly IReadOnlyList<string> InputExtensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
public sealed record ImageInfo(string Format, int Width, int Height);
/// <summary>Format reconnu au contenu parmi les formats d'entrée du launcher (null : autre chose).</summary>
public static string? DetectInputFormat(ReadOnlySpan<byte> data)
{
if (data.StartsWith(PngSignature)) return Png;
if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) return Jpeg;
if (data.Length >= 6 && (data[..6].SequenceEqual("GIF87a"u8) || data[..6].SequenceEqual("GIF89a"u8))) return "gif";
if (data.Length >= 14 && data[0] == (byte)'B' && data[1] == (byte)'M') return "bmp";
if (data.Length >= 12 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) return "webp";
return null;
}
/// <summary>
/// Contrôle complet et nettoyage. Null si l'image est acceptée (<paramref name="clean"/> : l'image sans
/// métadonnées, à enregistrer ou afficher à la place de l'original), sinon la raison du refus (français).
/// </summary>
public static string? Sanitize(ReadOnlySpan<byte> data, out ImageInfo? info, out byte[]? clean)
{
info = null;
clean = null;
if (data.Length == 0) return "Fichier vide.";
if (data.Length > MaxBytes) return $"Image trop volumineuse : {MaxBytes / 1024 / 1024} Mo au plus.";
string? error;
if (data.StartsWith(PngSignature)) (info, clean, error) = SanitizePng(data);
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) (info, clean, error) = SanitizeJpeg(data);
else return "Ce fichier n'est pas une image PNG ou JPEG.";
if (error is not null)
{
info = null;
clean = null;
return error;
}
if (info!.Width < MinSide || info.Height < MinSide || info.Width > MaxSide || info.Height > MaxSide ||
(long)info.Width * info.Height > MaxPixels)
{
var refused = info;
info = null;
clean = null;
return $"Dimensions refusées : {refused.Width} × {refused.Height} px ({MinSide} à {MaxSide} px de côté).";
}
return null;
}
// ------------------------------------------------------------------ PNG
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
private static (ImageInfo?, byte[]?, string?) SanitizePng(ReadOnlySpan<byte> data)
{
const string invalid = "Image PNG invalide ou incomplète.";
var output = new MemoryStream(data.Length);
output.Write(PngSignature);
var idat = new MemoryStream();
int width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
bool header = false, palette = false, ended = false, idatDone = false, inIdat = false;
var pos = 8;
while (pos < data.Length)
{
if (ended) return (null, null, "Image PNG suivie de données étrangères à l'image.");
if (pos + 12 > data.Length) return (null, null, invalid);
var length = BinaryPrimitives.ReadUInt32BigEndian(data[pos..]);
if (length > int.MaxValue || pos + 12 + (long)length > data.Length) return (null, null, invalid);
var type = data.Slice(pos + 4, 4);
foreach (var b in type)
if (b is not ((>= (byte)'A' and <= (byte)'Z') or (>= (byte)'a' and <= (byte)'z'))) return (null, null, invalid);
var body = data.Slice(pos + 8, (int)length);
var crc = BinaryPrimitives.ReadUInt32BigEndian(data[(pos + 8 + (int)length)..]);
if (Crc32(type, body) != crc) return (null, null, "Image PNG endommagée (somme de contrôle incorrecte).");
var chunk = data.Slice(pos, 12 + (int)length);
pos += 12 + (int)length;
var name = System.Text.Encoding.ASCII.GetString(type);
if (!header && name != "IHDR") return (null, null, invalid);
if (inIdat && name != "IDAT") idatDone = true;
inIdat = false;
switch (name)
{
case "IHDR":
if (header || length != 13) return (null, null, invalid);
header = true;
var w = BinaryPrimitives.ReadUInt32BigEndian(body);
var h = BinaryPrimitives.ReadUInt32BigEndian(body[4..]);
if (w is 0 or > MaxSide || h is 0 or > MaxSide)
return (null, null, $"Dimensions refusées ({MinSide} à {MaxSide} px de côté).");
(width, height, depth, colorType, interlace) = ((int)w, (int)h, body[8], body[9], body[12]);
var validDepth = colorType switch
{
0 => depth is 1 or 2 or 4 or 8 or 16,
3 => depth is 1 or 2 or 4 or 8,
2 or 4 or 6 => depth is 8 or 16,
_ => false
};
if (!validDepth || body[10] != 0 || body[11] != 0 || interlace > 1) return (null, null, invalid);
output.Write(chunk);
break;
case "PLTE":
if (palette || idat.Length > 0 || length == 0 || length % 3 != 0 || length > 768 || colorType is 0 or 4)
return (null, null, invalid);
palette = true;
output.Write(chunk);
break;
case "tRNS":
if (idat.Length > 0) return (null, null, invalid);
output.Write(chunk);
break;
case "IDAT":
if (idatDone) return (null, null, invalid); // blocs IDAT non consécutifs
inIdat = true;
idat.Write(body);
output.Write(chunk);
break;
case "IEND":
if (length != 0 || idat.Length == 0) return (null, null, invalid);
ended = true;
output.Write(chunk);
break;
default:
// Bloc critique inconnu (majuscule) : image non standard, refusée. Bloc auxiliaire (texte, eXIf,
// date, profil…) : retiré.
if (char.IsUpper(name[0])) return (null, null, "Image PNG non standard.");
break;
}
}
if (!ended) return (null, null, "Image PNG incomplète.");
if (colorType == 3 && !palette) return (null, null, invalid);
var channels = colorType switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, _ => 4 };
var bitsPerPixel = channels * depth;
if (!CheckPixels(idat.ToArray(), width, height, bitsPerPixel, interlace == 1))
return (null, null, "Image PNG endommagée (données de l'image incorrectes).");
return (new ImageInfo(Png, width, height), output.ToArray(), null);
}
/// <summary>
/// Décompresse les données (sans dépasser la taille attendue + 1 octet) et vérifie qu'elles ont exactement la taille
/// de l'image, ligne par ligne, avec un type de filtre valide (0 à 4) au début de chaque ligne.
/// </summary>
private static bool CheckPixels(byte[] compressed, int width, int height, int bitsPerPixel, bool interlaced)
{
var rows = new List<long>(); // octets par ligne (filtre compris), dans l'ordre
if (!interlaced) AddRows(rows, width, height, bitsPerPixel);
else
{
int[] x0 = { 0, 4, 0, 2, 0, 1, 0 }, y0 = { 0, 0, 4, 0, 2, 0, 1 }, dx = { 8, 8, 4, 4, 2, 2, 1 }, dy = { 8, 8, 8, 4, 4, 2, 2 };
for (var p = 0; p < 7; p++)
{
var pw = width > x0[p] ? (width - x0[p] + dx[p] - 1) / dx[p] : 0;
var ph = height > y0[p] ? (height - y0[p] + dy[p] - 1) / dy[p] : 0;
if (pw > 0 && ph > 0) AddRows(rows, pw, ph, bitsPerPixel);
}
}
try
{
using var zlib = new ZLibStream(new MemoryStream(compressed, writable: false), CompressionMode.Decompress);
var buffer = new byte[64 * 1024];
var row = 0;
long left = rows.Count > 0 ? rows[0] : 0; // octets restants dans la ligne en cours
int read;
while ((read = zlib.Read(buffer, 0, buffer.Length)) > 0)
{
for (var i = 0; i < read;)
{
if (row >= rows.Count) return false; // plus de données que l'image
if (left == rows[row] && buffer[i] > 4) return false; // type de filtre de la ligne
var take = (int)Math.Min(left, read - i);
i += take;
left -= take;
if (left == 0 && ++row < rows.Count) left = rows[row];
}
}
return row == rows.Count;
}
catch (InvalidDataException)
{
return false;
}
}
private static void AddRows(List<long> rows, int width, int height, int bitsPerPixel)
{
var bytesPerRow = 1 + ((long)width * bitsPerPixel + 7) / 8;
for (var y = 0; y < height; y++) rows.Add(bytesPerRow);
}
private static readonly uint[] CrcTable = BuildCrcTable();
private static uint[] BuildCrcTable()
{
var table = new uint[256];
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
table[n] = c;
}
return table;
}
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
{
var c = 0xFFFFFFFFu;
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
// ------------------------------------------------------------------ JPEG
private static (ImageInfo?, byte[]?, string?) SanitizeJpeg(ReadOnlySpan<byte> data)
{
const string invalid = "Image JPEG invalide ou incomplète.";
var output = new MemoryStream(data.Length);
output.Write(data[..2]); // SOI
int width = 0, height = 0, scans = 0;
bool frame = false, tables = false, ended = false;
var pos = 2;
while (pos < data.Length)
{
if (ended) return (null, null, "Image JPEG suivie de données étrangères à l'image.");
if (pos + 2 > data.Length || data[pos] != 0xFF) return (null, null, invalid);
var marker = data[pos + 1];
if (marker == 0xFF)
{
pos++; // octet de remplissage
continue;
}
if (marker == 0xD9)
{
if (!frame || scans == 0) return (null, null, invalid);
output.Write(data.Slice(pos, 2));
pos += 2;
ended = true;
continue;
}
if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) return (null, null, invalid);
if (pos + 4 > data.Length) return (null, null, invalid);
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]);
if (length < 2 || pos + 2 + length > data.Length) return (null, null, invalid);
var segment = data.Slice(pos, 2 + length);
var body = data.Slice(pos + 4, length - 2);
pos += 2 + length;
switch (marker)
{
case 0xE0: // APP0 (JFIF) : gardé
case 0xEE: // APP14 (Adobe : transformation des couleurs) : gardé
output.Write(segment);
break;
case >= 0xE1 and <= 0xEF: // APP1 à APP15 : EXIF (position, appareil), XMP, profils… : retirés
case 0xFE: // commentaire : retiré
break;
case 0xDB: // tables de quantification
case 0xC4: // tables de Huffman
case 0xDD: // intervalle de resynchronisation
if (marker != 0xDD) tables = true;
output.Write(segment);
break;
case 0xC0 or 0xC1 or 0xC2: // trame baseline, étendue ou progressive (Huffman)
if (frame || body.Length < 6) return (null, null, invalid);
frame = true;
if (body[0] != 8) return (null, null, "Image JPEG non prise en charge (précision).");
height = BinaryPrimitives.ReadUInt16BigEndian(body[1..]);
width = BinaryPrimitives.ReadUInt16BigEndian(body[3..]);
var components = body[5];
if (width == 0 || height == 0 || components is not (1 or 3) || body.Length != 6 + 3 * components)
return (null, null, invalid);
output.Write(segment);
break;
case 0xDA: // début d'un scan : en-tête, puis données compressées jusqu'au marqueur suivant
if (!frame || !tables || body.Length < 6) return (null, null, invalid);
scans++;
output.Write(segment);
var start = pos;
while (true)
{
if (pos + 1 >= data.Length) return (null, null, "Image JPEG incomplète.");
if (data[pos] == 0xFF && data[pos + 1] != 0x00 && data[pos + 1] is not (>= 0xD0 and <= 0xD7) && data[pos + 1] != 0xFF)
break;
pos++;
}
if (pos == start) return (null, null, invalid); // scan vide
output.Write(data[start..pos]);
break;
default:
// Autres trames (sans perte, arithmétiques, hiérarchiques), marqueurs réservés : refusés.
return (null, null, "Image JPEG non prise en charge.");
}
}
if (!ended) return (null, null, "Image JPEG incomplète.");
return (new ImageInfo(Jpeg, width, height), output.ToArray(), null);
}
}
@@ -0,0 +1,149 @@
using System.Text;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Repère les liens et adresses dans le texte d'une demande (envoyé par un inconnu) pour avertir l'administrateur
/// qui la lit : un lien peut mener à un site d'hameçonnage ou à un fichier malveillant. Rien n'est jamais rendu
/// cliquable ; l'avertissement montre les liens « désamorcés » (<see cref="Defang"/>).
///
/// Formes reconnues, y compris déguisées (« hxxp », « http[:]// », « [.] », « (dot) », caractères pleine chasse) :
/// • adresses avec schéma : http://, https://, ftp://, file://, ws://… et toute forme « schéma:// » ;
/// • schémas dangereux sans « // » : javascript:, data:, vbscript:, file:, mailto:, ms-…:, search-ms:, steam:… ;
/// • « www. » suivi d'un nom ;
/// • noms de domaine nus avec une extension courante (« exemple.com/page », « bit.ly/x », « discord.gg/… ») ;
/// • adresses IP (avec port ou chemin éventuels) ; chemins réseau Windows (\\serveur\partage) ;
/// • adresses e-mail.
/// </summary>
public static partial class FeedbackLinks
{
/// <summary>Liens rapportés au plus (le nombre total reste exact).</summary>
public const int MaxReported = 10;
// « . » collé, ou déguisé : « [.] », « (.) », « {.} », « [dot] », « (dot) » (espaces permis autour), « dot » entre espaces.
private const string Dot = @"(?:\.|\s*(?:\[\.\]|\(\.\)|\{\.\}|\[dot\]|\(dot\))\s*|\s+dot\s+)";
private const string Tlds =
"com|net|org|info|biz|xyz|top|site|online|app|dev|io|gg|co|me|tv|cc|ly|to|ai|gl|link|click|live|shop|store|" +
"page|zip|mov|pw|tk|ml|ga|cf|gq|su|ru|cn|fr|be|ch|ca|de|uk|eu|us|es|it|nl|pl|pt|br|in|jp|kr|au|io|lu|re|tech|" +
"space|website|club|fun|icu|win|bid|work|ink|lol|zone|cloud|download|stream|games|game|social|chat|email";
[GeneratedRegex(@"\b[a-z][a-z0-9+.\-]{0,20}\s*(?::|\[:\])\s*(?:/|\\){2}\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SchemeUrl();
[GeneratedRegex(@"\b(?:javascript|vbscript|data|file|mailto|steam|discord|tel|sms|skype|magnet|ms-[a-z\-]+|search-ms|shell)\s*:\s*\S+",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex DangerousScheme();
[GeneratedRegex(@"\bh(?:xx|\*\*|XX)ps?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex DisguisedHttp();
[GeneratedRegex(@"\bwww\d{0,3}\s*" + Dot + @"\s*[a-z0-9\-]+\S*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex Www();
[GeneratedRegex(@"\b[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?(?:" + Dot + @"[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*" + Dot +
"(?:" + Tlds + @")\b(?:[/:?#]\S*)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BareDomain();
[GeneratedRegex(@"\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:" + Dot + @"(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b(?::\d{1,5})?(?:/\S*)?",
RegexOptions.CultureInvariant)]
private static partial Regex IpAddress();
[GeneratedRegex(@"\\\\[a-z0-9.\-_$]+\\\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex UncPath();
[GeneratedRegex(@"\b[a-z0-9._%+\-]+\s*(?:@|\[at\]|\(at\))\s*[a-z0-9\-]+(?:" + Dot + @"[a-z0-9\-]+)+\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex Email();
/// <summary>Le texte contient-il au moins un lien ou une adresse ?</summary>
public static bool Contains(string? text) => Find(text).Count > 0;
/// <summary>Liens et adresses trouvés (sans doublon, dans l'ordre d'apparition, <see cref="MaxReported"/> au plus).</summary>
public static IReadOnlyList<string> Find(string? text) => Find(text, out _);
public static IReadOnlyList<string> Find(string? text, out int total)
{
total = 0;
if (string.IsNullOrWhiteSpace(text)) return Array.Empty<string>();
var normalized = Normalize(text);
var found = new List<(int Start, int End, string Value)>();
void Collect(Regex regex)
{
foreach (Match match in regex.Matches(normalized))
{
var value = TrimEnd(match.Value);
if (value.Length == 0) continue;
var end = match.Index + value.Length;
if (found.Any(f => match.Index < f.End && end > f.Start)) continue; // déjà couvert par un motif plus large
found.Add((match.Index, end, value));
}
}
Collect(SchemeUrl());
Collect(DangerousScheme());
Collect(UncPath());
Collect(Email());
Collect(Www());
Collect(IpAddress());
Collect(BareDomain());
Collect(DisguisedHttp());
var distinct = found.OrderBy(f => f.Start).Select(f => f.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
total = distinct.Count;
return distinct.Take(MaxReported).ToList();
}
/// <summary>
/// Forme « désamorcée » à afficher (« hxxps[://]exemple[.]com ») : ni cliquable, ni ouverte par un copier-coller
/// distrait dans la barre d'adresse.
/// </summary>
public static string Defang(string link)
{
var text = link.Length > 120 ? link[..117] + "…" : link;
text = Regex.Replace(text, @"(?i)\bhttp", "hxxp");
text = Regex.Replace(text, @"(?i)\bftp", "fxp");
return text.Replace("://", "[://]").Replace(".", "[.]").Replace("@", "[@]");
}
/// <summary>
/// Caractères pleine chasse (« https:// ») ramenés à leur équivalent ASCII, et « ⁄ ∕ / » à « / » : les
/// déguisements les plus courants ne passent pas inaperçus. (Pas de normalisation Unicode complète : indisponible
/// sur le serveur en mode « culture invariante » ; les caractères invisibles sont déjà refusés à l'envoi.)
/// </summary>
private static string Normalize(string text)
{
var sb = new StringBuilder(text.Length);
foreach (var c in text)
sb.Append(c switch
{
>= '!' and <= '~' => (char)(c - 0xFEE0),
'。' or '。' or '․' => '.', // points idéographique, demi-chasse, « point de conduite »
'⁄' or '∕' => '/',
_ => c
});
return sb.ToString();
}
/// <summary>Ponctuation de fin de phrase retirée ; une parenthèse ou un crochet fermant seulement s'il n'est pas ouvert dans le lien.</summary>
private static string TrimEnd(string value)
{
while (value.Length > 0)
{
var last = value[^1];
var trim = last switch
{
'.' or ',' or ';' or ':' or '!' or '?' or '»' or '"' or '\'' or '>' or '…' => true,
')' => value.Count(c => c == '(') < value.Count(c => c == ')'),
']' => value.Count(c => c == '[') < value.Count(c => c == ']'),
'}' => value.Count(c => c == '{') < value.Count(c => c == '}'),
_ => false
};
if (!trim) break;
value = value[..^1];
}
return value;
}
}
@@ -0,0 +1,65 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Preuve de travail anti-spam des demandes anonymes : trouver une solution (nombre) telle que
/// SHA-256(domaine ‖ nonce ‖ solution) commence par <c>difficulty</c> bits à zéro. Environ 2^difficulty essais
/// (une fraction de seconde pour un joueur, coûteux pour un robot qui veut en envoyer des milliers) ; la
/// vérification par le serveur ne coûte qu'une empreinte. Aucun service tiers, aucun captcha.
/// </summary>
public static class FeedbackProof
{
public const int MinDifficulty = 8;
public const int MaxDifficulty = 26;
/// <summary>Solution : nombre décimal (20 chiffres au plus).</summary>
public const int MaxSolutionLength = 20;
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Proof/v1\n");
/// <summary>Bits à zéro en tête de l'empreinte de cette solution.</summary>
public static int LeadingZeroBits(ReadOnlySpan<byte> nonce, ulong solution)
{
Span<byte> data = stackalloc byte[Domain.Length + nonce.Length + 8];
Domain.CopyTo(data);
nonce.CopyTo(data[Domain.Length..]);
BinaryPrimitives.WriteUInt64BigEndian(data[(Domain.Length + nonce.Length)..], solution);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
var bits = 0;
foreach (var b in hash)
{
if (b == 0)
{
bits += 8;
continue;
}
bits += System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
break;
}
return bits;
}
public static bool Verify(ReadOnlySpan<byte> nonce, int difficulty, string? solution) =>
solution is { Length: > 0 and <= MaxSolutionLength } &&
solution.All(char.IsAsciiDigit) &&
ulong.TryParse(solution, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value) &&
LeadingZeroBits(nonce, value) >= difficulty;
/// <summary>Cherche une solution (launcher). Annulable ; part d'un point aléatoire.</summary>
public static string Solve(ReadOnlySpan<byte> nonce, int difficulty, CancellationToken ct = default)
{
if (difficulty is < 0 or > MaxDifficulty) throw new ArgumentOutOfRangeException(nameof(difficulty));
var start = BinaryPrimitives.ReadUInt64BigEndian(RandomNumberGenerator.GetBytes(8)) >> 1;
for (var i = 0UL; ; i++)
{
if ((i & 0xFFFF) == 0) ct.ThrowIfCancellationRequested();
var candidate = start + i;
if (LeadingZeroBits(nonce, candidate) >= difficulty)
return candidate.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
}
}
@@ -0,0 +1,318 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Règles communes au launcher (contrôle avant l'envoi) et au serveur (refus de toute valeur invalide) pour les
/// demandes des joueurs. La route d'envoi est anonyme : n'importe qui peut l'appeler avec un client modifié, d'où
/// des bornes strictes et le refus des caractères invisibles (de contrôle, de mise en forme, d'inversion du sens
/// d'écriture) qui pourraient tromper l'administrateur qui lit la demande.
/// </summary>
public static partial class FeedbackRules
{
public const int MinTitleLength = 5;
public const int MaxTitleLength = 120;
public const int MaxMultilineLength = 2000;
public const int MaxLines = 40;
/// <summary>Total des textes d'une demande (titre et champs).</summary>
public const int MaxTotalLength = 8000;
/// <summary>Corps d'une requête d'envoi (Kestrel et nginx).</summary>
public const int MaxRequestBytes = 16 * 1024;
public const int MaxAdminNoteLength = 500;
/// <summary>Valeurs d'une case oui/non.</summary>
public const string Yes = "yes";
public const string No = "no";
/// <summary>Identifiant interne d'une demande : 128 bits en hexadécimal minuscule.</summary>
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
private static partial Regex IdRegex();
/// <summary>Référence montrée au joueur : « FB-7K3Q-9P2M » (base32 sans caractères ambigus).</summary>
[GeneratedRegex(@"\AFB-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}\z")]
private static partial Regex ReferenceRegex();
[GeneratedRegex(@"\A[0-9A-Za-z][0-9A-Za-z.+\-]{0,31}\z")]
private static partial Regex VersionRegex();
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9 .,()+\-]{0,79}\z")]
private static partial Regex OsRegex();
[GeneratedRegex(@"\A[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,2}\z")]
private static partial Regex LanguageRegex();
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
public static bool IsValidReference(string? reference) => reference is not null && ReferenceRegex().IsMatch(reference);
/// <summary>Alphabet des références (Crockford : ni I, L, O, U).</summary>
public const string ReferenceAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Référence aléatoire (40 bits).</summary>
public static string NewReference()
{
var bytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(5);
ulong value = 0;
foreach (var b in bytes) value = (value << 8) | b;
var chars = new char[8];
for (var i = 7; i >= 0; i--)
{
chars[i] = ReferenceAlphabet[(int)(value & 31)];
value >>= 5;
}
return $"FB-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Contrôle complet d'une demande avec les formulaires d'origine (révision 0).</summary>
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors) =>
Validate(submission, FeedbackForms.Find(submission?.Kind), errors);
/// <summary>
/// Contrôle complet d'une demande avec le formulaire de son type (celui de la révision annoncée). Renvoie la
/// demande nettoyée (textes normalisés, champs vides retirés, défi conservé) ou null avec les erreurs
/// (françaises, lisibles par le joueur).
/// </summary>
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, FeedbackForm? form, ICollection<string> errors)
{
if (submission is null)
{
errors.Add("Demande vide.");
return null;
}
if (!Catalog.CatalogRules.IsValidGameId(submission.GameId)) errors.Add("Jeu inconnu.");
if (submission.FormVersion != FeedbackForms.CurrentVersion)
errors.Add("Formulaire d'une autre version du launcher : mettez le launcher à jour.");
if (submission.FormRevision < 0) errors.Add("Révision des formulaires invalide.");
if (form is null || !string.Equals(form.Kind, submission.Kind, StringComparison.Ordinal))
{
errors.Add("Type de demande inconnu.");
return null;
}
if (!form.AllowAttachments && submission.AttachmentCount > 0)
errors.Add("Les captures d'écran ne sont pas acceptées pour ce type de demande.");
var title = ApiText.CleanLine(submission.Title, MaxTitleLength);
if (title is null || title.Length < MinTitleLength)
errors.Add($"Titre : {MinTitleLength} à {MaxTitleLength} caractères sur une ligne, sans caractère spécial invisible.");
var fields = new Dictionary<string, string>(StringComparer.Ordinal);
var given = submission.Fields ?? new Dictionary<string, string>();
foreach (var key in given.Keys)
if (form.Field(key) is null)
{
errors.Add("Champ inconnu dans la demande.");
break;
}
var total = title?.Length ?? 0;
foreach (var field in form.Fields)
{
given.TryGetValue(field.Id, out var raw);
var value = CheckField(field, raw, errors);
if (value is null) continue;
fields[field.Id] = value;
total += value.Length;
}
if (total > MaxTotalLength) errors.Add($"Demande trop longue ({total} caractères, {MaxTotalLength} au plus).");
if (submission.AttachmentCount is < 0 or > FeedbackImages.MaxAttachments)
errors.Add($"Captures d'écran : {FeedbackImages.MaxAttachments} au plus.");
var tech = CheckTech(submission.Tech, errors);
return errors.Count > 0 ? null : submission with
{
Title = title!,
Fields = fields,
Tech = tech
};
}
/// <summary>Valeur d'un champ contrôlée (null : vide ou invalide ; l'erreur est ajoutée si besoin).</summary>
public static string? CheckField(FeedbackField field, string? raw, ICollection<string> errors)
{
switch (field.Kind)
{
case FeedbackFieldKind.Choice:
case FeedbackFieldKind.Rating:
if (string.IsNullOrEmpty(raw))
{
if (field.Required) errors.Add($"{field.Label} : choix obligatoire.");
return null;
}
var valid = field.Kind == FeedbackFieldKind.Rating
? raw.Length <= 2 && raw.All(char.IsAsciiDigit) && raw[0] != '0' &&
int.Parse(raw, CultureInfo.InvariantCulture) <= field.RatingMax
: field.Choices.Any(o => string.Equals(o.Id, raw, StringComparison.Ordinal));
if (!valid)
{
errors.Add($"{field.Label} : valeur invalide.");
return null;
}
return raw;
case FeedbackFieldKind.Checkboxes:
if (string.IsNullOrEmpty(raw))
{
if (field.Required) errors.Add($"{field.Label} : cochez au moins un choix.");
return null;
}
var ids = raw.Split(',');
if (ids.Length > field.Choices.Count || ids.Distinct(StringComparer.Ordinal).Count() != ids.Length ||
ids.Any(id => !field.Choices.Any(o => string.Equals(o.Id, id, StringComparison.Ordinal))))
{
errors.Add($"{field.Label} : valeur invalide.");
return null;
}
// Ordre des options du formulaire : une même sélection donne toujours la même valeur.
return string.Join(',', field.Choices.Select(o => o.Id).Where(id => ids.Contains(id, StringComparer.Ordinal)));
case FeedbackFieldKind.YesNo:
if (string.IsNullOrEmpty(raw)) raw = No;
if (raw is not (Yes or No))
{
errors.Add($"{field.Label} : valeur invalide.");
return null;
}
if (field.Required && raw != Yes) errors.Add($"{field.Label} : case à cocher obligatoire.");
return raw;
case FeedbackFieldKind.Text:
if (string.IsNullOrWhiteSpace(raw))
{
if (field.Required) errors.Add($"{field.Label} : champ obligatoire.");
return null;
}
var line = ApiText.CleanLine(raw, field.MaxLength);
if (line is null)
{
errors.Add($"{field.Label} : {field.MaxLength} caractères au plus, sur une ligne, sans caractère spécial invisible.");
return null;
}
return line;
default:
var text = CleanMultiline(raw, field.MaxLength, out var problem);
if (problem is not null)
{
errors.Add($"{field.Label} : {problem}");
return null;
}
if (text is null && field.Required) errors.Add($"{field.Label} : champ obligatoire.");
return text;
}
}
/// <summary>
/// Texte sur plusieurs lignes : fins de ligne « \n », tabulations remplacées par une espace, espaces de fin de
/// ligne retirés, trois lignes vides consécutives au plus. Null si vide. <paramref name="problem"/> : raison du
/// refus (caractère interdit, trop long, trop de lignes).
/// </summary>
public static string? CleanMultiline(string? value, int maxLength, out string? problem)
{
problem = null;
if (string.IsNullOrWhiteSpace(value)) return null;
if (value.Length > maxLength * 2)
{
problem = $"{maxLength} caractères au plus.";
return null;
}
var sb = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
if (c == '\r')
{
sb.Append('\n');
if (i + 1 < value.Length && value[i + 1] == '\n') i++;
continue;
}
if (c == '\n')
{
sb.Append('\n');
continue;
}
if (c == '\t')
{
sb.Append(' ');
continue;
}
if (char.IsHighSurrogate(c))
{
if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]) &&
CharUnicodeInfo.GetUnicodeCategory(value, i) is not (UnicodeCategory.Format or UnicodeCategory.PrivateUse))
{
sb.Append(c).Append(value[i + 1]);
i++;
continue;
}
problem = "caractère non autorisé.";
return null;
}
if (char.IsLowSurrogate(c) || char.IsControl(c) || c is '￾' or '￿' ||
CharUnicodeInfo.GetUnicodeCategory(c) is UnicodeCategory.Format or UnicodeCategory.LineSeparator
or UnicodeCategory.ParagraphSeparator or UnicodeCategory.PrivateUse)
{
problem = "caractère invisible ou spécial non autorisé.";
return null;
}
sb.Append(c);
}
var lines = sb.ToString().Split('\n').Select(l => l.TrimEnd()).ToList();
var kept = new List<string>(lines.Count);
var blanks = 0;
foreach (var line in lines)
{
blanks = line.Length == 0 ? blanks + 1 : 0;
if (blanks <= 2) kept.Add(line);
}
var text = string.Join('\n', kept).Trim('\n', ' ');
if (text.Length == 0) return null;
if (text.Length > maxLength)
{
problem = $"{maxLength} caractères au plus ({text.Length} saisis).";
return null;
}
if (text.Count(ch => ch == '\n') + 1 > MaxLines)
{
problem = $"{MaxLines} lignes au plus.";
return null;
}
return text;
}
/// <summary>Informations techniques : formats stricts (rien d'autre que ce que le launcher envoie).</summary>
public static FeedbackTechInfo? CheckTech(FeedbackTechInfo? tech, ICollection<string> errors)
{
if (tech is null) return null;
bool Ok(string? value, Regex regex) => value is null || regex.IsMatch(value);
if (!Ok(tech.LauncherVersion, VersionRegex()) || !Ok(tech.GameVersion, VersionRegex()) ||
!Ok(tech.Os, OsRegex()) || !Ok(tech.Language, LanguageRegex()))
{
errors.Add("Informations techniques invalides.");
return null;
}
return tech.LauncherVersion is null && tech.GameVersion is null && tech.Os is null && tech.Language is null ? null : tech;
}
/// <summary>Note interne d'un administrateur (vide : null) ; <paramref name="valid"/> faux si elle est refusée.</summary>
public static string? CleanAdminNote(string? note, out bool valid)
{
var text = CleanMultiline(note, MaxAdminNoteLength, out var problem);
valid = problem is null;
return valid ? text : null;
}
/// <summary>Version au format des informations techniques (« 2.5.0 », « 0.9.3-beta »), sinon null.</summary>
public static string? SafeVersion(string? value) => value is not null && VersionRegex().IsMatch(value) ? value : null;
/// <summary>Description de Windows au format des informations techniques, sinon null.</summary>
public static string? SafeOs(string? value) => value is not null && OsRegex().IsMatch(value) ? value : null;
public static string? SafeLanguage(string? value) => value is not null && LanguageRegex().IsMatch(value) ? value : null;
}
@@ -0,0 +1,144 @@
namespace GameLauncher.Contracts.Files;
/// <summary>
/// Page « Fichiers du serveur » : contenu d'un dossier du site public (www), état de chaque fichier
/// vis-à-vis des manifests, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu.
/// Le serveur décide seul de ce qui peut être supprimé ; le launcher ne fait qu'afficher.
/// </summary>
public static class ServerFileStatus
{
/// <summary>Dossier (ouvrable, jamais supprimé depuis le menu).</summary>
public const string Directory = "directory";
/// <summary>Fichier de description (.json : manifest, catalogue, trousseau, mise à jour du launcher) : jamais supprimé.</summary>
public const string Protected = "protected";
/// <summary>Référencé par un manifest (archive, fichier de la version en ligne) ou par le catalogue (image).</summary>
public const string InUse = "in_use";
/// <summary>Référencé par aucun fichier de description : peut être supprimé.</summary>
public const string Unused = "unused";
/// <summary>Un fichier de description voisin est illisible : impossible de vérifier, donc jamais supprimé.</summary>
public const string Unknown = "unknown";
/// <summary>Lien symbolique : ni suivi, ni supprimé.</summary>
public const string Link = "link";
}
/// <summary>Un élément d'un dossier.</summary>
public sealed record ServerFileEntry
{
public string Name { get; init; } = "";
public bool IsDirectory { get; init; }
/// <summary>Taille en octets (dossier : total de son contenu ; -1 si trop de fichiers pour le calculer).</summary>
public long Size { get; init; }
/// <summary>Nombre de fichiers d'un dossier (-1 si non calculé).</summary>
public long FileCount { get; init; }
public DateTimeOffset Modified { get; init; }
/// <summary>Voir <see cref="ServerFileStatus"/>.</summary>
public string Status { get; init; } = ServerFileStatus.Unknown;
/// <summary>Explication de l'état (« Archive de la version 1.2.0 (manifest de exovivalzombie/) »…).</summary>
public string? Reason { get; init; }
}
/// <summary>Contenu d'un dossier du site public.</summary>
public sealed record ServerFolder
{
/// <summary>Chemin relatif à la racine du site (« » : racine ; séparateur « / »).</summary>
public string Path { get; init; } = "";
/// <summary>Nom affiché de la racine (dernier élément du chemin configuré, ex. « www »).</summary>
public string RootName { get; init; } = "";
public List<ServerFileEntry> Entries { get; init; } = new();
/// <summary>Le dossier contient plus d'éléments que la limite affichée.</summary>
public bool Truncated { get; init; }
/// <summary>Espace libre et taille du disque du site (-1 si inconnus).</summary>
public long FreeBytes { get; init; } = -1;
public long TotalBytes { get; init; } = -1;
/// <summary>Jeux dont le dossier « objects » peut être nettoyé (identifiants configurés sur le serveur).</summary>
public List<string> PrunableGames { get; init; } = new();
}
/// <summary>Suppression d'un fichier inutilisé (requête signée).</summary>
public sealed record ServerFileDeleteRequest
{
/// <summary>Chemin relatif à la racine du site (séparateur « / »).</summary>
public string Path { get; init; } = "";
/// <summary>Taille vue dans la liste : refus si le fichier a changé depuis (remplacé entre-temps).</summary>
public long Size { get; init; }
}
public sealed record ServerFileDeleteResult
{
public string Path { get; init; } = "";
public long FreedBytes { get; init; }
}
/// <summary>Analyse du dossier « objects » d'un jeu : fichiers que le manifest en ligne n'utilise plus.</summary>
public sealed record ObjectsPrunePreview
{
public string GameId { get; init; } = "";
/// <summary>Version du manifest en ligne.</summary>
public string Version { get; init; } = "";
/// <summary>Empreinte du manifest analysé : à renvoyer pour le nettoyage (refus s'il a changé entre-temps).</summary>
public string ManifestSha256 { get; init; } = "";
/// <summary>Fichiers de la version en ligne (objets distincts).</summary>
public int ReferencedCount { get; init; }
/// <summary>Fichiers de la version en ligne absents du serveur (information : à republier).</summary>
public int MissingCount { get; init; }
public int OrphanCount { get; init; }
public long OrphanBytes { get; init; }
/// <summary>Quelques fichiers inutilisés (aperçu).</summary>
public List<string> Examples { get; init; } = new();
/// <summary>Une publication de ce jeu est en cours : nettoyage impossible pour l'instant.</summary>
public bool PublicationInProgress { get; init; }
}
/// <summary>Nettoyage du dossier « objects » d'un jeu (requête signée).</summary>
public sealed record ObjectsPruneRequest
{
/// <summary><see cref="ObjectsPrunePreview.ManifestSha256"/> de l'analyse affichée.</summary>
public string ManifestSha256 { get; init; } = "";
}
public sealed record ObjectsPruneResult
{
public string GameId { get; init; } = "";
public int Deleted { get; init; }
public long FreedBytes { get; init; }
/// <summary>Fichiers qui n'ont pas pu être supprimés (droits) : le serveur l'explique dans son journal.</summary>
public int Failed { get; init; }
}
public static class ServerFilesLimits
{
/// <summary>Éléments renvoyés au plus pour un dossier.</summary>
public const int MaxEntries = 2000;
/// <summary>Fichiers parcourus au plus pour calculer la taille des sous-dossiers d'un dossier.</summary>
public const int MaxScannedFiles = 200_000;
public const int MaxPathLength = 1024;
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Protocole partagé entre le launcher et la Launcher API (serveur) : routes, objets échangés,
rôles et permissions, messages signés et contrôle des clés publiques.
Aucune dépendance (ni WPF, ni ASP.NET Core) : référencé par le launcher, le serveur et les outils.
Voir docs\ADMIN-ARCHITECTURE.md.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Contracts</RootNamespace>
<AssemblyName>GameLauncher.Contracts</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
</Project>
@@ -0,0 +1,202 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Maintenance;
/// <summary>
/// Mode maintenance du launcher (maintenance.json, commun à tous les jeux), activé ou désactivé depuis le menu
/// d'administration. Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée
/// « maintenance » dans le trousseau : les launchers des joueurs le vérifient comme le catalogue avant d'en tenir compte.
/// Maintenance active : le launcher passe au rouge / orange, affiche le message au centre et bloque tout, sauf la
/// désinstallation des jeux et le menu d'administration.
/// Révision 0 = rien n'a jamais été publié (maintenance inactive).
/// </summary>
public sealed record MaintenanceDocument
{
public const string FormatName = "gamelauncher-maintenance";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Révision : +1 à chaque publication (couverte par la signature).</summary>
public long Revision { get; init; }
/// <summary>Vrai : maintenance en cours.</summary>
public bool Enabled { get; init; }
/// <summary>
/// Précision facultative affichée aux joueurs sous le texte principal (« Mise à jour des serveurs, retour vers 18 h »).
/// Texte brut, sans lien, quelques lignes au plus (<see cref="MaintenanceRules"/>).
/// </summary>
public string? Message { get; init; }
/// <summary>Début de la maintenance (à la seconde, UTC) ; null quand elle est désactivée. Couvert par la signature.</summary>
public DateTimeOffset? Since { get; init; }
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
public DateTimeOffset? UpdatedAt { get; init; }
public ContentSignature? Signature { get; init; }
}
/// <summary>État du mode maintenance pour le menu d'administration (GET v1/maintenance).</summary>
public sealed record MaintenanceState
{
/// <summary>Document en vigueur (révision 0, maintenance inactive, si rien n'a été publié).</summary>
public MaintenanceDocument Document { get; init; } = new();
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
public string? PublicUrl { get; init; }
/// <summary>Administrateur et appareil de la clé qui a signé la dernière publication, si le serveur la connaît.</summary>
public string? UpdatedBy { get; init; }
}
/// <summary>Publication du mode maintenance (PUT v1/maintenance, requête signée, maintenance.manage).</summary>
public sealed record MaintenancePublishRequest
{
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
public long BaseRevision { get; init; }
/// <summary>Document signé, révision <see cref="BaseRevision"/> + 1.</summary>
public MaintenanceDocument Document { get; init; } = new();
}
/// <summary>
/// Règles du mode maintenance, communes au menu (contrôle avant de signer), au serveur (refus avant publication) et au
/// launcher des joueurs (refus d'un fichier hors limites, même signé). Le message est du texte brut, déjà nettoyé
/// (<see cref="CleanMessage"/>) et SANS lien : rien ne doit pouvoir renvoyer le joueur vers un site depuis ce bandeau.
/// </summary>
public static class MaintenanceRules
{
/// <summary>Taille de maintenance.json (et de la requête de publication, sous la limite de 64 Ko de l'API).</summary>
public const int MaxBytes = 8 * 1024;
public const int MaxMessageLength = 300;
public const int MaxMessageLines = 6;
/// <summary>Texte principal affiché aux joueurs (non modifiable : toujours présent, même sans message).</summary>
public const string Title = "Maintenance en cours";
public const string Subtitle = "Merci de revenir plus tard.";
/// <summary>Écart toléré entre le début annoncé et l'heure du serveur (horloges des PC).</summary>
public static readonly TimeSpan MaxSinceSkew = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Rien de publié (révision 0) : maintenance inactive.</summary>
public static MaintenanceDocument Defaults() => new() { Revision = 0, Enabled = false };
/// <summary>Lit maintenance.json (null : vide).</summary>
/// <exception cref="JsonException">JSON illisible ou trop volumineux.</exception>
public static MaintenanceDocument? Parse(ReadOnlySpan<byte> json)
{
if (json.Length > MaxBytes) throw new JsonException($"fichier de maintenance trop volumineux (au plus {MaxBytes / 1024} Ko)");
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
return JsonSerializer.Deserialize<MaintenanceDocument>(json, ApiJson.Options);
}
public static string Serialize(MaintenanceDocument document) => JsonSerializer.Serialize(document, FileOptions);
/// <summary>
/// Condensé signé : format, version, révision, état, message et début (à la seconde). La date de publication et la
/// signature n'en font pas partie.
/// </summary>
public static string Digest(MaintenanceDocument document)
{
var canonical = new CanonicalMaintenance(document.Format ?? "", document.Version, document.Revision, document.Enabled,
string.IsNullOrEmpty(document.Message) ? null : document.Message,
document.Since is { } since ? since.ToUnixTimeSeconds() : null);
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
}
private sealed record CanonicalMaintenance(string Format, int Version, long Revision, bool Enabled, string? Message, long? Since);
/// <summary>
/// Nettoie le message saisi par l'administrateur (fins de ligne, espaces, lignes vides ; mêmes refus que les demandes
/// des joueurs : caractères invisibles, de mise en forme ou d'inversion du sens d'écriture), puis vérifie les bornes et
/// l'absence de lien. Null si vide ; <paramref name="problem"/> : raison du refus.
/// </summary>
public static string? CleanMessage(string? value, out string? problem)
{
var text = FeedbackRules.CleanMultiline(value, MaxMessageLength, out problem);
if (problem is not null) return null;
if (text is null) return null;
if (text.Count(c => c == '\n') + 1 > MaxMessageLines)
{
problem = $"{MaxMessageLines} lignes au plus.";
return null;
}
if (FeedbackLinks.Contains(text))
{
problem = "aucun lien ni adresse de site n'est accepté (les joueurs ne doivent pas être renvoyés vers un site depuis ce message).";
return null;
}
return text;
}
/// <summary>
/// Contrôle d'un document publié (révision 1 au moins). Les erreurs, en français, sont ajoutées à <paramref name="errors"/>.
/// </summary>
/// <param name="now">Heure de référence (serveur) : le début ne peut pas être dans le futur ; null : non contrôlé (launcher).</param>
public static bool Validate(MaintenanceDocument? document, ICollection<string> errors, DateTimeOffset? now = null)
{
var count = errors.Count;
if (document is null)
{
errors.Add("Document de maintenance absent.");
return false;
}
if (document.Format != MaintenanceDocument.FormatName || document.Version != MaintenanceDocument.CurrentVersion)
errors.Add($"Format du mode maintenance non pris en charge ({document.Format} v{document.Version}).");
if (document.Revision < 1) errors.Add("Révision invalide (1 au moins).");
if (document.Message is { } message)
{
var clean = CleanMessage(message, out var problem);
if (problem is not null) errors.Add($"Message : {problem}");
else if (!string.Equals(clean, message, StringComparison.Ordinal)) errors.Add("Message : texte non nettoyé (espaces, lignes vides).");
}
if (document.Enabled)
{
if (document.Since is not { } since || since == default) errors.Add("Début de la maintenance absent.");
else if (since.UtcTicks % TimeSpan.TicksPerSecond != 0)
errors.Add("Début de la maintenance : à la seconde près.");
else if (now is { } reference && since > reference + MaxSinceSkew)
errors.Add("Début de la maintenance dans le futur : vérifiez l'heure de Windows.");
}
else
{
if (document.Since is not null) errors.Add("Maintenance désactivée : pas de date de début.");
if (document.Message is not null) errors.Add("Maintenance désactivée : pas de message.");
}
return errors.Count == count;
}
/// <summary>Résumé pour l'audit et les journaux (« activée — message : … », « désactivée »).</summary>
public static string Describe(MaintenanceDocument document) =>
document.Enabled
? "activée" + (document.Message is { } message ? $" — message : « {OneLine(message)} »" : " (sans message)")
: "désactivée";
private static string OneLine(string text)
{
var line = text.Replace('\n', ' ');
return line.Length <= 120 ? line : line[..119] + "…";
}
}
@@ -0,0 +1,207 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Publishing;
/// <summary>Ce que contient un manifest.json contrôlé.</summary>
/// <param name="Objects">Objets à servir (empreinte en minuscules → taille), une fois chacun.</param>
public sealed record ManifestSummary(
string GameId,
string Version,
string Executable,
string ArchiveName,
long ArchiveSize,
string ArchiveSha256,
IReadOnlyDictionary<string, long> Objects,
int FileCount,
long TotalSize,
string? NotesTitle);
/// <summary>
/// Contrôle d'un manifest.json produit par ManifestBuilder, AVANT de le publier : par le launcher
/// (dossier choisi) et par le serveur (manifest reçu). Mêmes règles des deux côtés ; le serveur ne fait
/// confiance à rien de ce qu'il reçoit.
/// • identifiant de jeu et version attendus ;
/// • archive : simple nom de fichier .zip (jamais un chemin), taille et empreinte valides ;
/// • objets dans « objects/ » (seul dossier écrit par la publication) ;
/// • chemins des fichiers relatifs et sûrs (ni « .. », ni chemin absolu, ni « : »), sans doublon ;
/// • exécutable présent parmi les fichiers ; tailles et nombre de fichiers bornés.
/// </summary>
public static partial class ManifestCheck
{
[GeneratedRegex(@"\A[A-Za-z0-9._+\-]{1,64}\z")]
private static partial Regex VersionRegex();
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}\.zip\z", RegexOptions.IgnoreCase)]
private static partial Regex ArchiveNameRegex();
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
public static bool IsValidVersion(string? version) => version is not null && VersionRegex().IsMatch(version);
/// <summary>Contrôle ; null (et <paramref name="errors"/> rempli) si le manifest n'est pas publiable.</summary>
public static ManifestSummary? Check(ReadOnlySpan<byte> json, string expectedGameId, string? expectedVersion, List<string> errors)
{
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
if (json.Length > PublishingLimits.MaxManifestBytes)
{
errors.Add("Manifest trop volumineux.");
return null;
}
JsonDocument document;
try
{
document = JsonDocument.Parse(json.ToArray(), ReadOptions);
}
catch (JsonException ex)
{
errors.Add($"Manifest illisible : {ex.Message}");
return null;
}
using (document)
{
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
errors.Add("Manifest illisible : objet JSON attendu.");
return null;
}
if (Get(root, "formatVersion") is { } format && (format.ValueKind != JsonValueKind.Number || format.GetDouble() != 1))
errors.Add("Format de manifest non pris en charge (formatVersion 1 attendu).");
var gameId = Text(root, "gameId");
if (!string.Equals(gameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
errors.Add($"Ce manifest est celui du jeu « {gameId} », pas de « {expectedGameId} ».");
var version = Text(root, "version");
if (!IsValidVersion(version))
errors.Add($"Version « {version} » invalide (lettres, chiffres, « . », « _ », « + », « - »).");
else if (expectedVersion is not null && !string.Equals(version, expectedVersion, StringComparison.Ordinal))
errors.Add($"Le manifest est celui de la version {version}, pas {expectedVersion}.");
var objectsBase = Text(root, "objectsBaseUrl");
if (!string.IsNullOrEmpty(objectsBase) && objectsBase is not ("objects/" or "objects"))
errors.Add($"« objectsBaseUrl » doit être « objects/ » (manifest produit par ManifestBuilder), pas « {objectsBase} ».");
// Archive (installation initiale)
string archiveName = "", archiveSha = "";
long archiveSize = 0;
if (Get(root, "archive") is { ValueKind: JsonValueKind.Object } archive)
{
archiveName = Text(archive, "url") ?? "";
archiveSha = (Text(archive, "sha256") ?? "").ToLowerInvariant();
archiveSize = Number(archive, "size");
if (!ArchiveNameRegex().IsMatch(archiveName))
errors.Add($"Archive « {archiveName} » : simple nom de fichier .zip attendu (à côté du manifest).");
if (!PublishingLimits.IsValidSha256(archiveSha)) errors.Add("Archive : empreinte SHA-256 invalide.");
if (archiveSize <= 0 || archiveSize > PublishingLimits.MaxArchiveBytes) errors.Add("Archive : taille invalide.");
}
else
{
errors.Add("Archive absente du manifest.");
}
// Fichiers du jeu
var objects = new Dictionary<string, long>(StringComparer.Ordinal);
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
long totalSize = 0;
var fileCount = 0;
if (Get(root, "files") is { ValueKind: JsonValueKind.Array } files)
{
fileCount = files.GetArrayLength();
if (fileCount == 0) errors.Add("Aucun fichier dans le manifest.");
if (fileCount > PublishingLimits.MaxFiles) errors.Add($"Trop de fichiers ({fileCount} ; {PublishingLimits.MaxFiles} au plus).");
else
{
var index = 0;
foreach (var file in files.EnumerateArray())
{
index++;
if (errors.Count > 20) break;
if (file.ValueKind != JsonValueKind.Object)
{
errors.Add($"Fichier n° {index} invalide.");
continue;
}
var path = Text(file, "path") ?? "";
var sha = (Text(file, "sha256") ?? "").ToLowerInvariant();
var size = Number(file, "size");
if (!IsSafeRelativePath(path)) errors.Add($"Chemin non autorisé : « {path} ».");
else if (!paths.Add(path.Replace('\\', '/'))) errors.Add($"Fichier en double : « {path} ».");
if (!PublishingLimits.IsValidSha256(sha)) errors.Add($"« {path} » : empreinte SHA-256 invalide.");
else if (size < 0 || size > PublishingLimits.MaxObjectBytes) errors.Add($"« {path} » : taille invalide.");
else if (objects.TryGetValue(sha, out var known) && known != size)
errors.Add($"« {path} » : même empreinte qu'un autre fichier mais taille différente.");
else
{
objects[sha] = size;
totalSize += size;
}
}
}
}
else
{
errors.Add("Liste des fichiers absente du manifest.");
}
var executable = (Text(root, "executable") ?? "").Replace('\\', '/');
if (string.IsNullOrWhiteSpace(executable)) errors.Add("Exécutable absent du manifest.");
else if (!paths.Contains(executable)) errors.Add($"L'exécutable « {executable} » ne fait pas partie des fichiers.");
if (Get(root, "changelog") is { } changelog && changelog.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null))
errors.Add("Notes de version (« changelog ») : liste attendue.");
if (errors.Count > 0) return null;
return new ManifestSummary(gameId!, version!, executable, archiveName, archiveSize, archiveSha, objects, fileCount, totalSize,
NotesTitle(root, version!));
}
}
/// <summary>Chemin relatif sûr (mêmes règles que le launcher pour l'installation).</summary>
public static bool IsSafeRelativePath(string path)
{
if (string.IsNullOrWhiteSpace(path) || path.Length > 1024 || path.Any(char.IsControl)) return false;
var p = path.Replace('\\', '/');
if (p.StartsWith('/') || p.Contains(':')) return false;
return p.Split('/').All(s => s.Length > 0 && s != "." && s != "..");
}
/// <summary>Chemin d'un objet sous le dossier du manifest : « objects/ab/abcdef….bin ».</summary>
public static string ObjectPath(string sha256)
{
var h = sha256.ToLowerInvariant();
return $"objects/{h[..2]}/{h}.bin";
}
private static string? NotesTitle(JsonElement root, string version)
{
if (Get(root, "changelog") is not { ValueKind: JsonValueKind.Array } changelog) return null;
foreach (var entry in changelog.EnumerateArray())
if (entry.ValueKind == JsonValueKind.Object && Text(entry, "version") == version)
return Text(entry, "title");
return null;
}
private static JsonElement? Get(JsonElement element, string name)
{
foreach (var property in element.EnumerateObject())
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
return property.Value;
return null;
}
private static string? Text(JsonElement element, string name) =>
Get(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null;
private static long Number(JsonElement element, string name) =>
Get(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : -1;
}
@@ -0,0 +1,160 @@
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Publishing;
/// <summary>
/// Bornes de la publication d'une version depuis le menu d'administration. Les requêtes d'envoi
/// (morceaux de fichiers) sont les seules à dépasser 64 Ko : elles ont leur propre limite, côté
/// serveur et côté nginx (server/deploy/nginx-publications.conf).
/// </summary>
public static partial class PublishingLimits
{
/// <summary>Taille d'un morceau envoyé par le launcher.</summary>
public const int ChunkBytes = 4 * 1024 * 1024;
/// <summary>Taille maximale d'un corps de requête d'envoi accepté par le serveur (morceau ou lot d'objets).</summary>
public const long MaxUploadRequestBytes = 8L * 1024 * 1024 + 64 * 1024;
/// <summary>Objets de cette taille au plus : envoyés par lots ; au-delà, en morceaux.</summary>
public const int BatchObjectMaxBytes = 2 * 1024 * 1024;
public const int MaxObjectsPerBatch = 500;
public const int MaxFiles = 10_000;
public const long MaxManifestBytes = 64L * 1024 * 1024;
public const long MaxObjectBytes = 32L * 1024 * 1024 * 1024;
public const long MaxArchiveBytes = 128L * 1024 * 1024 * 1024;
/// <summary>Publication abandonnée (aucun envoi) au-delà de cette durée : supprimée par le serveur.</summary>
public static readonly TimeSpan IdleExpiry = TimeSpan.FromHours(48);
/// <summary>Publication d'un autre administrateur sans envoi depuis cette durée : peut être remplacée.</summary>
public static readonly TimeSpan TakeoverAfter = TimeSpan.FromMinutes(30);
/// <summary>Type du corps d'un lot d'objets (voir <see cref="ObjectBatch"/>).</summary>
public const string ObjectBatchContentType = "application/vnd.gamelauncher.objects";
public const string BinaryContentType = "application/octet-stream";
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
private static partial Regex PublicationIdRegex();
[GeneratedRegex(@"\A[0-9a-fA-F]{64}\z")]
private static partial Regex Sha256Regex();
public static bool IsValidPublicationId(string? id) => id is not null && PublicationIdRegex().IsMatch(id);
public static bool IsValidSha256(string? value) => value is not null && Sha256Regex().IsMatch(value);
}
/// <summary>Début (ou reprise) de la publication d'une version : le manifest est envoyé ensuite.</summary>
public sealed record PublicationStartRequest
{
public string Version { get; init; } = "";
/// <summary>Empreinte du manifest.json à publier : même manifest = reprise d'un envoi interrompu.</summary>
public string ManifestSha256 { get; init; } = "";
public long ManifestSize { get; init; }
}
/// <summary>État d'une publication en cours (ce qui reste à envoyer).</summary>
public sealed record PublicationState
{
public string PublicationId { get; init; } = "";
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
/// <summary>Version en ligne au début de la publication (vide : aucune).</summary>
public string PublishedVersion { get; init; } = "";
/// <summary>Vrai si un envoi interrompu de ce même manifest est repris.</summary>
public bool Resumed { get; init; }
public long ManifestSize { get; init; }
public long ManifestReceived { get; init; }
/// <summary>Manifest reçu et contrôlé : la liste des objets à envoyer est connue.</summary>
public bool ManifestAccepted { get; init; }
/// <summary>Fichiers (objets) du manifest ; ceux déjà présents sur le serveur ne sont pas renvoyés.</summary>
public int ObjectCount { get; init; }
public int ObjectsOnServer { get; init; }
/// <summary>Empreintes des objets restant à envoyer.</summary>
public List<string> PendingObjects { get; init; } = new();
/// <summary>Objets envoyés en partie (empreinte → octets reçus) : l'envoi reprend à cet endroit.</summary>
public Dictionary<string, long> PartialObjects { get; init; } = new();
public string? ArchiveName { get; init; }
public long ArchiveSize { get; init; }
public long ArchiveReceived { get; init; }
public bool ArchiveComplete { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Réponse à l'envoi d'un morceau (ou d'un lot d'objets).</summary>
public sealed record ChunkResult
{
/// <summary>Octets reçus pour ce fichier (ou nombre d'objets enregistrés pour un lot).</summary>
public long Received { get; init; }
/// <summary>Fichier complet et vérifié (SHA-256).</summary>
public bool Complete { get; init; }
}
/// <summary>Version publiée.</summary>
public sealed record PublicationResult
{
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string PreviousVersion { get; init; } = "";
public string ManifestSha256 { get; init; } = "";
public int ObjectsAdded { get; init; }
public bool ArchiveAdded { get; init; }
}
/// <summary>
/// Lot d'objets dans un seul corps de requête (petits fichiers : une requête pour des centaines de
/// fichiers) : pour chaque objet, empreinte SHA-256 (32 octets bruts), taille (8 octets, gros-boutiste)
/// puis contenu.
/// </summary>
public static class ObjectBatch
{
public const int HeaderBytes = 32 + 8;
public static byte[] Write(IReadOnlyList<(string Sha256, byte[] Data)> objects)
{
var total = objects.Sum(o => (long)HeaderBytes + o.Data.Length);
var buffer = new byte[total];
var position = 0;
foreach (var (sha, data) in objects)
{
Convert.FromHexString(sha).CopyTo(buffer, position);
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(buffer.AsSpan(position + 32, 8), data.Length);
data.CopyTo(buffer, position + HeaderBytes);
position += HeaderBytes + data.Length;
}
return buffer;
}
/// <summary>Lecture bornée ; <see cref="InvalidDataException"/> si le lot est mal formé.</summary>
public static List<(string Sha256, ReadOnlyMemory<byte> Data)> Read(ReadOnlyMemory<byte> body, int maxObjects)
{
var result = new List<(string, ReadOnlyMemory<byte>)>();
var position = 0;
while (position < body.Length)
{
if (result.Count >= maxObjects) throw new InvalidDataException($"Lot trop grand ({maxObjects} objets au plus).");
if (body.Length - position < HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
var span = body.Span;
var sha = Convert.ToHexString(span.Slice(position, 32)).ToLowerInvariant();
var length = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(span.Slice(position + 32, 8));
if (length < 0 || length > body.Length - position - HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
result.Add((sha, body.Slice(position + HeaderBytes, (int)length)));
position += HeaderBytes + (int)length;
}
return result;
}
}
Loaded 100 of 407 files, more files were not shown because too many files have changed in this diff. Show more