Compare commits

...
131 Commits
Author SHA1 Message Date
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 9aa3876a0e Changelog 1.3.0 2026-10-25 12:11:21 +01:00
195 changed files with 29052 additions and 574 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ Publish/
.vs/
*.user
*.suo
Claude outputs/
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
+24
View File
@@ -30,6 +30,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
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
server-example\web.config = server-example\web.config
EndProjectSection
@@ -58,12 +60,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Server", "serv
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -160,6 +166,22 @@ Global
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -177,6 +199,8 @@ Global
{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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+126 -12
View File
@@ -35,12 +35,14 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `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, rôles, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
| `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) |
| `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 |
```
@@ -51,13 +53,15 @@ src/GameLauncher/
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
├─ 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, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
│ 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)
```
@@ -190,6 +194,11 @@ reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bur
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 |
|---|---|
@@ -282,6 +291,16 @@ Deux fenêtres distinctes :
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) :
@@ -306,6 +325,7 @@ referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) o
├─ 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)
```
@@ -346,6 +366,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| 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/)) |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran 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` |
@@ -488,9 +509,39 @@ Chantier en cours, découpé en phases : architecture, sécurité et planning da
**`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**. Les pages « Administrateurs »
et « Journal d'audit » arrivent à la phase 6.
**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** (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.
* **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.
* **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.
@@ -510,15 +561,22 @@ et « Journal d'audit » arrivent à la phase 6.
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.
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, fréquence des recherches de mise à jour, jeu masqué,
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é ne change ni le raccourci, ni le registre, ni les dossiers.
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),
@@ -528,6 +586,13 @@ et « Journal d'audit » arrivent à la phase 6.
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
@@ -537,17 +602,65 @@ et « Journal d'audit » arrivent à la phase 6.
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é. 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, déposées dans `catalog/images/` (nouveau nom à chaque changement).
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.
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu et
publication), éditeur (contenu).
* **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é.
@@ -556,5 +669,6 @@ et « Journal d'audit » arrivent à la phase 6.
|---|---|
| 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 | `tools\admin-key.bat` (`help` pour la liste) ; créer sa clé et se connecter : aussi depuis le launcher |
| 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/` |
+495 -22
View File
@@ -67,6 +67,7 @@ Conséquences :
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
@@ -77,8 +78,8 @@ Conséquences :
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `GameLauncher` → `Core`, `Modules`,
`Modules.Admin`.
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
## 5. Modules
@@ -139,6 +140,48 @@ public sealed record AdminPanelDescriptor(
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
### 5.3 Affichage : mode fenêtré ou plein écran, mise en page responsive (✅)
Deux modes seulement, pour la fenêtre principale et le menu d'administration (les autres fenêtres —
paramètres, notes agrandies, boîtes de dialogue — gardent leur taille fixe) : **fenêtré** (taille
d'origine, jamais redimensionnable par les bords ou les coins) et **plein écran** au sens d'une fenêtre
Windows agrandie (toute la zone utile de l'écran où se trouve la fenêtre, la barre des tâches restant visible,
même en masquage automatique ; pas de plein écran exclusif comme un jeu). Bascule : bouton entre « Réduire » et « Fermer », ou **F11**.
Le mode est mémorisé par fenêtre (`%AppData%\…\GameLauncher\window-modes.json`, ex.
`{ "main": true, "admin": false }` ; simple préférence, sans donnée personnelle).
* `Controls/WindowModeAssist` (propriétés attachées, aucun code dans la fenêtre) :
`ui:WindowModeAssist.AllowFullScreen="True"` + `ui:WindowModeAssist.PersistenceKey="clé"` sur la
fenêtre ; bouton `<Button Style="{StaticResource WindowModeButtonStyle}" />` (icône et infobulle
selon le mode, masqué si la fenêtre ne le permet pas). En plein écran, les ressources de la fenêtre
nommées `WindowChrome.*` (CornerRadius, Thickness : arrondis, marge de l'ombre) passent à 0 ; elles
sont utilisées en `DynamicResource`. En mode fenêtré, la taille est limitée à la zone utile de
l'écran de la fenêtre. Déplacement par la barre de titre : `WindowModeAssist.DragMove(window)` (sans
effet en plein écran) ; réaffichage d'une fenêtre réduite : `WindowModeAssist.RestoreFromMinimized`
(jamais `WindowState = Normal`, qui ferait quitter le plein écran).
* `Controls/ScaleToFitDecorator` (responsive) : `DesignWidth` × `DesignHeight` = taille du contenu
en mode fenêtré. Plus de place (plein écran) : le contenu s'étale (colonnes fluides, listes et
ascenseurs) ; moins de place (petit écran, mise à l'échelle Windows 125-150 %) : il est mis en page
à cette taille puis réduit d'un bloc. Rien n'est jamais rogné : tous les boutons restent
accessibles, tous les textes visibles. Le contenu n'est jamais agrandi.
* **Nouvelle page d'administration** (module futur) : elle s'affiche dans la fenêtre d'administration
et profite donc du plein écran sans rien déclarer. Règles de mise en page pour qu'elle reste
lisible dans les deux modes :
* ne pas fixer la largeur d'un bloc de texte (`MaxWidth` sur un bandeau…) : texte en
`TextWrapping="Wrap"` dans un `DockPanel` ou une colonne `*` d'un `Grid`, jamais dans un
`StackPanel` horizontal (il ne revient alors jamais à la ligne) ;
* listes de tuiles : `WrapPanel` ; libellés sur une ligne : `TextTrimming="CharacterEllipsis"` +
infobulle ;
* colonnes de largeur fixe : seulement pour une liste de navigation (≤ 260) ; la partie
principale en `*` ;
* page en deux colonnes qui défilent séparément : `HasSplitLayout => true` ; la page reçoit la
hauteur visible (au moins 360 px, sinon la page entière défile en plus) ;
* bandeaux d'erreur et de résultat : reprendre ceux des pages existantes (`AdminErrorBannerStyle`,
`AdminInfoBannerStyle`, icône à gauche dans un `DockPanel`).
* **Futur module du launcher** affiché dans sa propre fenêtre : `AllowFullScreen` seulement si la fenêtre
contient assez d'informations pour le mériter, avec une `PersistenceKey` propre et un
`ScaleToFitDecorator` autour du contenu.
## 6. Authentification par clé publique/privée
### 6.1 Clés
@@ -264,22 +307,73 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
| Erreur de manipulation | Impossible de se révoquer soi-même, de changer son propre rôle, ou de supprimer le dernier propriétaire |
| Perte de toutes les clés | Récupération par l'outil en ligne de commande, sur le VPS (accès SSH administrateur) |
## 7. Rôles et permissions
## 7. Groupes et permissions
Chaque administrateur appartient à **un groupe** (anciennement « rôle » : le champ `role` des comptes et des
invitations contient l'identifiant du groupe). Ses droits sont les permissions de ce groupe, **relues à chaque
requête** (une modification s'applique immédiatement, sessions ouvertes comprises). Groupes par défaut, créés au
premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les droits des anciens rôles :
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|---|:-:|:-:|:-:|---|
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
| `admins.manage` | ✔ | | | Gestion des administrateurs (page « Administrateurs ») |
| `audit.read` | ✔ | ✔ | | Journal d'audit |
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
Les permissions sont des chaînes « module.action » : un module futur déclare les siennes
(`news.edit`, `community.moderate`…) et les rôles qui les reçoivent.
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
d'un propriétaire, page « Groupes et permissions »). Les groupes par défaut créés **après** l'ajout la reçoivent.
Les permissions sont des chaînes « module.action ». **Intégrées** : déclarées dans le code
(`AdminPermissions.All`), vérifiées par les routes du serveur et les pages du launcher, jamais supprimables.
**Personnalisées** : créées depuis la page « Groupes et permissions » (`news.edit`, `community.moderate`…),
attribuables aux groupes en prévision d'un module futur ; supprimées, elles sont retirées de tous les groupes.
### 7.1 Page « Groupes et permissions » (✅)
Module serveur `Groups` (`GroupsModule`, données dans `admins.json` : `groups`, `customPermissions`) et page
launcher `GroupsPanelViewModel` (module `AccountsAdminModule`), permission `admins.manage`.
| Route | Signée | Rôle |
|---|:-:|---|
| `GET v1/groups` | | Groupes (permissions effectives, membres, invitations, révision) et permissions connues |
| `POST v1/groups` | ✔ | Crée un groupe (nom, description, permissions) ; identifiant tiré du nom (`moderateurs-forum`) |
| `PUT v1/groups/{id}` | ✔ | Modifie un groupe ; révision attendue (409 `revision_conflict` sinon) |
| `DELETE v1/groups/{id}` | ✔ | Supprime un groupe sans membre (actif ou en attente) ni invitation en cours |
| `POST v1/permissions` | ✔ | Crée une permission personnalisée (« module.action », libellé, description) |
| `DELETE v1/permissions/{name}` | ✔ | Supprime une permission personnalisée (retirée de tous les groupes) |
Garde-fous (serveur, sous le verrou d'`admins.json`, auteur et droits revérifiés au moment d'écrire —
`AdminRules`) :
* le groupe **Propriétaire** (`owner`) est un groupe système : toujours **toutes** les permissions, y compris
les personnalisées créées plus tard ; ni modifiable ni supprimable ; seul un propriétaire le donne, le
retire ou gère (groupe, révocation, appareils) un autre propriétaire ;
* **pas d'élévation** : on n'accorde à un groupe, et on ne place quelqu'un (invitation, changement de groupe,
approbation d'un compte en attente) que dans un groupe dont on a soi-même toutes les permissions ; on ne gère
pas un administrateur qui a des permissions que l'on n'a pas ;
* on ne modifie pas les permissions de **son propre groupe** (comme on ne change pas son propre groupe) ;
* identifiants : `[a-z][a-z0-9-]{1,31}`, jamais réutilisés tant qu'un compte (même révoqué) ou une invitation
les désigne ; noms uniques, une ligne, sans caractère invisible ; 50 groupes, 40 permissions personnalisées ;
* audit : `admins.group_create`, `admins.group_update` (permissions ajoutées/retirées), `admins.group_delete`,
`admins.permission_create`, `admins.permission_delete`.
`admins.manage` donnée à un autre groupe que Propriétaire équivaut presque aux droits d'un propriétaire (gestion
des comptes et des groupes, dans la limite de ses propres permissions) : la page demande une confirmation
renforcée. Trousseau de signature (phase 7) : les autorisations `releases`/`catalog` d'une clé suivent désormais les
permissions **de son groupe** ; la page Signature signale un trousseau à refaire après un changement.
Côté launcher : `AdminProfile`, `AdminSummary` et `InvitationSummary` portent `RoleName` (nom du groupe) et
`AdminSummary.Permissions` ; la page « Administrateurs » ne propose que les groupes attribuables. La page ne
propose que ce que le serveur accepte (cases grisées avec la raison en info-bulle) ; le serveur reste seul juge.
## 8. Ajouter un administrateur
1. Un **propriétaire** crée une invitation (nom, rôle) : le serveur renvoie **une seule fois** un
1. Un **propriétaire** crée une invitation (nom, groupe) : le serveur renvoie **une seule fois** un
code à usage unique (160 bits, `ABCD-EFGH-…`, valable 24 h ; seule son empreinte est stockée).
2. Il transmet ce code au futur administrateur.
3. Le futur administrateur ouvre le menu d'administration › « J'ai une invitation » : le launcher
@@ -288,8 +382,8 @@ Les permissions sont des chaînes « module.action » : un module futur déclare
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
(Phase 2 : étapes 3 et 4 dans le launcher ; création de l'invitation et approbation avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » de la phase 6.)
(Étapes 3 et 4 : « J'ai une invitation » ; étapes 1 et 5 : page « Administrateurs » du launcher,
phase 6. `tools\admin-key.bat` reste possible en ligne de commande.)
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
@@ -383,6 +477,12 @@ publication n'est pas configurée.
## 10. Notes de version (phase 4 ✅)
* **Plusieurs jeux** (ici et dans « Publier une version ») : la page s'ouvre sur le choix du jeu
(`GameTile`, une tuile par jeu de `launcher.json`, aucun jeu par défaut ; un seul jeu : choisi d'office)
et ne charge rien avant ; liste « Jeu » ensuite (désactivée pendant un chargement ou un envoi). Une
réponse du serveur arrivée après un changement de jeu est ignorée ; dossier, version en ligne et
signature sont effacés au changement ; un envoi interrompu est mémorisé par jeu. « Fichiers du
serveur » : jeu à nettoyer sans choix par défaut s'il y en a plusieurs.
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
@@ -476,19 +576,389 @@ de Windows et durée maximale de session toujours appliqués).
**VPS** : bloc nginx `server/deploy/nginx-publications.conf`, écriture dans le dossier du jeu
(`games.conf`, étape 3 ter), dossier `objects` existant avec le bit setgid (`LISEZMOI.md`, étape 3 quater).
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
## 11 bis. Administrateurs et journal d'audit (phase 6 ✅)
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
distribuer un faux jeu. La phase 7 ajoute :
Module `AccountsAdminModule` (pages « Mes appareils » et « Administrateurs ») et module
`AuditAdminModule` (page « Journal d'audit »), sur les routes de la phase 1. La page ne propose que ce
que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste seul juge).
* `keyring.json` : liste des clés publiques des administrateurs, **signée par la clé racine**
(votre clé de signature du launcher, déjà intégrée à l'exe) ;
* une **signature détachée** (`manifest.json.sig`, `catalog.json.sig`) produite par la clé de
l'administrateur au moment de la publication ou de l'édition — les anciens launchers ignorent
ces fichiers ;
* côté joueur, vérification puis refus de tout contenu non signé par une clé du trousseau.
* **Mes appareils** (tout administrateur connecté) : nouvelle route `GET v1/admins/me` (son compte, ses
clés, ses invitations « nouvel appareil » en cours ; empreinte masquée pour une clé en attente, comme
dans la liste complète). Ajout d'un PC : invitation `ForAdminId` = soi, puis approbation depuis ce PC
avec l'empreinte lue sur le nouveau. Révoquer un de ses appareils reste réservé à `admins.manage`.
Un administrateur peut supprimer une invitation « nouvel appareil » **créée par lui pour lui**
(`DELETE v1/admins/invitations/{id}` ouverte à tout administrateur, droit revérifié dans la
modification) ; les autres exigent `admins.manage`. `InvitationSummary.CreatedById` ajouté.
* **Administrateurs** (`admins.manage`) : comptes en attente d'abord, puis actifs (vous en premier), les
révoqués sur demande ; par compte : rôle (liste + « Appliquer », confirmation renforcée pour
« Propriétaire »), révocation ; par appareil : approbation (empreinte saisie : format contrôlé localement,
comparaison par le serveur en temps constant), révocation (sauf la clé de la session en cours). Nouvel
appareil d'un autre compte actif : la page explique qu'il l'approuve lui-même ; compte sans appareil
actif : restauration sur le VPS (`add-key`).
* **Code d'invitation** : affiché une seule fois (copie dans le presse-papiers, « J'ai transmis le code »
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
texte ; limite des 5 000 entrées signalée) ; **période** (« du / au », raccourcis) : `GET v1/audit?before=`
(ISO 8601, les mois postérieurs ne sont pas lus) puis pages suivantes jusqu'au début de la période, filtre
local en plus ; **consultations inscrites** : chaque `GET v1/audit` sans curseur (ouverture, « Actualiser »,
période) écrit `audit.view` APRÈS la lecture (auteur, clé, IP, entrées lues, borne `before`) ; les pages
suivantes (curseur) n'en réécrivent une que si la dernière consultation inscrite de la même clé date de plus
de 10 minutes (un curseur forgé ne permet donc pas de lire sans trace) ; l'outil `AdminKeyTool` est tracé
de la même façon ; **exports inscrits** : `POST v1/audit/export` (signé, `audit.read` ; nombre d'entrées,
période, filtres en clair, bornés) écrit `audit.export` AVANT que le launcher n'écrive le fichier, qui n'est
pas écrit en cas de refus ou d'erreur (le contenu du fichier reste celui déjà lu : un client modifié pourrait
sauter l'annonce, mais pas la lecture, elle-même tracée) ; export CSV des entrées filtrées (période
comprise), toutes les cellules entre guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
* **Appareil** : chaque entrée liée à une clé affiche le **nom de l'appareil choisi à l'enregistrement de
la clé** (formulaire « J'ai une invitation »), jamais le nom Windows du PC. Le serveur l'ajoute à la
lecture (`AuditEntry.KeyLabel`, d'après `admins.json`) : il n'est jamais écrit dans les fichiers du
journal (ajout seul), et les anciennes entrées en profitent aussi ; colonne « Appareil » dans l'export
CSV, recherche comprise. À la connexion (`auth.login`), le détail est « appareil « Nom » · GameLauncher
x.y.z » : le launcher n'envoie plus le nom Windows du PC (`LoginRequest.ClientName` = logiciel et
version) et le serveur retire celui qu'ajoutaient les launchers jusqu'à la version 2.4.1 (il n'est ni
journalisé ni conservé dans la session). Les lignes déjà écrites par un ancien serveur gardent leur texte
d'origine (journal en ajout seul).
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
tant qu'un code non transmis est affiché.
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
### 11 ter. Fichiers du serveur
Module serveur `Files` (`FilesModule`, `ServerFilesStore`) et module launcher `FilesAdminModule` (page « Fichiers du
serveur »), permission `files.manage`.
| Route | Signée | Rôle |
|---|:-:|---|
| `GET v1/files?path=` | | Contenu d'un dossier du site (2 000 éléments au plus, tailles des sous-dossiers dans la limite de 200 000 fichiers parcourus), espace disque, jeux nettoyables |
| `POST v1/files/delete` | ✔ | Supprime UN fichier (`path`, `size` vue dans la liste : refus s'il a changé) |
| `GET v1/games/{id}/objects/prune` | | Analyse : objets du dossier `objects` que plus aucun manifest n'utilise (nombre, taille, empreinte du manifest analysé) |
| `POST v1/games/{id}/objects/prune` | ✔ | Nettoyage, refusé si le manifest a changé depuis l'analyse (`revision_conflict`) |
* **Racine** : `LauncherApi:Files:Root`, sinon le parent commun des dossiers des jeux et du catalogue ; jamais la
racine du disque ni un dossier mêlé aux données privées. Chemins relatifs uniquement (ni `..`, ni `\`, ni `:`, ni
élément caché) ; aucun lien symbolique sur le chemin ; un dossier n'est jamais supprimé.
* **Références** : à chaque requête, tous les `manifest.json` et `launcher-update.json` du site (dossiers cachés et
liens exclus, 50 000 dossiers au plus) et le catalogue configuré sont relus ; les adresses sont résolues comme le
fait le launcher (relatives au fichier, `..` compris ; adresse complète du même serveur rattachée au site grâce à
l'emplacement et à l'adresse du catalogue). État d'un fichier : `protected` (`.json`), `in_use`, `unused`,
`unknown` (fichier de description illisible dans le dossier, ou adresse du même serveur impossible à rattacher :
tout le site), `link`.
* **Suppression** : verrou d'écriture du manifest de chaque jeu configuré qui contient le fichier, refus pendant une
publication (dossier `.publish-…` à côté d'un manifest parent), références relues sous le verrou, seul un fichier
`unused` est supprimé. Audit `files.delete` (chemin relatif, taille libérée).
* **Nettoyage** : jeux configurés uniquement, `objectsBaseUrl` = `objects/` ; seuls les fichiers
`objects/<2 hex>/<empreinte>.bin` au nom exact, ni liens, que le manifest du jeu n'utilise pas ET qu'aucun autre
fichier de description du site ne désigne ni ne rend invérifiable. Sous le verrou du jeu, refus pendant une
publication. Audit `files.prune`.
* **Erreurs** : refus métier en 409 (`publication_in_progress`, `revision_conflict`) ; droits du dossier
(`ReadWritePaths` de systemd) ou fichier occupé en 503 sans chemin du serveur (détail dans journalctl).
### 11 quater. Images du catalogue envoyées depuis le menu (✅)
Page « Jeux » › icône ou image de fond › **« Parcourir… »** (explorateur Windows) : plus besoin de client FTP.
1. Le launcher lit le fichier (taille contrôlée avant la lecture) et applique **les mêmes contrôles que le
serveur** (`Contracts/Catalog/CatalogImages.Check`) : PNG ou JPEG reconnu à son **contenu**, 4 Mo au plus,
fichier complet sans données ajoutées après l'image (bloc `IEND` / marqueur `EOI`), dimensions lues dans
l'en-tête et bornées (icône 32–2048 px, fond 640 × 360 à 7680 × 4320 px, 40 mégapixels au plus).
2. `PUT v1/catalog/images/{icon|background}` (`games.edit`, **requête signée** : la signature couvre l'empreinte
du contenu) ; corps binaire, seule route du catalogue à 4 Mo + 64 Ko (Kestrel `WithBodyLimit`, nginx
`location ^~ /api/v1/catalog/images/`, 5 Mo).
3. Serveur (`Storage/CatalogImageStore`) : contrôle complet avant toute écriture, dossier `catalog/images` à côté
du catalogue public (créé à l'installation, jamais par le service ; refusé s'il est un lien), nom
`<sha256>.png|.jpg` (même contenu déjà présent : rien n'est écrit), écriture atomique (fichier temporaire caché
puis renommage) en 0644, 300 images / 300 Mo au plus, espace disque vérifié (507). Audit
`catalog.image_upload` (refus compris).
4. Le launcher vérifie que l'adresse renvoyée est en https, sur l'hôte du catalogue, et porte l'empreinte du
fichier envoyé, puis la met dans le champ (aperçu décodé en mémoire). L'image n'est visible des joueurs
qu'après **« Publier »** (catalogue signé).
5. Joueurs (`CatalogService`) : une image dont le nom est une empreinte est **vérifiée** au téléchargement ; son
adresse figurant dans le catalogue signé, l'image est couverte par cette signature. Les images inutilisées se
suppriment depuis « Fichiers du serveur ».
### 11 quinquies. Demandes des joueurs : bouton « Aide » (7d ✅)
**Besoin** : depuis le launcher, un joueur envoie, **sans compte**, un **avis**, une **demande d'évolution**, un **bug**
ou **autre chose**, rattaché au **jeu sélectionné** ; les administrateurs les lisent par jeu dans le menu
d'administration, sans pouvoir répondre (un futur module « comptes » permettra l'échange ; les demandes anonymes
actuelles resteront sans propriétaire : aucun secret local n'est conservé pour les rattacher plus tard).
**Formulaires partagés** (`Contracts/Feedback`) : `FeedbackForms` décrit une fois chaque type et ses champs
prédéfinis (texte, choix, note 1 à 5 ; obligatoires ou non) ; le launcher génère le formulaire, le serveur valide
avec `FeedbackRules` (mêmes règles des deux côtés), le menu affiche les libellés. Titre 5 à 120 caractères, textes
2 000 caractères / 40 lignes, total 8 000, corps 16 Ko ; caractères de contrôle, invisibles, de mise en forme,
d'inversion du sens d'écriture, d'usage privé et demi-caractères refusés ; fins de ligne normalisées.
**Route anonyme** (première route d'écriture ouverte à tous ; module serveur `Feedback`) :
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `POST v1/feedback/challenge` | anonyme | | Défi anti-spam sans état (HMAC, secret en mémoire) |
| `POST v1/feedback` | anonyme | | Dépôt : `{ reference }` seulement (+ jeton d'envoi si des captures sont annoncées) |
| `PUT v1/feedback/attachments/{n}` | anonyme + jeton | | Capture d'écran n° n (1 à 3) de la demande que le jeton désigne |
| `GET v1/feedback/overview` | `feedback.read` | | Compteurs par jeu, réception activée, plafond atteint, durées de conservation |
| `GET v1/games/{id}/feedback?kind=&status=&cursor=&limit=` | `feedback.read` | | Liste (100 par page, curseur stable, filtres `open`/`closed`/statut) |
| `GET v1/games/{id}/feedback/{id}` | `feedback.read` | | Détail |
| `GET v1/games/{id}/feedback/{id}/attachments/{n}` | `feedback.read` | | Capture d'écran (octets bruts, `nosniff`) |
| `PUT v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Statut et note interne (révision attendue, 409 sinon) |
| `DELETE v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Suppression définitive |
Défenses, dans l'ordre de traitement : réception désactivée par défaut (`LauncherApi:Feedback:Enabled`, 503) ;
limite de débit ASP.NET (`feedback`, 10/min par IP) et nginx (`feedback_par_ip`, 6/min) ; corps 16 Ko ; **preuve de
travail** (`FeedbackProof` : SHA-256 avec 18 bits à zéro par défaut, défi à usage unique valable 10 min) ; jeu parmi
ceux configurés (`Feedback:Games`, sinon `Games` ; l'identifiant **configuré** sert seul à construire un chemin, `.`
et `..` refusés) ; contenu ; 3 demandes / 10 min et 10 / jour par IP (IPv6 : /64), **comptées en mémoire seulement**
(429 + `Retry-After`) ; plafonds globaux (300 / jour / jeu, 5 000 / jeu, 1 Go captures comprises, 500 Mo libres :
503 `feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
et le statut.
**Captures d'écran** (3 au plus par demande, 4 Mo chacune, **PNG ou JPEG uniquement**) : le joueur choisit une image
(PNG, JPEG, BMP, GIF, WebP, reconnue à son **contenu**, jamais à l'extension ; 30 Mo au plus, dimensions lues avant
tout décodage) ou la colle (Ctrl+V) ; Windows la décode (preuve que c'est une image), elle est réduite (3 840 px,
surface de la limite serveur) et **réencodée sans métadonnées** (ni GPS, ni appareil, ni date) ; le nom du fichier
n'est jamais envoyé. Envoi en deux temps : la demande annonce `AttachmentCount`, le serveur répond avec un **jeton
d'envoi** `<id>.<secret>` (128 bits, seule son empreinte SHA-256 est gardée, en mémoire, 15 min, comparaison à temps
constant) valable pour CETTE demande et les numéros annoncés ; chaque capture part en `PUT` (corps 4 Mo + 16 Ko,
en-tête `X-GL-Feedback-Upload`). Le serveur contrôle **réellement** l'image sans bibliothèque de décodage
(`FeedbackImages.Sanitize`) : PNG (signature, IHDR, CRC de chaque bloc, données zlib décompressées et taille exacte
des lignes vérifiée, filtres valides, IEND final sans données après) ou JPEG (marqueurs, SOF0/1/2, tables, SOS, EOI,
rien après) ; dimensions 16 à 4 096 px, 9,4 Mpx au plus ; puis **retire toutes les métadonnées** (blocs PNG
facultatifs : textes, EXIF, profil… ; JPEG : APP1 à APP15 dont EXIF/XMP/GPS, commentaires). Une capture refusée
n'annule pas la demande (le joueur est prévenu). Audit `feedback.attachment` sans IP. Dans le menu, chaque capture est
**décodée à nouveau** par le launcher (contrôle + dimensions) avant affichage ; aucun fichier n'est écrit sur le PC.
**Liens** (`FeedbackLinks`) : toute demande contenant un lien est signalée (icône dans la liste, bandeau
d'avertissement et champs concernés surlignés dans la fiche ; liens affichés **désamorcés**, ex. `hxxps[://]site[.]com`,
jamais cliquables). Formes reconnues : `http(s)://`, `ftp://`, `file:`, schémas dangereux (`javascript:`, `data:`,
`vbscript:`, `ms-*:`, `steam:`…), `www.`, domaines nus avec extension connue (`site.ru`, `exemple.com/chemin`), adresses
IP, chemins réseau `\\serveur\partage`, adresses e-mail, formes déguisées (`hxxp`, `[.]`, `(dot)`, ` dot `,
caractères pleine chasse).
**Anonymat** : ni adresse IP ni identifiant du PC dans la demande ; audit `feedback.submit` **sans IP** (référence,
jeu, type) ; refus résumés par `WriteAnonymousFailure` sans IP ; nginx `access_log off` sur les deux routes ;
informations techniques facultatives (versions du launcher et du jeu, Windows, langue), cochées par défaut pour un
bug seulement, contenu exact montré au joueur avant l'envoi. Agent utilisateur générique (`GameLauncher/<version>`).
Le launcher ne contacte l'API qu'à l'envoi.
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` (captures :
`<id>-<n>.png|jpg` à côté, supprimées avec la demande) dans les données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
et signalé), verrou en mémoire (seul le service écrit ici). **SQLite évalué et écarté pour ce module** : volumes bornés,
aucune relation, une demande = un document modifié isolément, purge = suppression de fichiers ; SQLite ajouterait une
dépendance native (paquet NuGet + bibliothèque `e_sqlite3`) à maintenir sur le VPS et une sauvegarde spécifique, sans
gain de sécurité. À réévaluer avec le module « comptes + échanges » (relations comptes ⇄ demandes ⇄ messages,
requêtes croisées, volumes non bornés), où SQLite serait légitime. Captures : fichiers également (des blobs binaires
en base alourdiraient sauvegardes et purge sans rien apporter) (requêtes paramétrées uniquement, fichier 0600 dans
les données privées, mode WAL, sauvegarde par l'API de sauvegarde).
**Consultations et traitement** : `feedback.view` inscrit à chaque ouverture de liste (première page) et à chaque
lecture d'une demande (une fois par demande et par clé toutes les 10 min), sans le contenu ; `feedback.update`
(ancien → nouveau statut, note modifiée oui/non), `feedback.delete` (référence). Statuts : Nouvelle, En cours, Traitée,
Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur. Comme partout dans le menu, les **écritures**
(`PUT`, `DELETE`) sont **signées** par la clé de l'administrateur (horodatage + nonce, rejeu refusé) ; les lectures
(liste, détail, captures) exigent la session et `feedback.read`, revérifiés à chaque requête, et sont journalisées.
Le journal d'audit du menu affiche des libellés lisibles (« Demande de joueur reçue », « … consultée », « … traitée »,
« Capture d'écran reçue »…) et une catégorie « Demandes des joueurs ».
**Conservation** (`Hosting/RetentionService`, au démarrage puis toutes les 24 h) : demande close supprimée
`RetentionClosedMonths` (12) après sa clôture, toute demande au plus `RetentionMaxMonths` (24) après réception
(`feedback.purge`) ; **journal d'audit** : fichiers mensuels au-delà de `Audit:RetentionMonths` (12) supprimés
(`audit.purge`) — corrige l'écart relevé par la fiche RGPD.
**Launcher (joueur)** : bouton **Aide** (bulle avec point d'interrogation) dans la barre de titre, absent sans
`apiUrl` (même règle que le menu : `launcher.json` intégré en Release). Fenêtre modale `FeedbackWindow` (le jeu est
figé à l'ouverture) : type (4 tuiles) › formulaire (contrôle local, compteurs de caractères, étoiles, saisie conservée
en cas d'échec, Échap sans effet pendant la saisie) › référence (copie). Formulaire limité à une hauteur de base
(500 px, moins sur un petit écran) avec ascenseur ; la fenêtre reste centrée quand l'étape change ; molette gérée
par `WheelAssist` comme dans le menu. ViewModel `FeedbackFormViewModel`
(`GameLauncher.Modules.Feedback`), envoi par `LauncherApiClient.SendFeedbackAsync` (défi résolu hors du fil de
l'interface).
**Menu d'administration** : module `FeedbackAdminModule`, page **« Demandes des joueurs »** (`feedback.read`) : choix
du jeu (tuiles avec compteurs ; un seul jeu : choisi d'office), filtres type / statut (« À traiter » par défaut) /
recherche, liste et fiche en deux colonnes (texte brut, sélectionnable, jamais interprété), statut et note
(`feedback.manage`), suppression confirmée ; bandeaux « réception désactivée » et « plafond atteint ». Tableau de bord :
carte « Demandes des joueurs » (nouvelles demandes par jeu).
### 11 sexies. Formulaires des demandes modifiables (7e ✅)
**Besoin** : gérer depuis le menu les types de demande (**1 à 8** : ajout, suppression, **ordre d'affichage**, nom,
description, icône, exemple de titre, informations techniques cochées ou non, **captures d'écran autorisées ou non**,
type masqué) et leurs champs (ajout, retrait, ordre, libellé, aide, obligatoire, longueur, choix), **communs à tous
les jeux**. Les types de champs proposés restent ceux ci-dessous.
**Icônes** (`Contracts/Feedback/FeedbackIcons`) : l'une des **quatre icônes d'origine** (`Star`, `Lightbulb`, `Bug`,
`Forum`), ou une icône **importée depuis un fichier .ico** (extension et en-tête vérifiés ; 1 Mo au plus) qui doit
contenir une image de **64 × 64 pixels** (PNG intégré, revérifié, ou bitmap 24/32 bits avec son masque ; 256 couleurs
et moins refusés ; les autres tailles sont ignorées). Lecture sans bibliothèque, puis réécriture en **PNG 64 × 64
neuf, sans métadonnées**, intégré en base64 aux formulaires (`FeedbackForm.CustomIcon`) : il est donc **couvert par la
signature** ; serveur et launchers le revérifient (`CheckCustom` : PNG valide, exactement 64 × 64, 20 Ko au plus,
déjà nettoyé).
**Captures d'écran par type** (`FeedbackForm.AllowAttachments`, vrai par défaut) : faux = section masquée dans la
fenêtre « Aide », aucune capture envoyée, et le serveur refuse une demande qui en annonce (`FeedbackRules.Validate`).
Mêmes règles qu'avant sinon (3 au plus, conversion, contrôles).
**Types de champs** (`FeedbackFieldKind`, écrits en toutes lettres) : `Multiline` (zone de texte, 20 à 2 000
caractères), `Text` (texte court, 1 à 200), `Choice` (liste déroulante, choix unique), `Checkboxes` (cases à cocher :
identifiants séparés par des virgules, remis dans l'ordre du formulaire), `YesNo` (`yes`/`no` ; obligatoire = doit
être cochée), `Rating` (3, 5 ou 10 étoiles). `ShowInList` : la valeur d'un champ (liste, note ou oui/non) sert
d'aperçu dans la liste du menu (remplace la règle figée « note » / « gravité »).
**Document** (`Contracts/Feedback/FeedbackFormsDocument`) : `feedback-forms.json` = format, version, **révision**,
formulaires, signature ; révision 0 = formulaires d'origine (`FeedbackForms`, jamais publiés). Règles
(`FeedbackFormRules.Validate`, communes au menu, au serveur et au launcher) : 1 à 8 types (identifiants
`[a-z][a-z0-9_]{0,31}` uniques ; ordre du document = ordre d'affichage), au moins un proposé ; 15 champs au plus ;
identifiants de champ uniques ; 2 à 20 choix ; textes déjà propres (une ligne ; aide : 4 lignes, 200 caractères), sans
caractère invisible ni **aucun lien** (`FeedbackLinks`) ; icône d'origine parmi les quatre, icône importée contrôlée ;
**256 Ko** au plus (icônes comprises : route `PUT v1/feedback/forms` à 264 Ko, bloc nginx dédié). Condensé signé : format, version, révision et formulaires (la date de publication n'en fait
pas partie).
**Signature** : nouveau type de contenu `feedback-forms` (identifiant signé `feedback-forms`) et nouvelle autorisation
du trousseau **`forms`** (permission `feedback.forms`, Propriétaire et Administrateur par défaut, pas l'Éditeur).
Une autorisation inconnue mais bien formée est désormais **acceptée** dans le trousseau (sans effet : `Verify` exige
l'autorisation exacte), pour que les prochaines n'invalident plus le trousseau des launchers ; les launchers 3.1 et
avant refusent encore un trousseau contenant `forms` et gardent le précédent (publier la 3.2 avant de re-signer).
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
Formulaires signés obligatoires (pas de mode « non signé »). SQLite écarté : un seul petit document, rarement modifié,
publié en fichier de toute façon.
**Validation des demandes** : `FeedbackSubmission.FormRevision`. Révision 0 (launchers 3.0 / 3.1, ou formulaires
publiés illisibles chez le joueur) : formulaires d'origine, toujours acceptés sauf type masqué depuis. Révision en
vigueur : son formulaire, **conservé avec la demande** (`FeedbackRecord.Form`, `FeedbackDetail.Form`) pour l'afficher
avec ses libellés d'alors. Autre révision : **409 `feedback_form_changed`** (avant le comptage des limites par
adresse).
**Launcher (joueur)** : `FeedbackFormsService` (`Modules.Feedback`) lit `feedback-forms.json` (adresse déduite du
trousseau, sinon du catalogue) **à l'ouverture de la fenêtre « Aide » seulement**, 256 Ko au plus (icônes importées comprises), 8 s au plus ;
règles contrôlées même si signé ; signature vérifiée par `ContentTrustService.VerifyFeedbackFormsAsync` selon
`contentSigning` (clé autorisée `forms`, date de signature non antérieure) ; révision inférieure à la copie gardée
refusée ; copie gardée dans `feedback\forms.json` et revérifiée à la lecture. Rien de valide : formulaires d'origine.
Fenêtre : « Chargement des formulaires… », types masqués absents, nouveaux champs (texte court, cases à cocher
arrondies, case oui/non, 3 à 10 étoiles), aide affichée sous les champs sans zone de saisie. Sur 409 : formulaires
relus, type reconstruit, saisie des champs de même identifiant et de même type reprise, message au joueur ; type masqué
depuis : retour au choix du type.
**Menu d'administration** : la fiche d'une demande utilise le formulaire conservé avec elle (libellés, nom du type).
Page **« Formulaires des demandes »** (`Feedback/FeedbackFormsPanelViewModel`, `FeedbackFormEditors`, permission
`feedback.forms`, ordre 16) : trois colonnes, types de demande (proposé / masqué, nouveau / modifié ; « Ajouter un
type de demande », 8 au plus, identifiant généré `type_1`… jamais celui d'un type publié ou d'origine, avec un champ
« Message ») ; réglages du type (monter, descendre, supprimer avec confirmation — les demandes reçues restent
consultables ; nom ; icône d'origine ou « Importer un fichier .ico… » / « Retirer l'icône importée » ; description,
exemple de titre, informations techniques cochées par défaut, captures d'écran autorisées, masqué) et champs repliables
(monter, descendre, retirer ; libellé, aide, obligatoire, aperçu dans la liste, longueur, nombre d'étoiles, choix
ordonnés) ; « Ajouter un champ » par type, identifiant généré et fixe (`texte_1`, `cases_2`…, jamais un identifiant
publié) ; **aperçu** rendu avec les modèles de la fenêtre « Aide » (`Themes/FeedbackForms.xaml`, partagé). Contrôle
en direct avec `FeedbackFormRules` (publication bloquée tant qu'il reste une erreur) ; avertissements sans refus :
libellé qui semble demander une donnée personnelle (`PersonalDataWords` : e-mail, mot de passe, téléphone…), type sans
champ. « Publier » : confirmation (types masqués rappelés), contrôle du trousseau publié (`PlayerSigningCheck` :
autorisation « formulaires »), signature par la clé de ce PC, requête signée, révision attendue (409 : bandeau
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
**Ancre de confiance** : la ou les clés racine intégrées à l'exe (`Security\update-signing.pub`, celles des
mises à jour du launcher). La clé privée racine reste **hors ligne** : le menu d'administration refuse toujours
de l'ouvrir (nom de fichier réservé, et toute clé dont l'identifiant ou la clé publique est celle d'une racine
intégrée, quel que soit le nom du fichier) ; elle ne sert qu'à `LauncherPublisher` (mises à jour du launcher,
`sign-keyring`).
**Trousseau** (`keyring.json`, `Contracts/Signing/Keyring`) : `sequence` croissante, `issuedAt`, clés
(identifiant, clé publique SPKI, nom, autorisations `releases` et/ou `catalog`), signature racine sur le SHA-256
du trousseau canonique (message `GameLauncher/Keyring/v1`, clés et autorisations triées). Production : page
**Signature** (propriétaires) › « Exporter la demande » (appareils ACTIFS des comptes actifs ; `releases` si le rôle
a `releases.publish`, `catalog` s'il a `games.edit` ; noms débarrassés des caractères invisibles, de mise en forme
ou d'inversion du sens d'écriture, refusés aussi par `Keyring.Validate`) → `tools\sign-keyring.bat` (compare la
demande au trousseau signé précédent `keyring-N.json` du même dossier, vérifié avec `update-signing.pub`, et
signale les **nouvelles clés** et autorisations élargies ; la demande vient du serveur et n'est donc pas digne de
confiance : ces empreintes se vérifient hors du serveur, avec celle que la page **Mes appareils** calcule sur le
PC de chacun — pas celle annoncée par le serveur ; confirmation, phrase de passe ; refuse une racine absente
d'`update-signing.pub` ; écrit `keyring-N.json`) → « Publier le trousseau signé » (le launcher vérifie la racine et
le numéro avant l'envoi ; l'export reste possible quand le trousseau publié est refusé par ce launcher). Les noms
sont publics. Serveur : `PUT v1/keyring` (`admins.manage`,
requête signée) contrôle la structure et un numéro strictement croissant, écrit le fichier public (par défaut à
côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish` (clés ajoutées/retirées). La liste
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
permettrait de calculer l'empreinte à dicter).
**Contenu en ligne** (page Signature, `GET v1/signing` : `SigningState`) : versions en ligne de chaque jeu, catalogue,
puis **formulaires des demandes** (`FeedbackFormsSignatureState` : révision, condensé, signature, signataire ;
révision 0 = formulaires d'origine intégrés au launcher, rien à signer ; fichier de référence illisible signalé).
Chaque contenu est vérifié par le launcher avec le trousseau publié (accepté ou refusé par les joueurs, et pourquoi)
et avec le trousseau proposé (« sa clé ne sera plus dans le trousseau proposé » ; l'export de la demande le rappelle).
Un serveur antérieur à la 3.2.1 n'envoie pas les formulaires : la ligne n'apparaît pas.
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
chacun à partir de ce qu'il a lu (même code dans `Contracts`) :
* manifest : jeu, version, exécutable, arguments, archive (adresse, taille, SHA-256), base des objets, fichiers
(chemin, taille, SHA-256) triés — **pas** les notes ni la date : les notes restent corrigeables en ligne (texte
borné, jamais exécuté) ; le launcher des joueurs calcule le même condensé depuis son modèle `GameManifest` ;
* catalogue : tout sauf `revision`, `updatedAt` et `signature` (posés par le serveur), jeux triés par identifiant
**tel que publié** (sans mise en minuscules) ; un catalogue dont deux jeux ne diffèrent que par la casse est
refusé par les launchers (`HasDuplicateGames`), même signé.
Les membres absents ou `null` (trousseau, manifest) ne provoquent jamais d'exception : trousseau refusé, condensé
calculé sur des valeurs vides.
**Production des signatures** (clé de la session ; date = heure du serveur) : à la mise en ligne d'une version
(`ReleaseUploader`, juste avant le commit : la reprise des envois, fondée sur l'empreinte du manifest, n'est pas
touchée ; corps `PublicationCommitRequest`), à chaque publication du catalogue (page Jeux, sur le catalogue
contrôlé tel qu'il sera publié), et pour une version déjà en ligne (WinSCP, antérieure) : page « Publier une
version » › « Signer la version en ligne » depuis son dossier (`POST v1/games/{id}/signature`, accepté seulement si
le condensé en ligne est celui du dossier). Le serveur vérifie chaque signature (clé = celle de la session, date à
± 10 min de son heure — une date future bloquerait les mises à jour suivantes chez les joueurs, signature valide)
et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire toute `signature` d'un manifest reçu
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
**Avertissement avant publication** (`Modules/Admin/PlayerSigningCheck`, côté administration uniquement) : avant de
publier le catalogue (page Jeux), une version ou de signer la version en ligne (page « Publier une version »), le
launcher télécharge le trousseau publié (`keyringUrl` du launcher.json intégré, sans cache, 15 s au plus) et le
vérifie comme les launchers des joueurs (structure, racine intégrée, `keyringMinSequence`). Si la clé de ce PC
(identifiant calculé localement) en est absente, n'a pas l'autorisation voulue (`catalog` / `releases`), ou si le
trousseau est injoignable ou refusé : confirmation « Clé non reconnue par les launchers des joueurs » qui explique la
conséquence selon `contentSigning` (« require » : catalogue ignoré / version refusée ; « warn » : alerte au journal)
et le remède (page Signature), avec « Publier quand même » / « Signer quand même ». Aucun avertissement en
`contentSigning` « off ». **Le serveur n'est pas modifié** : il accepte toujours ces publications (il ne contrôle que
la clé de la session, la date et la validité de la signature) ; une erreur imprévue du contrôle ne bloque pas la
publication.
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
sécurité », pas d'installation ni de mise à jour, le jeu installé reste jouable), catalogue ignoré (cache conservé).
Le cache du catalogue contient le catalogue tel que publié (signature comprise) : il est revérifié à chaque
démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou modifié sur le disque est ignoré en
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
pas les versions suivantes).
**Limites assumées** : images du catalogue déposées à la main (nom libre) non signées — celles envoyées depuis la
page Jeux le sont par leur empreinte (§ 11 quater) ; un launcher qui n'a jamais vu de trousseau accepte tout
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
(la page Signature le signale : re-signer d'abord).
## 13. Phases
@@ -501,8 +971,11 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
@@ -514,5 +987,5 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) |
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
+5 -2
View File
@@ -150,14 +150,17 @@ public static class AdminCli
if (db.Admins.Count == 0) o.WriteLine("Aucun administrateur.");
foreach (var admin in db.Admins.OrderBy(a => a.CreatedAt))
{
o.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
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} ({invitation.Role}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
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;
}
@@ -25,10 +25,14 @@ public static class ApiModules
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.Publishing.PublishingModule(),
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule(),
new Modules.Feedback.FeedbackModule()
};
}
@@ -43,4 +47,7 @@ public static class RateLimitPolicies
/// à 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";
}
@@ -41,6 +41,18 @@ public sealed class LauncherApiOptions
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.
@@ -76,6 +88,95 @@ public sealed class LauncherApiOptions
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
{
@@ -132,10 +233,31 @@ public sealed class LauncherApiOptions
/// </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>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)
{
@@ -163,7 +285,10 @@ public sealed class LauncherApiOptions
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)");
@@ -175,6 +300,33 @@ public sealed class LauncherApiOptions
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()
@@ -209,6 +361,53 @@ public sealed class LauncherApiOptions
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;
}
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
public const string FeedbackFormsFileName = "feedback-forms.json";
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -233,6 +432,55 @@ public sealed class LauncherApiOptions
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).
@@ -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");
}
}
}
@@ -84,6 +84,8 @@ public static class ServerApp
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,
@@ -98,6 +100,8 @@ public static class ServerApp
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();
@@ -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))));
}
}
@@ -18,8 +18,11 @@ namespace GameLauncher.Server.Modules.Admins;
/// 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 rôle, on ne se révoque pas soi-même, on ne révoque pas
/// 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
{
@@ -33,10 +36,12 @@ public sealed class AdminsModule : IApiModule
manage.MapGet(ApiRoutes.Admins, List);
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
// 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();
@@ -46,14 +51,36 @@ public sealed class AdminsModule : IApiModule
// ------------------------------------------------------------------ Lecture
private static IResult List(AdminStore store) =>
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
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();
return Results.Json(store.Read().Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
.Select(ToSummary).ToList(), ApiJson.Options);
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
@@ -88,7 +115,8 @@ public sealed class AdminsModule : IApiModule
{
displayName = ApiText.CleanLine(request.DisplayName, ApiText.MaxDisplayNameLength)
?? throw ApiRejection.BadRequest($"Nom obligatoire ({ApiText.MaxDisplayNameLength} caractères au plus).");
role = AdminRoles.IsValid(request.Role) ? request.Role! : throw ApiRejection.BadRequest("Rôle inconnu.");
role = db.FindGroup(request.Role)?.Id ?? throw ApiRejection.BadRequest("Groupe inconnu : rechargez la page.");
AdminRules.EnsureCanAssign(db, actor, role);
}
var record = new InvitationRecord
@@ -107,20 +135,24 @@ public sealed class AdminsModule : IApiModule
return record;
});
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
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();
store.Update(db =>
var deleted = store.Update(db =>
{
EnsureActor(db, actor);
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
// 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}");
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
return Results.NoContent();
}
@@ -148,7 +180,13 @@ public sealed class AdminsModule : IApiModule
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);
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))
@@ -170,7 +208,7 @@ public sealed class AdminsModule : IApiModule
return admin.DisplayName;
});
Audit(audit, context, "admins.key_approve", $"{name} / clé {keyId}");
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
return Summary(store, adminId);
}
private static IResult RevokeKey(Guid adminId, string keyId, HttpContext context, AdminStore store, SessionStore sessions,
@@ -184,6 +222,7 @@ public sealed class AdminsModule : IApiModule
{
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();
@@ -193,7 +232,7 @@ public sealed class AdminsModule : IApiModule
});
sessions.RemoveWhere(s => s.KeyId == keyId);
Audit(audit, context, "admins.key_revoke", $"{name} / clé {keyId}");
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
return Summary(store, adminId);
}
// ------------------------------------------------------------------ Comptes
@@ -202,22 +241,24 @@ public sealed class AdminsModule : IApiModule
{
var actor = context.Admin();
if (adminId == actor.Admin.Id)
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre rôle.");
if (!AdminRoles.IsValid(request.Role)) throw ApiRejection.BadRequest("Rôle inconnu.");
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 = request.Role;
admin.Role = group.Id;
EnsureUsableOwner(db);
return (admin.DisplayName, old);
});
// Les droits sont relus à chaque requête : le nouveau rôle s'applique immédiatement.
// Les droits sont relus à chaque requête : le nouveau groupe s'applique immédiatement.
Audit(audit, context, "admins.role", name, $"{previous} → {request.Role}");
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
return Summary(store, adminId);
}
private static IResult RevokeAdmin(Guid adminId, HttpContext context, AdminStore store, SessionStore sessions,
@@ -233,6 +274,7 @@ public sealed class AdminsModule : IApiModule
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;
@@ -248,7 +290,13 @@ public sealed class AdminsModule : IApiModule
});
sessions.RemoveWhere(s => s.AdminId == adminId);
Audit(audit, context, "admins.revoke", name);
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
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
@@ -266,14 +314,8 @@ public sealed class AdminsModule : IApiModule
/// 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)
{
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 ||
(required == AdminPermission.Manage && !AdminRoles.Has(current.Admin.Role, AdminPermissions.AdminsManage)))
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
required == AdminPermission.Manage ? "Vos droits ne permettent pas cette action." : "Vos droits ont changé entre-temps : action refusée.");
}
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 }
@@ -285,6 +327,9 @@ public sealed class AdminsModule : IApiModule
"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)
{
@@ -292,11 +337,13 @@ public sealed class AdminsModule : IApiModule
audit.Write(action, result, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
}
internal static AdminSummary ToSummary(AdminRecord admin) => new()
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
@@ -306,19 +353,22 @@ public sealed class AdminsModule : IApiModule
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) => new()
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);
}
}
@@ -1,4 +1,7 @@
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;
@@ -6,18 +9,133 @@ 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.</summary>
/// <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) =>
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<AuditLog>();
services.AddSingleton<AuditConsultations>();
}
public void MapEndpoints(IEndpointRouteBuilder api) =>
api.MapGet(ApiRoutes.Audit, (int? limit, string? cursor, AuditLog audit) =>
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))
{
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
return Results.Json(audit.Read(limit ?? 50, cursor), ApiJson.Options);
}).RequirePermission(AdminPermissions.AuditRead);
// 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;
}
}
}
@@ -97,7 +97,7 @@ public sealed class AuthModule : IApiModule
return ApiResults.Unauthorized(LoginRefused);
}
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
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).
@@ -117,8 +117,12 @@ public sealed class AuthModule : IApiModule
}
}
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
var authenticated = new AuthenticatedAdmin(session, admin, key, AdminRoles.PermissionsOf(admin.Role));
// 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,
@@ -129,6 +133,23 @@ public sealed class AuthModule : IApiModule
}, 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();
@@ -3,6 +3,7 @@ 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;
@@ -16,19 +17,58 @@ namespace GameLauncher.Server.Modules.Catalog;
/// 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.
/// 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) =>
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) =>
@@ -50,6 +90,20 @@ public sealed class CatalogModule : IApiModule
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
{
@@ -62,9 +116,9 @@ public sealed class CatalogModule : IApiModule
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
var actor = context.Admin();
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", Describe(previous, published));
$"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);
}
@@ -0,0 +1,352 @@
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.
/// </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).");
}
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; 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)";
}
@@ -1,11 +1,14 @@
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;
@@ -81,14 +84,36 @@ public sealed class PublishingModule : IApiModule
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, ILogger<PublishingModule> logger) =>
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
var (result, record) = store.Commit(id, context.Admin().Admin.Id);
// 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]}");
(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);
});
@@ -0,0 +1,217 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
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.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 et des formulaires des demandes ;
/// • 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, 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 };
}
}
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
}, 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).");
}
}
@@ -10,7 +10,10 @@ using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
/// <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);
@@ -19,6 +22,7 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
Id = Admin.Id,
DisplayName = Admin.DisplayName,
Role = Admin.Role,
RoleName = RoleName ?? AdminRoles.DisplayName(Admin.Role),
Permissions = Permissions,
KeyId = Key.KeyId,
KeyLabel = Key.Label
@@ -28,8 +32,9 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
/// <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 rôle est relu
/// à chaque requête : un changement de rôle s'applique aussitôt.
/// 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>
{
@@ -61,7 +66,8 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
var session = _sessions.Find(header["Bearer ".Length..].Trim());
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
var match = _store.Read().FindKey(session.KeyId);
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)
{
@@ -69,7 +75,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
}
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
var permissions = database.PermissionsOf(found.Admin.Role);
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
@@ -80,7 +86,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
};
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
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)));
}
@@ -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];
}
}
@@ -5,15 +5,82 @@ 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 int FormatVersion { get; set; } = 1;
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)
{
@@ -33,7 +100,10 @@ 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; }
@@ -81,3 +151,36 @@ public sealed class InvitationRecord
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; }
}
@@ -84,15 +84,29 @@ public sealed class AdminStore
private AdminDatabase Load()
{
if (!File.Exists(_path)) return new AdminDatabase();
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 != 1) throw new InvalidDataException($"format {database.FormatVersion} inconnu");
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)
@@ -102,6 +116,20 @@ public sealed class AdminStore
}
}
/// <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);
+40 -4
View File
@@ -144,11 +144,41 @@ public sealed class AuditLog
}
/// <summary>
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
/// précédente (« aaaa-MM:position »). Les fichiers sont lus depuis la fin, par blocs : la mémoire
/// utilisée ne dépend pas de leur taille.
/// 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 AuditPage Read(int limit, string? cursor)
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)
@@ -169,17 +199,23 @@ public sealed class AuditLog
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)
{
@@ -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
};
}
@@ -23,8 +23,6 @@ public sealed class CatalogStore
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private const UnixFileMode PublicFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
private readonly string _path;
private readonly string _lockPath;
@@ -57,6 +55,14 @@ public sealed class CatalogStore
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.
@@ -110,37 +116,7 @@ public sealed class CatalogStore
}
}
private void WritePublic(string json)
{
var directory = Path.GetDirectoryName(_publicFile!)!;
var temp = Path.Combine(directory, $".{Path.GetFileName(_publicFile)}.{Guid.NewGuid():N}.tmp");
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PublicFileMode;
using (var stream = new FileStream(temp, options))
{
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json));
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, PublicFileMode);
File.Move(temp, _publicFile!, 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 catalogue public impossible ({_publicFile}) : {ex.Message}", ex);
}
}
private void WritePublic(string json) => PublicFileWriter.Write(_publicFile!, json, "catalogue public");
private void RestorePrivate(string? previousText)
{
@@ -0,0 +1,189 @@
using System.Text;
using System.Text.Json;
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.
}
}
/// <summary>
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
/// retirés, modifiés ; captures d'écran, icône).
/// </summary>
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<string>();
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
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)) changes.Add("ordre des types modifié");
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) continue;
var parts = new List<string>();
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
old.TechByDefault != form.TechByDefault)
parts.Add("présentation modifiée");
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
if (old.AllowAttachments != form.AllowAttachments)
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var addedFields = newIds.Count(id => !oldIds.Contains(id));
var removedFields = oldIds.Count(id => !newIds.Contains(id));
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
parts.Add("ordre modifié");
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
}
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
}
}
@@ -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,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);
}
}
}
@@ -6,6 +6,7 @@ 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;
@@ -109,7 +110,7 @@ public sealed class PublicationStore
AdminName = adminName,
CreatedAt = _time.GetUtcNow(),
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
PublishedContentSha256 = ContentSignature(currentRoot)
PublishedContentSha256 = OnlineContentHash(currentRoot)
};
CreateStaging(record);
Save(record);
@@ -238,7 +239,10 @@ public sealed class PublicationStore
/// 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>
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId)
/// <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);
@@ -268,14 +272,21 @@ public sealed class PublicationStore
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 (ContentSignature(currentRoot) != record.PublishedContentSha256)
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);
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne).
// 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.
@@ -395,12 +406,13 @@ public sealed class PublicationStore
return merged;
}
/// <summary>Empreinte du manifest en ligne SANS les notes (fichiers, archive, version…) ; vide s'il n'existe pas.</summary>
private static string ContentSignature(JsonObject? root)
/// <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()));
}
@@ -33,6 +33,18 @@ internal static class SecureFiles
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)
{
@@ -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);
}
}
@@ -31,10 +31,47 @@
"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.
+257 -19
View File
@@ -116,11 +116,14 @@ de ses données privées. Exemple ci-dessous avec le site de « Serveur de tél
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) : envoie-les en SFTP (compte `deploy`) dans `www/catalog/images/`, en PNG
ou JPEG de 4 Mo au plus, puis saisis leur adresse dans la page Jeux, par ex.
**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, envoie-la sous un **nouveau nom**. Les images d'un autre
serveur sont refusées.
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).
@@ -227,6 +230,230 @@ Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cl
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 pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
après (nouveau téléchargement du trousseau) ou à son prochain dé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.
## 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 :
@@ -270,26 +497,29 @@ Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste d
## 7. Ajouter un administrateur
Depuis la phase 2, la personne invitée peut créer et enregistrer sa clé **dans le launcher** (menu
d'administration › « J'ai une invitation », code collé) : l'étape 2 ci-dessous se fait alors sans
ligne de commande, et le launcher affiche l'empreinte à te dicter. Inviter et approuver se font avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » du launcher (phase 6) :
Tout se fait dans le launcher (menu d'administration), sans ligne de commande :
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
2. **Paul** : launcher › menu d'administration › « J'ai une invitation » (ou `admin-key.bat`, puis
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>`) ; il te communique
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** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
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 (`revoke-admin`) et recommence.
utiliser le code. Révoque ce compte (page Administrateurs, ou `revoke-admin`) et recommence.
Nouveau PC pour un administrateur existant : **lui-même** lance `invite --api … --for <son id>`
depuis un PC déjà enregistré, `enroll` sur le nouveau PC, puis `approve` depuis l'ancien. Personne
d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
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
@@ -308,6 +538,7 @@ pas, le script remet la précédente en place.
| 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
@@ -326,6 +557,7 @@ Le service n'a pas besoin d'être redémarré : une révocation ferme immédiate
|---|---|
| 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` |
@@ -343,14 +575,20 @@ recréer les administrateurs.
/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
├─ 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
└─ 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)
└─ 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é)
+110 -1
View File
@@ -7,7 +7,9 @@
#
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
# (le bloc ci-dessous)
# (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
# =============================================================================
@@ -32,3 +34,110 @@ location ^~ /api/ {
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;
}
+19 -1
View File
@@ -1,5 +1,6 @@
# =============================================================================
# Catalogue des jeux (écrit par la Launcher API) — un ajout (voir LISEZMOI.md, étape 3 bis) :
# Catalogue des jeux, trousseau et formulaires des demandes (écrits par la Launcher API) — voir LISEZMOI.md,
# étapes 3 bis, 3 quinquies et 3 nonies :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
@@ -18,3 +19,20 @@ location = /catalog/catalog.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;
}
+160 -11
View File
@@ -1,10 +1,14 @@
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.Security;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.ApiClient;
@@ -44,6 +48,12 @@ public sealed class AdminSession : IAsyncDisposable
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
@@ -82,6 +92,9 @@ public sealed class AdminSession : IAsyncDisposable
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);
@@ -103,12 +116,59 @@ public sealed class AdminSession : IAsyncDisposable
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, CancellationToken ct = default)
{
var route = $"{ApiRoutes.Audit}?limit={limit}";
if (!string.IsNullOrEmpty(cursor)) route += "&cursor=" + Uri.EscapeDataString(cursor);
return GetAsync<AuditPage>(route, 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)
@@ -120,6 +180,15 @@ public sealed class AdminSession : IAsyncDisposable
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>
@@ -171,9 +240,88 @@ public sealed class AdminSession : IAsyncDisposable
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
public Task<PublicationResult> CommitPublicationAsync(string id, CancellationToken ct = default) =>
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)), null, signed: true, ct,
timeout: UploadTimeout);
/// <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>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));
@@ -196,7 +344,8 @@ public sealed class AdminSession : IAsyncDisposable
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)
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.");
@@ -222,7 +371,7 @@ public sealed class AdminSession : IAsyncDisposable
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
}
}, ct, contentType, timeout).ConfigureAwait(false);
}, ct, contentType, timeout, maxResponseBytes).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
@@ -3,6 +3,7 @@ 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;
@@ -106,6 +107,65 @@ public sealed class LauncherApiClient : IDisposable
}), 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);
@@ -131,7 +191,8 @@ public sealed class LauncherApiClient : IDisposable
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
Action<HttpRequestMessage>? prepare, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
string contentType = "application/json", TimeSpan? timeout = null,
long maxResponseBytes = ApiJson.MaxResponseBytes)
{
using var request = new HttpRequestMessage(method, Resolve(route));
if (body is not null)
@@ -164,7 +225,7 @@ public sealed class LauncherApiClient : IDisposable
byte[] bytes;
try
{
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
bytes = await ReadBoundedAsync(response, deadline.Token, maxResponseBytes).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
@@ -176,6 +237,7 @@ public sealed class LauncherApiClient : IDisposable
}
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)
@@ -189,9 +251,9 @@ public sealed class LauncherApiClient : IDisposable
}
}
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct, long maxBytes)
{
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
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();
@@ -199,7 +261,7 @@ public sealed class LauncherApiClient : IDisposable
int read;
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > ApiJson.MaxResponseBytes)
if (buffer.Length + read > maxBytes)
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
buffer.Write(chunk, 0, read);
}
@@ -261,3 +323,8 @@ 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);
+98
View File
@@ -28,18 +28,66 @@ public static class ApiRoutes
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}";
@@ -49,6 +97,56 @@ public static class ApiRoutes
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}";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -1,3 +1,5 @@
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
@@ -23,6 +25,12 @@ public sealed record CatalogDocument
/// <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>
@@ -40,7 +48,7 @@ public sealed record CatalogLauncher
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
public sealed record CatalogGame
{
/// <summary>Nom AFFICHÉ (le nom de launcher.json reste celui du raccourci, du registre et des dossiers).</summary>
/// <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; }
@@ -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; }
}
+31 -1
View File
@@ -12,6 +12,12 @@ public sealed record AdminKeySummary
/// </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; }
}
@@ -20,18 +26,34 @@ 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>Rôle du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
/// <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>
@@ -43,9 +65,16 @@ 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; }
}
@@ -65,5 +94,6 @@ public sealed record ApproveKeyRequest
public sealed record ChangeRoleRequest
{
/// <summary>Identifiant du nouveau groupe.</summary>
public string Role { get; init; } = "";
}
@@ -13,6 +13,14 @@ public sealed record AuditEntry
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; }
@@ -26,6 +34,30 @@ public sealed record AuditPage
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";
@@ -34,7 +34,13 @@ 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; }
@@ -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; }
}
@@ -68,4 +68,13 @@ public static class ApiErrorCodes
/// <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,91 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Security;
/// <summary>
/// Groupes d'administrateurs (anciennement « rôles ») et permissions personnalisées : règles communes au
/// serveur (refus de toute valeur invalide) et au menu d'administration (contrôle avant l'envoi).
/// • Chaque administrateur appartient à UN groupe ; ses droits sont les permissions de ce groupe, relues
/// à chaque requête (une modification s'applique immédiatement, sessions ouvertes comprises).
/// • Le groupe « Propriétaire » (<see cref="AdminRoles.Owner"/>) est un groupe SYSTÈME : il a toujours
/// toutes les permissions (y compris celles créées plus tard) et ne peut être ni modifié ni supprimé.
/// • Les groupes « Administrateur » et « Éditeur » sont créés par défaut ; comme tout autre groupe, ils
/// se modifient, et se suppriment s'ils n'ont plus de membre ni d'invitation en cours.
/// </summary>
public static partial class AdminGroups
{
public const int MaxGroups = 50;
public const int MaxNameLength = 40;
public const int MaxDescriptionLength = 200;
public const int MaxCustomPermissions = 40;
public const int MaxPermissionLabelLength = 40;
public const int MaxPermissionNameLength = 57;
/// <summary>Identifiant d'un groupe : minuscules, chiffres et tirets (jamais modifié après création).</summary>
[GeneratedRegex(@"\A[a-z][a-z0-9-]{1,31}\z")]
private static partial Regex GroupIdRegex();
/// <summary>Permission personnalisée : « module.action » (une seule séparation, minuscules).</summary>
[GeneratedRegex(@"\A[a-z][a-z0-9]{1,23}\.[a-z][a-z0-9_-]{1,31}\z")]
private static partial Regex PermissionNameRegex();
public static bool IsValidGroupId(string? id) => id is not null && GroupIdRegex().IsMatch(id);
public static bool IsValidPermissionName(string? name) => name is not null && PermissionNameRegex().IsMatch(name);
/// <summary>Groupe système (« Propriétaire ») : toutes les permissions, non modifiable.</summary>
public static bool IsSystem(string? groupId) => groupId == AdminRoles.Owner;
/// <summary>
/// Identifiant proposé pour un nouveau groupe, d'après son nom : « Modérateurs forum » → « moderateurs-forum ».
/// Le serveur ajoute un suffixe s'il existe déjà.
/// </summary>
public static string Slug(string name)
{
var decomposed = name.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder();
foreach (var c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
var lower = char.ToLowerInvariant(c);
if (lower is >= 'a' and <= 'z' or >= '0' and <= '9') sb.Append(lower);
else if (sb.Length > 0 && sb[^1] != '-') sb.Append('-');
}
var slug = sb.ToString().Trim('-');
if (slug.Length > 24) slug = slug[..24].TrimEnd('-');
if (slug.Length < 2 || !char.IsAsciiLetterLower(slug[0])) slug = "groupe-" + slug;
return slug.TrimEnd('-');
}
/// <summary>Nom d'un groupe : une ligne, sans caractère invisible, <see cref="MaxNameLength"/> caractères au plus.</summary>
public static string? CleanName(string? name) => ApiText.CleanLine(name, MaxNameLength);
/// <summary>Description facultative (vide : null) ; <paramref name="valid"/> faux si elle est invalide.</summary>
public static string? CleanDescription(string? description, int maxLength, out bool valid)
{
valid = true;
if (string.IsNullOrWhiteSpace(description)) return null;
var clean = ApiText.CleanLine(description, maxLength);
valid = clean is not null;
return clean;
}
/// <summary>Nom affiché d'un groupe connu par défaut (null : groupe créé depuis le menu d'administration).</summary>
public static string? DefaultName(string groupId) => groupId switch
{
AdminRoles.Owner => "Propriétaire",
AdminRoles.Admin => "Administrateur",
AdminRoles.Editor => "Éditeur",
_ => null
};
public static string? DefaultDescription(string groupId) => groupId switch
{
AdminRoles.Owner => "Tous les droits, y compris ceux des permissions créées plus tard. Groupe système : non modifiable.",
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes des joueurs et journal d'audit.",
AdminRoles.Editor => "Informations des jeux et notes de version.",
_ => null
};
}
@@ -1,8 +1,11 @@
namespace GameLauncher.Contracts.Security;
/// <summary>
/// Permissions d'administration, au format « module.action ». Un module futur ajoute les siennes
/// ici (ou dans son propre fichier de contrats) et les attribue aux rôles dans <see cref="AdminRoles"/>.
/// Permissions d'administration INTÉGRÉES, au format « module.action » : celles que le code vérifie (routes
/// du serveur, pages du launcher). Un module futur ajoute les siennes ici. Elles ne peuvent être ni créées ni
/// supprimées depuis le menu d'administration ; les groupes (<see cref="AdminGroups"/>) les reçoivent.
/// Des permissions PERSONNALISÉES peuvent être ajoutées depuis la page « Groupes et permissions » (même
/// format) : elles sont attribuables aux groupes, en prévision de modules futurs.
/// Le serveur est seul juge : côté launcher, elles servent uniquement à masquer les pages inutiles.
/// </summary>
public static class AdminPermissions
@@ -22,8 +25,57 @@ public static class AdminPermissions
/// <summary>Publication d'une nouvelle version du jeu — phase 5.</summary>
public const string ReleasesPublish = "releases.publish";
/// <summary>Fichiers du site public (www) : consultation, suppression des fichiers inutilisés, nettoyage d'« objects ».</summary>
public const string FilesManage = "files.manage";
/// <summary>Lecture des demandes des joueurs (avis, évolutions, bugs…), rangées par jeu.</summary>
public const string FeedbackRead = "feedback.read";
/// <summary>Traitement des demandes des joueurs : statut, note interne, suppression.</summary>
public const string FeedbackManage = "feedback.manage";
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
public const string FeedbackForms = "feedback.forms";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms
};
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
public static bool IsBuiltIn(string? permission) => permission is not null && All.Contains(permission, StringComparer.Ordinal);
/// <summary>Nom court d'une permission intégrée (null : permission personnalisée).</summary>
public static string? BuiltInLabel(string permission) => permission switch
{
AdminsManage => "Gestion des administrateurs",
AuditRead => "Journal d'audit",
GamesEdit => "Informations des jeux",
ChangelogEdit => "Notes de version",
ReleasesPublish => "Publication des versions",
FilesManage => "Fichiers du serveur",
FeedbackRead => "Demandes des joueurs (lecture)",
FeedbackManage => "Demandes des joueurs (traitement)",
FeedbackForms => "Formulaires des demandes",
_ => null
};
/// <summary>Ce que permet une permission intégrée (null : permission personnalisée).</summary>
public static string? BuiltInDescription(string permission) => permission switch
{
AdminsManage => "Inviter, approuver, changer de groupe et révoquer les administrateurs ; gérer les groupes et les permissions ; " +
"publier le trousseau de signature. Équivaut presque aux droits d'un propriétaire.",
AuditRead => "Consulter et exporter le journal d'audit.",
GamesEdit => "Modifier et publier les informations des jeux (nom affiché, présentation, liens, icône, image de fond).",
ChangelogEdit => "Corriger les notes des versions déjà publiées.",
ReleasesPublish => "Publier une nouvelle version d'un jeu et signer une version déjà en ligne.",
FilesManage => "Parcourir le site, supprimer les fichiers inutilisés et nettoyer le dossier « objects ».",
FeedbackRead => "Lire les demandes envoyées par les joueurs (avis, évolutions, bugs) : leur texte libre peut contenir " +
"des données personnelles. Chaque consultation est inscrite au journal d'audit.",
FeedbackManage => "Changer le statut des demandes des joueurs, écrire une note interne, supprimer une demande " +
"(à donner avec la lecture).",
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
_ => null
};
}
@@ -1,12 +1,17 @@
namespace GameLauncher.Contracts.Security;
/// <summary>Rôles des administrateurs et permissions associées.</summary>
/// <summary>
/// Groupes créés par défaut (identifiants historiques des « rôles ») et leurs permissions INITIALES.
/// Depuis la gestion des groupes, les permissions effectives d'un groupe sont celles enregistrées sur le
/// serveur (admins.json, voir <see cref="AdminGroups"/>) : <see cref="PermissionsOf"/> ne sert plus qu'à
/// créer les groupes par défaut (premier démarrage, migration) et de valeur de repli pour un ancien serveur.
/// </summary>
public static class AdminRoles
{
/// <summary>Tous les droits, dont la gestion des administrateurs.</summary>
public const string Owner = "owner";
/// <summary>Contenu et publication, sans gestion des administrateurs.</summary>
/// <summary>Contenu, publication et fichiers du site, sans gestion des administrateurs.</summary>
public const string Admin = "admin";
/// <summary>Contenu uniquement (informations des jeux, notes de version).</summary>
@@ -21,7 +26,8 @@ public static class AdminRoles
[Admin] = new[]
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -39,7 +45,8 @@ public static class AdminRoles
Owner => "Propriétaire",
Admin => "Administrateur",
Editor => "Éditeur",
_ => "Inconnu"
null or "" => "Inconnu",
_ => role // groupe créé depuis le menu d'administration : son identifiant (le nom vient du serveur)
};
}
@@ -38,6 +38,28 @@ public static class SignedMessages
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
/// <summary>
/// Signature d'un contenu servi aux joueurs (manifest d'un jeu, catalogue) par la clé d'un administrateur
/// (phase 7). En-tête distinct : une signature de contenu ne peut pas servir de signature de requête,
/// et inversement.
/// </summary>
public const string ContentHeader = "GameLauncher/Content/v1";
/// <summary>Signature du trousseau (clés autorisées à signer le contenu) par la clé racine du launcher.</summary>
public const string KeyringHeader = "GameLauncher/Keyring/v1";
/// <param name="kind">« manifest » ou « catalog ».</param>
/// <param name="id">Identifiant du jeu (minuscules) ou « catalog ».</param>
/// <param name="digest">SHA-256 (hexadécimal minuscule) du contenu canonique.</param>
public static byte[] Content(string kind, string id, long signedAt, string keyId, string digest) =>
Build(ContentHeader,
("kind", kind), ("id", id), ("signed-at", signedAt.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("key", keyId), ("digest", digest));
/// <param name="digest">SHA-256 (hexadécimal minuscule) du trousseau canonique (sans sa signature).</param>
public static byte[] Keyring(string rootKeyId, string digest) =>
Build(KeyringHeader, ("root", rootKeyId), ("digest", digest));
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
public static string HashInvitationCode(string normalizedCode) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
@@ -0,0 +1,268 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Signing;
/// <summary>
/// Signature d'un contenu servi aux joueurs (propriété « signature » du manifest.json d'un jeu ou du
/// catalog.json), produite par la clé d'un administrateur. Les anciens launchers ignorent cette propriété.
/// </summary>
public sealed record ContentSignature
{
/// <summary>Identifiant de la clé qui a signé (16 caractères hexadécimaux).</summary>
public string KeyId { get; init; } = "";
/// <summary>Date de la signature (heure du serveur, à la seconde) : sert aussi à refuser un contenu plus ancien que le dernier vu.</summary>
public DateTimeOffset SignedAt { get; init; }
/// <summary>Signature ECDSA P-256 / SHA-256 (64 octets, IEEE P1363) en Base64.</summary>
public string Value { get; init; } = "";
}
/// <summary>Types de contenu signés et autorisations (« scopes ») correspondantes dans le trousseau.</summary>
public static class ContentKinds
{
public const string Manifest = "manifest";
public const string Catalog = "catalog";
public const string FeedbackForms = "feedback-forms";
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
public const string CatalogId = "catalog";
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
public const string FeedbackFormsId = "feedback-forms";
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
public static string ScopeOf(string kind) => kind switch
{
Manifest => SigningScopes.Releases,
FeedbackForms => SigningScopes.Forms,
_ => SigningScopes.Catalog
};
}
/// <summary>Autorisations d'une clé du trousseau.</summary>
public static class SigningScopes
{
/// <summary>Signer le manifest d'un jeu (ce qui est installé et exécuté) : permission releases.publish.</summary>
public const string Releases = "releases";
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
public const string Catalog = "catalog";
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
public const string Forms = "forms";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms };
/// <summary>
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
/// acceptée dans le trousseau mais n'autorise rien ici (<see cref="ContentSigning.Verify"/> exige l'autorisation exacte).
/// </summary>
public static bool IsWellFormed(string? scope) =>
scope is { Length: >= 1 and <= 32 } && scope[0] is >= 'a' and <= 'z' && scope.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
{
var set = permissions.ToHashSet(StringComparer.Ordinal);
var scopes = new List<string>();
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
return scopes;
}
public static string DisplayName(string scope) => scope switch
{
Releases => "versions du jeu",
Catalog => "catalogue",
Forms => "formulaires des demandes",
_ => scope
};
}
/// <summary>
/// Partie d'un manifest couverte par la signature : tout ce qui décide de ce qui est installé et exécuté
/// (jeu, version, exécutable, arguments, archive, fichiers et leurs empreintes). Les notes de version et la
/// date ne le sont pas : elles restent modifiables en ligne (texte borné, jamais exécuté).
/// </summary>
public sealed record ManifestContent
{
public int FormatVersion { get; init; } = 1;
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string Executable { get; init; } = "";
public string? LaunchArguments { get; init; }
public ManifestContentArchive Archive { get; init; } = new();
public string? ObjectsBaseUrl { get; init; }
public List<ManifestContentFile> Files { get; init; } = new();
/// <summary>Lit un manifest.json (mêmes noms de propriétés que le launcher, casse indifférente).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static ManifestContent Parse(ReadOnlySpan<byte> json)
{
if (json.StartsWith(Utf8Bom)) json = json[Utf8Bom.Length..];
return JsonSerializer.Deserialize<ManifestContent>(json, ReadOptions) ?? throw new JsonException("manifest vide");
}
private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 32
};
}
public sealed record ManifestContentArchive
{
public string Url { get; init; } = "";
public long Size { get; init; }
public string Sha256 { get; init; } = "";
}
public sealed record ManifestContentFile
{
public string Path { get; init; } = "";
public long Size { get; init; }
public string Sha256 { get; init; } = "";
}
/// <summary>Résultat d'une vérification de signature de contenu.</summary>
public sealed record ContentCheck(bool Valid, string Reason, KeyringKey? Key)
{
public static ContentCheck Fail(string reason) => new(false, reason, null);
}
/// <summary>
/// Condensés canoniques et signatures du contenu. Le même code est utilisé par le menu d'administration
/// (qui signe), le serveur (qui contrôle avant d'écrire) et le launcher des joueurs (qui vérifie) : chacun
/// calcule le condensé à partir de ce qu'il a lui-même lu.
/// </summary>
public static class ContentSigning
{
/// <summary>Écart toléré par le serveur entre la date de signature et son horloge.</summary>
public static readonly TimeSpan MaxSignedAtSkew = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions Canonical = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Condensé du contenu signé d'un manifest (fichiers triés, empreintes en minuscules ; valeurs absentes = vides).</summary>
public static string ManifestDigest(ManifestContent manifest)
{
var archive = manifest.Archive ?? new ManifestContentArchive();
var canonical = new ManifestContent
{
FormatVersion = manifest.FormatVersion,
GameId = (manifest.GameId ?? "").ToLowerInvariant(),
Version = manifest.Version ?? "",
Executable = (manifest.Executable ?? "").Replace('\\', '/'),
LaunchArguments = string.IsNullOrEmpty(manifest.LaunchArguments) ? null : manifest.LaunchArguments,
Archive = new ManifestContentArchive
{
Url = archive.Url ?? "",
Size = archive.Size,
Sha256 = (archive.Sha256 ?? "").ToLowerInvariant()
},
ObjectsBaseUrl = string.IsNullOrWhiteSpace(manifest.ObjectsBaseUrl) ? "objects/" : manifest.ObjectsBaseUrl,
Files = (manifest.Files ?? new List<ManifestContentFile>())
.Where(f => f is not null)
.Select(f => new ManifestContentFile { Path = f.Path ?? "", Size = f.Size, Sha256 = (f.Sha256 ?? "").ToLowerInvariant() })
.OrderBy(f => f.Path, StringComparer.Ordinal)
.ThenBy(f => f.Sha256, StringComparer.Ordinal)
.ToList()
};
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>Condensé du contenu signé d'un manifest.json (octets bruts).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static string ManifestDigest(ReadOnlySpan<byte> manifestJson) => ManifestDigest(ManifestContent.Parse(manifestJson));
/// <summary>
/// Condensé du contenu signé d'un catalogue : tout sauf la révision, la date et la signature (posées par le
/// serveur) ; jeux triés par identifiant, tels qu'écrits (sans changer la casse : deux identifiants qui ne
/// diffèrent que par la casse donneraient deux entrées distinctes, voir <see cref="HasDuplicateGames"/>).
/// </summary>
public static string CatalogDigest(CatalogDocument catalog)
{
var games = new SortedDictionary<string, CatalogGame>(StringComparer.Ordinal);
foreach (var (id, game) in catalog.Games ?? new Dictionary<string, CatalogGame>())
games[id] = game;
var canonical = new CanonicalCatalog(catalog.Format, catalog.Version, catalog.Launcher, games);
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>
/// Identifiants de jeux en double à la casse près : le launcher n'en garderait qu'un, différent de celui
/// couvert par la signature. Un tel catalogue est refusé par la vérification.
/// </summary>
public static bool HasDuplicateGames(CatalogDocument catalog) =>
(catalog.Games?.Keys ?? Enumerable.Empty<string>()).GroupBy(k => k, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
public static string SignedId(string kind, string id) => kind switch
{
ContentKinds.Catalog => ContentKinds.CatalogId,
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
_ => id.ToLowerInvariant()
};
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
/// <summary>Message à signer par la clé de l'administrateur.</summary>
public static byte[] Message(string kind, string id, DateTimeOffset signedAt, string keyId, string digest) =>
SignedMessages.Content(kind, SignedId(kind, id), signedAt.ToUnixTimeSeconds(), keyId, digest);
/// <summary>Vérifie une signature avec une clé publique donnée (le serveur : la clé de l'administrateur qui envoie).</summary>
public static bool VerifyWithKey(string kind, string id, string digest, ContentSignature signature, ReadOnlySpan<byte> publicKey)
{
if (!AdminKeys.IsValidKeyId(signature.KeyId) || signature.SignedAt == default) return false;
if (AdminKeys.DecodeSignature(signature.Value) is not { } value) return false;
if (!string.Equals(AdminKeys.ComputeKeyId(publicKey), signature.KeyId, StringComparison.Ordinal)) return false;
return AdminKeys.Verify(publicKey, Message(kind, id, signature.SignedAt, signature.KeyId, digest), value);
}
/// <summary>
/// Vérifie une signature avec le trousseau (le launcher des joueurs) : clé présente, autorisée pour ce type
/// de contenu, signature valide sur le condensé calculé localement.
/// </summary>
public static ContentCheck Verify(string kind, string id, string digest, ContentSignature? signature, KeyringDocument keyring)
{
if (signature is null) return ContentCheck.Fail("contenu non signé");
if (!AdminKeys.IsValidKeyId(signature.KeyId)) return ContentCheck.Fail("signature mal formée");
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.Ordinal));
if (key is null) return ContentCheck.Fail($"signé par une clé absente du trousseau ({signature.KeyId})");
if (key.Scopes is null || !key.Scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal))
return ContentCheck.Fail($"la clé {signature.KeyId} ({key.Name}) n'est pas autorisée à signer ce contenu");
byte[] publicKey;
try
{
publicKey = Convert.FromBase64String(key.PublicKey);
}
catch (FormatException)
{
return ContentCheck.Fail("clé du trousseau illisible");
}
return VerifyWithKey(kind, id, digest, signature, publicKey)
? new ContentCheck(true, $"signé par {key.Name} le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC", key)
: ContentCheck.Fail("SIGNATURE INVALIDE (contenu modifié après signature, ou signature d'un autre contenu)");
}
internal static string Sha256(ReadOnlySpan<byte> data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}
@@ -0,0 +1,181 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Signing;
/// <summary>
/// Trousseau (keyring.json) : clés d'administrateurs autorisées à signer le contenu servi aux joueurs, signé
/// par la clé RACINE (celle des mises à jour du launcher, dont la clé publique est intégrée à l'exe). La clé
/// racine reste hors ligne (LauncherPublisher, « sign-keyring.bat ») : un serveur compromis ne peut ni
/// ajouter une clé, ni modifier le trousseau sans que les launchers le refusent.
/// </summary>
public sealed record KeyringDocument
{
public const string FormatName = "gamelauncher-keyring";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Numéro croissant : un launcher refuse un trousseau plus ancien que le dernier qu'il a vu.</summary>
public long Sequence { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public List<KeyringKey> Keys { get; init; } = new();
/// <summary>Signature de la clé racine (absente dans une demande de trousseau, avant signature).</summary>
public KeyringSignature? Signature { get; init; }
}
public sealed record KeyringKey
{
public string KeyId { get; init; } = "";
/// <summary>Clé publique ECDSA P-256 (SPKI, Base64).</summary>
public string PublicKey { get; init; } = "";
/// <summary>Administrateur et appareil (affichage, journal du launcher).</summary>
public string Name { get; init; } = "";
/// <summary>Ce que la clé peut signer (<see cref="SigningScopes"/>).</summary>
public List<string> Scopes { get; init; } = new();
}
public sealed record KeyringSignature
{
/// <summary>Identifiant de la clé racine (16 caractères hexadécimaux, comme pour les mises à jour du launcher).</summary>
public string RootKeyId { get; init; } = "";
public string Value { get; init; } = "";
}
/// <summary>Contrôles, condensé et signature du trousseau (même code partout).</summary>
public static class Keyring
{
/// <summary>Taille maximale de keyring.json.</summary>
public const int MaxBytes = 60 * 1024; // envoyé à l'API : sous la limite de 64 Ko des requêtes
public const int MaxKeys = 100;
public const int MaxNameLength = 120;
private static readonly JsonSerializerOptions Canonical = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Lit un trousseau (null : JSON vide).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static KeyringDocument? Parse(ReadOnlySpan<byte> json)
{
if (json.Length > MaxBytes) throw new JsonException($"trousseau trop volumineux (au plus {MaxBytes / 1024} Ko)");
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
return JsonSerializer.Deserialize<KeyringDocument>(json, ApiJson.Options);
}
public static string Serialize(KeyringDocument keyring) =>
JsonSerializer.Serialize(keyring, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true });
/// <summary>Condensé du trousseau sans sa signature (clés triées, autorisations triées).</summary>
public static string Digest(KeyringDocument keyring)
{
var canonical = keyring with
{
Signature = null,
IssuedAt = ContentSigning.Truncate(keyring.IssuedAt),
Keys = (keyring.Keys ?? new List<KeyringKey>())
.Select(k => k with { Scopes = (k.Scopes ?? new List<string>()).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal).ToList() })
.OrderBy(k => k.KeyId, StringComparer.Ordinal)
.ToList()
};
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>Contrôle de structure (sans la signature racine). Les erreurs sont ajoutées à <paramref name="errors"/>.</summary>
public static bool Validate(KeyringDocument keyring, List<string> errors)
{
var count = errors.Count;
if (keyring.Format != KeyringDocument.FormatName || keyring.Version != KeyringDocument.CurrentVersion)
errors.Add($"Format de trousseau non pris en charge ({keyring.Format} v{keyring.Version}).");
if (keyring.Sequence < 1) errors.Add("Numéro du trousseau invalide (1 au moins).");
if (keyring.IssuedAt == default) errors.Add("Date du trousseau absente.");
if (keyring.Keys is null || keyring.Keys.Any(k => k is null || k.Scopes is null || k.PublicKey is null || k.Name is null))
{
errors.Add("Trousseau incomplet (clés, clé publique, nom ou autorisations absents).");
return false;
}
if (keyring.Keys.Count > MaxKeys) errors.Add($"Trop de clés dans le trousseau (au plus {MaxKeys}).");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in keyring.Keys)
{
var label = AdminKeys.IsValidKeyId(key.KeyId) ? key.KeyId : "(identifiant invalide)";
if (!AdminKeys.IsValidKeyId(key.KeyId)) errors.Add("Clé du trousseau : identifiant invalide.");
else if (!seen.Add(key.KeyId)) errors.Add($"Clé {label} en double.");
try
{
var spki = AdminKeys.ParsePublicKey(key.PublicKey);
if (!string.Equals(AdminKeys.ComputeKeyId(spki), key.KeyId, StringComparison.Ordinal))
errors.Add($"Clé {label} : l'identifiant ne correspond pas à la clé publique.");
}
catch (CryptographicException)
{
errors.Add($"Clé {label} : clé publique illisible ou d'un autre type que ECDSA P-256.");
}
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
// Autorisation inconnue mais bien formée (ajoutée par une version plus récente) : acceptée, sans effet ici.
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.IsWellFormed(s)))
errors.Add($"Clé {label} : autorisations absentes ou invalides.");
}
return errors.Count == count;
}
/// <summary>
/// Nom affichable sans tromperie : ni caractères de contrôle, ni caractères de mise en forme invisibles
/// (inversion du sens d'écriture…), qui pourraient maquiller un nom dans la console ou les fenêtres.
/// </summary>
public static bool IsDisplaySafe(string name) =>
!name.Any(c => char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
or System.Globalization.UnicodeCategory.LineSeparator
or System.Globalization.UnicodeCategory.ParagraphSeparator);
/// <summary>Signe le trousseau avec la clé racine (LauncherPublisher).</summary>
public static KeyringDocument Sign(KeyringDocument keyring, ECDsa rootKey)
{
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
var unsigned = keyring with { Signature = null, IssuedAt = ContentSigning.Truncate(keyring.IssuedAt) };
var signature = AdminKeys.Sign(rootKey, SignedMessages.Keyring(rootKeyId, Digest(unsigned)));
return unsigned with { Signature = new KeyringSignature { RootKeyId = rootKeyId, Value = Convert.ToBase64String(signature) } };
}
/// <summary>
/// Vérifie la signature racine avec les clés publiques intégrées au launcher (celles de update-signing.pub).
/// </summary>
/// <param name="rootKeys">Identifiant et clé publique SPKI de chaque clé racine de confiance.</param>
public static bool VerifyRoot(KeyringDocument keyring, IEnumerable<(string KeyId, byte[] PublicKey)> rootKeys, out string error)
{
error = "";
if (keyring.Signature is not { } signature)
{
error = "trousseau non signé par la clé racine";
return false;
}
var root = rootKeys.FirstOrDefault(k => string.Equals(k.KeyId, signature.RootKeyId, StringComparison.OrdinalIgnoreCase));
if (root.PublicKey is null)
{
error = $"trousseau signé par une clé racine inconnue de ce launcher ({signature.RootKeyId})";
return false;
}
if (AdminKeys.DecodeSignature(signature.Value) is not { } value ||
!AdminKeys.Verify(root.PublicKey, SignedMessages.Keyring(signature.RootKeyId.ToLowerInvariant(), Digest(keyring)), value))
{
error = "SIGNATURE DU TROUSSEAU INVALIDE";
return false;
}
return true;
}
}
@@ -0,0 +1,85 @@
namespace GameLauncher.Contracts.Signing;
/// <summary>Mise en ligne d'une version : signature du manifest par l'administrateur qui publie.</summary>
public sealed record PublicationCommitRequest
{
/// <summary>Signature du contenu du manifest envoyé (<see cref="ContentSigning.ManifestDigest(ManifestContent)"/>).</summary>
public ContentSignature? Signature { get; init; }
}
/// <summary>Signature du manifest en ligne d'un jeu (avec le condensé calculé par le serveur).</summary>
public sealed record ManifestSignatureState
{
public string GameId { get; init; } = "";
/// <summary>Version en ligne (vide : aucun manifest publié).</summary>
public string Version { get; init; } = "";
/// <summary>Condensé du contenu signé de la version en ligne (vide : aucun manifest publié).</summary>
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
/// <summary>Administrateur et appareil de la clé qui a signé, si le serveur la connaît.</summary>
public string? SignerName { get; init; }
}
/// <summary>
/// Signature de la version en ligne (déjà publiée, par exemple avec WinSCP) à partir du manifest.json du dossier
/// de publication : le serveur ne l'accepte que si le contenu en ligne est identique.
/// </summary>
public sealed record ManifestSignRequest
{
/// <summary>Condensé calculé par le launcher à partir du manifest.json local.</summary>
public string Digest { get; init; } = "";
public ContentSignature Signature { get; init; } = new();
}
/// <summary>Signatures du contenu, pour la page « Signature » (propriétaires).</summary>
public sealed record SigningState
{
/// <summary>Trousseau publié (null : aucun).</summary>
public KeyringDocument? Keyring { get; init; }
/// <summary>Faux : publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou catalogue absent).</summary>
public bool KeyringPublishingEnabled { get; init; }
/// <summary>Adresse publique du trousseau (à mettre dans « keyringUrl » de launcher.json).</summary>
public string? KeyringPublicUrl { get; init; }
/// <summary>Vrai : le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require).</summary>
public bool RequireSignatures { get; init; }
public List<ManifestSignatureState> Manifests { get; init; } = new();
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
public CatalogSignatureState? Catalog { get; init; }
/// <summary>Formulaires des demandes (null : publication des formulaires non configurée, ou serveur antérieur à la 3.2.1).</summary>
public FeedbackFormsSignatureState? FeedbackForms { get; init; }
}
public sealed record CatalogSignatureState
{
public long Revision { get; init; }
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
}
/// <summary>Signature des formulaires des demandes en vigueur (feedback-forms.json).</summary>
public sealed record FeedbackFormsSignatureState
{
/// <summary>Révision en vigueur (0 : formulaires d'origine, intégrés au launcher, jamais publiés).</summary>
public long Revision { get; init; }
/// <summary>Condensé du contenu signé (vide : formulaires d'origine, ou fichier illisible).</summary>
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
/// <summary>Vrai : le fichier de référence des formulaires est illisible sur le serveur.</summary>
public bool Unreadable { get; init; }
}
@@ -8,7 +8,7 @@ namespace GameLauncher.Core.Catalog;
/// <summary>
/// Applique le catalogue aux jeux déclarés dans launcher.json — affichage uniquement :
/// • jeux inconnus de launcher.json : ignorés (le serveur ne peut ni ajouter ni retirer un jeu) ;
/// • nom AFFICHÉ seulement (raccourci, registre et dossier des données gardent le nom de launcher.json) ;
/// • nom AFFICHÉ (repris par le raccourci du Bureau ; registre et dossier des données gardent le nom de launcher.json) ;
/// • thème : seulement un thème intégré au launcher ;
/// • images : seulement des fichiers du cache local (téléchargés et contrôlés par <see cref="CatalogService"/>) ;
/// • jeu masqué : toujours affiché s'il est installé sur le PC ou demandé (raccourci, désinstallation),
+33 -14
View File
@@ -5,6 +5,7 @@ using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
@@ -14,11 +15,13 @@ namespace GameLauncher.Core.Catalog;
/// téléchargé, contrôlé et borné (<see cref="CatalogRules"/> : une valeur invalide est ignorée), puis
/// mis en cache dans le dossier des données du launcher. Sans réseau : dernier cache, sinon les
/// valeurs de launcher.json. Images (icône, fond) : PNG ou JPEG du serveur du catalogue, 4 Mo au plus,
/// téléchargées une fois puis conservées en cache.
/// téléchargées une fois puis conservées en cache. Image envoyée depuis le menu d'administration (nom =
/// empreinte SHA-256 du contenu, <see cref="CatalogImages"/>) : l'empreinte est vérifiée au téléchargement ;
/// l'adresse figurant dans le catalogue signé, l'image est ainsi couverte par sa signature.
/// </summary>
public sealed class CatalogService
{
public const long MaxImageBytes = 4 * 1024 * 1024;
public const long MaxImageBytes = CatalogImages.MaxBytes;
private const string FolderName = "catalog";
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
@@ -27,14 +30,19 @@ public sealed class CatalogService
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl)
: this(http.GetBytesAsync, dataDirectory, catalogUrl) { }
private readonly ContentTrustService? _trust;
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl, ContentTrustService? trust = null)
: this(http.GetBytesAsync, dataDirectory, catalogUrl, trust) { }
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl)
/// <param name="trust">Vérification de la signature (phase 7) : en mode « require », un catalogue refusé est ignoré.</param>
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl,
ContentTrustService? trust = null)
{
_download = download;
_catalogUrl = catalogUrl;
_trust = trust;
Directory = Path.Combine(dataDirectory, FolderName);
CachePath = Path.Combine(Directory, "catalog.json");
ImagesDirectory = Path.Combine(Directory, "images");
@@ -44,13 +52,18 @@ public sealed class CatalogService
public string CachePath { get; }
public string ImagesDirectory { get; }
/// <summary>Catalogue du cache (null : absent ou illisible).</summary>
/// <summary>
/// Catalogue du cache (null : absent, illisible ou, en mode « require », non signé). Le cache contient le
/// catalogue tel que publié (signature comprise) : il est revérifié puis contrôlé à chaque lecture.
/// </summary>
public CatalogDocument? LoadCached()
{
try
{
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
return Parse(File.ReadAllBytes(CachePath), out _);
if (ParseRaw(File.ReadAllBytes(CachePath)) is not { } raw) return null;
if (_trust is not null && !_trust.VerifyCachedCatalog(raw)) return null;
return CatalogRules.Normalize(raw, _catalogUrl.Host, new List<string>());
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
{
@@ -68,12 +81,16 @@ public sealed class CatalogService
try
{
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
var catalog = Parse(bytes, out var errors);
if (catalog is null) return null;
if (ParseRaw(bytes) is not { } raw) return null;
// Signature vérifiée sur le catalogue tel que publié, avant tout contrôle des valeurs.
if (_trust is not null && !await _trust.VerifyCatalogAsync(raw, ct).ConfigureAwait(false)) return null;
var errors = new List<string>();
var catalog = CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
// Cache : le catalogue tel que publié (la signature reste vérifiable hors ligne).
System.IO.Directory.CreateDirectory(Directory);
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(catalog, ApiJson.Options));
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(raw, ApiJson.Options));
return catalog;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
@@ -88,10 +105,9 @@ public sealed class CatalogService
}
}
/// <summary>Lecture + contrôle (valeurs invalides retirées). Null si le format est inconnu.</summary>
private CatalogDocument? Parse(byte[] bytes, out List<string> errors)
/// <summary>Catalogue tel que publié (null : format non pris en charge).</summary>
private static CatalogDocument? ParseRaw(byte[] bytes)
{
errors = new List<string>();
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
?? throw new InvalidDataException("catalogue vide");
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
@@ -99,7 +115,7 @@ public sealed class CatalogService
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
return null;
}
return CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
return raw;
}
// ------------------------------------------------------------------ Images
@@ -130,6 +146,9 @@ public sealed class CatalogService
{
var bytes = await _download(new Uri(url), MaxImageBytes, CancellationToken.None).ConfigureAwait(false);
if (!IsPngOrJpeg(bytes)) throw new InvalidDataException("ce n'est pas une image PNG ou JPEG");
if (CatalogImages.ExpectedSha256(new Uri(url)) is { } expected &&
!string.Equals(Convert.ToHexString(SHA256.HashData(bytes)), expected, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("son contenu ne correspond pas à l'empreinte de son nom (image modifiée sur le serveur ?)");
System.IO.Directory.CreateDirectory(ImagesDirectory);
await File.WriteAllBytesAsync(temp, bytes).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Models;
@@ -40,9 +41,28 @@ public sealed class GameManifest
public List<ChangelogEntry> Changelog { get; set; } = new();
/// <summary>
/// Signature de l'administrateur qui a mis la version en ligne (phase 7), vérifiée avec le trousseau
/// (<see cref="Trust.ContentTrustService"/>). Absente des manifests produits par ManifestBuilder.
/// </summary>
public ContentSignature? Signature { get; set; }
[JsonIgnore]
public long TotalSize => Files.Sum(f => f.Size);
/// <summary>Partie couverte par la signature (tout ce qui décide de ce qui est installé et exécuté).</summary>
public ManifestContent ToSignedContent() => new()
{
FormatVersion = FormatVersion,
GameId = GameId,
Version = Version,
Executable = Executable,
LaunchArguments = LaunchArguments,
Archive = new ManifestContentArchive { Url = Archive.Url, Size = Archive.Size, Sha256 = Archive.Sha256 },
ObjectsBaseUrl = ObjectsBaseUrl,
Files = Files.Select(f => new ManifestContentFile { Path = f.Path, Size = f.Size, Sha256 = f.Sha256 }).ToList()
};
/// <summary>URL d'où le manifest a été téléchargé (sert à résoudre les URL relatives).</summary>
[JsonIgnore]
public Uri? SourceUri { get; set; }
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
/// </summary>
public sealed class LauncherCatalog
{
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl" };
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\&lt;Publisher&gt;\GameLauncher).</summary>
public const string DefaultDataFolderName = "GameLauncher";
@@ -98,6 +98,34 @@ public sealed class LauncherCatalog
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? CatalogUrlError { get; private set; }
/// <summary>
/// Adresse du trousseau (keyring.json : clés autorisées à signer le contenu, signé par la clé racine du
/// launcher), ex. « https://launcher.lanisys.fr/catalog/keyring.json ». Facultatif. Comme <see cref="ApiUrl"/>,
/// le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
/// </summary>
public string? KeyringUrl { get; private set; }
/// <summary>Raison pour laquelle « keyringUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? KeyringUrlError { get; private set; }
/// <summary>
/// « contentSigning » : vérification des signatures du manifest et du catalogue. « warn » (défaut si
/// keyringUrl est renseignée) : un contenu non signé ou mal signé est signalé dans le journal mais utilisé ;
/// « require » : il est refusé (aucune installation ni mise à jour, catalogue ignoré) ; « off » : rien n'est vérifié.
/// Comme keyringUrl, lu uniquement dans le launcher.json intégré à l'exe.
/// </summary>
public Trust.ContentSigningMode ContentSigning { get; private set; }
/// <summary>Raison pour laquelle « contentSigning » a été ignorée ou est incohérente, sinon null.</summary>
public string? ContentSigningError { get; private set; }
/// <summary>
/// « keyringMinSequence » : numéro minimal du trousseau accepté (0 par défaut). Un trousseau plus ancien, même
/// signé par la clé racine, est refusé : à relever dans le launcher publié après le retrait d'une clé compromise,
/// pour qu'une installation neuve n'accepte pas un ancien trousseau qui la contenait encore. Intégré à l'exe.
/// </summary>
public long KeyringMinSequence { get; private set; }
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
{
@@ -290,7 +318,7 @@ public sealed class LauncherCatalog
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
CatalogUrlError = singleCatalogError,
DataFolderName = ReadDataFolder(root)
};
}.WithContentSigning(root);
}
if (gamesNode is not JsonArray array || array.Count == 0)
@@ -345,7 +373,37 @@ public sealed class LauncherCatalog
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
CatalogUrlError = catalogError,
DataFolderName = ReadDataFolder(root)
}.WithContentSigning(root);
}
/// <summary>« keyringUrl », « contentSigning » et « keyringMinSequence » (phase 7).</summary>
private LauncherCatalog WithContentSigning(JsonObject root)
{
KeyringUrl = ReadTrustedUrl(root, "keyringUrl", "https://launcher.exemple.fr/catalog/keyring.json", out var keyringError);
KeyringUrlError = keyringError;
var text = Get(root, "contentSigning") is JsonValue value && value.TryGetValue<string>(out var s) ? s.Trim() : null;
var declared = Get(root, "contentSigning") is not null;
(ContentSigning, ContentSigningError) = text?.ToLowerInvariant() switch
{
"off" => (Trust.ContentSigningMode.Off, null),
"warn" => (Trust.ContentSigningMode.Warn, null),
"require" => (Trust.ContentSigningMode.Require, null),
_ when !declared => (KeyringUrl is null ? Trust.ContentSigningMode.Off : Trust.ContentSigningMode.Warn, null),
// Valeur invalide : prudence (vérification sans blocage), signalée dans le journal.
_ => (Trust.ContentSigningMode.Warn,
"launcher.json : contentSigning ignorée (valeurs possibles : \"off\", \"warn\", \"require\") : vérification sans blocage (« warn »).")
};
if (Get(root, "keyringMinSequence") is { } minNode)
{
if (minNode is JsonValue minValue && minValue.TryGetValue<long>(out var min) && min >= 0)
KeyringMinSequence = min;
else
ContentSigningError ??= "launcher.json : keyringMinSequence ignorée (nombre entier positif attendu).";
}
if (ContentSigning == Trust.ContentSigningMode.Require && KeyringUrl is null)
ContentSigningError = "launcher.json : contentSigning « require » sans keyringUrl valide : aucun manifest ni catalogue ne pourra être " +
"vérifié, donc aucune installation ni mise à jour ne sera possible.";
return this;
}
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
@@ -51,8 +51,8 @@ public sealed class LauncherConfig
// ------------------------------------------------------------------ Catalogue (affichage modifiable)
/// <summary>
/// Nom AFFICHÉ, donné par le catalogue. <see cref="GameName"/> reste le nom de launcher.json : celui
/// du raccourci du Bureau, de « Applications installées » et du dossier des données du jeu.
/// Nom AFFICHÉ, donné par le catalogue (repris par le nom du raccourci du Bureau). <see cref="GameName"/>
/// reste le nom de launcher.json : celui de « Applications installées » et du dossier des données du jeu.
/// </summary>
[JsonIgnore]
public string? DisplayName { get; set; }
@@ -1,6 +1,7 @@
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Services;
@@ -9,11 +10,16 @@ public sealed class ManifestClient
{
private readonly HttpDownloader _http;
private readonly Uri _manifestUri;
private readonly ContentTrustService? _trust;
private readonly string? _gameId;
public ManifestClient(HttpDownloader http, Uri manifestUri)
/// <param name="trust">Vérification des signatures (phase 7), avec l'identifiant du jeu de launcher.json.</param>
public ManifestClient(HttpDownloader http, Uri manifestUri, ContentTrustService? trust = null, string? gameId = null)
{
_http = http;
_manifestUri = manifestUri;
_trust = trust;
_gameId = gameId;
}
public async Task<GameManifest> FetchAsync(CancellationToken ct = default)
@@ -32,6 +38,8 @@ public sealed class ManifestClient
manifest.SourceUri = _manifestUri;
manifest.Validate();
// Signature (phase 7) : refus en mode « require » (ContentSecurityException), sinon journal seulement.
if (_trust is not null && _gameId is not null) await _trust.VerifyManifestAsync(manifest, _gameId, ct).ConfigureAwait(false);
// Notes de version bornées et nettoyées : elles sont affichées telles quelles dans l'interface.
manifest.Changelog = ChangelogLimits.Normalize(manifest.Changelog);
Log.Info($"Manifest récupéré : version {manifest.Version}, {manifest.Files.Count} fichiers, {Format.Bytes(manifest.TotalSize)}.");
@@ -0,0 +1,354 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Trust;
/// <summary>Vérification des signatures du contenu (« contentSigning » de launcher.json).</summary>
public enum ContentSigningMode
{
/// <summary>Rien n'est vérifié (comportement antérieur à la phase 7).</summary>
Off,
/// <summary>Signatures vérifiées ; un problème est inscrit au journal, le contenu est utilisé quand même (mise en place).</summary>
Warn,
/// <summary>Un contenu non signé, mal signé ou plus ancien que le dernier vu est refusé.</summary>
Require
}
/// <summary>Contenu refusé (mode « require ») : signature absente ou invalide, trousseau indisponible, retour en arrière.</summary>
public sealed class ContentSecurityException : Exception
{
public ContentSecurityException(string message) : base(message) { }
}
/// <summary>
/// Confiance dans le contenu servi par le serveur (phase 7) :
/// • trousseau (keyring.json) téléchargé puis vérifié avec la ou les clés RACINE intégrées à l'exe (celles des
/// mises à jour du launcher), conservé en cache ; un trousseau plus ancien que le dernier vu est refusé ;
/// • manifest et catalogue : signature d'une clé du trousseau autorisée pour ce contenu, sur le condensé calculé
/// ici à partir de ce qui a été téléchargé ; un contenu signé plus tôt que le dernier accepté est refusé (un
/// serveur compromis ne peut pas resservir une ancienne version vulnérable) ;
/// • mode « warn » : problèmes inscrits au journal sans blocage ; « require » : contenu refusé.
/// État conservé dans le dossier des données du launcher (trust\content-trust.json).
/// </summary>
public sealed class ContentTrustService
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri? _keyringUrl;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
private readonly TimeProvider _time;
private readonly object _lock = new();
private Task<KeyringDocument?>? _keyringTask;
private bool _keyringFetched;
private DateTimeOffset _lastAttempt;
private TrustState? _state;
/// <param name="rootKeys">Clés racine de confiance (update-signing.pub intégré à l'exe).</param>
/// <param name="minKeyringSequence">
/// Numéro minimal du trousseau (« keyringMinSequence » de launcher.json) : un trousseau plus ancien, même signé par
/// la racine, est refusé. À relever après le retrait d'une clé compromise, pour les installations neuves.
/// </param>
/// <param name="time">Horloge (tests).</param>
public ContentTrustService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri? keyringUrl,
IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys, ContentSigningMode mode, long minKeyringSequence = 0,
TimeProvider? time = null)
{
_download = download;
_time = time ?? TimeProvider.System;
_keyringUrl = keyringUrl;
_rootKeys = rootKeys;
Mode = mode;
MinKeyringSequence = Math.Max(0, minKeyringSequence);
StatePath = Path.Combine(dataDirectory, "trust", "content-trust.json");
}
public ContentSigningMode Mode { get; }
public long MinKeyringSequence { get; }
public string StatePath { get; }
// ------------------------------------------------------------------ Trousseau
/// <summary>
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
/// </summary>
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
{
Task<KeyringDocument?> task;
lock (_lock)
{
var elapsed = _time.GetUtcNow() - _lastAttempt;
if (_keyringTask is null
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
{
_lastAttempt = _time.GetUtcNow();
_keyringTask = FetchKeyringAsync();
}
task = _keyringTask;
}
return await task.WaitAsync(ct).ConfigureAwait(false);
}
/// <summary>Dernier trousseau vérifié en cache (sans réseau).</summary>
public KeyringDocument? CachedKeyring => LoadState().Keyring;
private async Task<KeyringDocument?> FetchKeyringAsync()
{
var cached = LoadState().Keyring;
if (_keyringUrl is null || Mode == ContentSigningMode.Off) return cached;
try
{
using var timeout = new CancellationTokenSource(FetchTimeout);
var bytes = await _download(_keyringUrl, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0]);
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error);
if (keyring.Sequence < MinKeyringSequence)
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
lock (_lock)
{
_keyringFetched = true;
if (cached is not null)
{
if (keyring.Sequence < cached.Sequence)
{
Log.Error($"Trousseau n° {keyring.Sequence} refusé : plus ancien que le n° {cached.Sequence} déjà vu (retour en arrière).");
return cached;
}
if (keyring.Sequence == cached.Sequence && Keyring.Digest(keyring) != Keyring.Digest(cached))
{
Log.Error($"Trousseau n° {keyring.Sequence} refusé : différent de celui déjà vu sous le même numéro.");
return cached;
}
}
var state = LoadState();
if (cached is null || keyring.Sequence > cached.Sequence)
{
state.Keyring = keyring;
SaveState(state);
Log.Info($"Trousseau n° {keyring.Sequence} vérifié ({keyring.Keys.Count} clé(s)).");
}
return keyring;
}
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
return cached;
}
catch (Exception ex)
{
// Réseau, délai dépassé, réponse trop grande… : jamais d'exception vers l'appelant, nouvel essai plus tard.
Log.Warn($"Trousseau injoignable : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu est utilisé)."));
return cached;
}
}
// ------------------------------------------------------------------ Contenu
/// <summary>Vérifie le manifest d'un jeu (lève <see cref="ContentSecurityException"/> en mode « require »).</summary>
public Task VerifyManifestAsync(GameManifest manifest, string gameId, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return Task.CompletedTask;
return VerifyAsync(ContentKinds.Manifest, gameId, ContentSigning.ManifestDigest(manifest.ToSignedContent()), manifest.Signature,
$"Manifest de {gameId} (version {manifest.Version})", ct);
}
/// <summary>Vérifie le catalogue tel que téléchargé (avant tout contrôle des valeurs). Faux : à ignorer.</summary>
public async Task<bool> VerifyCatalogAsync(CatalogDocument catalog, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Catalogue (révision {catalog.Revision})";
try
{
if (ContentSigning.HasDuplicateGames(catalog))
return Report(what, "jeux en double (identifiants ne différant que par la casse)");
await VerifyAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, what, ct)
.ConfigureAwait(false);
return true;
}
catch (ContentSecurityException)
{
return false;
}
}
/// <summary>
/// Vérifie les formulaires des demandes téléchargés (feedback-forms.json) : clé du trousseau autorisée « forms »,
/// signature valide, pas plus anciens que les derniers acceptés. Faux : à ignorer (formulaires précédents).
/// </summary>
public async Task<bool> VerifyFeedbackFormsAsync(FeedbackFormsDocument forms, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return true;
try
{
await VerifyAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms), forms.Signature,
$"Formulaires des demandes (révision {forms.Revision})", ct).ConfigureAwait(false);
return true;
}
catch (ContentSecurityException)
{
return false;
}
}
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Formulaires des demandes en cache (révision {forms.Revision})";
var keyring = LoadState().Keyring;
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
var check = ContentSigning.Verify(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms),
forms.Signature, keyring);
return check.Valid || Report(what, check.Reason);
}
/// <summary>
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
/// L'antériorité n'est pas contrôlée ici (le cache est ce qui a déjà été accepté).
/// </summary>
public bool VerifyCachedCatalog(CatalogDocument catalog)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Catalogue en cache (révision {catalog.Revision})";
if (ContentSigning.HasDuplicateGames(catalog)) return Report(what, "jeux en double (identifiants ne différant que par la casse)");
var keyring = LoadState().Keyring;
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, keyring);
return check.Valid || Report(what, check.Reason);
}
/// <summary>Problème : refus en mode « require » (faux), avertissement en mode « warn » (vrai).</summary>
private bool Report(string what, string problem)
{
if (Mode == ContentSigningMode.Require)
{
Log.Error($"{what} : {problem}. Contenu refusé (contentSigning « require »).");
return false;
}
Log.Warn($"{what} : {problem}. Contenu utilisé quand même (contentSigning « warn »).");
return true;
}
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
{
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
string problem;
if (keyring is null)
{
problem = _keyringUrl is null ? "aucun trousseau configuré (keyringUrl)" : "trousseau indisponible (jamais téléchargé)";
}
else
{
var check = ContentSigning.Verify(kind, id, digest, signature, keyring);
if (!check.Valid)
{
problem = check.Reason;
}
else
{
var slot = $"{kind}:{ContentSigning.SignedId(kind, id)}";
lock (_lock)
{
var state = LoadState();
if (state.LastSigned.TryGetValue(slot, out var last) && signature!.SignedAt < last)
{
problem = $"signé le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC, plus ancien que le contenu déjà vu " +
$"(signé le {last:yyyy-MM-dd HH:mm} UTC) : retour en arrière refusé";
}
else
{
// Date enregistrée plafonnée : une signature datée loin dans le futur (horloge déréglée) ne doit
// pas faire refuser toutes les publications suivantes.
var ceiling = ContentSigning.Truncate(_time.GetUtcNow() + MaxFutureSignedAt);
var recorded = signature!.SignedAt > ceiling ? ceiling : signature.SignedAt;
if (recorded > last)
{
state.LastSigned[slot] = recorded;
SaveState(state);
}
Log.Info($"{what} : {check.Reason}.");
return;
}
}
}
}
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
}
// ------------------------------------------------------------------ État
private sealed class TrustState
{
public KeyringDocument? Keyring { get; set; }
public Dictionary<string, DateTimeOffset> LastSigned { get; set; } = new(StringComparer.Ordinal);
}
/// <summary>État en cache (le trousseau est revérifié à la lecture : un fichier modifié à la main est ignoré).</summary>
private TrustState LoadState()
{
lock (_lock)
{
if (_state is not null) return _state;
var state = new TrustState();
try
{
if (File.Exists(StatePath) && new FileInfo(StatePath).Length <= Keyring.MaxBytes * 2)
{
var read = JsonSerializer.Deserialize<TrustState>(File.ReadAllText(StatePath), ApiJson.Options);
if (read is not null)
{
state.LastSigned = new Dictionary<string, DateTimeOffset>(read.LastSigned ?? new(), StringComparer.Ordinal);
var errors = new List<string>();
if (read.Keyring is { } keyring && Keyring.Validate(keyring, errors) && Keyring.VerifyRoot(keyring, _rootKeys, out _)
&& keyring.Sequence >= MinKeyringSequence)
state.Keyring = keyring;
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"État de vérification des signatures illisible ({ex.Message}) : recréé.");
}
return _state = state;
}
}
private void SaveState(TrustState state)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!);
FileUtil.WriteAllTextAtomic(StatePath, JsonSerializer.Serialize(state, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true }));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"État de vérification des signatures non enregistré : {ex.Message}");
}
}
}
@@ -0,0 +1,310 @@
using System.Globalization;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>Textes communs des pages « Administrateurs » et « Mes appareils ».</summary>
public static class AccountTexts
{
/// <summary>« 01/11/2026 à 13:40 » (heure du PC).</summary>
public static string Date(DateTimeOffset value) =>
value.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
public static string Status(string status) => status switch
{
AdminStatus.Active => "Actif",
AdminStatus.Pending => "En attente d'approbation",
AdminStatus.Revoked => "Révoqué",
_ => status
};
/// <summary>Ce que permet chaque groupe par défaut (repli si le serveur ne fournit pas la description du groupe).</summary>
public static string RoleHint(string role) => role switch
{
AdminRoles.Owner => "tous les droits, dont la gestion des administrateurs",
AdminRoles.Admin => "jeux, notes de version, publication et journal d'audit",
AdminRoles.Editor => "informations des jeux et notes de version",
_ => ""
};
public static string KeyLabel(string? label) => string.IsNullOrWhiteSpace(label) ? "Appareil sans nom" : label;
/// <summary>Son propre appareil en attente : l'empreinte se lit sur le nouveau PC.</summary>
public const string OwnPendingText = "Empreinte affichée sur le nouveau PC : saisissez-la ci-dessous pour l'approuver.";
}
/// <summary>
/// Actions communes aux pages de comptes (approuver ou révoquer un appareil, supprimer une invitation,
/// afficher un code d'invitation) : confirmation, appel signé, puis rechargement de la page.
/// </summary>
public abstract class AccountsPanelBase : AdminPanelViewModel
{
private InvitationCodeViewModel? _createdInvitation;
private string? _statusMessage;
protected AccountsPanelBase(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
}
public AsyncRelayCommand RefreshCommand { get; }
/// <summary>Faux pendant un échange avec le serveur (boutons des listes désactivés).</summary>
public bool CanEdit => !IsBusy;
/// <summary>Résultat de la dernière action (« Appareil approuvé. »…).</summary>
public string? StatusMessage
{
get => _statusMessage;
protected set
{
if (!SetProperty(ref _statusMessage, value)) return;
OnPropertyChanged(nameof(HasStatus));
if (value is not null) RequestScrollToTop(); // résultat d'une action d'une liste : bandeau en haut de la page
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Code d'une invitation tout juste créée : affiché une seule fois, jusqu'à « J'ai transmis le code ».</summary>
public InvitationCodeViewModel? CreatedInvitation
{
get => _createdInvitation;
private set
{
if (!SetProperty(ref _createdInvitation, value)) return;
OnPropertyChanged(nameof(HasCreatedInvitation));
OnCommandsChanged(); // pas de nouvelle invitation tant qu'un code non transmis est affiché
}
}
public bool HasCreatedInvitation => _createdInvitation is not null;
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
RefreshCommand.RaiseCanExecuteChanged();
OnCommandsChanged();
}
/// <summary>Réévalue les commandes propres à la page.</summary>
protected virtual void OnCommandsChanged()
{
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
/// <summary>Relit les données de la page sur le serveur.</summary>
protected abstract Task ReloadAsync();
/// <summary>
/// Modification signée, puis rechargement SÉPARÉ : si seul le rechargement échoue, la modification
/// (faite sur le serveur) est quand même annoncée, avec l'erreur du rechargement.
/// </summary>
protected async Task<bool> ApplyAsync(Func<Task> change, string success)
{
StatusMessage = null;
if (!await RunAsync(change)) return false;
StatusMessage = success;
await RunAsync(ReloadAsync);
return true;
}
protected void ShowInvitation(InvitationCreated created, string title, string instructions)
{
StatusMessage = null;
CreatedInvitation = new InvitationCodeViewModel(created.Code, title, instructions, created.Invitation.ExpiresAt,
Context.Dialogs, () => CreatedInvitation = null);
}
internal async Task ApproveAsync(AdminKeyItem key)
{
StatusMessage = null;
var typed = AdminKeys.NormalizeFingerprint(key.FingerprintInput);
if (typed is null)
{
ErrorMessage = "Empreinte incomplète : saisissez les 20 caractères (5 groupes de 4) affichés sur le nouvel appareil.";
return;
}
if (!Context.Dialogs.Confirm($"Approuver l'appareil « {key.Label} » ?",
$"{key.AdminName} pourra se connecter depuis cet appareil.\n\nN'approuvez que si l'empreinte vous a été " +
"communiquée par la personne elle-même (téléphone, message), et non recopiée depuis un message reçu d'un inconnu.",
"Approuver"))
return;
await ApplyAsync(() => Context.Session.ApproveKeyAsync(key.AdminId, key.KeyId, typed),
$"Appareil « {key.Label} » de {key.AdminName} approuvé.");
}
internal async Task RevokeKeyAsync(AdminKeyItem key)
{
if (!Context.Dialogs.Confirm($"Révoquer l'appareil « {key.Label} » ?",
$"{key.AdminName} ne pourra plus se connecter depuis cet appareil ; les sessions qui l'utilisent sont fermées " +
"immédiatement. Une clé révoquée ne peut pas être réactivée.", "Révoquer", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeKeyAsync(key.AdminId, key.KeyId),
$"Appareil « {key.Label} » de {key.AdminName} révoqué.");
}
internal async Task DeleteInvitationAsync(InvitationItem invitation)
{
if (!Context.Dialogs.Confirm("Supprimer l'invitation ?",
$"{invitation.Title} : le code ne pourra plus être utilisé.", "Supprimer", danger: true))
return;
await ApplyAsync(async () =>
{
try
{
await Context.Session.DeleteInvitationAsync(invitation.Id);
}
catch (ApiException ex) when (ex.Status == 404)
{
// déjà utilisée ou expirée entre-temps
}
}, "Invitation supprimée.");
}
}
/// <summary>Appareil (clé) d'un administrateur.</summary>
public sealed class AdminKeyItem : ObservableObject
{
private string _fingerprintInput = "";
private readonly string? _fingerprintWarning;
internal AdminKeyItem(AccountsPanelBase page, Guid adminId, string adminName, AdminKeySummary key, bool isCurrentSession,
bool canApprove, string? approveHint, bool canRevoke, string pendingText)
{
// Cet appareil : empreinte calculée sur le PC (celle à dicter lors de la vérification du trousseau), pas celle
// annoncée par le serveur ; une différence est signalée.
var local = isCurrentSession ? page.Context.Session.LocalFingerprint : null;
if (local is not null && key.Fingerprint is not null &&
!string.Equals(AdminKeys.NormalizeFingerprint(local), AdminKeys.NormalizeFingerprint(key.Fingerprint), StringComparison.Ordinal))
_fingerprintWarning = $" · ATTENTION : le serveur annonce une autre empreinte ({key.Fingerprint})";
else if (local is not null)
_fingerprintWarning = " · empreinte calculée sur ce PC";
FingerprintText = local ?? key.Fingerprint ?? pendingText;
AdminId = adminId;
AdminName = adminName;
KeyId = key.KeyId;
Label = AccountTexts.KeyLabel(key.Label);
Status = key.Status;
IsCurrentSession = isCurrentSession;
Fingerprint = local ?? key.Fingerprint;
AddedText = $"Ajouté le {AccountTexts.Date(key.AddedAt)}";
LastUsedText = key.LastUsedAt is { } used ? $"Dernière connexion le {AccountTexts.Date(used)}" : "Jamais utilisé";
CanApprove = canApprove && IsPending;
ApproveHint = IsPending ? approveHint : null;
CanRevoke = canRevoke && !IsRevoked && !isCurrentSession;
ApproveCommand = new AsyncRelayCommand(() => page.ApproveAsync(this), () => CanApprove);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeKeyAsync(this), () => CanRevoke);
}
public Guid AdminId { get; }
public string AdminName { get; }
public string KeyId { get; }
public string Label { get; }
public string Status { get; }
public string StatusText => IsCurrentSession ? "Cet appareil (session en cours)" : AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsActive => Status == AdminStatus.Active;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsCurrentSession { get; }
/// <summary>Empreinte (clés actives ou révoquées) ; masquée par le serveur pour une clé en attente.</summary>
public string? Fingerprint { get; }
/// <summary>Empreinte, ou (clé en attente) où la trouver : sur le nouvel appareil.</summary>
public string FingerprintText { get; }
public string AddedText { get; }
public string LastUsedText { get; }
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}{_fingerprintWarning}";
public bool CanApprove { get; }
/// <summary>Pourquoi ce n'est pas vous qui approuvez (nouvel appareil d'un autre administrateur…).</summary>
public string? ApproveHint { get; }
public bool HasApproveHint => !string.IsNullOrEmpty(ApproveHint);
public bool CanRevoke { get; }
/// <summary>Empreinte dictée par la personne (« AB12 CD34 EF56 7890 1A2B »).</summary>
public string FingerprintInput
{
get => _fingerprintInput;
set => SetProperty(ref _fingerprintInput, value ?? "");
}
public AsyncRelayCommand ApproveCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
}
/// <summary>Invitation en cours (code pas encore utilisé).</summary>
public sealed class InvitationItem
{
internal InvitationItem(AccountsPanelBase page, InvitationSummary invitation, bool canDelete)
{
Id = invitation.Id;
IsDevice = invitation.ForAdminId is not null;
Title = IsDevice
? $"Nouvel appareil de {invitation.DisplayName}"
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role, invitation.RoleName)}";
DetailText = $"Créée par {invitation.CreatedBy ?? "le serveur"} le {AccountTexts.Date(invitation.CreatedAt)} · " +
$"expire le {AccountTexts.Date(invitation.ExpiresAt)}";
CanDelete = canDelete;
DeleteCommand = new AsyncRelayCommand(() => page.DeleteInvitationAsync(this), () => CanDelete);
}
public Guid Id { get; }
public bool IsDevice { get; }
public string Title { get; }
public string DetailText { get; }
public bool CanDelete { get; }
public AsyncRelayCommand DeleteCommand { get; }
}
/// <summary>
/// Code d'une invitation tout juste créée. Le serveur n'en garde que l'empreinte : il n'est affiché
/// qu'ici, une seule fois, et oublié dès que l'administrateur indique l'avoir transmis.
/// </summary>
public sealed class InvitationCodeViewModel : ObservableObject
{
private readonly IModuleDialogs _dialogs;
private bool _copied;
internal InvitationCodeViewModel(string code, string title, string instructions, DateTimeOffset expiresAt,
IModuleDialogs dialogs, Action dismiss)
{
Code = code;
Title = title;
Instructions = instructions;
ExpiresText = $"Valable une seule fois, jusqu'au {AccountTexts.Date(expiresAt)}. Il ne sera plus affiché.";
_dialogs = dialogs;
CopyCommand = new RelayCommand(Copy);
DismissCommand = new RelayCommand(dismiss);
}
public string Code { get; }
public string Title { get; }
public string Instructions { get; }
public string ExpiresText { get; }
public bool Copied
{
get => _copied;
private set => SetProperty(ref _copied, value);
}
public RelayCommand CopyCommand { get; }
/// <summary>« J'ai transmis le code » : le code disparaît de l'écran.</summary>
public RelayCommand DismissCommand { get; }
private void Copy()
{
_dialogs.CopyToClipboard(Code);
Copied = true;
}
}
@@ -0,0 +1,44 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Module « Comptes » : page « Mes appareils » (tout administrateur), page « Administrateurs » et page
/// « Groupes et permissions » (gestion des administrateurs : admins.manage).
/// </summary>
public sealed class AccountsAdminModule : ILauncherModule
{
public string Id => "accounts";
public string DisplayName => "Comptes";
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor(
MyDevicesPanelViewModel.PanelId,
"Mes appareils",
"Les PC depuis lesquels vous pouvez vous connecter : ajoutez-en un (invitation « nouvel appareil »), puis approuvez-le " +
"ici avec l'empreinte qu'il affiche.",
"KeyVariant",
RequiredPermission: null,
Order: 40,
context => new MyDevicesPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
AdminsPanelViewModel.PanelId,
"Administrateurs",
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son groupe ou révoquez-le.",
"AccountGroup",
AdminPermissions.AdminsManage,
Order: 50,
context => new AdminsPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
GroupsPanelViewModel.PanelId,
"Groupes et permissions",
"Créez, modifiez ou supprimez des groupes d'administrateurs et choisissez leurs permissions ; créez des permissions " +
"personnalisées. Les changements s'appliquent immédiatement, sessions ouvertes comprises.",
"ShieldAccount",
AdminPermissions.AdminsManage,
Order: 52,
context => new GroupsPanelViewModel(context)));
}
}
@@ -0,0 +1,328 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Administrateurs » (admins.manage) : comptes et appareils, invitations, approbation par
/// empreinte, groupe de chaque compte, révocation. Le serveur reste seul juge : cette page ne propose que
/// ce qu'il accepte (pas de changement de son propre groupe, pas d'appareil approuvé au nom d'un autre
/// administrateur actif, seulement les groupes dont on a soi-même toutes les permissions…) et affiche ses
/// refus tels quels. Les groupes viennent du serveur (page « Groupes et permissions »).
/// </summary>
public sealed class AdminsPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.manage";
private List<AdminSummary> _summaries = new();
private IReadOnlyList<AdminAccountItem> _admins = Array.Empty<AdminAccountItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
private bool _showRevoked;
private string _newName = "";
private Choice _newRole;
private IReadOnlyList<GroupSummary> _groupSummaries = Array.Empty<GroupSummary>();
private IReadOnlyList<Choice> _roleChoices;
public AdminsPanelViewModel(IAdminContext context) : base(context)
{
// Groupes par défaut, remplacés au chargement par ceux du serveur.
_roleChoices = AdminRoles.All.Select(r => new Choice(r, AdminTexts.Role(r))).ToList();
_newRole = _roleChoices.First(c => c.Value == AdminRoles.Editor);
InviteCommand = new AsyncRelayCommand(InviteAsync,
() => !IsBusy && !HasCreatedInvitation && !string.IsNullOrWhiteSpace(_newName) && _newRole.Value is not null);
}
/// <summary>Groupes que l'administrateur connecté peut attribuer (ceux dont il a toutes les permissions).</summary>
public IReadOnlyList<Choice> RoleChoices
{
get => _roleChoices;
private set => SetProperty(ref _roleChoices, value);
}
internal string GroupName(string? groupId) =>
_groupSummaries.FirstOrDefault(g => g.Id == groupId)?.Name ?? AdminTexts.Role(groupId);
public IReadOnlyList<AdminAccountItem> Admins
{
get => _admins;
private set => SetProperty(ref _admins, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
/// <summary>Afficher aussi les comptes révoqués (historique).</summary>
public bool ShowRevoked
{
get => _showRevoked;
set
{
if (SetProperty(ref _showRevoked, value)) BuildAdmins();
}
}
public string RevokedText
{
get
{
var count = _summaries.Count(a => a.Status == AdminStatus.Revoked);
return count == 0 ? "Aucun compte révoqué" : $"Afficher les comptes révoqués ({count})";
}
}
public string SummaryText
{
get
{
var active = _summaries.Count(a => a.Status == AdminStatus.Active);
var pending = _summaries.Where(a => a.Status != AdminStatus.Revoked).SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
return $"{active} compte(s) actif(s)" + (pending == 0 ? "" : $" · {pending} appareil(s) en attente d'approbation");
}
}
// ------------------------------------------------------------------ Nouvelle invitation
public string NewName
{
get => _newName;
set
{
if (SetProperty(ref _newName, value ?? "")) InviteCommand.RaiseCanExecuteChanged();
}
}
public Choice NewRole
{
get => _newRole;
set
{
if (value is null || !SetProperty(ref _newRole, value)) return;
OnPropertyChanged(nameof(NewRoleHint));
InviteCommand.RaiseCanExecuteChanged();
}
}
public string NewRoleHint => _newRole.Value is null ? "Aucun groupe disponible." : $"{GroupName(_newRole.Value)} : {GroupHint(_newRole.Value)}";
/// <summary>Ce que permet un groupe : sa description, sinon la liste de ses permissions.</summary>
internal string GroupHint(string groupId)
{
var group = _groupSummaries.FirstOrDefault(g => g.Id == groupId);
if (group is null) return AccountTexts.RoleHint(groupId) is { Length: > 0 } hint ? hint + "." : "";
if (!string.IsNullOrWhiteSpace(group.Description)) return group.Description!;
return group.Permissions.Count == 0 ? "aucune permission." : string.Join(", ", group.Permissions.Select(AdminTexts.Permission)) + ".";
}
internal bool GroupHas(string groupId, string permission) =>
_groupSummaries.FirstOrDefault(g => g.Id == groupId) is { } group ? group.Permissions.Contains(permission)
: AdminRoles.Has(groupId, permission);
public AsyncRelayCommand InviteCommand { get; }
protected override void OnCommandsChanged() => InviteCommand.RaiseCanExecuteChanged();
private async Task InviteAsync()
{
var name = _newName.Trim();
var role = _newRole.Value!;
if (role == AdminRoles.Owner &&
!Context.Dialogs.Confirm("Inviter un propriétaire ?",
$"{name} aura tous les droits, dont celui de gérer les administrateurs (y compris de révoquer votre compte).",
"Inviter", danger: true))
return;
if (role != AdminRoles.Owner && GroupHas(role, AdminPermissions.AdminsManage) &&
!Context.Dialogs.Confirm("Inviter un gestionnaire d'administrateurs ?",
$"Le groupe « {GroupName(role)} » permet de gérer les administrateurs, les groupes et les permissions (sans dépasser ses propres droits).",
"Inviter", danger: true))
return;
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = name, Role = role })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
NewName = "";
ShowInvitation(created!, $"Invitation de {created!.Invitation.DisplayName} ({AdminTexts.Role(created.Invitation.Role, created.Invitation.RoleName)})",
$"Transmettez ce code à {created.Invitation.DisplayName}. Dans le launcher : menu d'administration › « J'ai une invitation ». " +
"Le launcher lui affichera ensuite une empreinte : faites-la-vous dicter (téléphone, message), puis approuvez son appareil " +
"ci-dessous en la saisissant.");
await RunAsync(ReloadAsync);
}
// ------------------------------------------------------------------ Comptes
internal async Task ChangeRoleAsync(AdminAccountItem admin)
{
var role = admin.SelectedRole.Value!;
if (role == admin.Role) return;
var message = $"{admin.DisplayName} : {admin.RoleText} → {GroupName(role)}. Le changement s'applique " +
"immédiatement, y compris à ses sessions ouvertes.";
var sensitive = role == AdminRoles.Owner || GroupHas(role, AdminPermissions.AdminsManage);
if (role == AdminRoles.Owner) message += "\n\nUn propriétaire peut gérer tous les administrateurs, y compris révoquer votre compte.";
else if (sensitive) message += "\n\nCe groupe permet de gérer les administrateurs, les groupes et les permissions.";
if (!Context.Dialogs.Confirm("Changer de groupe ?", message, "Changer de groupe", danger: sensitive))
{
admin.ResetRole();
return;
}
if (!await ApplyAsync(() => Context.Session.ChangeRoleAsync(admin.Id, role),
$"{admin.DisplayName} est maintenant dans le groupe « {GroupName(role)} »."))
admin.ResetRole();
}
internal async Task RevokeAdminAsync(AdminAccountItem admin)
{
if (!Context.Dialogs.Confirm($"Révoquer le compte de {admin.DisplayName} ?",
"Ses sessions sont fermées et tous ses appareils révoqués immédiatement ; ses invitations « nouvel appareil » sont " +
"supprimées. Un compte révoqué ne peut pas être réactivé : il faudrait l'inviter de nouveau.",
"Révoquer le compte", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeAdminAsync(admin.Id), $"Compte de {admin.DisplayName} révoqué.");
}
// ------------------------------------------------------------------ Chargement
protected override async Task ReloadAsync()
{
await LoadGroupsAsync();
var admins = await Context.Session.ListAdminsAsync();
var invitations = await Context.Session.ListInvitationsAsync();
_summaries = admins;
BuildAdmins();
Invitations = invitations.Select(i => new InvitationItem(this, i, canDelete: true)).ToList();
OnPropertyChanged(nameof(RevokedText));
OnPropertyChanged(nameof(SummaryText));
}
/// <summary>Groupes du serveur : choix proposés limités à ceux que l'administrateur connecté peut attribuer.</summary>
private async Task LoadGroupsAsync()
{
try
{
_groupSummaries = (await Context.Session.GetGroupsAsync()).Groups;
}
catch (ApiException ex) when (ex.Status == 404)
{
return; // serveur antérieur à la gestion des groupes : rôles par défaut
}
var me = Context.Session.Profile;
var mine = me.Permissions.ToHashSet(StringComparer.Ordinal);
RoleChoices = _groupSummaries
.Where(g => g.System ? me.Role == AdminRoles.Owner : g.Permissions.All(mine.Contains))
.Select(g => new Choice(g.Id, g.Name)).ToList();
_newRole = RoleChoices.FirstOrDefault(c => c.Value == _newRole.Value)
?? RoleChoices.FirstOrDefault(c => c.Value == AdminRoles.Editor)
?? RoleChoices.FirstOrDefault(c => c.Value != AdminRoles.Owner)
?? RoleChoices.FirstOrDefault()
?? new Choice(null, "Aucun groupe");
OnPropertyChanged(nameof(NewRole));
OnPropertyChanged(nameof(NewRoleHint));
InviteCommand.RaiseCanExecuteChanged();
}
private void BuildAdmins()
{
var me = Context.Session.Profile;
// À traiter d'abord : comptes en attente, puis comptes actifs (vous en premier), puis révoqués.
Admins = _summaries
.Where(a => _showRevoked || a.Status != AdminStatus.Revoked)
.OrderBy(a => a.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenBy(a => a.Id == me.Id ? 0 : 1)
.ThenBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(a => new AdminAccountItem(this, a, me.Id, me.KeyId,
RoleChoices.Any(c => c.Value == a.Role) ? RoleChoices : RoleChoices.Prepend(new Choice(a.Role, AdminTexts.Role(a.Role, a.RoleName))).ToList()))
.ToList();
}
}
/// <summary>Compte d'un administrateur et ses appareils.</summary>
public sealed class AdminAccountItem : ObservableObject
{
private readonly IReadOnlyList<Choice> _roles;
private Choice _selectedRole;
internal AdminAccountItem(AdminsPanelViewModel page, AdminSummary admin, Guid myId, string myKeyId, IReadOnlyList<Choice> roles)
{
_roles = roles;
Id = admin.Id;
DisplayName = admin.DisplayName;
Initials = AdminTexts.Initials(admin.DisplayName);
Role = admin.Role;
RoleText = AdminTexts.Role(admin.Role, admin.RoleName);
Status = admin.Status;
IsYou = admin.Id == myId;
CreatedText = $"Invité le {AccountTexts.Date(admin.CreatedAt)}";
_selectedRole = roles.FirstOrDefault(r => r.Value == admin.Role) ?? roles[^1];
var hasActiveKey = admin.Keys.Any(k => k.Status == AdminStatus.Active);
// Premier appareil (compte en attente) : approuvé par un propriétaire. Nouvel appareil d'un compte
// actif : par son titulaire seulement, depuis un de ses appareils actifs (règle du serveur).
string? hint = null;
var canApprove = !IsRevoked && (Status == AdminStatus.Pending || IsYou);
if (!canApprove && Status == AdminStatus.Active)
hint = hasActiveKey
? $"Nouvel appareil : {DisplayName} l'approuve lui-même depuis un de ses appareils actifs (page « Mes appareils »)."
: $"{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.";
Keys = admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(page, admin.Id, admin.DisplayName, k, IsYou && k.KeyId == myKeyId,
canApprove, hint, canRevoke: !IsRevoked,
IsYou ? AccountTexts.OwnPendingText : $"Empreinte affichée sur le nouvel appareil : faites-la-vous dicter par {admin.DisplayName}."))
.ToList();
ChangeRoleCommand = new AsyncRelayCommand(() => page.ChangeRoleAsync(this), () => CanChangeRole && RoleChanged);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeAdminAsync(this), () => CanRevoke);
}
public Guid Id { get; }
public string DisplayName { get; }
public string Initials { get; }
public string Role { get; }
public string RoleText { get; }
public string Status { get; }
public string StatusText => AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsYou { get; }
public string CreatedText { get; }
public IReadOnlyList<AdminKeyItem> Keys { get; }
/// <summary>On ne change ni son propre groupe ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
public bool CanChangeRole => !IsYou && !IsRevoked;
public bool CanRevoke => !IsYou && !IsRevoked;
public IReadOnlyList<Choice> RoleChoices => _roles;
public Choice SelectedRole
{
get => _selectedRole;
set
{
if (value is null || !SetProperty(ref _selectedRole, value)) return;
OnPropertyChanged(nameof(RoleChanged));
ChangeRoleCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Groupe choisi différent du groupe actuel : bouton « Appliquer » affiché.</summary>
public bool RoleChanged => _selectedRole.Value != Role;
public AsyncRelayCommand ChangeRoleCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
internal void ResetRole() => SelectedRole = _roles.FirstOrDefault(r => r.Value == Role) ?? _selectedRole;
}
@@ -0,0 +1,632 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Groupes et permissions » (admins.manage) :
/// • à gauche, les groupes (nombre de membres, pastille « modifié ») et « Nouveau groupe » ;
/// • à droite, le groupe choisi : nom, description, permissions à cocher ; « Enregistrer » dans la barre du bas ;
/// • en dessous, les permissions : intégrées (vérifiées par le code, non supprimables) et personnalisées
/// (création, suppression après confirmation : elle est retirée de tous les groupes).
/// La page ne propose que ce que le serveur accepte (groupe « Propriétaire » en lecture seule, pas de
/// modification des permissions de son propre groupe, pas de permission accordée que l'on n'a pas soi-même,
/// pas de suppression d'un groupe qui a des membres ou des invitations) ; le serveur reste seul juge et ses
/// refus sont affichés tels quels.
/// </summary>
public sealed class GroupsPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "admins.groups";
private GroupsState _state = new();
private IReadOnlyList<GroupItem> _groups = Array.Empty<GroupItem>();
private IReadOnlyList<PermissionItem> _permissions = Array.Empty<PermissionItem>();
private GroupItem? _selected;
private string? _statusMessage;
private string _newPermissionName = "", _newPermissionLabel = "", _newPermissionDescription = "";
public GroupsPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(ReloadWithConfirmationAsync), () => !IsBusy);
SaveCommand = new AsyncRelayCommand(ScrollingToTop(SaveAsync), () => !IsBusy && _selected?.Editor.CanSave == true);
NewGroupCommand = new RelayCommand(NewGroup, () => !IsBusy && !_groups.Any(g => g.IsNew) && _groups.Count < AdminGroups.MaxGroups);
CreatePermissionCommand = new AsyncRelayCommand(CreatePermissionAsync,
() => !IsBusy && !string.IsNullOrWhiteSpace(_newPermissionName) && !string.IsNullOrWhiteSpace(_newPermissionLabel));
}
/// <summary>Liste des groupes et fiche (puis permissions) défilent séparément.</summary>
public override bool HasSplitLayout => true;
public IReadOnlyList<GroupItem> Groups
{
get => _groups;
private set => SetProperty(ref _groups, value);
}
public IReadOnlyList<PermissionItem> Permissions
{
get => _permissions;
private set => SetProperty(ref _permissions, value);
}
/// <summary>Groupe affiché à droite (ses modifications non enregistrées restent en attente si l'on en choisit un autre).</summary>
public GroupItem? Selected
{
get => _selected;
set
{
if (value is null || !SetProperty(ref _selected, value)) return; // null : liste en cours de remplacement
SaveCommand.RaiseCanExecuteChanged();
}
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Faux pendant un échange avec le serveur (saisie suspendue).</summary>
public bool IsEditable => !IsBusy;
public string SummaryText =>
$"{_state.Groups.Count} groupe(s) · {_state.Permissions.Count(p => p.BuiltIn)} permission(s) intégrée(s), " +
$"{_state.Permissions.Count(p => !p.BuiltIn)} personnalisée(s)";
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand SaveCommand { get; }
public RelayCommand NewGroupCommand { get; }
public AsyncRelayCommand CreatePermissionCommand { get; }
// ------------------------------------------------------------------ Nouvelle permission
public string NewPermissionName
{
get => _newPermissionName;
set
{
if (SetProperty(ref _newPermissionName, value ?? "")) CreatePermissionCommand.RaiseCanExecuteChanged();
}
}
public string NewPermissionLabel
{
get => _newPermissionLabel;
set
{
if (SetProperty(ref _newPermissionLabel, value ?? "")) CreatePermissionCommand.RaiseCanExecuteChanged();
}
}
public string NewPermissionDescription
{
get => _newPermissionDescription;
set => SetProperty(ref _newPermissionDescription, value ?? "");
}
// ------------------------------------------------------------------ Droits de l'administrateur connecté
internal string MyGroupId => Context.Session.Profile.Role;
internal bool IAmOwner => AdminGroups.IsSystem(MyGroupId);
internal bool IHave(string permission) => Context.Session.Has(permission);
internal string LabelOf(string permission) =>
_state.Permissions.FirstOrDefault(p => p.Name == permission)?.Label ?? AdminTexts.Permission(permission);
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
RefreshCommand.RaiseCanExecuteChanged();
RaiseCommands();
}
internal void RaiseCommands()
{
SaveCommand.RaiseCanExecuteChanged();
NewGroupCommand.RaiseCanExecuteChanged();
CreatePermissionCommand.RaiseCanExecuteChanged();
foreach (var group in _groups) group.Editor.RaiseCommands();
foreach (var permission in _permissions) permission.DeleteCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(LoadAsync) : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
private async Task LoadAsync()
{
var state = await Context.Session.GetGroupsAsync();
await RefreshOwnProfileAsync(); // droits à jour : cases que l'on peut cocher, permissions supprimables
Apply(state);
}
private async Task ReloadWithConfirmationAsync()
{
if (_groups.Any(g => g.Editor.IsDirty) &&
!Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les modifications non enregistrées des groupes seront perdues : la page affichera les groupes enregistrés sur le serveur.",
"Abandonner et recharger"))
return;
StatusMessage = null;
await RunAsync(LoadAsync);
}
/// <summary>Affiche l'état renvoyé par le serveur ; resélectionne <paramref name="selectId"/> (sinon le groupe affiché).</summary>
private void Apply(GroupsState state, string? selectId = null)
{
_state = state;
selectId ??= _selected is { IsNew: false } current ? current.Id : null;
var items = state.Groups.Select(g => new GroupItem(this, g, state.Permissions)).ToList();
Groups = items;
Permissions = state.Permissions.Select(p => new PermissionItem(this, p)).ToList();
_selected = items.FirstOrDefault(g => g.Id == selectId) ?? items.FirstOrDefault(g => !g.IsSystem) ?? items.FirstOrDefault();
OnPropertyChanged(nameof(Selected));
OnPropertyChanged(nameof(SummaryText));
RaiseCommands();
}
// ------------------------------------------------------------------ Groupes
private void NewGroup()
{
var draft = GroupItem.NewDraft(this, _state.Permissions);
Groups = _groups.Append(draft).ToList();
Selected = draft;
StatusMessage = null;
RaiseCommands();
}
/// <summary>Retire le nouveau groupe pas encore enregistré.</summary>
internal void DiscardDraft(GroupItem draft)
{
Groups = _groups.Where(g => g != draft).ToList();
_selected = _groups.FirstOrDefault(g => !g.IsSystem) ?? _groups.FirstOrDefault();
OnPropertyChanged(nameof(Selected));
RaiseCommands();
}
private Task SaveAsync() => RunAsync(async () =>
{
if (_selected is not { } group) return;
var editor = group.Editor;
var errors = editor.Validate();
if (errors.Count > 0)
{
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors), ModuleMessageKind.Warning);
return;
}
var added = editor.AddedPermissions();
if (added.Contains(AdminPermissions.AdminsManage) &&
!Context.Dialogs.Confirm("Donner la gestion des administrateurs ?",
$"Les membres de « {editor.Name.Trim()} » pourront inviter et révoquer des administrateurs, et gérer les groupes et les " +
"permissions (sans jamais dépasser leurs propres droits). C'est presque un droit de propriétaire.",
"Donner ce droit", danger: true))
return;
if (!group.IsNew && editor.RemovedPermissions().Count > 0 && group.Summary.MemberCount > 0 &&
!Context.Dialogs.Confirm("Retirer des permissions ?",
$"Les {group.Summary.MemberCount} membre(s) de « {group.Name} » perdent immédiatement ces droits, y compris dans leurs sessions ouvertes :\n• " +
string.Join("\n• ", editor.RemovedPermissions().Select(LabelOf)),
"Retirer"))
return;
var request = editor.ToRequest();
StatusMessage = null;
try
{
if (group.IsNew)
{
var state = await Context.Session.CreateGroupAsync(request);
await RefreshOwnProfileAsync();
var created = state.Groups.FirstOrDefault(g => string.Equals(g.Name, request.Name.Trim(), StringComparison.CurrentCultureIgnoreCase));
Apply(state, created?.Id);
StatusMessage = $"Groupe « {request.Name.Trim()} » créé. Placez-y des administrateurs depuis la page « Administrateurs ».";
Log.Info($"Administration : groupe « {created?.Id} » créé.");
}
else
{
var state = await Context.Session.UpdateGroupAsync(group.Id, request);
await RefreshOwnProfileAsync();
Apply(state, group.Id);
StatusMessage = $"Groupe « {request.Name.Trim()} » enregistré. Les droits de ses membres changent immédiatement.";
Log.Info($"Administration : groupe « {group.Id} » modifié.");
}
}
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
{
Context.Dialogs.ShowMessage("Modifié entre-temps",
"Ce groupe a été modifié par un autre administrateur pendant vos modifications. Rechargez la page pour partir de sa " +
"version (vos modifications non enregistrées seront perdues : notez-les si besoin).", ModuleMessageKind.Warning);
}
});
internal async Task DeleteGroupAsync(GroupItem group)
{
if (group.IsNew)
{
DiscardDraft(group);
return;
}
if (!Context.Dialogs.Confirm($"Supprimer le groupe « {group.Name} » ?",
"Il n'a plus de membre ni d'invitation en cours. La suppression est définitive (inscrite au journal d'audit).",
"Supprimer le groupe", danger: true))
return;
StatusMessage = null;
if (await RunAsync(async () => Apply(await Context.Session.DeleteGroupAsync(group.Id))))
StatusMessage = $"Groupe « {group.Name} » supprimé.";
}
// ------------------------------------------------------------------ Permissions personnalisées
private Task CreatePermissionAsync() => RunAsync(async () =>
{
var name = _newPermissionName.Trim().ToLowerInvariant();
var errors = new List<string>();
if (!AdminGroups.IsValidPermissionName(name))
errors.Add("Nom : format « module.action » en minuscules, sans espace ni accent (ex. « news.edit »).");
else if (_state.Permissions.Any(p => p.Name == name)) errors.Add("Cette permission existe déjà.");
if (ApiText.CleanLine(_newPermissionLabel, AdminGroups.MaxPermissionLabelLength) is null)
errors.Add($"Libellé : une ligne, {AdminGroups.MaxPermissionLabelLength} caractères au plus.");
AdminGroups.CleanDescription(_newPermissionDescription, AdminGroups.MaxDescriptionLength, out var validDescription);
if (!validDescription) errors.Add($"Description : une ligne, {AdminGroups.MaxDescriptionLength} caractères au plus.");
if (errors.Count > 0)
{
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors), ModuleMessageKind.Warning);
return;
}
var label = _newPermissionLabel.Trim();
var state = await Context.Session.CreatePermissionAsync(new CreatePermissionRequest
{
Name = name,
Label = label,
Description = string.IsNullOrWhiteSpace(_newPermissionDescription) ? null : _newPermissionDescription.Trim()
});
await RefreshOwnProfileAsync(); // un propriétaire reçoit d'office la nouvelle permission
KeepDraftsAndApply(state);
NewPermissionName = NewPermissionLabel = NewPermissionDescription = "";
StatusMessage = $"Permission « {name} » créée. Cochez-la dans les groupes qui doivent l'avoir. Elle n'a d'effet que si un module " +
"du launcher ou du serveur la vérifie.";
});
internal async Task DeletePermissionAsync(PermissionItem permission)
{
var groups = _state.Groups.Where(g => !g.System && g.Permissions.Contains(permission.Name)).Select(g => g.Name).ToList();
if (!Context.Dialogs.Confirm($"Supprimer la permission « {permission.Name} » ?",
(groups.Count == 0 ? "Aucun groupe ne l'a (hors « Propriétaire »)." : $"Elle sera retirée de : {string.Join(", ", groups)}.") +
"\n\nUn module qui la vérifierait refuserait alors l'accès à tous, sauf aux propriétaires. Suppression définitive.",
"Supprimer la permission", danger: true))
return;
StatusMessage = null;
if (await RunAsync(async () =>
{
var state = await Context.Session.DeletePermissionAsync(permission.Name);
await RefreshOwnProfileAsync();
KeepDraftsAndApply(state);
}))
StatusMessage = $"Permission « {permission.Name} » supprimée.";
}
/// <summary>
/// Nouvel état après une modification des permissions : les groupes en cours de modification gardent leur saisie
/// (une permission supprimée en disparaît), le nouveau groupe non enregistré aussi.
/// </summary>
private void KeepDraftsAndApply(GroupsState state)
{
var drafts = _groups.Where(g => g.Editor.IsDirty).ToDictionary(g => g.IsNew ? "" : g.Id, g => g.Editor.Snapshot());
var selected = _selected;
Apply(state, selected is { IsNew: false } ? selected.Id : null);
foreach (var group in _groups)
if (drafts.TryGetValue(group.Id, out var draft)) group.Editor.Restore(draft);
if (drafts.TryGetValue("", out var newDraft))
{
var draft = GroupItem.NewDraft(this, state.Permissions);
draft.Editor.Restore(newDraft);
Groups = _groups.Append(draft).ToList();
if (selected is { IsNew: true }) Selected = draft;
}
RaiseCommands();
}
/// <summary>Droits de l'administrateur connecté relus (si son propre groupe a changé, par exemple).</summary>
private async Task RefreshOwnProfileAsync()
{
try
{
await Context.Session.RefreshAsync();
}
catch (ApiException)
{
// sans conséquence : relu au prochain prolongement de la session
}
}
}
/// <summary>Groupe de la liste, et sa saisie (<see cref="GroupEditor"/>).</summary>
public sealed class GroupItem : ObservableObject
{
internal GroupItem(GroupsPanelViewModel page, GroupSummary summary, IReadOnlyList<PermissionSummary> permissions, bool isNew = false)
{
Summary = summary;
IsNew = isNew;
Editor = new GroupEditor(page, this, permissions);
Editor.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(GroupEditor.IsDirty)) OnPropertyChanged(nameof(IsDirty));
if (e.PropertyName == nameof(GroupEditor.Name)) OnPropertyChanged(nameof(Title));
};
}
internal static GroupItem NewDraft(GroupsPanelViewModel page, IReadOnlyList<PermissionSummary> permissions) =>
new(page, new GroupSummary { Id = "", Name = "" }, permissions, isNew: true);
public GroupSummary Summary { get; }
public GroupEditor Editor { get; }
public bool IsNew { get; }
public string Id => Summary.Id;
public string Name => Summary.Name;
public bool IsSystem => Summary.System;
public string Title => IsNew ? (string.IsNullOrWhiteSpace(Editor.Name) ? "Nouveau groupe" : Editor.Name.Trim()) : Summary.Name;
public string Subtitle => IsNew
? "Pas encore enregistré"
: IsSystem
? $"{Members(Summary.MemberCount)} · toutes les permissions"
: $"{Members(Summary.MemberCount)} · {Summary.Permissions.Count} permission(s)";
public string IconKey => IsSystem ? "ShieldAccount" : IsNew ? "Plus" : "AccountGroup";
/// <summary>Modifié, non enregistré (pastille dans la liste).</summary>
public bool IsDirty => Editor.IsDirty;
private static string Members(int count) => count switch { 0 => "aucun membre", 1 => "1 membre", _ => $"{count} membres" };
}
/// <summary>Saisie d'un groupe : nom, description, permissions cochées.</summary>
public sealed class GroupEditor : ObservableObject
{
private readonly GroupsPanelViewModel _page;
private readonly GroupItem _group;
private string _name, _description;
private bool _isDirty;
internal GroupEditor(GroupsPanelViewModel page, GroupItem group, IReadOnlyList<PermissionSummary> permissions)
{
_page = page;
_group = group;
var summary = group.Summary;
_name = summary.Name;
_description = summary.Description ?? "";
IsYourGroup = !group.IsNew && summary.Id == page.MyGroupId;
Toggles = permissions.Select(p => new PermissionToggle(this, p, summary.Permissions.Contains(p.Name), CanToggle(p.Name, summary))).ToList();
DeleteCommand = new AsyncRelayCommand(() => page.DeleteGroupAsync(group), () => !page.IsBusy && CanDelete);
}
public bool IsNew => _group.IsNew;
public bool IsSystem => _group.IsSystem;
public bool IsYourGroup { get; }
/// <summary>Nom et description modifiables (pas pour le groupe système).</summary>
public bool CanEditText => !IsSystem;
public string Name
{
get => _name;
set
{
if (SetProperty(ref _name, value ?? "")) Changed();
}
}
public string Description
{
get => _description;
set
{
if (SetProperty(ref _description, value ?? "")) Changed();
}
}
public IReadOnlyList<PermissionToggle> Toggles { get; }
public bool IsDirty
{
get => _isDirty;
private set
{
if (!SetProperty(ref _isDirty, value)) return;
OnPropertyChanged(nameof(CanSave));
_page.SaveCommand.RaiseCanExecuteChanged();
}
}
public bool CanSave => !IsSystem && (IsDirty || IsNew);
public string Header => IsNew ? "Nouveau groupe" : _group.Summary.Name;
public string IdText => IsNew ? "Identifiant attribué à l'enregistrement, d'après le nom." : $"Identifiant : {_group.Id}";
public string MembersText
{
get
{
if (IsNew) return "";
var s = _group.Summary;
var text = s.MemberCount switch { 0 => "Aucun membre", 1 => "1 membre", _ => $"{s.MemberCount} membres" };
if (s.InvitationCount > 0) text += $" · {s.InvitationCount} invitation(s) en cours";
if (s.UpdatedAt is { } at) text += $" · modifié le {AccountTexts.Date(at)}" + (s.UpdatedBy is { } by ? $" par {by}" : "");
return text;
}
}
/// <summary>Explication en haut de la carte (groupe système, votre groupe…), ou rien.</summary>
public string? Notice =>
IsSystem ? "Groupe système : il a toujours TOUTES les permissions, y compris celles créées plus tard. Il ne peut être ni modifié ni " +
"supprimé, pour qu'il reste toujours quelqu'un capable de tout réparer."
: IsYourGroup ? "C'est votre groupe : vous pouvez changer son nom et sa description, mais pas ses permissions (demandez-le à un autre " +
"administrateur, comme pour changer de groupe)."
: null;
public bool HasNotice => Notice is not null;
public bool CanDelete => !IsSystem && !IsYourGroup && (IsNew || (_group.Summary.MemberCount == 0 && _group.Summary.InvitationCount == 0));
public string DeleteText => IsNew ? "Annuler ce nouveau groupe" : "Supprimer le groupe";
public string? DeleteHint =>
IsSystem || IsNew ? null
: IsYourGroup ? "Votre propre groupe ne peut pas être supprimé."
: _group.Summary.MemberCount > 0 || _group.Summary.InvitationCount > 0
? "Pour le supprimer, placez d'abord ses membres dans un autre groupe et supprimez ses invitations (page « Administrateurs »)."
: null;
public AsyncRelayCommand DeleteCommand { get; }
internal void RaiseCommands() => DeleteCommand.RaiseCanExecuteChanged();
/// <summary>Case modifiable : pas pour le groupe système ni le sien ; une permission que l'on n'a pas peut être retirée, pas donnée.</summary>
private bool CanToggle(string permission, GroupSummary summary) =>
!summary.System && !IsYourGroup && (_page.IHave(permission) || summary.Permissions.Contains(permission));
internal void Changed()
{
var s = _group.Summary;
IsDirty = IsNew
? !string.IsNullOrWhiteSpace(_name) || !string.IsNullOrWhiteSpace(_description) || Toggles.Any(t => t.IsChecked)
: _name.Trim() != s.Name || (_description.Trim().Length == 0 ? null : _description.Trim()) != s.Description ||
AddedPermissions().Count > 0 || RemovedPermissions().Count > 0;
if (IsNew) OnPropertyChanged(nameof(Header));
}
internal List<string> Checked() => Toggles.Where(t => t.IsChecked).Select(t => t.Name).ToList();
internal List<string> AddedPermissions() => Checked().Except(IsNew ? Array.Empty<string>() : _group.Summary.Permissions).ToList();
internal List<string> RemovedPermissions() =>
IsNew ? new List<string>() : _group.Summary.Permissions.Except(Checked()).ToList();
internal List<string> Validate()
{
var errors = new List<string>();
if (AdminGroups.CleanName(_name) is null)
errors.Add($"Nom du groupe : obligatoire, une ligne, {AdminGroups.MaxNameLength} caractères au plus.");
AdminGroups.CleanDescription(_description, AdminGroups.MaxDescriptionLength, out var valid);
if (!valid) errors.Add($"Description : une ligne, {AdminGroups.MaxDescriptionLength} caractères au plus.");
var missing = AddedPermissions().Where(p => !_page.IHave(p)).ToList();
if (missing.Count > 0) errors.Add($"Vous ne pouvez pas accorder des permissions que vous n'avez pas : {string.Join(", ", missing)}.");
return errors;
}
internal GroupRequest ToRequest() => new()
{
Name = _name.Trim(),
Description = string.IsNullOrWhiteSpace(_description) ? null : _description.Trim(),
Permissions = Checked(),
Revision = _group.Summary.Revision
};
internal (string Name, string Description, List<string> Checked) Snapshot() => (_name, _description, Checked());
internal void Restore((string Name, string Description, List<string> Checked) draft)
{
_name = draft.Name;
_description = draft.Description;
foreach (var toggle in Toggles.Where(t => t.IsEnabled)) toggle.SetChecked(draft.Checked.Contains(toggle.Name));
OnPropertyChanged(nameof(Name));
OnPropertyChanged(nameof(Description));
Changed();
}
}
/// <summary>Case d'une permission dans la saisie d'un groupe.</summary>
public sealed class PermissionToggle : ObservableObject
{
private readonly GroupEditor _editor;
private bool _isChecked;
internal PermissionToggle(GroupEditor editor, PermissionSummary permission, bool isChecked, bool isEnabled)
{
_editor = editor;
Name = permission.Name;
Label = permission.Label;
Description = permission.Description;
IsBuiltIn = permission.BuiltIn;
_isChecked = isChecked;
IsEnabled = isEnabled;
IsSensitive = permission.Name == AdminPermissions.AdminsManage;
}
public string Name { get; }
public string Label { get; }
public string? Description { get; }
public bool HasDescription => !string.IsNullOrEmpty(Description);
public bool IsBuiltIn { get; }
public string KindText => IsBuiltIn ? "Intégrée" : "Personnalisée";
/// <summary>Permission sensible (gestion des administrateurs) : mise en évidence.</summary>
public bool IsSensitive { get; }
public bool IsEnabled { get; }
public string? DisabledReason => IsEnabled ? null
: _editor.IsSystem ? "Le groupe « Propriétaire » a toutes les permissions."
: _editor.IsYourGroup ? "Vous ne pouvez pas modifier les permissions de votre propre groupe."
: "Vous n'avez pas cette permission : vous ne pouvez pas l'accorder.";
public bool IsChecked
{
get => _isChecked;
set
{
if (!IsEnabled || !SetProperty(ref _isChecked, value)) return;
_editor.Changed();
}
}
internal void SetChecked(bool value)
{
if (SetProperty(ref _isChecked, value, nameof(IsChecked))) _editor.Changed();
}
}
/// <summary>Permission de la liste (intégrée ou personnalisée).</summary>
public sealed class PermissionItem : ObservableObject
{
internal PermissionItem(GroupsPanelViewModel page, PermissionSummary permission)
{
Summary = permission;
DeleteCommand = new AsyncRelayCommand(() => page.DeletePermissionAsync(this), () => !page.IsBusy && CanDelete);
CanDelete = !permission.BuiltIn && page.IHave(permission.Name);
}
public PermissionSummary Summary { get; }
public string Name => Summary.Name;
public string Label => Summary.Label;
public string? Description => Summary.Description;
public bool HasDescription => !string.IsNullOrEmpty(Description);
public bool IsBuiltIn => Summary.BuiltIn;
public string KindText => IsBuiltIn ? "Intégrée" : "Personnalisée";
/// <summary>« 2 groupes (Propriétaire compris) · créée le … par … ».</summary>
public string UsageText
{
get
{
var text = Summary.GroupCount <= 1 ? "Groupe « Propriétaire » uniquement" : $"{Summary.GroupCount} groupes (Propriétaire compris)";
if (Summary.CreatedAt is { } at) text += $" · créée le {AccountTexts.Date(at)}" + (Summary.CreatedBy is { } by ? $" par {by}" : "");
return text;
}
}
public bool CanDelete { get; }
public AsyncRelayCommand DeleteCommand { get; }
}
@@ -0,0 +1,81 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Mes appareils » (tout administrateur connecté) : ses clés, l'ajout d'un nouveau PC (invitation
/// « nouvel appareil », puis approbation ici avec l'empreinte affichée sur le nouveau PC), ses invitations.
/// Personne d'autre ne peut approuver un appareil à son nom (règle du serveur).
/// </summary>
public sealed class MyDevicesPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.devices";
private IReadOnlyList<AdminKeyItem> _devices = Array.Empty<AdminKeyItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
public MyDevicesPanelViewModel(IAdminContext context) : base(context)
{
AddDeviceCommand = new AsyncRelayCommand(AddDeviceAsync, () => !IsBusy && !HasCreatedInvitation);
}
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role, Context.Session.Profile.RoleName);
/// <summary>Révoquer un de ses appareils exige la gestion des administrateurs (sinon : un propriétaire s'en charge).</summary>
public bool CanRevokeDevices => Context.Session.Has(AdminPermissions.AdminsManage);
public IReadOnlyList<AdminKeyItem> Devices
{
get => _devices;
private set => SetProperty(ref _devices, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
public AsyncRelayCommand AddDeviceCommand { get; }
protected override void OnCommandsChanged() => AddDeviceCommand.RaiseCanExecuteChanged();
private async Task AddDeviceAsync()
{
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = Context.Session.Profile.Id })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
ShowInvitation(created!, "Invitation pour votre nouvel appareil",
"Sur le nouveau PC : launcher › menu d'administration › « J'ai une invitation », puis collez ce code. Le nouveau PC " +
"affiche ensuite une empreinte : revenez ici, saisissez-la sous l'appareil en attente et approuvez-le.");
await RunAsync(ReloadAsync);
}
protected override async Task ReloadAsync()
{
var account = await Context.Session.GetMyAccountAsync();
var me = Context.Session.Profile;
Devices = account.Admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(this, account.Admin.Id, account.Admin.DisplayName, k, k.KeyId == me.KeyId,
canApprove: true, approveHint: null, canRevoke: CanRevokeDevices, AccountTexts.OwnPendingText))
.ToList();
Invitations = account.Invitations
.Select(i => new InvitationItem(this, i, canDelete: i.CreatedById == me.Id || CanRevokeDevices))
.ToList();
}
}
@@ -0,0 +1,21 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>Module « Audit » : page « Journal d'audit » (permission audit.read : propriétaires, administrateurs).</summary>
public sealed class AuditAdminModule : ILauncherModule
{
public string Id => "audit";
public string DisplayName => "Audit";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
AuditPanelViewModel.PanelId,
"Journal d'audit",
"Connexions, publications, modifications et refus enregistrés par le serveur, les plus récents d'abord.",
"History",
AdminPermissions.AuditRead,
Order: 60,
context => new AuditPanelViewModel(context)));
}
@@ -0,0 +1,567 @@
using System.Globalization;
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>
/// Page « Journal d'audit » (audit.read) : actions enregistrées par le serveur, les plus récentes
/// d'abord, chargées par pages de 200 ; filtres (période, texte, catégorie, résultat) ; export CSV
/// des entrées filtrées. Période : le serveur ne renvoie que les entrées antérieures à la fin de la
/// période, et les pages sont chargées jusqu'à son début (dans la limite de <see cref="MaxLoaded"/>).
/// Chaque consultation est inscrite au journal par le serveur ; chaque export y est inscrit avant l'écriture du fichier.
/// </summary>
public sealed class AuditPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "audit.read";
/// <summary>Entrées par page (maximum accepté par le serveur).</summary>
public const int PageSize = 200;
/// <summary>Au-delà, « Charger plus » s'arrête (mémoire du launcher) : utiliser journalctl ou les fichiers du VPS.</summary>
public const int MaxLoaded = 5000;
/// <summary>Entrées affichées au plus (la page n'est pas virtualisée) ; l'export reprend toutes celles filtrées.</summary>
public const int MaxDisplayed = 500;
private readonly List<AuditEntry> _loaded = new();
private IReadOnlyList<AuditItem> _filtered = Array.Empty<AuditItem>();
private string? _nextCursor;
private IReadOnlyList<AuditItem> _entries = Array.Empty<AuditItem>();
private string _searchText = "";
private Choice _category;
private Choice _result;
private string? _lastRefresh;
private bool _hasLoaded;
private Choice _period;
private string _fromText = "";
private string _toText = "";
private string? _dateError;
private DateTime? _from; // début de la période (jour inclus, heure locale)
private DateTime? _to; // fin de la période (jour inclus, heure locale)
private bool _settingPeriod;
private bool _periodReloadPending;
public AuditPanelViewModel(IAdminContext context) : base(context)
{
Categories = new List<Choice>
{
new(null, "Toutes les actions"),
new("auth.", "Connexions"),
new("admins.", "Administrateurs"),
new("cli.", "Outil du VPS (ligne de commande)"),
new("catalog.", "Informations des jeux"),
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("files.", "Fichiers du serveur"),
new("keyring.", "Signature (trousseau)"),
new("feedback.", "Demandes des joueurs"),
new("audit.", "Journal d'audit (consultations, exports, purges)"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
{
new(null, "Tous les résultats"),
new(AuditResults.Ok, "Réussis"),
new(AuditResults.Denied, "Refusés")
};
Periods = new List<Choice>
{
new(null, "Toutes les dates"),
new(PeriodToday, "Aujourd'hui"),
new(PeriodWeek, "7 derniers jours"),
new(PeriodMonth, "30 derniers jours"),
new(PeriodThisMonth, "Ce mois-ci"),
new(PeriodLastMonth, "Mois précédent"),
new(PeriodCustom, "Dates choisies")
};
_category = Categories[0];
_result = Results[0];
_period = Periods[0];
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(reset: true))), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: false)), () => !IsBusy && CanLoadMore);
ClearFiltersCommand = new RelayCommand(ClearFilters, () => HasFilters);
ExportCommand = new AsyncRelayCommand(ExportAsync, () => !IsBusy && _entries.Count > 0);
}
public IReadOnlyList<Choice> Categories { get; }
public IReadOnlyList<Choice> Results { get; }
public IReadOnlyList<Choice> Periods { get; }
private const string PeriodToday = "today", PeriodWeek = "7d", PeriodMonth = "30d", PeriodThisMonth = "this-month",
PeriodLastMonth = "last-month", PeriodCustom = "custom";
private const string DateFormat = "dd/MM/yyyy";
/// <summary>Aujourd'hui (heure locale) ; remplaçable par les tests.</summary>
internal Func<DateTime> Today { get; set; } = () => DateTime.Today;
/// <summary>Rechargement lancé par un changement de période (tests).</summary>
internal Task PeriodReload { get; private set; } = Task.CompletedTask;
/// <summary>Période rapide ; « Dates choisies » : champs « Du » / « Au ».</summary>
public Choice Period
{
get => _period;
set
{
if (value is null || !SetProperty(ref _period, value)) return;
if (value.Value == PeriodCustom) return; // les champs restent tels quels
var today = Today().Date;
var (from, to) = value.Value switch
{
PeriodToday => (today, today),
PeriodWeek => (today.AddDays(-6), today),
PeriodMonth => (today.AddDays(-29), today),
PeriodThisMonth => (new DateTime(today.Year, today.Month, 1), today),
PeriodLastMonth => (new DateTime(today.Year, today.Month, 1).AddMonths(-1), new DateTime(today.Year, today.Month, 1).AddDays(-1)),
_ => ((DateTime?)null, (DateTime?)null)
};
_settingPeriod = true;
FromText = from?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
ToText = to?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
_settingPeriod = false;
ApplyPeriod();
}
}
/// <summary>Début de la période (jj/mm/aaaa, inclus ; vide : depuis le début du journal).</summary>
public string FromText
{
get => _fromText;
set
{
if (!SetProperty(ref _fromText, value ?? "")) return;
if (!_settingPeriod) OnDateEdited();
}
}
/// <summary>Fin de la période (jj/mm/aaaa, inclus ; vide : jusqu'à maintenant).</summary>
public string ToText
{
get => _toText;
set
{
if (!SetProperty(ref _toText, value ?? "")) return;
if (!_settingPeriod) OnDateEdited();
}
}
/// <summary>Date saisie invalide (affichée sous les champs) ; la période précédente reste appliquée.</summary>
public string? DateError
{
get => _dateError;
private set
{
if (SetProperty(ref _dateError, value)) OnPropertyChanged(nameof(HasDateError));
}
}
public bool HasDateError => !string.IsNullOrEmpty(DateError);
public bool HasPeriod => _from is not null || _to is not null;
private void OnDateEdited()
{
if (_period.Value != PeriodCustom)
{
_period = Periods.First(p => p.Value == PeriodCustom);
OnPropertyChanged(nameof(Period));
}
ApplyPeriod();
}
/// <summary>Contrôle les champs ; période changée → rechargement depuis le serveur.</summary>
private void ApplyPeriod()
{
if (!TryParseDate(_fromText, out var from) || !TryParseDate(_toText, out var to))
{
DateError = "Date attendue au format jj/mm/aaaa (ex. 07/11/2026).";
return;
}
if (from is { } f && to is { } t && f > t)
{
DateError = "La date de début est postérieure à la date de fin.";
return;
}
DateError = null;
if (from == _from && to == _to) return;
_from = from;
_to = to;
OnPropertyChanged(nameof(HasPeriod));
OnPropertyChanged(nameof(HasFilters));
ClearFiltersCommand.RaiseCanExecuteChanged();
ApplyFilters();
if (IsBusy) _periodReloadPending = true; // rechargé dès la fin du chargement en cours
else if (_hasLoaded) PeriodReload = RunAsync(() => LoadAsync(reset: true));
}
private static bool TryParseDate(string text, out DateTime? date)
{
date = null;
if (string.IsNullOrWhiteSpace(text)) return true;
if (!DateTime.TryParseExact(text.Trim(), new[] { DateFormat, "d/M/yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ||
parsed.Year < 2000)
return false;
date = parsed.Date;
return true;
}
/// <summary>Fin de la période en heure universelle (le lendemain du dernier jour, 0 h locale), pour le serveur.</summary>
private DateTimeOffset? Before => _to is { } to ? new DateTimeOffset(to.AddDays(1), TimeZoneInfo.Local.GetUtcOffset(to.AddDays(1))) : null;
private DateTimeOffset? After => _from is { } from ? new DateTimeOffset(from, TimeZoneInfo.Local.GetUtcOffset(from)) : null;
public IReadOnlyList<AuditItem> Entries
{
get => _entries;
private set
{
if (!SetProperty(ref _entries, value)) return;
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(EmptyText));
OnPropertyChanged(nameof(TruncatedText));
OnPropertyChanged(nameof(IsTruncated));
ExportCommand.RaiseCanExecuteChanged();
}
}
public bool HasEntries => _entries.Count > 0;
public bool IsTruncated => _filtered.Count > _entries.Count;
public string TruncatedText => IsTruncated
? $"Seules les {MaxDisplayed} premières entrées sont affichées : affinez la recherche, ou exportez les {_filtered.Count} entrées filtrées."
: "";
/// <summary>Message quand rien n'est affiché (vide tant que rien n'a été chargé : l'erreur éventuelle suffit).</summary>
public string EmptyText => !_hasLoaded || HasEntries ? ""
: _loaded.Count == 0 ? "Le journal est vide."
: "Aucune entrée chargée ne correspond aux filtres" + (CanLoadMore ? " : chargez des entrées plus anciennes." : ".");
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) ApplyFilters();
}
}
public Choice Category
{
get => _category;
set
{
if (value is not null && SetProperty(ref _category, value)) ApplyFilters();
}
}
public Choice Result
{
get => _result;
set
{
if (value is not null && SetProperty(ref _result, value)) ApplyFilters();
}
}
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null || HasPeriod;
/// <summary>« 37 entrées affichées sur 200 chargées (depuis le 12/10/2026 à 08:14) ».</summary>
public string SummaryText
{
get
{
if (_loaded.Count == 0) return "";
var oldest = _loaded[^1].Timestamp.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
var shown = HasFilters ? $"{_filtered.Count} entrée(s) sur {_loaded.Count} chargée(s)" : $"{_loaded.Count} entrée(s) chargée(s)";
var period = _to is { } to ? $" jusqu'au {to.ToString(DateFormat, CultureInfo.InvariantCulture)}" : "";
return $"{shown}{period}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
: _loaded.Count >= MaxLoaded ? $". Limite de {MaxLoaded} entrées atteinte : les plus anciennes restent dans les fichiers du journal sur le VPS."
: ".");
}
}
public bool CanLoadMore => _nextCursor is not null && _loaded.Count < MaxLoaded;
public string? LastRefresh
{
get => _lastRefresh;
private set => SetProperty(ref _lastRefresh, value);
}
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public RelayCommand ClearFiltersCommand { get; }
/// <summary>Exporte les entrées filtrées (toutes, pas seulement celles affichées).</summary>
public AsyncRelayCommand ExportCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
LoadMoreCommand.RaiseCanExecuteChanged();
ExportCommand.RaiseCanExecuteChanged();
if (!IsBusy && _periodReloadPending)
{
_periodReloadPending = false;
PeriodReload = ReloadAfterCurrentAsync();
}
}
/// <summary>Période changée pendant un chargement : nouveau chargement juste après celui-ci.</summary>
private async Task ReloadAfterCurrentAsync()
{
await Task.Yield(); // après la fin du chargement en cours (fil de l'interface)
await RunAsync(() => LoadAsync(reset: true));
}
/// <summary>Chargé au premier affichage ; ensuite « Actualiser » (garde les pages déjà chargées et les filtres d'ici là).</summary>
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync(reset: true)) : Task.CompletedTask;
private async Task LoadAsync(bool reset)
{
var before = Before;
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor, before);
if (reset) _loaded.Clear();
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
// Période : pages suivantes jusqu'à son début (l'export doit contenir toute la période chargée).
while (reset && After is { } after && _nextCursor is not null && _loaded.Count < MaxLoaded && _loaded[^1].Timestamp >= after)
{
page = await Context.Session.GetAuditAsync(PageSize, _nextCursor, before);
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
}
_hasLoaded = true;
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
OnPropertyChanged(nameof(CanLoadMore));
LoadMoreCommand.RaiseCanExecuteChanged();
ApplyFilters();
}
private void ClearFilters()
{
_searchText = "";
_category = Categories[0];
_result = Results[0];
OnPropertyChanged(nameof(SearchText));
OnPropertyChanged(nameof(Category));
OnPropertyChanged(nameof(Result));
if (HasPeriod || _period.Value is not null || HasDateError) Period = Periods[0]; // recharge toutes les dates
ApplyFilters();
}
private void ApplyFilters()
{
var text = _searchText.Trim();
var (after, before) = (After, Before);
_filtered = _loaded
.Where(e => (after is null || e.Timestamp >= after) && (before is null || e.Timestamp < before))
.Where(e => AuditTexts.InCategory(e.Action, _category.Value))
.Where(e => _result.Value is null || e.Result == _result.Value)
.Select(e => new AuditItem(e))
.Where(item => text.Length == 0 || item.Matches(text))
.ToList();
Entries = _filtered.Count > MaxDisplayed ? _filtered.Take(MaxDisplayed).ToList() : _filtered;
OnPropertyChanged(nameof(HasFilters));
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(EmptyText));
ClearFiltersCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Export
private async Task ExportAsync()
{
var period = (_from, _to) switch
{
(null, null) => "",
({ } f, null) => $"-depuis-{f:yyyy-MM-dd}",
(null, { } t) => $"-jusqu-au-{t:yyyy-MM-dd}",
({ } f, { } t) => $"-{f:yyyy-MM-dd}-au-{t:yyyy-MM-dd}"
};
var path = Context.Dialogs.PickSaveFile("Exporter le journal d'audit", "Fichier CSV (*.csv)|*.csv",
$"journal-audit{period}-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
if (path is null) return;
var entries = _filtered;
var announce = new AuditExportRequest
{
Count = entries.Count,
From = _from is { } from ? DateOnly.FromDateTime(from) : null,
To = _to is { } to ? DateOnly.FromDateTime(to) : null,
Filters = ExportFiltersText()
};
// L'export est inscrit au journal du serveur AVANT l'écriture du fichier : s'il ne peut pas l'être
// (serveur injoignable, session expirée, signature refusée), le fichier n'est pas écrit.
if (await RunAsync(async () =>
{
await Context.Session.RecordAuditExportAsync(announce);
await File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
}))
Context.Dialogs.ShowMessage("Journal exporté", $"{entries.Count} entrée(s) enregistrée(s) dans {Path.GetFileName(path)}.",
ModuleMessageKind.Success);
}
/// <summary>Filtres de l'export, en clair pour le journal (la période est transmise à part).</summary>
private string? ExportFiltersText()
{
var parts = new List<string>();
if (_category.Value is not null) parts.Add($"catégorie « {_category.Label} »");
if (_result.Value is not null) parts.Add($"résultat « {_result.Label} »");
var text = _searchText.Trim();
if (text.Length > 0) parts.Add($"recherche « {(text.Length > 80 ? text[..80] + "…" : text)} »");
return parts.Count == 0 ? null : string.Join(", ", parts);
}
}
/// <summary>Entrée du journal telle qu'affichée.</summary>
public sealed class AuditItem
{
internal AuditItem(AuditEntry entry)
{
Entry = entry;
TimeText = entry.Timestamp.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
ActionText = AuditTexts.Action(entry.Action);
ResultText = AuditTexts.Result(entry.Result);
ActorText = entry.ActorName ?? (entry.KeyId is null ? "Inconnu" : "Clé inconnue");
var parts = new List<string>();
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
DetailText = string.Join(" — ", parts);
// Appareil : nom choisi à l'enregistrement de la clé (invitation), fourni par le serveur — jamais le nom Windows.
DeviceText = string.IsNullOrEmpty(entry.KeyLabel) ? "" : $"Appareil « {entry.KeyLabel} »";
var origin = new List<string>();
if (DeviceText.Length > 0) origin.Add(DeviceText);
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
OriginText = string.Join(" · ", origin);
}
public AuditEntry Entry { get; }
public string TimeText { get; }
public string ActionText { get; }
/// <summary>Code technique (« admins.key_approve »), en infobulle.</summary>
public string ActionCode => Entry.Action;
public string Result => Entry.Result;
public string ResultText { get; }
public bool IsOk => Entry.Result == AuditResults.Ok;
public bool IsDenied => Entry.Result == AuditResults.Denied;
public bool IsFailed => Entry.Result == AuditResults.Failed;
public string ActorText { get; }
/// <summary>« Appareil « PC bureau » » (vide : entrée sans clé, ou clé inconnue du serveur).</summary>
public string DeviceText { get; }
public string DetailText { get; }
public bool HasDetail => DetailText.Length > 0;
public string OriginText { get; }
/// <summary>Recherche dans tout ce qui est affiché (sans tenir compte de la casse).</summary>
internal bool Matches(string text) =>
new[] { TimeText, ActionText, Entry.Action, ResultText, ActorText, DetailText, OriginText }
.Any(value => value.Contains(text, StringComparison.CurrentCultureIgnoreCase));
}
/// <summary>Libellés du journal (français) et export CSV.</summary>
public static class AuditTexts
{
public static string Action(string action) => action switch
{
"auth.login" => "Connexion",
"auth.logout" => "Déconnexion",
"admins.enroll" => "Appareil enregistré avec une invitation",
"admins.invite" => "Invitation créée",
"admins.invite_deleted" => "Invitation supprimée",
"admins.key_approve" => "Appareil approuvé",
"admins.key_revoke" => "Appareil révoqué",
"admins.role" => "Groupe d'un administrateur modifié",
"admins.group_create" => "Groupe créé",
"admins.group_update" => "Groupe modifié",
"admins.group_delete" => "Groupe supprimé",
"admins.permission_create" => "Permission créée",
"admins.permission_delete" => "Permission supprimée",
"admins.revoke" => "Compte révoqué",
"cli.add_owner" => "Propriétaire ajouté sur le VPS",
"cli.add_key" => "Clé ajoutée sur le VPS",
"cli.revoke_admin" => "Compte révoqué sur le VPS",
"cli.revoke_key" => "Clé révoquée sur le VPS",
"catalog.publish" => "Informations des jeux publiées",
"catalog.image_upload" => "Image du catalogue envoyée",
"changelog.edit" => "Notes de version modifiées",
"release.start" => "Publication d'une version commencée",
"release.publish" => "Version mise en ligne",
"release.cancel" => "Publication abandonnée",
"release.sign" => "Version en ligne signée",
"keyring.publish" => "Trousseau de signature publié",
"feedback.submit" => "Demande de joueur reçue",
"feedback.attachment" => "Capture d'écran reçue",
"feedback.view" => "Demande de joueur consultée",
"feedback.update" => "Demande de joueur traitée (statut ou note)",
"feedback.delete" => "Demande de joueur supprimée",
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
"feedback.forms" => "Formulaires des demandes publiés",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
"audit.view" => "Journal d'audit consulté",
"audit.export" => "Journal d'audit exporté (CSV)",
"audit.suppressed" => "Tentatives refusées regroupées",
"audit.purge" => "Anciens mois du journal supprimés (conservation)",
_ => action
};
public static string Result(string result) => result switch
{
AuditResults.Ok => "Réussi",
AuditResults.Denied => "Refusé",
AuditResults.Failed => "Échec",
_ => result
};
/// <summary>
/// Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées ;
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité).
/// </summary>
public static bool InCategory(string action, string? category) => category switch
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
"audit." => action is "audit.view" or "audit.export" or "audit.purge",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
/// <summary>
/// CSV (séparateur « ; », pour Excel en français). Les valeurs viennent en partie de requêtes anonymes ou
/// de saisies (nom du client, de l'appareil…) : une cellule qui commencerait par = + - @ serait interprétée
/// comme une formule par le tableur ; elle est donc précédée d'une apostrophe, et toutes les cellules sont
/// entre guillemets (un tableur qui sépare par des virgules ne peut pas couper une valeur en deux).
/// </summary>
public static string ToCsv(IEnumerable<AuditItem> items)
{
var csv = new StringBuilder();
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;Appareil;IP;Clé;Cible;Détails");
foreach (var item in items)
{
var e = item.Entry;
csv.AppendLine(string.Join(';', new[]
{
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
item.ActionText, e.Action, item.ResultText, e.ActorName, e.KeyLabel, e.Ip, e.KeyId, e.Target, e.Details
}.Select(Cell)));
}
return csv.ToString();
}
private static string Cell(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
value = value.Replace("\r", " ").Replace("\n", " ");
var first = value.TrimStart(' ');
if (value[0] == '\t' || (first.Length > 0 && first[0] is '=' or '+' or '-' or '@')) value = "'" + value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}
@@ -107,8 +107,12 @@ public sealed class GameEditor : CatalogEditor
private string _gameName = "", _tagline = "", _description = "", _websiteUrl = "", _icon = "", _background = "", _interval = "";
private Choice _theme;
private bool _hidden;
private UploadedImagePreview? _iconPreview, _backgroundPreview;
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed) : base(changed)
/// <param name="browseImage">« Parcourir… » d'une image (type <see cref="CatalogImages.Icon"/> ou <see cref="CatalogImages.Background"/>) :
/// choix du fichier, contrôle, envoi au serveur, puis adresse renseignée ici. Null : boutons absents.</param>
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed,
Func<GameEditor, string, Task>? browseImage = null) : base(changed)
{
Declared = declared;
_builtInThemeChoices = new[] { new Choice(null, $"Par défaut ({ThemeOf(declared)})") }
@@ -117,9 +121,16 @@ public sealed class GameEditor : CatalogEditor
_theme = ThemeChoices[0];
LinkKinds = CatalogLinkKinds.All.Select(k => new Choice(k, CatalogLinkKinds.DefaultLabel(k))).ToList();
AddLinkCommand = new RelayCommand(AddLink, () => Links.Count < CatalogRules.MaxLinks);
CanBrowseImages = browseImage is not null;
BrowseIconCommand = new AsyncRelayCommand(() => browseImage?.Invoke(this, CatalogImages.Icon) ?? Task.CompletedTask, () => CanBrowseImages);
BrowseBackgroundCommand = new AsyncRelayCommand(() => browseImage?.Invoke(this, CatalogImages.Background) ?? Task.CompletedTask,
() => CanBrowseImages);
ClearIconCommand = new RelayCommand(() => Icon = "", () => !string.IsNullOrEmpty(Icon));
ClearBackgroundCommand = new RelayCommand(() => Background = "", () => !string.IsNullOrEmpty(Background));
Links.CollectionChanged += (_, _) =>
{
AddLinkCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(HasLinks));
Changed();
};
}
@@ -145,8 +156,65 @@ public sealed class GameEditor : CatalogEditor
public string Tagline { get => _tagline; set => Set(ref _tagline, value ?? ""); }
public string Description { get => _description; set => Set(ref _description, value ?? ""); }
public string WebsiteUrl { get => _websiteUrl; set => Set(ref _websiteUrl, value ?? ""); }
public string Icon { get => _icon; set => Set(ref _icon, value ?? ""); }
public string Background { get => _background; set => Set(ref _background, value ?? ""); }
public string Icon
{
get => _icon;
set
{
if (!Set(ref _icon, value ?? "")) return;
if (_iconPreview is not null && _iconPreview.Url != _icon) IconPreview = null; // adresse changée à la main
ClearIconCommand?.RaiseCanExecuteChanged();
}
}
public string Background
{
get => _background;
set
{
if (!Set(ref _background, value ?? "")) return;
if (_backgroundPreview is not null && _backgroundPreview.Url != _background) BackgroundPreview = null;
ClearBackgroundCommand?.RaiseCanExecuteChanged();
}
}
// ------------------------------------------------------------------ Images envoyées depuis le menu (sans FTP)
/// <summary>Boutons « Parcourir… » disponibles (page reliée au serveur).</summary>
public bool CanBrowseImages { get; }
public AsyncRelayCommand BrowseIconCommand { get; }
public AsyncRelayCommand BrowseBackgroundCommand { get; }
public RelayCommand ClearIconCommand { get; }
public RelayCommand ClearBackgroundCommand { get; }
/// <summary>Aperçu de la dernière icône envoyée (tant que son adresse est celle du champ).</summary>
public UploadedImagePreview? IconPreview
{
get => _iconPreview;
private set => SetProperty(ref _iconPreview, value);
}
public UploadedImagePreview? BackgroundPreview
{
get => _backgroundPreview;
private set => SetProperty(ref _backgroundPreview, value);
}
/// <summary>Image envoyée au serveur : son adresse remplit le champ (à publier ensuite avec « Publier »).</summary>
internal void SetUploadedImage(string kind, UploadedImagePreview preview)
{
if (kind == CatalogImages.Icon)
{
Icon = preview.Url;
IconPreview = preview;
}
else
{
Background = preview.Url;
BackgroundPreview = preview;
}
}
/// <summary>Minutes entre deux recherches de mise à jour (vide : launcher.json ; 0 : désactivé).</summary>
public string UpdateInterval { get => _interval; set => Set(ref _interval, value ?? ""); }
@@ -178,17 +246,35 @@ public sealed class GameEditor : CatalogEditor
}
public ObservableCollection<LinkEditor> Links { get; } = new();
/// <summary>Au moins un lien : les en-têtes des colonnes (type, texte, adresse) sont affichés.</summary>
public bool HasLinks => Links.Count > 0;
public IReadOnlyList<Choice> LinkKinds { get; }
public RelayCommand AddLinkCommand { get; }
// ------------------------------------------------------------------ Valeurs de launcher.json (rappel)
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci et « Applications installées » gardent ce nom.";
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci du Bureau prend le nom affiché (au démarrage du launcher, " +
"bouton « Raccourci » ou « Réparer ») ; « Applications installées » garde celui de launcher.json.";
public string TaglineHint => string.IsNullOrWhiteSpace(Declared.Tagline) ? "Vide : aucune accroche." : $"Vide : « {Declared.Tagline} ».";
public string WebsiteHint => string.IsNullOrWhiteSpace(Declared.WebsiteUrl) ? "Adresse https. Vide : aucun site." : $"Adresse https. Vide : {Declared.WebsiteUrl}";
public string IntervalHint => $"0 : désactivé, ou {CatalogRules.MinUpdateInterval} à {CatalogRules.MaxUpdateInterval} minutes. Vide : {Declared.UpdateCheckIntervalMinutes} (launcher.json).";
public string ImageHint => "Image .png ou .jpg (4 Mo au plus) déposée sur le serveur du catalogue, ex. https://…/catalog/images/fond.jpg. " +
"Les launchers gardent l'image en cache : pour la changer, déposez-la sous un NOUVEAU nom. Vide : image de launcher.json.";
public string ImageHint => "« Parcourir… » : choisissez une image .png ou .jpg sur ce PC (4 Mo au plus) ; elle est contrôlée, envoyée au serveur " +
"du catalogue sous un nom unique, puis son adresse est renseignée ici. Elle n'est visible des joueurs qu'après « Publier ». " +
"Une adresse https du serveur du catalogue peut aussi être saisie. Vide : image de launcher.json.";
/// <summary>Où l'icône apparaît chez les joueurs (avec un seul jeu, elle n'est pas affichée).</summary>
public string IconBoundsHint => BoundsText(CatalogImages.Icon) + " Carrée de préférence. Affichée sur la tuile du jeu, dans la " +
"liste des jeux à gauche de la fenêtre : visible seulement quand le launcher propose plusieurs jeux " +
"(avec un seul jeu, le logo en haut à gauche vient du thème). Prise en compte au prochain démarrage " +
"du launcher, après « Publier ».";
public string BackgroundBoundsHint => BoundsText(CatalogImages.Background) + " Format 16:9 conseillé (1920 × 1080 ou plus).";
private static string BoundsText(string kind)
{
var (minW, minH, maxW, maxH) = CatalogImages.Bounds(kind);
return $"De {minW} × {minH} à {maxW} × {maxH} px, 4 Mo au plus.";
}
private static string ThemeOf(LauncherConfig config) => string.IsNullOrWhiteSpace(config.Theme) ? "default" : config.Theme;
@@ -208,6 +294,8 @@ public sealed class GameEditor : CatalogEditor
_websiteUrl = game?.WebsiteUrl ?? "";
_icon = game?.Icon ?? "";
_background = game?.Background ?? "";
if (_iconPreview?.Url != _icon) IconPreview = null;
if (_backgroundPreview?.Url != _background) BackgroundPreview = null;
_interval = game?.UpdateCheckIntervalMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
_hidden = game?.Hidden == true;
ThemeChoices = _builtInThemeChoices;
@@ -225,6 +313,8 @@ public sealed class GameEditor : CatalogEditor
foreach (var name in new[] { nameof(GameName), nameof(Tagline), nameof(Description), nameof(WebsiteUrl), nameof(Icon), nameof(Background),
nameof(UpdateInterval), nameof(Hidden), nameof(ThemeChoices), nameof(Theme), nameof(Title), nameof(Subtitle), nameof(IconKey) })
OnPropertyChanged(name);
ClearIconCommand.RaiseCanExecuteChanged();
ClearBackgroundCommand.RaiseCanExecuteChanged();
}
/// <summary>Valeurs saisies (contrôlées ensuite par <see cref="CatalogRules"/>) ; null si tout est par défaut.</summary>
@@ -309,3 +399,11 @@ public sealed class LinkEditor : ObservableObject
internal bool IsEmpty => string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Label);
}
/// <summary>
/// Image tout juste envoyée au serveur : contenu (pour l'aperçu, décodé par le launcher WPF), adresse et dimensions.
/// </summary>
public sealed record UploadedImagePreview(string Url, byte[] Content, int Width, int Height, long Bytes, string FileName)
{
public string Text => $"{FileName} · {Width} × {Height} px · {Bytes / 1024.0:0} Ko · envoyée, à publier";
}
@@ -1,9 +1,11 @@
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
@@ -15,7 +17,10 @@ namespace GameLauncher.Modules.Admin.Catalog;
/// • une liste : « Launcher » (nom, jeu par défaut) puis les jeux, dans l'ordre d'affichage (↑ ↓) ;
/// • champ vide = valeur de launcher.json (rappelée sous le champ) ;
/// • « Publier » : contrôle complet (mêmes règles que le serveur), requête signée, révision attendue
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé).
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé) ;
/// • icône et image de fond : « Parcourir… » ouvre l'explorateur Windows ; l'image choisie est contrôlée sur le
/// PC (<see cref="CatalogImages.Check"/>), envoyée au serveur (requête signée), puis son adresse renseignée dans
/// le champ ; elle n'est appliquée aux joueurs qu'à la publication du catalogue (signé).
/// </summary>
public sealed class GamesPanelViewModel : AdminPanelViewModel
{
@@ -32,17 +37,20 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
public GamesPanelViewModel(IAdminContext context) : base(context)
{
Launcher = new LauncherEditor(context.LauncherName, context.DefaultGameId, context.Games, OnEdited);
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited, BrowseImageAsync));
RebuildItems(Launcher);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => !IsBusy && HasChanges && PublishingEnabled);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => !IsBusy && HasChanges && PublishingEnabled);
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
}
public LauncherEditor Launcher { get; }
/// <summary>Liste « Launcher + jeux » et fiche défilent séparément.</summary>
public override bool HasSplitLayout => true;
/// <summary>Jeux dans l'ordre d'affichage du launcher.</summary>
public ObservableCollection<GameEditor> Games { get; } = new();
@@ -183,10 +191,15 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
return;
}
// Clé de ce PC absente du trousseau publié : les launchers des joueurs ignoreraient ce catalogue (avertissement seulement).
if (!await ConfirmPlayerSigningAsync(ContentKinds.Catalog)) return;
try
{
var published = await Context.Session.PublishCatalogAsync(draft, state.Catalog.Revision);
// Signature (phase 7) du catalogue tel qu'il sera publié (contrôlé avec les mêmes règles que le serveur).
var signature = await Context.Session.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId,
ContentSigning.CatalogDigest(normalized));
var published = await Context.Session.PublishCatalogAsync(normalized with { Signature = signature }, state.Catalog.Revision);
Apply(published);
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
@@ -200,6 +213,75 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
}
});
// ------------------------------------------------------------------ Images (icône, fond) envoyées sans FTP
private const string ImageFilter = "Images PNG ou JPEG (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg";
private async Task BrowseImageAsync(GameEditor editor, string kind)
{
var label = CatalogImages.KindLabel(kind);
if (IsBusy) return;
if (_state is not { PublishingEnabled: true } state)
{
Context.Dialogs.ShowMessage("Envoi impossible",
"La publication du catalogue n'est pas configurée sur le serveur : les images ne peuvent pas y être envoyées.",
ModuleMessageKind.Warning);
return;
}
var path = Context.Dialogs.PickOpenFile($"Choisir l'{label} de « {editor.Title} »", ImageFilter, null);
if (path is null) return;
await RunAsync(async () =>
{
StatusMessage = null;
// Lecture bornée : la taille est contrôlée avant de lire, puis le contenu lui-même (format, dimensions, fin du fichier).
byte[] content;
try
{
var file = new FileInfo(path);
if (!file.Exists) throw new FileNotFoundException("Fichier introuvable.", path);
if (file.Length > CatalogImages.MaxBytes)
{
Context.Dialogs.ShowMessage("Image trop volumineuse",
$"« {file.Name} » fait {file.Length / 1024.0 / 1024.0:0.0} Mo : {CatalogImages.MaxBytes / 1024 / 1024} Mo au plus.",
ModuleMessageKind.Warning);
return;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
if (stream.Length > CatalogImages.MaxBytes) throw new IOException("Le fichier a changé pendant sa lecture.");
content = new byte[stream.Length];
await stream.ReadExactlyAsync(content);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Context.Dialogs.ShowMessage("Lecture impossible", $"Impossible de lire « {Path.GetFileName(path)} » : {ex.Message}", ModuleMessageKind.Error);
return;
}
if (CatalogImages.Check(kind, content, out _) is { } refused)
{
Context.Dialogs.ShowMessage("Image refusée", refused, ModuleMessageKind.Warning);
return;
}
var uploaded = await Context.Session.UploadCatalogImageAsync(kind, content);
// L'adresse renvoyée sera SIGNÉE avec le catalogue : elle doit désigner exactement ce fichier, sur le serveur du catalogue.
var sha256 = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
var catalogHost = Uri.TryCreate(state.PublicUrl, UriKind.Absolute, out var catalogUrl) ? catalogUrl.Host : null;
if (!Uri.TryCreate(uploaded.Url, UriKind.Absolute, out var url) || url.Scheme != Uri.UriSchemeHttps ||
!string.Equals(url.Host, catalogHost, StringComparison.OrdinalIgnoreCase) || CatalogImages.ExpectedSha256(url) != sha256)
throw new InvalidOperationException("Le serveur a renvoyé une adresse d'image inattendue : elle n'a pas été utilisée.");
editor.SetUploadedImage(kind, new UploadedImagePreview(url.AbsoluteUri, content, uploaded.Width, uploaded.Height, content.Length,
Path.GetFileName(path)));
Selected = editor;
StatusMessage = $"{char.ToUpperInvariant(label[0])}{label[1..]} de « {editor.Title} » envoyée sur le serveur" +
(uploaded.AlreadyPresent ? " (elle y était déjà)" : "") +
". Cliquez sur « Publier » pour l'appliquer aux launchers des joueurs.";
Log.Info($"Administration : {label} envoyée pour {editor.GameId} ({uploaded.FileName}).");
});
}
/// <summary>Catalogue saisi (ordre affiché, champs non vides).</summary>
private CatalogDocument BuildCatalog(List<string> errors)
{
@@ -6,6 +6,7 @@ using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
@@ -13,6 +14,7 @@ namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
/// • plusieurs jeux déclarés : rien n'est chargé ni affiché avant le choix du jeu (aucun jeu par défaut) ;
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
@@ -32,11 +34,11 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
public ChangelogPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
GameChoices = GameTile.Choices(context);
_selectedGame = GameTile.Initial(GameChoices);
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
SaveCommand = new AsyncRelayCommand(ScrollingToTop(SaveAsync), () => !IsBusy && HasChanges && !HasConflict && _state is not null);
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy && _selectedGame is not null);
}
// ------------------------------------------------------------------ Jeu
@@ -44,12 +46,36 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
public IReadOnlyList<GameTile> GameTiles { get; }
public bool HasSelectedGame => _selectedGame is not null;
public bool ShowGamePicker => _selectedGame is null;
/// <summary>Chargement lancé par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
/// <summary>Pas de changement de jeu pendant un chargement ou un enregistrement.</summary>
public bool IsGameSwitchEnabled => !IsBusy;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez modifier les notes de version.";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame)) return;
if (IsBusy)
{
OnPropertyChanged(); // pas pendant un chargement ou un enregistrement
return;
}
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
{
@@ -58,8 +84,12 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher) })
OnPropertyChanged(name);
ReloadCommand.RaiseCanExecuteChanged();
ErrorMessage = null;
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
_ = LoadAsync();
GameLoad = LoadAsync();
}
}
@@ -78,6 +108,9 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
}
public bool HasVersions => Versions.Count > 0;
/// <summary>Liste des versions et notes défilent séparément (seulement quand il y a des versions à afficher).</summary>
public override bool HasSplitLayout => HasVersions;
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
@@ -126,6 +159,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
SaveCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
@@ -138,6 +172,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
{
if (_selectedGame?.Value is not { } gameId) return;
var state = await Context.Session.GetChangelogAsync(gameId);
if (!string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase)) return; // jeu changé entre-temps
Apply(state);
StatusMessage = null;
});
@@ -162,7 +197,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
_skippedVersions = state.Entries.Count - editable.Count;
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(HasSplitLayout), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
}
@@ -231,7 +266,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
Versions.Clear();
_selectedVersion = null;
_skippedVersions = 0;
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(HasSplitLayout), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
SaveCommand.RaiseCanExecuteChanged();
@@ -0,0 +1,36 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>
/// Module « Demandes des joueurs » : page de lecture et de traitement des demandes envoyées depuis le bouton « Aide »
/// (permission feedback.read ; statut, note et suppression : feedback.manage), et page « Formulaires des demandes »
/// (feedback.forms : types de demande et champs proposés aux joueurs, publication signée). Le formulaire des joueurs
/// est dans GameLauncher.Modules.Feedback.
/// </summary>
public sealed class FeedbackAdminModule : ILauncherModule
{
public string Id => "feedback";
public string DisplayName => "Demandes des joueurs";
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor(
FeedbackPanelViewModel.PanelId,
"Demandes des joueurs",
"Avis, demandes d'évolution, bugs et autres demandes envoyés depuis le bouton « Aide » du launcher, rangés par jeu.",
"MessageQuestion",
AdminPermissions.FeedbackRead,
Order: 15,
context => new FeedbackPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
FeedbackFormsPanelViewModel.PanelId,
"Formulaires des demandes",
"Les quatre types de demande du bouton « Aide » et leurs champs, communs à tous les jeux : modifier, masquer, publier (signé).",
"TextBoxOutline",
AdminPermissions.FeedbackForms,
Order: 16,
context => new FeedbackFormsPanelViewModel(context)));
}
}
@@ -0,0 +1,533 @@
using System.Collections.ObjectModel;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>Type de champ proposé par « Ajouter un champ ».</summary>
public sealed class FeedbackFieldKindChoice
{
public FeedbackFieldKindChoice(FeedbackFieldKind kind, Action<FeedbackFieldKind> add)
{
Kind = kind;
Label = FeedbackFieldEditor.KindLabel(kind);
IconKey = FeedbackFieldEditor.KindIcon(kind);
AddCommand = new RelayCommand(() => add(kind));
}
public FeedbackFieldKind Kind { get; }
public string Label { get; }
public string IconKey { get; }
public RelayCommand AddCommand { get; }
}
/// <summary>Choix d'une liste déroulante ou d'une case à cocher : identifiant fixe (clé des valeurs envoyées), libellé modifiable.</summary>
public sealed class FeedbackOptionEditor : ObservableObject
{
private readonly Action _changed;
private string _label;
public FeedbackOptionEditor(string id, string label, Action changed, Action<FeedbackOptionEditor, int> move, Action<FeedbackOptionEditor> remove)
{
Id = id;
_label = label;
_changed = changed;
MoveUpCommand = new RelayCommand(() => move(this, -1));
MoveDownCommand = new RelayCommand(() => move(this, 1));
RemoveCommand = new RelayCommand(() => remove(this));
}
public string Id { get; }
public string Label
{
get => _label;
set
{
if (SetProperty(ref _label, value ?? "")) _changed();
}
}
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
public FeedbackOption Build() => new(Id, ApiText.CleanLine(_label, FeedbackFormRules.MaxOptionLabelLength) ?? _label);
}
/// <summary>
/// Champ d'un formulaire en cours de modification. Son identifiant (clé des valeurs envoyées par les joueurs) et son
/// type sont fixés à la création : changer de type revient à retirer le champ et à en ajouter un autre, pour que les
/// demandes déjà reçues restent lisibles.
/// </summary>
public sealed class FeedbackFieldEditor : ObservableObject
{
private readonly Action _changed;
private string _label;
private string _hint;
private bool _required;
private int _maxLength;
private Choice _ratingMax;
private bool _showInList;
private bool _isExpanded;
private int _optionCounter;
public FeedbackFieldEditor(FeedbackField field, bool isNew, Action changed, Action<FeedbackFieldEditor, int> move,
Action<FeedbackFieldEditor> remove)
{
Id = field.Id;
Kind = field.Kind;
IsNew = isNew;
_changed = changed;
_label = field.Label;
_hint = field.Hint ?? "";
_required = field.Required;
_maxLength = field.MaxLength;
RatingSizes = FeedbackFormRules.RatingSizes.Select(n => new Choice(n.ToString(System.Globalization.CultureInfo.InvariantCulture), $"{n} étoiles")).ToList();
_ratingMax = RatingSizes.FirstOrDefault(c => c.Value == field.RatingMax.ToString(System.Globalization.CultureInfo.InvariantCulture)) ?? RatingSizes[1];
_showInList = field.ShowInList;
_isExpanded = isNew;
foreach (var option in field.Choices) Options.Add(NewOption(option.Id, option.Label));
_optionCounter = field.Choices.Count;
Options.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(Summary));
_changed();
};
MoveUpCommand = new RelayCommand(() => move(this, -1));
MoveDownCommand = new RelayCommand(() => move(this, 1));
RemoveCommand = new RelayCommand(() => remove(this));
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded);
AddOptionCommand = new RelayCommand(AddOption, () => Options.Count < FeedbackFormRules.MaxOptions);
}
public string Id { get; }
public FeedbackFieldKind Kind { get; }
/// <summary>Champ ajouté depuis la dernière publication.</summary>
public bool IsNew { get; }
public string KindText => KindLabel(Kind);
public string KindIconKey => KindIcon(Kind);
public bool HasOptions => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes;
public bool HasMaxLength => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
public bool IsRating => Kind == FeedbackFieldKind.Rating;
/// <summary>Seuls une liste, une note ou une case oui/non peuvent servir d'aperçu dans la liste des demandes.</summary>
public bool CanShowInList => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo;
public string RequiredText => Kind == FeedbackFieldKind.YesNo ? "Obligatoire (la case doit être cochée)" : "Obligatoire";
public string MaxLengthText => Kind == FeedbackFieldKind.Text
? $"Longueur maximale (1 à {FeedbackFormRules.MaxTextLength})"
: $"Longueur maximale ({FeedbackFormRules.MinMultilineLength} à {FeedbackRules.MaxMultilineLength})";
public string Label
{
get => _label;
set
{
if (!SetProperty(ref _label, value ?? "")) return;
OnPropertyChanged(nameof(Title));
_changed();
}
}
/// <summary>Titre de la ligne repliée (libellé, sinon « Champ sans libellé »).</summary>
public string Title => string.IsNullOrWhiteSpace(_label) ? "Champ sans libellé" : _label;
public string Hint
{
get => _hint;
set
{
if (SetProperty(ref _hint, value ?? "")) _changed();
}
}
public bool Required
{
get => _required;
set
{
if (!SetProperty(ref _required, value)) return;
OnPropertyChanged(nameof(Summary));
_changed();
}
}
public int MaxLength
{
get => _maxLength;
set
{
if (SetProperty(ref _maxLength, value)) _changed();
}
}
public IReadOnlyList<Choice> RatingSizes { get; }
public Choice RatingMax
{
get => _ratingMax;
set
{
if (value is null || !SetProperty(ref _ratingMax, value)) return;
OnPropertyChanged(nameof(Summary));
_changed();
}
}
public bool ShowInList
{
get => _showInList;
set
{
if (!SetProperty(ref _showInList, value)) return;
OnPropertyChanged(nameof(Summary));
_changed();
}
}
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
/// <summary>Résumé de la ligne repliée : type, obligatoire, choix, aperçu.</summary>
public string Summary
{
get
{
var parts = new List<string> { KindText };
if (_required) parts.Add("obligatoire");
if (HasOptions) parts.Add($"{Options.Count} choix");
if (IsRating) parts.Add(_ratingMax.Label);
if (_showInList && CanShowInList) parts.Add("aperçu dans la liste");
if (IsNew) parts.Add("nouveau");
return string.Join(" · ", parts);
}
}
public ObservableCollection<FeedbackOptionEditor> Options { get; } = new();
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
public RelayCommand ToggleCommand { get; }
public RelayCommand AddOptionCommand { get; }
/// <summary>Champ tel qu'il sera publié (espaces en trop retirés ; le reste est contrôlé par FeedbackFormRules).</summary>
public FeedbackField Build() => new(
Id,
ApiText.CleanLine(_label, FeedbackFormRules.MaxFieldLabelLength) ?? _label,
Kind,
_required,
string.IsNullOrWhiteSpace(_hint) ? null : FeedbackRules.CleanMultiline(_hint, FeedbackFormRules.MaxHintLength, out _) ?? _hint,
HasMaxLength ? _maxLength : FeedbackRules.MaxMultilineLength,
HasOptions ? Options.Select(o => o.Build()).ToList() : null,
IsRating && int.TryParse(_ratingMax.Value, out var max) ? max : 5,
_showInList && CanShowInList);
private void AddOption()
{
var id = NextOptionId();
Options.Add(NewOption(id, $"Choix {Options.Count + 1}"));
AddOptionCommand.RaiseCanExecuteChanged();
}
private string NextOptionId()
{
string id;
do id = $"choix_{++_optionCounter}";
while (Options.Any(o => o.Id == id));
return id;
}
private FeedbackOptionEditor NewOption(string id, string label) => new(id, label, _changed, MoveOption, option =>
{
Options.Remove(option);
AddOptionCommand.RaiseCanExecuteChanged();
});
private void MoveOption(FeedbackOptionEditor option, int delta)
{
var index = Options.IndexOf(option);
var target = index + delta;
if (index < 0 || target < 0 || target >= Options.Count) return;
Options.Move(index, target);
}
public static string KindLabel(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "Zone de texte",
FeedbackFieldKind.Text => "Texte court",
FeedbackFieldKind.Choice => "Liste déroulante",
FeedbackFieldKind.Checkboxes => "Cases à cocher",
FeedbackFieldKind.YesNo => "Case oui/non",
FeedbackFieldKind.Rating => "Étoiles",
_ => kind.ToString()
};
public static string KindIcon(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "TextBoxOutline",
FeedbackFieldKind.Text => "Minus",
FeedbackFieldKind.Choice => "ChevronRight",
FeedbackFieldKind.Checkboxes => "CheckCircle",
FeedbackFieldKind.YesNo => "Check",
FeedbackFieldKind.Rating => "Star",
_ => "Info"
};
}
/// <summary>Actions d'un type de demande qui touchent la liste (ordre, suppression) ou demandent un fichier (icône).</summary>
public sealed record FeedbackFormEditorActions(
Action<FeedbackFormEditor, int> Move,
Action<FeedbackFormEditor> Remove,
Action<FeedbackFormEditor> ImportIcon);
/// <summary>
/// Type de demande en cours de modification (ajouté, supprimé, réordonné, masqué) : présentation (nom, description,
/// icône d'origine ou importée, captures d'écran autorisées…) et champs.
/// </summary>
public sealed class FeedbackFormEditor : ObservableObject
{
private readonly Action _changed;
private readonly HashSet<string> _publishedIds;
private string _label;
private string _description;
private Choice _icon;
private string _titleHint;
private bool _techByDefault;
private bool _hidden;
private bool _allowAttachments;
private string? _customIcon;
private bool _isDirty;
private int _fieldCounter;
/// <param name="isNew">Type ajouté depuis la dernière publication.</param>
public FeedbackFormEditor(FeedbackForm form, Action changed, FeedbackFormEditorActions? actions = null, bool isNew = false)
{
Kind = form.Kind;
IsNew = isNew;
_changed = changed;
_label = form.Label;
_description = form.Description;
Icons = FeedbackFormRules.Icons.Select(i => new Choice(i, FeedbackIcons.DefaultIconLabel(i))).ToList();
_icon = Icons.FirstOrDefault(i => i.Value == form.IconKey) ?? Icons[^1];
_titleHint = form.TitleHint;
_techByDefault = form.TechByDefault;
_hidden = form.Hidden;
_allowAttachments = form.AllowAttachments;
_customIcon = string.IsNullOrEmpty(form.CustomIcon) ? null : form.CustomIcon;
MoveUpCommand = new RelayCommand(() => actions?.Move(this, -1));
MoveDownCommand = new RelayCommand(() => actions?.Move(this, 1));
RemoveCommand = new RelayCommand(() => actions?.Remove(this));
ImportIconCommand = new RelayCommand(() => actions?.ImportIcon(this));
RemoveIconCommand = new RelayCommand(() => SetCustomIcon(null), () => _customIcon is not null);
_publishedIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
foreach (var field in form.Fields) Fields.Add(NewField(field, isNew: false));
Fields.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(FieldsText));
AddFieldsEnabled = Fields.Count < FeedbackFormRules.MaxFields;
_changed();
};
FieldKinds = Enum.GetValues<FeedbackFieldKind>().Select(k => new FeedbackFieldKindChoice(k, AddField)).ToList();
}
public string Kind { get; }
/// <summary>Type ajouté depuis la dernière publication.</summary>
public bool IsNew { get; }
/// <summary>Repère fixe dans la liste, même renommé : nom d'origine, ou « Type ajouté ».</summary>
public string OriginalLabel => FeedbackForms.Find(Kind)?.Label ?? (IsNew ? "Type ajouté" : $"Type « {Kind} »");
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
public RelayCommand ImportIconCommand { get; }
public RelayCommand RemoveIconCommand { get; }
/// <summary>Icône importée (PNG 64 × 64 en base64, issu d'un fichier .ico) ; null : icône d'origine choisie.</summary>
public string? CustomIcon => _customIcon;
public byte[]? CustomIconBytes => FeedbackIcons.Decode(_customIcon);
public bool HasCustomIcon => _customIcon is not null;
/// <summary>Icône d'origine utilisée (liste des quatre icônes active).</summary>
public bool UsesDefaultIcon => _customIcon is null;
public void SetCustomIcon(string? base64)
{
if (_customIcon == base64) return;
_customIcon = base64;
foreach (var name in new[] { nameof(CustomIcon), nameof(CustomIconBytes), nameof(HasCustomIcon), nameof(UsesDefaultIcon) }) OnPropertyChanged(name);
RemoveIconCommand.RaiseCanExecuteChanged();
_changed();
}
/// <summary>Captures d'écran proposées aux joueurs pour ce type (3 au plus, contrôlées et nettoyées).</summary>
public bool AllowAttachments
{
get => _allowAttachments;
set
{
if (SetProperty(ref _allowAttachments, value)) _changed();
}
}
public string Label
{
get => _label;
set
{
if (!SetProperty(ref _label, value ?? "")) return;
OnPropertyChanged(nameof(Title));
_changed();
}
}
public string Title => string.IsNullOrWhiteSpace(_label) ? OriginalLabel : _label;
public string Description
{
get => _description;
set
{
if (SetProperty(ref _description, value ?? "")) _changed();
}
}
public IReadOnlyList<Choice> Icons { get; }
public Choice Icon
{
get => _icon;
set
{
if (value is null || !SetProperty(ref _icon, value)) return;
OnPropertyChanged(nameof(IconKey));
_changed();
}
}
public string IconKey => _icon.Value ?? "Forum";
public string TitleHint
{
get => _titleHint;
set
{
if (SetProperty(ref _titleHint, value ?? "")) _changed();
}
}
public bool TechByDefault
{
get => _techByDefault;
set
{
if (SetProperty(ref _techByDefault, value)) _changed();
}
}
/// <summary>Type masqué : plus proposé aux joueurs (les demandes déjà reçues restent consultables).</summary>
public bool Hidden
{
get => _hidden;
set
{
if (!SetProperty(ref _hidden, value)) return;
OnPropertyChanged(nameof(StateText));
_changed();
}
}
/// <summary>Différent des formulaires publiés (calculé par la page).</summary>
public bool IsDirty
{
get => _isDirty;
set
{
if (SetProperty(ref _isDirty, value)) OnPropertyChanged(nameof(StateText));
}
}
public string StateText => (_hidden ? "Masqué" : "Proposé aux joueurs") + (IsNew ? " · nouveau" : _isDirty ? " · modifié" : "");
public string FieldsText => Fields.Count switch
{
0 => "Aucun champ (titre seulement)",
1 => "1 champ",
var n => $"{n} champs"
};
public ObservableCollection<FeedbackFieldEditor> Fields { get; } = new();
/// <summary>Types proposés par « Ajouter un champ ».</summary>
public IReadOnlyList<FeedbackFieldKindChoice> FieldKinds { get; }
private bool _addFieldsEnabled = true;
public bool AddFieldsEnabled
{
get => _addFieldsEnabled;
private set => SetProperty(ref _addFieldsEnabled, value);
}
/// <summary>Formulaire tel qu'il sera publié.</summary>
public FeedbackForm Build() => new(
Kind,
ApiText.CleanLine(_label, FeedbackFormRules.MaxFormLabelLength) ?? _label,
ApiText.CleanLine(_description, FeedbackFormRules.MaxDescriptionLength) ?? _description,
IconKey,
ApiText.CleanLine(_titleHint, FeedbackFormRules.MaxTitleHintLength) ?? _titleHint,
_techByDefault,
Fields.Select(f => f.Build()).ToList(),
_hidden,
_allowAttachments,
_customIcon);
/// <summary>Nouveau champ (identifiant généré, jamais réutilisé dans ce type), déplié et placé à la fin.</summary>
private void AddField(FeedbackFieldKind kind)
{
if (Fields.Count >= FeedbackFormRules.MaxFields) return;
var prefix = kind switch
{
FeedbackFieldKind.Multiline => "texte",
FeedbackFieldKind.Text => "ligne",
FeedbackFieldKind.Choice => "liste",
FeedbackFieldKind.Checkboxes => "cases",
FeedbackFieldKind.YesNo => "oui_non",
_ => "note"
};
string id;
do id = $"{prefix}_{++_fieldCounter}";
while (Fields.Any(f => f.Id == id) || _publishedIds.Contains(id));
var field = new FeedbackField(id, FeedbackFieldEditor.KindLabel(kind), kind, Required: false,
MaxLength: kind == FeedbackFieldKind.Text ? FeedbackFormRules.DefaultTextLength : FeedbackRules.MaxMultilineLength,
Options: kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes
? new[] { new FeedbackOption("choix_1", "Choix 1"), new FeedbackOption("choix_2", "Choix 2") }
: null);
Fields.Add(NewField(field, isNew: true));
}
private FeedbackFieldEditor NewField(FeedbackField field, bool isNew) =>
new(field, isNew, _changed, MoveField, f => Fields.Remove(f));
private void MoveField(FeedbackFieldEditor field, int delta)
{
var index = Fields.IndexOf(field);
var target = index + delta;
if (index < 0 || target < 0 || target >= Fields.Count) return;
Fields.Move(index, target);
}
}
@@ -0,0 +1,422 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Feedback;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>
/// Page « Formulaires des demandes » (permission feedback.forms) : les types de demande du bouton « Aide » (1 à 8,
/// communs à tous les jeux, dans l'ordre d'affichage aux joueurs) et leurs champs.
/// • à gauche, les types (ajout, ordre, suppression ; proposé / masqué, modifié) ; au centre, la présentation du type
/// (nom, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées…), ses champs
/// (ajout, ordre, retrait, libellé, aide, obligatoire, longueur, étoiles, choix) ; à droite, l'aperçu tel que les
/// joueurs le verront ;
/// • contrôle en direct avec les règles des formulaires (FeedbackFormRules : celles du serveur et des launchers) :
/// aucun lien dans les textes, limites ; avertissement (sans refus) si un libellé semble demander des données
/// personnelles — les demandes sont anonymes ;
/// • « Publier » : avertissement si la clé de ce PC n'a pas l'autorisation « formulaires » dans le trousseau publié,
/// formulaires signés par la clé de ce PC, requête signée, révision attendue (409 si quelqu'un a publié entre-temps :
/// rien n'est écrasé) ; « Annuler les modifications », « Revenir aux formulaires d'origine ».
/// </summary>
public sealed class FeedbackFormsPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "feedback.forms";
private FeedbackFormsState? _state;
private string _publishedJson = "";
private Dictionary<string, string> _publishedForms = new();
private FeedbackFormEditor? _selectedForm;
private string? _statusMessage;
private bool _hasConflict;
private bool _isDirty;
private bool _suspendChanges;
private IReadOnlyList<string> _validationErrors = Array.Empty<string>();
private IReadOnlyList<string> _warnings = Array.Empty<string>();
public FeedbackFormsPanelViewModel(IAdminContext context) : base(context)
{
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync),
() => !IsBusy && _isDirty && !_hasConflict && _validationErrors.Count == 0 && _state is { PublishingEnabled: true });
DiscardCommand = new RelayCommand(Discard, () => !IsBusy && _isDirty);
RestoreOriginalCommand = new RelayCommand(RestoreOriginal, () => !IsBusy && _state is not null);
AddFormCommand = new RelayCommand(AddForm, () => !IsBusy && _state is not null && Forms.Count < FeedbackFormRules.MaxForms);
Forms.CollectionChanged += (_, _) =>
{
AddFormCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(FormsText));
OnEdited();
};
}
/// <summary>Ajouter un type de demande (8 au plus).</summary>
public RelayCommand AddFormCommand { get; }
public string FormsText => $"Types de demande · {Forms.Count} / {FeedbackFormRules.MaxForms}";
public override bool HasSplitLayout => Forms.Count > 0;
public ObservableCollection<FeedbackFormEditor> Forms { get; } = new();
public FeedbackFormEditor? SelectedForm
{
get => _selectedForm;
set
{
if (value is null || !SetProperty(ref _selectedForm, value)) return;
RefreshPreview();
}
}
public AsyncRelayCommand ReloadCommand { get; }
public AsyncRelayCommand PublishCommand { get; }
public RelayCommand DiscardCommand { get; }
public RelayCommand RestoreOriginalCommand { get; }
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
public bool HasConflict
{
get => _hasConflict;
private set
{
if (SetProperty(ref _hasConflict, value)) PublishCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Modifications non publiées.</summary>
public bool IsDirty => _isDirty;
public bool IsEditable => !IsBusy;
/// <summary>Publication non configurée sur le serveur (pas de fichier public du trousseau).</summary>
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
public string PublishedText
{
get
{
if (_state is null) return "";
var document = _state.Document;
if (document.Revision == 0) return "Formulaires d'origine : rien n'a encore été publié depuis ce menu.";
var when = document.UpdatedAt?.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
return $"Révision {document.Revision} publiée" + (when is null ? "" : $" le {when}") +
(_state.UpdatedBy is null ? "" : $" par {_state.UpdatedBy}") + ".";
}
}
public string? PublicUrl => _state?.PublicUrl;
// ------------------------------------------------------------------ Contrôles
/// <summary>Erreurs qui empêchent la publication (règles des formulaires, celles du serveur et des launchers).</summary>
public IReadOnlyList<string> ValidationErrors
{
get => _validationErrors;
private set
{
_validationErrors = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasValidationErrors));
PublishCommand.RaiseCanExecuteChanged();
}
}
public bool HasValidationErrors => _validationErrors.Count > 0;
/// <summary>Avertissements (sans refus) : libellé qui semble demander des données personnelles, type masqué…</summary>
public IReadOnlyList<string> Warnings
{
get => _warnings;
private set
{
_warnings = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasWarnings));
}
}
public bool HasWarnings => _warnings.Count > 0;
// ------------------------------------------------------------------ Aperçu (mêmes champs que la fenêtre « Aide »)
/// <summary>Champs du type sélectionné tels que les joueurs les verront (saisie possible, rien n'est envoyé).</summary>
public ObservableCollection<FeedbackFieldViewModel> Preview { get; } = new();
public string PreviewTitle => _selectedForm is null ? "" : $"Aperçu · {_selectedForm.Title}";
public string PreviewTitleHint => _selectedForm?.TitleHint ?? "";
public bool PreviewTechByDefault => _selectedForm?.TechByDefault ?? false;
public string PreviewNote => _selectedForm switch
{
{ Hidden: true } => "Ce type est masqué : les joueurs ne le voient pas.",
{ AllowAttachments: true } => "Les joueurs voient aussi les captures d'écran (3 au plus) et les informations techniques.",
_ => "Captures d'écran non proposées pour ce type. Les joueurs voient aussi les informations techniques."
};
// ------------------------------------------------------------------ Chargement
protected override async Task OnActivatedAsync(bool firstTime)
{
if (firstTime) await ReloadAsync();
}
private Task ReloadAsync()
{
if (_isDirty && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les formulaires modifiés n'ont pas été publiés : les modifications seront perdues.", "Abandonner"))
return Task.CompletedTask;
return RunAsync(async () =>
{
StatusMessage = null;
Apply(await Context.Session.GetFeedbackFormsAsync());
});
}
/// <summary>Formulaires en vigueur sur le serveur : éditeurs reconstruits, rien de modifié.</summary>
private void Apply(FeedbackFormsState state)
{
_state = state;
HasConflict = false;
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, Snapshot, StringComparer.Ordinal);
_publishedJson = Snapshot(state.Document.Forms);
LoadEditors(state.Document.Forms);
foreach (var name in new[] { nameof(ShowPublishingDisabled), nameof(PublishedText), nameof(PublicUrl) }) OnPropertyChanged(name);
RestoreOriginalCommand.RaiseCanExecuteChanged();
}
private void LoadEditors(IEnumerable<FeedbackForm> forms)
{
var selectedKind = _selectedForm?.Kind;
_suspendChanges = true;
try
{
Forms.Clear();
// Ordre du document : celui affiché aux joueurs.
foreach (var form in forms)
Forms.Add(new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: !_publishedForms.ContainsKey(form.Kind)));
}
finally
{
_suspendChanges = false;
}
_selectedForm = Forms.FirstOrDefault(f => f.Kind == selectedKind) ?? Forms.FirstOrDefault();
OnPropertyChanged(nameof(SelectedForm));
SplitLayoutChanged();
OnEdited();
}
// ------------------------------------------------------------------ Modifications
/// <summary>Une valeur a changé : état « modifié », contrôles et aperçu recalculés.</summary>
private void OnEdited()
{
if (_suspendChanges) return;
OnPropertyChanged(nameof(PreviewNote));
var forms = Forms.Select(f => f.Build()).ToList();
foreach (var (editor, form) in Forms.Zip(forms))
editor.IsDirty = !_publishedForms.TryGetValue(form.Kind, out var published) || published != Snapshot(form);
SetDirty(_state is not null && Snapshot(forms) != _publishedJson);
var errors = new List<string>();
FeedbackFormRules.Validate(new FeedbackFormsDocument { Revision = (_state?.Document.Revision ?? 0) + 1, Forms = forms }, errors);
ValidationErrors = errors.Distinct().ToList();
Warnings = WarningsOf(forms);
RefreshPreview();
}
private void SetDirty(bool value)
{
if (_isDirty == value) return;
_isDirty = value;
OnPropertyChanged(nameof(IsDirty));
PublishCommand.RaiseCanExecuteChanged();
DiscardCommand.RaiseCanExecuteChanged();
}
/// <summary>Avertissements sans refus : données personnelles demandées (les demandes sont anonymes), tout masqué sauf un…</summary>
private static IReadOnlyList<string> WarningsOf(IReadOnlyList<FeedbackForm> forms)
{
var warnings = new List<string>();
foreach (var form in forms)
{
var texts = new[] { form.Label, form.Description, form.TitleHint }
.Concat(form.Fields.SelectMany(f => new[] { f.Label, f.Hint ?? "" }.Concat(f.Choices.Select(o => o.Label))));
var words = FeedbackFormRules.PersonalDataWords
.Where(w => texts.Any(t => t.Contains(w, StringComparison.OrdinalIgnoreCase))).ToList();
if (words.Count > 0)
warnings.Add($"« {form.Label} » mentionne « {string.Join(" », « ", words)} » : les demandes sont anonymes, ne demandez " +
"aucune donnée personnelle (adresse e-mail, mot de passe, identifiant de compte, téléphone…).");
if (!form.Hidden && form.Fields.Count == 0)
warnings.Add($"« {form.Label} » n'a aucun champ : les joueurs n'écriront qu'un titre.");
}
return warnings;
}
private void RefreshPreview()
{
Preview.Clear();
if (_selectedForm is not null)
foreach (var field in _selectedForm.Build().Fields) Preview.Add(new FeedbackFieldViewModel(field));
foreach (var name in new[] { nameof(PreviewTitle), nameof(PreviewTitleHint), nameof(PreviewTechByDefault), nameof(PreviewNote) })
OnPropertyChanged(name);
}
private void Discard()
{
if (_state is null) return;
if (!Context.Dialogs.Confirm("Annuler les modifications ?",
"Les formulaires reviennent à ceux actuellement publiés. Les modifications non publiées seront perdues.", "Annuler les modifications"))
return;
StatusMessage = null;
LoadEditors(_state.Document.Forms);
}
/// <summary>Formulaires d'origine dans l'éditeur (à publier ensuite) : les quatre types, leurs champs d'origine.</summary>
private void RestoreOriginal()
{
if (!Context.Dialogs.Confirm("Revenir aux formulaires d'origine ?",
"Les types de demande redeviennent les quatre d'origine (avis, demande d'évolution, bug, autre), avec leurs noms, icônes " +
"et champs d'origine ; les types ajoutés disparaissent. " +
"Rien n'est envoyé avant « Publier » ; les demandes déjà reçues ne changent pas.", "Revenir à l'origine"))
return;
StatusMessage = null;
LoadEditors(FeedbackForms.All);
}
// ------------------------------------------------------------------ Types de demande (ajout, ordre, suppression, icône)
private FeedbackFormEditorActions EditorActions => new(MoveForm, RemoveForm, ImportIcon);
/// <summary>
/// Nouveau type de demande (identifiant généré, jamais celui d'un type publié ou d'origine), avec un champ « Message »
/// obligatoire ; placé à la fin et sélectionné.
/// </summary>
private void AddForm()
{
if (Forms.Count >= FeedbackFormRules.MaxForms) return;
var taken = Forms.Select(f => f.Kind).Concat(_publishedForms.Keys).Concat(FeedbackForms.All.Select(f => f.Kind))
.ToHashSet(StringComparer.Ordinal);
var n = 1;
while (taken.Contains($"type_{n}")) n++;
var form = new FeedbackForm($"type_{n}", "Nouveau type", "Décrivez votre demande.", "Forum", "", TechByDefault: false,
new[] { new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true) });
var editor = new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: true);
Forms.Add(editor);
SelectedForm = editor;
}
private void MoveForm(FeedbackFormEditor form, int delta)
{
var index = Forms.IndexOf(form);
var target = index + delta;
if (index < 0 || target < 0 || target >= Forms.Count) return;
Forms.Move(index, target);
}
private void RemoveForm(FeedbackFormEditor form)
{
if (Forms.Count <= 1) return;
if (!Context.Dialogs.Confirm("Supprimer ce type de demande ?",
$"« {form.Title} » ne sera plus proposé aux joueurs après la publication. Les demandes déjà reçues de ce type restent " +
"consultables (avec leur formulaire d'alors). Pour le retirer seulement un temps, masquez-le plutôt.",
"Supprimer", danger: true))
return;
var index = Forms.IndexOf(form);
Forms.Remove(form);
if (ReferenceEquals(_selectedForm, form)) SelectedForm = Forms[Math.Min(index, Forms.Count - 1)];
}
/// <summary>
/// Icône importée : fichier .ico OBLIGATOIREMENT, contenant une image de 64 × 64 pixels, convertie en PNG neuf sans
/// métadonnées (<see cref="FeedbackIcons.FromIco"/>) ; tout autre fichier est refusé avec la raison.
/// </summary>
private void ImportIcon(FeedbackFormEditor form)
{
var path = Context.Dialogs.PickOpenFile($"Icône du type « {form.Title} » : fichier .ico contenant une image de {FeedbackIcons.Size} × {FeedbackIcons.Size} pixels",
"Icône Windows (*.ico)|*.ico", null);
if (path is null) return;
string? error;
byte[]? png = null;
try
{
if (!string.Equals(Path.GetExtension(path), ".ico", StringComparison.OrdinalIgnoreCase))
error = "Seuls les fichiers .ico sont acceptés.";
else if (new FileInfo(path).Length > FeedbackIcons.MaxIcoBytes)
error = $"Fichier trop volumineux (au plus {FeedbackIcons.MaxIcoBytes / 1024} Ko).";
else
error = FeedbackIcons.FromIco(File.ReadAllBytes(path), out png);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
error = $"Lecture du fichier impossible : {ex.Message}";
}
if (error is not null)
{
Context.Dialogs.ShowMessage("Icône refusée", error, ModuleMessageKind.Error);
return;
}
form.SetCustomIcon(Convert.ToBase64String(png!));
}
// ------------------------------------------------------------------ Publication
private async Task PublishAsync()
{
if (_state is null || _validationErrors.Count > 0) return;
var forms = Forms.Select(f => f.Build()).ToList();
var hidden = forms.Where(f => f.Hidden).Select(f => $"« {f.Label} »").ToList();
var message = "Les formulaires seront signés avec la clé de ce PC puis publiés : les launchers des joueurs les utiliseront " +
"à la prochaine ouverture de la fenêtre « Aide ». Les demandes déjà reçues gardent leur formulaire d'origine." +
(hidden.Count > 0 ? $"\n\nTypes masqués (plus proposés aux joueurs) : {string.Join(", ", hidden)}." : "");
if (!Context.Dialogs.Confirm("Publier les formulaires ?", message, "Publier")) return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.FeedbackForms)) return;
await RunAsync(async () =>
{
StatusMessage = null;
try
{
var state = await Context.Session.PublishFeedbackFormsAsync(forms, _state.Document.Revision);
Apply(state);
StatusMessage = $"Formulaires publiés (révision {state.Document.Revision}) : les joueurs les verront à la prochaine " +
"ouverture de la fenêtre « Aide ».";
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
throw;
}
});
}
protected override void OnBusyChanged()
{
ReloadCommand.RaiseCanExecuteChanged();
PublishCommand.RaiseCanExecuteChanged();
DiscardCommand.RaiseCanExecuteChanged();
RestoreOriginalCommand.RaiseCanExecuteChanged();
AddFormCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(IsEditable));
}
private static string Snapshot(object value) => JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -0,0 +1,341 @@
using System.Globalization;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>Tuile du choix du jeu, avec les compteurs de demandes (même modèle d'affichage que <see cref="ViewModels.GameTile"/>).</summary>
public sealed class FeedbackGameTile : ObservableObject
{
private string _detail;
private int _newCount;
public FeedbackGameTile(Choice choice, string name, Action<Choice> select)
{
Choice = choice;
Name = name;
_detail = $"Identifiant {choice.Value}";
SelectCommand = new RelayCommand(() => select(choice));
}
public Choice Choice { get; }
public string Name { get; }
/// <summary>« 3 nouvelles · 5 à traiter · 12 au total ».</summary>
public string Detail
{
get => _detail;
private set => SetProperty(ref _detail, value);
}
public int NewCount
{
get => _newCount;
private set => SetProperty(ref _newCount, value);
}
public RelayCommand SelectCommand { get; }
public void Apply(FeedbackGameCounts? counts)
{
NewCount = counts?.New ?? 0;
Detail = counts is null
? $"Identifiant {Choice.Value} · ne reçoit pas de demandes"
: counts.Total == 0
? "Aucune demande"
: $"{Plural(counts.New, "nouvelle", "nouvelles")} · {counts.Open} à traiter · {counts.Total} au total";
}
internal static string Plural(int count, string one, string many) => $"{count} {(count > 1 ? many : one)}";
}
/// <summary>Demande dans la liste (résumé).</summary>
public sealed class FeedbackItem : ObservableObject
{
private FeedbackSummary _summary;
public FeedbackItem(FeedbackSummary summary) => _summary = summary;
public FeedbackSummary Summary
{
get => _summary;
set
{
_summary = value;
OnPropertyChanged(string.Empty); // toutes les propriétés
}
}
public string Id => _summary.Id;
public string Reference => _summary.Reference;
public string Title => _summary.Title;
public string Kind => _summary.Kind;
/// <summary>Formulaire publié du type (nom, icône) ; null : formulaires d'origine.</summary>
public FeedbackForm? Form
{
get => _form;
set
{
_form = value;
foreach (var name in new[] { nameof(KindLabel), nameof(KindIconKey), nameof(KindCustomIcon), nameof(HasKindCustomIcon), nameof(DetailText) })
OnPropertyChanged(name);
}
}
private FeedbackForm? _form;
public string KindLabel => _form?.Label ?? FeedbackForms.KindLabel(_summary.Kind);
public string KindIconKey => _form?.IconKey ?? FeedbackForms.Find(_summary.Kind)?.IconKey ?? "Forum";
/// <summary>Icône importée du type (PNG 64 × 64) ; null : icône d'origine.</summary>
public byte[]? KindCustomIcon => _form?.CustomIconBytes();
public bool HasKindCustomIcon => KindCustomIcon is not null;
public string Status => _summary.Status;
public string StatusLabel => FeedbackStatuses.Label(_summary.Status);
public bool IsNew => _summary.Status == FeedbackStatuses.New;
public bool IsClosed => FeedbackStatuses.IsClosed(_summary.Status);
public string? Badge => _summary.Badge;
public bool HasBadge => !string.IsNullOrEmpty(_summary.Badge);
public bool HasNote => _summary.HasNote;
/// <summary>Contient un lien ou une adresse : icône d'avertissement dans la liste.</summary>
public bool HasLinks => _summary.HasLinks;
public bool HasAttachments => _summary.AttachmentCount > 0;
public string AttachmentsToolTip => $"{_summary.AttachmentCount} capture(s) d'écran";
/// <summary>« 08/11/2026 21:14 » (heure du PC).</summary>
public string DateText => _summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
/// <summary>« Bug · FB-7K3Q-9P2M · 08/11/2026 21:14 ».</summary>
public string DetailText => $"{KindLabel} · {Reference} · {DateText}";
public bool Matches(string search) =>
string.IsNullOrWhiteSpace(search) ||
Title.Contains(search.Trim(), StringComparison.CurrentCultureIgnoreCase) ||
Reference.Contains(search.Trim(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Champ d'une demande, tel qu'affiché dans la fiche (libellé du formulaire, valeur en texte brut).</summary>
/// <param name="HasLink">Le texte contient un lien ou une adresse (cadre d'avertissement).</param>
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong, bool HasLink = false);
/// <summary>
/// Capture d'écran d'une demande : chargée après la fiche, REVÉRIFIÉE (<see cref="FeedbackImages.Sanitize"/> : vraie
/// image PNG ou JPEG, sans métadonnées) avant d'être affichée, réduite pour l'aperçu, agrandie à la demande.
/// </summary>
public sealed class FeedbackAttachmentView : ObservableObject
{
private byte[]? _content;
private string? _error;
private bool _isExpanded;
public FeedbackAttachmentView(FeedbackAttachmentInfo info)
{
Info = info;
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded, () => HasContent);
}
public FeedbackAttachmentInfo Info { get; }
/// <summary>« Capture 1 · 1920 × 1080 · PNG · 845 Ko ».</summary>
public string Label => $"Capture {Info.Index} · {Info.Width} × {Info.Height} · {Info.Format.ToUpperInvariant()} · {Math.Max(1, Info.Bytes / 1024)} Ko";
public byte[]? Content
{
get => _content;
private set
{
if (!SetProperty(ref _content, value)) return;
OnPropertyChanged(nameof(HasContent));
OnPropertyChanged(nameof(IsLoading));
ToggleCommand.RaiseCanExecuteChanged();
}
}
public bool HasContent => _content is not null;
public string? Error
{
get => _error;
private set
{
if (!SetProperty(ref _error, value)) return;
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(IsLoading));
}
}
public bool HasError => !string.IsNullOrEmpty(_error);
public bool IsLoading => _content is null && _error is null;
public bool IsExpanded
{
get => _isExpanded;
set
{
if (SetProperty(ref _isExpanded, value)) OnPropertyChanged(nameof(ToggleText));
}
}
public string ToggleText => _isExpanded ? "Réduire" : "Agrandir";
public RelayCommand ToggleCommand { get; }
/// <summary>Contenu reçu du serveur : affiché seulement s'il passe le contrôle commun.</summary>
public void Load(byte[] received)
{
var error = FeedbackImages.Sanitize(received, out var info, out var clean);
if (error is not null || info is null) Error = $"Image refusée : {error}";
else if (info.Width != Info.Width || info.Height != Info.Height) Error = "Image refusée : dimensions différentes de celles annoncées.";
else Content = clean;
}
public void Fail(string message) => Error = message;
}
/// <summary>Statut proposé dans la fiche.</summary>
public sealed record FeedbackStatusChoice(string Value, string Label);
/// <summary>
/// Fiche d'une demande : tous les champs (texte brut, jamais interprété : il vient d'un inconnu), informations
/// techniques, statut et note interne modifiables (feedback.manage), suppression.
/// </summary>
public sealed class FeedbackDetailView : ObservableObject
{
private readonly Action _changed;
private FeedbackDetail _detail;
private FeedbackStatusChoice _status;
private string _note;
public FeedbackDetailView(FeedbackDetail detail, bool canManage, Action changed, Action<string>? copy = null)
{
_detail = detail;
_changed = changed;
CanManage = canManage;
CopyReferenceCommand = new RelayCommand(() => copy?.Invoke(detail.Summary.Reference));
_status = AllStatuses.FirstOrDefault(s => s.Value == detail.Summary.Status) ?? AllStatuses[0];
_note = detail.AdminNote ?? "";
Lines = BuildLines(detail);
Attachments = detail.Attachments.Select(a => new FeedbackAttachmentView(a)).ToList();
var text = detail.Summary.Title + "\n" + string.Join("\n", detail.Fields.Values);
Links = FeedbackLinks.Find(text, out var total).Select(FeedbackLinks.Defang).ToList();
LinkCount = total;
}
// ------------------------------------------------------------------ Liens (avertissement)
/// <summary>Liens et adresses trouvés, sous forme désamorcée (« hxxps[://]exemple[.]com »).</summary>
public IReadOnlyList<string> Links { get; }
public int LinkCount { get; }
public bool HasLinks => LinkCount > 0;
public string LinksWarning => LinkCount == 0 ? "" :
$"Cette demande contient {(LinkCount == 1 ? "un lien ou une adresse" : $"{LinkCount} liens ou adresses")}. Elle vient d'un " +
"inconnu : n'ouvrez pas ces liens et ne les copiez pas dans un navigateur, ils peuvent mener à un site d'hameçonnage ou " +
"à un fichier malveillant. En cas de doute, supprimez la demande.";
public string LinksText => string.Join("\n", Links.Select(l => "• " + l)) +
(LinkCount > Links.Count ? $"\n… et {LinkCount - Links.Count} autre(s)" : "");
// ------------------------------------------------------------------ Captures
public IReadOnlyList<FeedbackAttachmentView> Attachments { get; }
public bool HasAttachments => Attachments.Count > 0;
private static readonly IReadOnlyList<FeedbackStatusChoice> AllStatuses =
FeedbackStatuses.All.Select(s => new FeedbackStatusChoice(s, FeedbackStatuses.Label(s))).ToList();
/// <summary>Statuts proposés (propriété d'instance : liaison WPF).</summary>
public IReadOnlyList<FeedbackStatusChoice> Statuses => AllStatuses;
/// <summary>Copie la référence dans le presse-papiers.</summary>
public RelayCommand CopyReferenceCommand { get; }
public FeedbackDetail Detail => _detail;
public string Id => _detail.Summary.Id;
public long Revision => _detail.Summary.Revision;
public bool CanManage { get; }
public string Title => _detail.Summary.Title;
public string Reference => _detail.Summary.Reference;
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés depuis le menu), sinon nom d'origine.</summary>
public string KindLabel => _detail.Form?.Label ?? FeedbackForms.KindLabel(_detail.Summary.Kind);
public string KindIconKey => _detail.Form?.IconKey ?? FeedbackForms.Find(_detail.Summary.Kind)?.IconKey ?? "Forum";
/// <summary>Icône importée du type tel qu'à l'envoi ; null : icône d'origine.</summary>
public byte[]? KindCustomIcon => _detail.Form?.CustomIconBytes();
public bool HasKindCustomIcon => KindCustomIcon is not null;
public string ReceivedText =>
$"Reçue le {_detail.Summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}";
public string? StatusChangedText => _detail.Summary.StatusChangedAt is { } at
? $"Dernière modification le {at.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}" +
(string.IsNullOrEmpty(_detail.StatusChangedBy) ? "" : $" par {_detail.StatusChangedBy}")
: null;
public IReadOnlyList<FeedbackFieldLine> Lines { get; }
public bool HasTech => _detail.Tech is not null;
public string TechText => _detail.Tech is not { } tech ? "Non jointes par le joueur." : string.Join("\n", new[]
{
tech.LauncherVersion is { } l ? $"Launcher : {l}" : null,
$"Jeu : {tech.GameVersion ?? "non installé"}",
tech.Os is { } os ? $"Système : {os}" : null,
tech.Language is { } lang ? $"Langue : {lang}" : null
}.Where(l => l is not null));
public FeedbackStatusChoice Status
{
get => _status;
set
{
if (value is null || !SetProperty(ref _status, value)) return;
OnPropertyChanged(nameof(HasChanges));
_changed();
}
}
public string Note
{
get => _note;
set
{
if (!SetProperty(ref _note, value ?? "")) return;
OnPropertyChanged(nameof(HasChanges));
OnPropertyChanged(nameof(NoteLengthText));
_changed();
}
}
public int NoteMaxLength => FeedbackRules.MaxAdminNoteLength;
public string NoteLengthText => $"{_note.Trim().Length} / {FeedbackRules.MaxAdminNoteLength}";
/// <summary>Statut ou note différents de ceux enregistrés (note comparée telle que le serveur la nettoiera).</summary>
public bool HasChanges =>
_status.Value != _detail.Summary.Status ||
(FeedbackRules.CleanMultiline(_note, int.MaxValue / 4, out _) ?? "") != (_detail.AdminNote ?? "");
/// <summary>Note telle qu'envoyée (vide : null).</summary>
public string? NoteToSend => string.IsNullOrWhiteSpace(_note) ? null : _note;
/// <summary>
/// Champs avec les libellés du formulaire TEL QU'À L'ENVOI (conservé avec la demande), ou ceux d'origine pour une
/// demande reçue avant la 3.2 : une demande reste lisible même si le formulaire a changé depuis.
/// </summary>
private static IReadOnlyList<FeedbackFieldLine> BuildLines(FeedbackDetail detail)
{
var form = detail.Form ?? FeedbackForms.Find(detail.Summary.Kind);
var lines = new List<FeedbackFieldLine>();
foreach (var field in form?.Fields ?? Array.Empty<FeedbackField>())
if (detail.Fields.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value))
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline,
field.IsText && FeedbackLinks.Contains(value)));
// Champ d'une version plus récente du formulaire, inconnu de ce launcher : affiché tel quel.
foreach (var (id, value) in detail.Fields.Where(f => form?.Field(f.Key) is null))
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n'), FeedbackLinks.Contains(value)));
return lines;
}
}
@@ -0,0 +1,478 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>
/// Page « Demandes des joueurs » : avis, demandes d'évolution, bugs et autres demandes envoyés SANS compte depuis le
/// bouton « Aide » du launcher, rangés par jeu.
/// • plusieurs jeux : choix du jeu d'abord (tuiles avec le nombre de nouvelles demandes), rien n'est chargé avant ;
/// • liste filtrable (type, statut, recherche dans le titre ou la référence), pages de 100, les plus récentes d'abord ;
/// • fiche : champs en texte brut, informations techniques, statut et note interne (feedback.manage, requête signée,
/// révision attendue : si un autre administrateur l'a modifiée entre-temps, rien n'est écrasé), suppression ;
/// • chaque consultation est inscrite au journal d'audit par le serveur (le texte libre peut contenir des données
/// personnelles). Pas de réponse au joueur : les demandes sont anonymes (module futur « comptes »).
/// </summary>
public sealed class FeedbackPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "feedback.requests";
private const int PageSize = 100;
private readonly List<FeedbackItem> _loaded = new();
private readonly Dictionary<string, FeedbackGameTile> _tiles;
private FeedbackOverview? _overview;
private Choice? _selectedGame;
private Choice _kind;
private Choice _status;
private string _searchText = "";
private string? _nextCursor;
private int _total;
private FeedbackItem? _selectedItem;
private FeedbackDetailView? _detail;
private string? _statusMessage;
private bool _hasConflict;
private int _loadVersion;
public FeedbackPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.Count == 1 ? GameChoices[0] : null;
_tiles = GameChoices.ToDictionary(c => c.Value!, c => new FeedbackGameTile(c,
context.Games.First(g => g.GameId == c.Value).GameName, choice => SelectedGame = choice), StringComparer.OrdinalIgnoreCase);
GameTiles = _tiles.Values.ToList();
Kinds = new ObservableCollection<Choice>(new[] { new Choice(null, "Tous les types") }
.Concat(FeedbackForms.All.Select(f => new Choice(f.Kind, f.Label))));
Statuses = new[]
{
new Choice("open", "À traiter (nouvelles et en cours)"),
new Choice(FeedbackStatuses.New, "Nouvelles"),
new Choice(FeedbackStatuses.InProgress, "En cours"),
new Choice("closed", "Closes (traitées, rejetées, doublons)"),
new Choice(FeedbackStatuses.Done, "Traitées"),
new Choice(FeedbackStatuses.Rejected, "Rejetées"),
new Choice(FeedbackStatuses.Duplicate, "Doublons"),
new Choice(null, "Toutes")
};
_kind = Kinds[0];
_status = Statuses[0];
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(LoadMoreAsync, () => !IsBusy && _nextCursor is not null);
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && _detail is { CanManage: true, HasChanges: true } && !_hasConflict);
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => !IsBusy && _detail is { CanManage: true });
}
public bool CanManage => Context.Session.Has(AdminPermissions.FeedbackManage);
// ------------------------------------------------------------------ Jeu (modèles AdminGamePickerTemplate / AdminGameSwitcherTemplate)
public IReadOnlyList<Choice> GameChoices { get; }
public IReadOnlyList<FeedbackGameTile> GameTiles { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
public bool ShowGamePicker => _selectedGame is null;
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
public bool HasSelectedGame => _selectedGame is not null;
public bool IsGameSwitchEnabled => !IsBusy;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez lire les demandes : les demandes d'un jeu ne se mélangent jamais à celles d'un autre.";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(ShowGamePicker), nameof(ShowGameSwitcher), nameof(HasSelectedGame) }) OnPropertyChanged(name);
ClearList();
ErrorMessage = null;
StatusMessage = null;
GameLoad = LoadAsync();
}
}
/// <summary>Chargement lancé par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
// ------------------------------------------------------------------ Serveur
/// <summary>Réception désactivée sur le serveur (LauncherApi:Feedback:Enabled).</summary>
public bool ShowDisabled => _overview is { Enabled: false };
/// <summary>Plafond atteint : les nouvelles demandes sont refusées.</summary>
public bool ShowStorageFull => _overview is { StorageFull: true };
public string RetentionText => _overview is null ? "" :
$"Conservation : {_overview.RetentionClosedMonths} mois après la clôture d'une demande, {_overview.RetentionMaxMonths} mois au plus (purge automatique).";
// ------------------------------------------------------------------ Filtres et liste
/// <summary>Types de demande du filtre : ceux publiés depuis le menu (même masqués), puis ceux d'origine absents.</summary>
public ObservableCollection<Choice> Kinds { get; }
/// <summary>Formulaires publiés (noms et icônes des types dans la liste) ; vide : formulaires d'origine.</summary>
private Dictionary<string, FeedbackForm> _publishedForms = new(StringComparer.Ordinal);
private FeedbackForm? FormOf(string kind) =>
_publishedForms.TryGetValue(kind, out var form) ? form : FeedbackForms.Find(kind);
/// <summary>
/// Noms et icônes des types publiés depuis la page « Formulaires des demandes » (lecture permise à tout
/// administrateur : contenu public). En cas d'échec, les noms d'origine restent affichés.
/// </summary>
private async Task LoadKindsAsync()
{
try
{
var state = await Context.Session.GetFeedbackFormsAsync();
if (state.Document.Revision == 0) return;
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, StringComparer.Ordinal);
}
catch (ApiException)
{
return;
}
var selected = _kind.Value;
var choices = new[] { new Choice(null, "Tous les types") }
.Concat(_publishedForms.Values.Select(f => new Choice(f.Kind, f.Hidden ? $"{f.Label} (masqué)" : f.Label)))
.Concat(FeedbackForms.All.Where(f => !_publishedForms.ContainsKey(f.Kind)).Select(f => new Choice(f.Kind, $"{f.Label} (supprimé)")))
.ToList();
Kinds.Clear();
foreach (var choice in choices) Kinds.Add(choice);
_kind = Kinds.FirstOrDefault(k => k.Value == selected) ?? Kinds[0];
OnPropertyChanged(nameof(Kind));
foreach (var item in _loaded) item.Form = FormOf(item.Kind);
}
public IReadOnlyList<Choice> Statuses { get; }
public Choice Kind
{
get => _kind;
set
{
if (value is null || Equals(value, _kind)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged();
return;
}
_kind = value;
OnPropertyChanged();
GameLoad = LoadAsync();
}
}
public Choice Status
{
get => _status;
set
{
if (value is null || Equals(value, _status)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged();
return;
}
_status = value;
OnPropertyChanged();
GameLoad = LoadAsync();
}
}
/// <summary>Recherche locale (titre, référence) parmi les demandes chargées.</summary>
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) ApplySearch();
}
}
public ObservableCollection<FeedbackItem> Items { get; } = new();
public FeedbackItem? SelectedItem
{
get => _selectedItem;
set
{
if (value is null || ReferenceEquals(value, _selectedItem)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged();
return;
}
_selectedItem = value;
OnPropertyChanged();
DetailLoad = LoadDetailAsync(value);
}
}
/// <summary>Chargement de la fiche lancé par la dernière sélection (tests).</summary>
internal Task DetailLoad { get; private set; } = Task.CompletedTask;
public FeedbackDetailView? Detail
{
get => _detail;
private set
{
if (!SetProperty(ref _detail, value)) return;
OnPropertyChanged(nameof(HasDetail));
OnPropertyChanged(nameof(ShowNoSelection));
HasConflict = false;
RaiseCommands();
}
}
public bool HasDetail => _detail is not null;
public bool ShowNoSelection => _detail is null && Items.Count > 0;
public bool HasItems => Items.Count > 0;
public override bool HasSplitLayout => HasItems;
public string? EmptyText => _selectedGame is null || IsBusy || Items.Count > 0 ? null
: _loaded.Count > 0 ? "Aucune demande ne correspond à la recherche."
: "Aucune demande pour ces filtres.";
/// <summary>« 12 demandes · 100 affichées ».</summary>
public string CountText => _selectedGame is null ? "" :
$"{FeedbackGameTile.Plural(_total, "demande", "demandes")}" + (_loaded.Count < _total ? $" · {_loaded.Count} chargées" : "");
public bool CanLoadMore => _nextCursor is not null;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
/// <summary>La demande a été modifiée par un autre administrateur : à recharger avant d'enregistrer.</summary>
public bool HasConflict
{
get => _hasConflict;
private set
{
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
public bool IsEditable => !IsBusy;
public AsyncRelayCommand ReloadCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public AsyncRelayCommand SaveCommand { get; }
public AsyncRelayCommand DeleteCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(EmptyText));
RaiseCommands();
}
private void RaiseCommands()
{
ReloadCommand.RaiseCanExecuteChanged();
LoadMoreCommand.RaiseCanExecuteChanged();
SaveCommand.RaiseCanExecuteChanged();
DeleteCommand.RaiseCanExecuteChanged();
}
protected override async Task OnActivatedAsync(bool firstTime)
{
if (!firstTime) return;
await LoadKindsAsync();
await LoadAsync();
}
// ------------------------------------------------------------------ Chargement
/// <summary>Compteurs (tuiles, bandeaux) puis, si un jeu est choisi, la première page de ses demandes.</summary>
public Task LoadAsync() => RunAsync(async () =>
{
var version = ++_loadVersion;
var overview = await Context.Session.GetFeedbackOverviewAsync();
ApplyOverview(overview);
if (_selectedGame?.Value is not { } gameId) return;
var page = await Context.Session.GetFeedbackPageAsync(gameId, _kind.Value, _status.Value, null, PageSize);
if (version != _loadVersion) return; // jeu ou filtre changé entre-temps
ClearList();
AddPage(page);
});
private async Task ReloadAsync()
{
if (!ConfirmDiscard()) return;
var selectedId = _selectedItem?.Id;
StatusMessage = null;
await LoadAsync();
if (selectedId is not null && Items.FirstOrDefault(i => i.Id == selectedId) is { } again)
{
_selectedItem = again;
OnPropertyChanged(nameof(SelectedItem));
await LoadDetailAsync(again);
}
}
private Task LoadMoreAsync() => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId || _nextCursor is null) return;
var version = _loadVersion;
var page = await Context.Session.GetFeedbackPageAsync(gameId, _kind.Value, _status.Value, _nextCursor, PageSize);
if (version != _loadVersion) return;
AddPage(page);
});
private Task LoadDetailAsync(FeedbackItem item) => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId) return;
var detail = await Context.Session.GetFeedbackAsync(gameId, item.Id);
if (!ReferenceEquals(item, _selectedItem)) return; // autre demande choisie entre-temps
item.Summary = detail.Summary;
var view = NewDetail(detail);
Detail = view;
await LoadAttachmentsAsync(gameId, view);
});
/// <summary>Captures de la fiche affichée, une par une (chacune revérifiée avant l'affichage).</summary>
private async Task LoadAttachmentsAsync(string gameId, FeedbackDetailView view)
{
foreach (var attachment in view.Attachments)
{
if (!ReferenceEquals(view, _detail)) return; // autre demande affichée entre-temps
try
{
attachment.Load(await Context.Session.GetFeedbackAttachmentAsync(gameId, view.Id, attachment.Info.Index));
}
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
{
attachment.Fail(ApiErrorText.Describe(ex));
}
}
}
private FeedbackDetailView NewDetail(FeedbackDetail detail) =>
new(detail, CanManage, () => SaveCommand.RaiseCanExecuteChanged(), reference => Context.Dialogs.CopyToClipboard(reference));
private void ApplyOverview(FeedbackOverview overview)
{
_overview = overview;
foreach (var (gameId, tile) in _tiles)
tile.Apply(overview.Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)));
foreach (var name in new[] { nameof(ShowDisabled), nameof(ShowStorageFull), nameof(RetentionText) }) OnPropertyChanged(name);
}
private void ClearList()
{
_loaded.Clear();
_nextCursor = null;
_total = 0;
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ApplySearch();
}
private void AddPage(FeedbackPage page)
{
_loaded.AddRange(page.Items.Select(s => new FeedbackItem(s) { Form = FormOf(s.Kind) }));
_nextCursor = page.NextCursor;
_total = page.Total;
ApplySearch();
}
private void ApplySearch()
{
Items.Clear();
foreach (var item in _loaded.Where(i => i.Matches(_searchText))) Items.Add(item);
foreach (var name in new[] { nameof(HasItems), nameof(HasSplitLayout), nameof(EmptyText), nameof(CountText), nameof(CanLoadMore), nameof(ShowNoSelection) })
OnPropertyChanged(name);
LoadMoreCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Traitement
private Task SaveAsync() => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId || _detail is not { } detail) return;
StatusMessage = null;
if (FeedbackRules.CleanMultiline(detail.Note, FeedbackRules.MaxAdminNoteLength, out var problem) is null && problem is not null)
{
ErrorMessage = $"Note interne : {problem}";
return;
}
try
{
var saved = await Context.Session.UpdateFeedbackAsync(gameId, detail.Id, detail.Revision, detail.Status.Value, detail.NoteToSend);
if (_loaded.FirstOrDefault(i => i.Id == saved.Summary.Id) is { } item) item.Summary = saved.Summary;
Detail = NewDetail(saved);
await LoadAttachmentsAsync(gameId, _detail!);
StatusMessage = $"Demande {saved.Summary.Reference} enregistrée : {FeedbackStatuses.Label(saved.Summary.Status)}.";
await RefreshCountsAsync();
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
throw;
}
});
private async Task DeleteAsync()
{
if (_detail is not { } detail || _selectedGame?.Value is not { } gameId) return;
if (!Context.Dialogs.Confirm("Supprimer la demande ?",
$"La demande {detail.Reference} (« {detail.Title} ») sera supprimée définitivement du serveur. Cette action est inscrite au journal d'audit.",
"Supprimer", danger: true))
return;
await RunAsync(async () =>
{
await Context.Session.DeleteFeedbackAsync(gameId, detail.Id);
_loaded.RemoveAll(i => i.Id == detail.Id);
_total = Math.Max(0, _total - 1);
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ApplySearch();
StatusMessage = $"Demande {detail.Reference} supprimée.";
await RefreshCountsAsync();
});
}
/// <summary>Compteurs des tuiles après un changement (sans effet sur la page en cas d'échec).</summary>
private async Task RefreshCountsAsync()
{
try
{
ApplyOverview(await Context.Session.GetFeedbackOverviewAsync());
}
catch (ApiException)
{
// compteurs indicatifs : le prochain rechargement les mettra à jour
}
}
/// <summary>Statut ou note modifiés et non enregistrés : confirmation avant de les abandonner.</summary>
private bool ConfirmDiscard() =>
_detail is not { HasChanges: true } ||
Context.Dialogs.Confirm("Abandonner les modifications ?",
"Le statut ou la note interne de la demande affichée n'ont pas été enregistrés : ils seront perdus.", "Abandonner");
}
@@ -0,0 +1,26 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Files;
/// <summary>
/// Module « Fichiers du serveur » : parcours du site public (www), suppression des fichiers inutilisés
/// (anciennes archives…) et nettoyage du dossier « objects » des jeux. Permission files.manage
/// (propriétaires, administrateurs).
/// </summary>
public sealed class FilesAdminModule : ILauncherModule
{
public string Id => "files";
public string DisplayName => "Fichiers du serveur";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
FilesPanelViewModel.PanelId,
"Fichiers du serveur",
"Contenu du site public. Libérez de la place en supprimant les fichiers qu'aucun manifest n'utilise plus " +
"(anciennes archives, anciens exécutables…) : le serveur vérifie les manifests avant chaque suppression.",
"Harddisk",
AdminPermissions.FilesManage,
Order: 35,
context => new FilesPanelViewModel(context)));
}
@@ -0,0 +1,405 @@
using System.Globalization;
using GameLauncher.Contracts.Files;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Files;
/// <summary>
/// Page « Fichiers du serveur » (files.manage) : parcours du site public (www), suppression d'un fichier que le
/// serveur juge inutilisé (après confirmation), nettoyage du dossier « objects » d'un jeu (analyse, puis
/// confirmation). Le serveur revérifie tout au moment de supprimer : la page ne fait qu'afficher ses réponses.
/// </summary>
public sealed class FilesPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "files.manage";
private ServerFolder? _folder;
private IReadOnlyList<ServerFileItem> _entries = Array.Empty<ServerFileItem>();
private IReadOnlyList<FolderCrumb> _crumbs = Array.Empty<FolderCrumb>();
private string _path = "";
private bool _showOnlyUnused;
private string? _statusMessage;
private IReadOnlyList<Choice> _pruneGames = Array.Empty<Choice>();
private Choice? _pruneGame;
private ObjectsPrunePreview? _prune;
public FilesPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(_path))), () => !IsBusy);
UpCommand = new AsyncRelayCommand(() => OpenAsync(Parent(_path)), () => !IsBusy && _path.Length > 0);
AnalyzeCommand = new AsyncRelayCommand(AnalyzeAsync, () => !IsBusy && _pruneGame is not null);
PruneCommand = new AsyncRelayCommand(ScrollingToTop(PruneAsync), () => !IsBusy && CanPrune);
}
// ------------------------------------------------------------------ Dossier affiché
public string RootName => _folder?.RootName is { Length: > 0 } name ? name : "www";
/// <summary>Chemin du dossier affiché (« » : racine du site).</summary>
public string CurrentPath => _path;
/// <summary>Fil d'Ariane : racine, puis chaque dossier jusqu'au dossier affiché.</summary>
public IReadOnlyList<FolderCrumb> Crumbs
{
get => _crumbs;
private set => SetProperty(ref _crumbs, value);
}
public IReadOnlyList<ServerFileItem> Entries
{
get => _entries;
private set
{
if (!SetProperty(ref _entries, value)) return;
OnPropertyChanged(nameof(EmptyText));
}
}
/// <summary>N'afficher que les fichiers supprimables (et les dossiers, pour continuer à parcourir).</summary>
public bool ShowOnlyUnused
{
get => _showOnlyUnused;
set
{
if (SetProperty(ref _showOnlyUnused, value)) ApplyFolder();
}
}
public string EmptyText => _folder is null || Entries.Count > 0 ? ""
: _showOnlyUnused ? "Aucun fichier inutilisé dans ce dossier." : "Dossier vide.";
/// <summary>« 12 éléments · 3 fichier(s) inutilisé(s) : 1,2 Go ».</summary>
public string SummaryText
{
get
{
if (_folder is null) return "";
var unused = _folder.Entries.Where(e => e.Status == ServerFileStatus.Unused).ToList();
var text = $"{_folder.Entries.Count} élément(s)";
if (unused.Count > 0) text += $" · {unused.Count} fichier(s) inutilisé(s) : {Bytes(unused.Sum(e => e.Size))}";
return text + (_folder.Truncated ? $" · seuls les {ServerFilesLimits.MaxEntries} premiers sont affichés" : "");
}
}
/// <summary>« Espace libre sur le serveur : 12,4 Go sur 40 Go ».</summary>
public string DiskText => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f
? $"Espace libre sur le serveur : {Bytes(f.FreeBytes)} sur {Bytes(f.TotalBytes)}"
: "";
/// <summary>Part utilisée du disque (0 à 1), pour la jauge.</summary>
public double DiskUsedRatio => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f ? 1 - (double)f.FreeBytes / f.TotalBytes : 0;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand UpCommand { get; }
// ------------------------------------------------------------------ Nettoyage du dossier « objects »
/// <summary>Jeux configurés sur le serveur (seuls leurs dossiers « objects » peuvent être nettoyés).</summary>
public IReadOnlyList<Choice> PruneGames
{
get => _pruneGames;
private set
{
if (!SetProperty(ref _pruneGames, value)) return;
OnPropertyChanged(nameof(HasPruneGames));
}
}
public bool HasPruneGames => _pruneGames.Count > 0;
public Choice? PruneGame
{
get => _pruneGame;
set
{
if (!SetProperty(ref _pruneGame, value)) return;
Prune = null;
AnalyzeCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Dernière analyse (null : pas encore analysé, ou jeu changé).</summary>
public ObjectsPrunePreview? Prune
{
get => _prune;
private set
{
if (!SetProperty(ref _prune, value)) return;
OnPropertyChanged(nameof(HasPrune));
OnPropertyChanged(nameof(PruneText));
OnPropertyChanged(nameof(PruneDetail));
OnPropertyChanged(nameof(CanPrune));
OnPropertyChanged(nameof(PruneButtonText));
PruneCommand.RaiseCanExecuteChanged();
}
}
public bool HasPrune => _prune is not null;
public bool CanPrune => _prune is { OrphanCount: > 0, PublicationInProgress: false };
public string PruneButtonText => _prune is { OrphanCount: > 0 } p
? $"Supprimer {p.OrphanCount} fichier(s) ({Bytes(p.OrphanBytes)})"
: "Supprimer les fichiers inutilisés";
public string PruneText => _prune switch
{
null => "",
{ PublicationInProgress: true } p =>
$"Une publication de {p.GameId} est en cours : nettoyage possible une fois la version en ligne (ou la publication abandonnée).",
{ OrphanCount: 0 } p => $"Rien à nettoyer : les {p.ReferencedCount} fichier(s) du dossier « objects » sont tous utilisés par la version {p.Version}.",
var p => $"{p.OrphanCount} fichier(s) ne sont plus utilisés par la version en ligne ({p.Version}) : {Bytes(p.OrphanBytes)} à libérer."
};
public string PruneDetail => _prune switch
{
null => "",
{ MissingCount: > 0 } p => $"Attention : {p.MissingCount} fichier(s) de la version {p.Version} sont absents du serveur. Republiez la version.",
{ OrphanCount: > 0 } p => "Exemples : " + string.Join(", ", p.Examples.Take(3).Select(e => Shorten(e.Split('/').Last()))),
_ => ""
};
public AsyncRelayCommand AnalyzeCommand { get; }
public AsyncRelayCommand PruneCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
UpCommand.RaiseCanExecuteChanged();
AnalyzeCommand.RaiseCanExecuteChanged();
PruneCommand.RaiseCanExecuteChanged();
foreach (var entry in _entries) entry.RaiseCanExecuteChanged();
foreach (var crumb in _crumbs) crumb.OpenCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync("")) : Task.CompletedTask;
// ------------------------------------------------------------------ Actions
/// <summary>
/// Ouvre un dossier (fil d'Ariane, double-clic, « Remonter ») sans déplacer l'ascenseur : la page reste où elle est
/// (le fil d'Ariane et le contenu restent visibles d'un dossier à l'autre). Seule une erreur remonte en haut.
/// </summary>
internal async Task OpenAsync(string path)
{
StatusMessage = null;
await RunAsync(() => LoadAsync(path));
}
private async Task LoadAsync(string path)
{
var folder = await Context.Session.GetServerFolderAsync(path);
_folder = folder;
_path = folder.Path;
Crumbs = BuildCrumbs(folder.RootName, folder.Path);
var games = folder.PrunableGames.Select(id => new Choice(id, GameLabel(id))).ToList();
if (!games.Select(g => g.Value).SequenceEqual(_pruneGames.Select(g => g.Value)))
{
PruneGames = games;
PruneGame = games.Count == 1 ? games[0] : null; // plusieurs jeux : choisi par l'administrateur, jamais par défaut
}
ApplyFolder();
OnPropertyChanged(nameof(CurrentPath));
OnPropertyChanged(nameof(RootName));
OnPropertyChanged(nameof(DiskText));
OnPropertyChanged(nameof(DiskUsedRatio));
UpCommand.RaiseCanExecuteChanged();
}
private void ApplyFolder()
{
if (_folder is null) return;
Entries = _folder.Entries
.Where(e => !_showOnlyUnused || e.IsDirectory || e.Status == ServerFileStatus.Unused)
.Select(e => new ServerFileItem(this, e))
.ToList();
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(EmptyText));
}
internal async Task DeleteAsync(ServerFileItem item)
{
if (!item.CanDelete) return;
var path = Combine(_path, item.Name);
if (!Context.Dialogs.Confirm("Supprimer ce fichier",
$"« {item.Name} » ({item.SizeText}) va être supprimé définitivement du serveur.\n\n" +
"Le serveur vérifie d'abord qu'aucun manifest ne l'utilise : un fichier encore utilisé n'est jamais supprimé.",
"Supprimer", danger: true))
return;
StatusMessage = null;
ServerFileDeleteResult? result = null;
if (await RunAsync(async () => result = await Context.Session.DeleteServerFileAsync(path, item.Entry.Size)))
StatusMessage = $"« {item.Name} » supprimé : {Bytes(result!.FreedBytes)} libéré(s).";
RequestScrollToTop();
await ReloadKeepingErrorAsync(); // la liste reflète l'état réel du serveur
}
/// <summary>Recharge le dossier sans effacer l'erreur de l'action qui précède (refus du serveur).</summary>
private async Task ReloadKeepingErrorAsync()
{
var error = ErrorMessage;
await RunAsync(() => LoadAsync(_path));
if (error is not null && ErrorMessage is null) ErrorMessage = error;
}
private async Task AnalyzeAsync()
{
if (_pruneGame?.Value is not { } gameId) return;
Prune = null;
ObjectsPrunePreview? preview = null;
if (await RunAsync(async () => preview = await Context.Session.GetObjectsPruneAsync(gameId))) Prune = preview;
}
private async Task PruneAsync()
{
if (_prune is not { OrphanCount: > 0 } preview) return;
if (!Context.Dialogs.Confirm("Nettoyer le dossier « objects »",
$"{preview.OrphanCount} fichier(s) que la version en ligne de {preview.GameId} ({preview.Version}) n'utilise plus vont être " +
$"supprimés définitivement ({Bytes(preview.OrphanBytes)}).\n\n" +
"Les joueurs qui ont déjà la version en ligne ne sont pas concernés. Une version plus ancienne ne pourra plus être " +
"republiée sans renvoyer ces fichiers.",
"Supprimer", danger: true))
return;
StatusMessage = null;
ObjectsPruneResult? result = null;
if (await RunAsync(async () => result = await Context.Session.PruneObjectsAsync(preview.GameId, preview.ManifestSha256)))
StatusMessage = $"Dossier « objects » de {result!.GameId} nettoyé : {result.Deleted} fichier(s) supprimé(s), {Bytes(result.FreedBytes)} libéré(s)." +
(result.Failed > 0 ? $" {result.Failed} fichier(s) n'ont pas pu être supprimés (journal du serveur)." : "");
Prune = null;
await ReloadKeepingErrorAsync();
}
// ------------------------------------------------------------------ Outils
private string GameLabel(string gameId) =>
Context.Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)) is { } game
? $"{game.GameName} ({gameId})"
: gameId;
private IReadOnlyList<FolderCrumb> BuildCrumbs(string rootName, string path)
{
var crumbs = new List<FolderCrumb> { new(this, string.IsNullOrEmpty(rootName) ? "www" : rootName, "") };
var current = "";
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
current = Combine(current, segment);
crumbs.Add(new FolderCrumb(this, segment, current));
}
crumbs[^1].IsCurrent = true;
return crumbs;
}
internal static string Combine(string folder, string name) => folder.Length == 0 ? name : folder + "/" + name;
private static string Parent(string path) => path.LastIndexOf('/') is var i and > 0 ? path[..i] : "";
/// <summary>Taille lisible : « 812 o », « 4,2 Ko », « 1,3 Go ».</summary>
public static string Bytes(long bytes)
{
if (bytes < 0) return "?";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return unit == 0 ? $"{bytes} o" : value.ToString(value >= 100 ? "0" : "0.#", CultureInfo.InvariantCulture).Replace('.', ',') + " " + units[unit];
}
private static string Shorten(string name) => name.Length > 16 ? name[..12] + "…" : name;
}
/// <summary>Élément du fil d'Ariane.</summary>
public sealed class FolderCrumb
{
internal FolderCrumb(FilesPanelViewModel page, string label, string path)
{
Label = label;
Path = path;
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(path), () => !page.IsBusy && !IsCurrent);
}
public string Label { get; }
public string Path { get; }
public bool IsCurrent { get; internal set; }
public AsyncRelayCommand OpenCommand { get; }
}
/// <summary>Fichier ou dossier tel qu'affiché.</summary>
public sealed class ServerFileItem
{
internal ServerFileItem(FilesPanelViewModel page, ServerFileEntry entry)
{
Entry = entry;
Name = entry.Name;
IsDirectory = entry.IsDirectory && entry.Status == ServerFileStatus.Directory;
SizeText = entry.IsDirectory
? entry.Size >= 0 ? $"{FilesPanelViewModel.Bytes(entry.Size)} · {entry.FileCount} fichier(s)" : "taille non calculée (trop de fichiers)"
: FilesPanelViewModel.Bytes(entry.Size);
DateText = entry.Modified.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(FilesPanelViewModel.Combine(page.CurrentPath, entry.Name)),
() => IsDirectory && !page.IsBusy);
DeleteCommand = new AsyncRelayCommand(() => page.DeleteAsync(this), () => CanDelete && !page.IsBusy);
}
public ServerFileEntry Entry { get; }
public string Name { get; }
public bool IsDirectory { get; }
public string SizeText { get; }
public string DateText { get; }
public string Status => Entry.Status;
/// <summary>Supprimable : le serveur l'a jugé inutilisé (il le revérifie au moment de supprimer).</summary>
public bool CanDelete => Entry.Status == ServerFileStatus.Unused;
public string StatusText => Entry.Status switch
{
ServerFileStatus.Directory => "",
ServerFileStatus.Protected => "Protégé",
ServerFileStatus.InUse => "Utilisé",
ServerFileStatus.Unused => "Inutilisé",
ServerFileStatus.Link => "Lien",
_ => "Non vérifiable"
};
/// <summary>Explication (infobulle) : pourquoi le fichier est protégé ou utilisé.</summary>
public string ToolTipText => Entry.Status switch
{
ServerFileStatus.Directory => "Ouvrir le dossier",
ServerFileStatus.Unused => "Aucun manifest ni fichier de description n'utilise ce fichier : il peut être supprimé.",
_ => Capitalize(Entry.Reason ?? "Ce fichier ne peut pas être supprimé depuis le menu.")
};
public string IconKey => Entry.IsDirectory ? "Folder"
: Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "PackageVariant"
: Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? "FileDocument"
: Name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "ImageOutline"
: "TextBoxOutline";
public AsyncRelayCommand OpenCommand { get; }
public AsyncRelayCommand DeleteCommand { get; }
internal void RaiseCanExecuteChanged()
{
OpenCommand.RaiseCanExecuteChanged();
DeleteCommand.RaiseCanExecuteChanged();
}
private static string Capitalize(string text) => text.Length > 0 ? char.ToUpper(text[0], CultureInfo.InvariantCulture) + text[1..] : text;
}
@@ -14,6 +14,8 @@
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
<!-- Aperçu des formulaires des demandes : mêmes champs que la fenêtre « Aide » des joueurs -->
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
<ItemGroup>
@@ -1,9 +1,11 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Publishing;
@@ -13,7 +15,9 @@ namespace GameLauncher.Modules.Admin.Publishing;
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète.
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète ;
/// • plusieurs jeux déclarés : rien n'est affiché avant le choix du jeu (aucun jeu par défaut) ; un envoi
/// interrompu reste attaché à son jeu (bandeau « Reprendre / Abandonner » quand on revient à ce jeu).
/// </summary>
public sealed class PublishPanelViewModel : AdminPanelViewModel
{
@@ -23,22 +27,25 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
private LocalRelease? _release;
private string? _manifestPath, _publishedVersion, _statusMessage, _progressText, _speedText;
private string _publishedText = "";
private ManifestSignatureState? _online;
private IReadOnlyList<string> _warnings = Array.Empty<string>();
private bool _isPublishing, _committing;
private double _progressValue;
private PublicationState? _interrupted;
private readonly Dictionary<string, PublicationState> _interruptedByGame = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _cancellation;
public PublishPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit);
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _manifestPath is not null);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => CanEdit && _release is not null);
GameChoices = GameTile.Choices(context);
_selectedGame = GameTile.Initial(GameChoices);
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit && _selectedGame is not null);
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _selectedGame is not null && _manifestPath is not null);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => CanEdit && _release is not null);
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
AbandonCommand = new AsyncRelayCommand(AbandonAsync, () => CanEdit && _interrupted is not null);
SignOnlineCommand = new AsyncRelayCommand(ScrollingToTop(SignOnlineAsync), () => CanEdit && CanSignOnline);
AbandonCommand = new AsyncRelayCommand(ScrollingToTop(AbandonAsync), () => CanEdit && _interrupted is not null);
}
// ------------------------------------------------------------------ Jeu, dossier
@@ -46,16 +53,56 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
public IReadOnlyList<GameTile> GameTiles { get; }
public bool HasSelectedGame => _selectedGame is not null;
public bool ShowGamePicker => _selectedGame is null;
/// <summary>Lecture de la version en ligne lancée par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
/// <summary>Pas de changement de jeu pendant un contrôle ou un envoi.</summary>
public bool IsGameSwitchEnabled => CanEdit;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez publier une version.";
/// <summary>Nom du jeu choisi (titres, confirmations) : « Exovival Zombie Reborn (EZR) ».</summary>
public string SelectedGameLabel => _selectedGame?.Label ?? "";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame) || _isPublishing) return;
if (value is null || Equals(value, _selectedGame) || _isPublishing || IsBusy)
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher), nameof(SelectedGameLabel) })
OnPropertyChanged(name);
// Rien de l'ancien jeu ne reste affiché ni utilisable : dossier, version en ligne, signature, avancement.
ErrorMessage = null;
StatusMessage = null;
ManifestPath = null;
SetRelease(null);
_ = RefreshPublishedAsync();
_publishedVersion = null;
_online = null;
PublishedText = "";
ProgressText = null;
ProgressValue = 0;
SpeedText = null;
SetInterrupted(_interruptedByGame.GetValueOrDefault(value.Value ?? ""), remember: false);
RefreshCommands();
GameLoad = RefreshPublishedAsync();
}
}
@@ -111,6 +158,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
if (!SetProperty(ref _isPublishing, value)) return;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
@@ -167,9 +215,82 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
public RelayCommand CancelCommand { get; }
public AsyncRelayCommand AbandonCommand { get; }
// ------------------------------------------------------------------ Signature de la version en ligne (phase 7)
/// <summary>« Version en ligne signée par … » / « non signée ».</summary>
public string OnlineSignatureText => _online is not { Version.Length: > 0 } online ? ""
: online.Signature is { } signature
? $"Version en ligne {online.Version} signée par {online.SignerName ?? $"la clé {signature.KeyId}"} le {Accounts.AccountTexts.Date(signature.SignedAt)}."
: $"Version en ligne {online.Version} non signée : les launchers en mode « require » la refusent.";
public bool HasOnlineSignatureText => OnlineSignatureText.Length > 0;
public bool IsOnlineSigned => _online?.Signature is not null;
/// <summary>Le dossier choisi a exactement le contenu de la version en ligne : elle peut être signée avec lui.</summary>
public bool CanSignOnline => !_isPublishing && _release is { } release && _online is { Digest.Length: > 0 } online &&
string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase);
/// <summary>Même numéro de version que celle en ligne, mais contenu différent : impossible de la signer avec ce dossier.</summary>
public string? OnlineMismatchText => _release is { } release && _online is { Digest.Length: > 0 } online &&
release.Summary.Version == online.Version &&
!string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase)
? "La version en ligne porte ce numéro mais son contenu diffère de ce dossier : elle ne peut pas être signée avec lui."
: null;
public AsyncRelayCommand SignOnlineCommand { get; }
/// <summary>
/// Signe la version déjà en ligne (publiée avant la signature, ou avec WinSCP) à partir du dossier choisi : le
/// serveur n'accepte que si les fichiers en ligne sont exactement ceux de ce manifest.
/// </summary>
private async Task SignOnlineAsync()
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId || _online is not { } online) return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Manifest, "Signer quand même")) return;
if (!Context.Dialogs.Confirm($"Signer la version en ligne {online.Version} ?",
$"Jeu : {SelectedGameLabel}.\n\n" +
"Vous attestez que la version en ligne est bien celle de ce dossier de publication (le serveur vérifie que les fichiers, " +
"l'archive et l'exécutable sont identiques). Les launchers des joueurs l'accepteront ensuite.", "Signer"))
return;
StatusMessage = null;
if (await RunAsync(async () =>
{
var signature = await Context.Session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest);
_online = await Context.Session.SignOnlineManifestAsync(gameId,
new ManifestSignRequest { Digest = release.ContentDigest, Signature = signature });
}))
StatusMessage = $"Version en ligne {online.Version} signée.";
NotifySignature();
}
private async Task RefreshSignatureAsync(string gameId)
{
ManifestSignatureState? online;
try
{
online = await Context.Session.GetManifestSignatureAsync(gameId);
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
}
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
_online = online;
NotifySignature();
}
private void NotifySignature()
{
foreach (var name in new[] { nameof(OnlineSignatureText), nameof(HasOnlineSignatureText), nameof(IsOnlineSigned),
nameof(CanSignOnline), nameof(OnlineMismatchText) })
OnPropertyChanged(name);
SignOnlineCommand.RaiseCanExecuteChanged();
}
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
@@ -206,8 +327,10 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
"" => "aucune version en ligne",
_ => $"version en ligne : {_publishedVersion}"
};
// Clé de ce PC absente du trousseau publié : les launchers des joueurs refuseraient cette version (avertissement seulement).
if (!await ConfirmPlayerSigningAsync(ContentKinds.Manifest)) return;
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
$"Les joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
$"Jeu : {SelectedGameLabel}.\n\nLes joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
return;
@@ -229,11 +352,12 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
SetInterrupted(null);
_publishedVersion = result.Version;
PublishedText = $"Version en ligne : {result.Version}";
StatusMessage = $"Version {result.Version} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
StatusMessage = $"Version {result.Version} de {SelectedGameLabel} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
(result.ArchiveAdded ? " et l'archive" : "") +
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
UpdateWarnings();
Log.Info($"Administration : version {result.Version} de {gameId} publiée.");
await RefreshSignatureAsync(gameId);
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
{
@@ -282,21 +406,26 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
try
{
var state = await Context.Session.GetChangelogAsync(gameId);
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
_publishedVersion = state.ManifestVersion;
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
await RefreshSignatureAsync(gameId);
}
catch (ApiException ex) when (ex.Status == 404)
{
if (!IsSelected(gameId)) return;
_publishedVersion = "";
PublishedText = "Aucune version en ligne pour l'instant.";
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
{
if (!IsSelected(gameId)) return;
_publishedVersion = null;
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
if (!IsSelected(gameId)) return;
_publishedVersion = null;
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
}
@@ -310,6 +439,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
OnPropertyChanged(name);
UpdateWarnings();
RefreshCommands();
NotifySignature();
}
private void UpdateWarnings()
@@ -334,8 +464,14 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
}
private void SetInterrupted(PublicationState? state)
/// <param name="remember">Faux au changement de jeu : l'état affiché est celui déjà mémorisé pour ce jeu.</param>
private void SetInterrupted(PublicationState? state, bool remember = true)
{
if (remember && _selectedGame?.Value is { } gameId)
{
if (state is null) _interruptedByGame.Remove(gameId);
else _interruptedByGame[gameId] = state;
}
_interrupted = state;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(InterruptedText));
@@ -363,7 +499,10 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
PublishCommand.RaiseCanExecuteChanged();
CancelCommand.RaiseCanExecuteChanged();
AbandonCommand.RaiseCanExecuteChanged();
SignOnlineCommand.RaiseCanExecuteChanged();
}
private bool IsSelected(string gameId) => string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase);
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -3,12 +3,16 @@ using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>Version prête à publier : dossier produit par ManifestBuilder (le .bat), contrôlé.</summary>
public sealed record LocalRelease(string Folder, string ManifestPath, byte[] ManifestBytes, string ManifestSha256, ManifestSummary Summary)
{
/// <summary>Condensé du contenu signé (phase 7) : fichiers, archive, exécutable… (notes exclues).</summary>
public string ContentDigest { get; } = ContentSigning.ManifestDigest(ManifestBytes);
public string ArchivePath => Path.Combine(Folder, Summary.ArchiveName);
public string ObjectFile(string sha256) =>
@@ -34,6 +38,9 @@ public static class ReleaseUploader
{
private const int MaxAttempts = 8;
/// <summary>Signature réutilisée pour un nouvel essai de mise en ligne pendant ce délai au plus (serveur : ± 10 min).</summary>
private static readonly TimeSpan SignatureReuseLimit = TimeSpan.FromMinutes(5);
/// <summary>Dernière étape (mise en ligne sur le serveur) : elle ne s'interrompt pas.</summary>
public const string CommitStep = "Mise en ligne";
@@ -85,6 +92,8 @@ public static class ReleaseUploader
started?.Report(state);
var committing = false;
ContentSignature? signature = null;
var signedLocally = DateTime.MinValue;
for (var attempt = 1; ; attempt++)
{
var before = run.Done;
@@ -103,7 +112,15 @@ public static class ReleaseUploader
}
// Mise en ligne : ni interrompue (elle se termine sur le serveur de toute façon), ni perdue
// (réponse perdue : le serveur renvoie le même résultat au nouvel essai).
return await session.CommitPublicationAsync(state.PublicationId, CancellationToken.None).ConfigureAwait(false);
// Signature du contenu (phase 7) juste avant la mise en ligne : le serveur la contrôle (clé de cette
// session, date proche de son heure), puis l'intègre au manifest publié.
// Nouvel essai après plusieurs minutes : signature refaite (le serveur refuse une date trop éloignée).
if (signature is null || DateTime.UtcNow - signedLocally > SignatureReuseLimit)
{
signature = await session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest).ConfigureAwait(false);
signedLocally = DateTime.UtcNow;
}
return await session.CommitPublicationAsync(state.PublicationId, signature, CancellationToken.None).ConfigureAwait(false);
}
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
{
@@ -12,14 +12,21 @@ public sealed class AdminLoginService
private readonly LauncherApiClient _client;
private readonly string _clientName;
private readonly IAdminTpm? _tpm;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
/// <param name="clientName">Logiciel pour le journal d'audit, ex. « GameLauncher 2.5.0 » (l'appareil est identifié par sa clé).</param>
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
/// <param name="rootKeys">
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
/// le nom du fichier (la clé racine reste hors ligne, réservée à tools\sign-keyring.bat et aux mises à jour).
/// </param>
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null,
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null)
{
_client = client;
_clientName = clientName;
_tpm = tpm;
_rootKeys = rootKeys ?? Array.Empty<(string, byte[])>();
}
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
@@ -59,6 +66,14 @@ public sealed class AdminLoginService
key = opened;
}
if (_rootKeys.Any(r => string.Equals(r.KeyId, signer.KeyId, StringComparison.Ordinal) || r.PublicKey.AsSpan().SequenceEqual(signer.PublicKey)))
{
key.Dispose();
throw new InvalidOperationException(
"Cette clé est la clé racine du launcher (signature des mises à jour et du trousseau) : elle ne doit jamais servir " +
"à se connecter. Utilisez votre clé d'administrateur.");
}
try
{
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
@@ -5,17 +5,12 @@ namespace GameLauncher.Modules.Admin.Services;
/// <summary>Libellés affichés (français).</summary>
public static class AdminTexts
{
public static string Permission(string permission) => permission switch
{
AdminPermissions.AdminsManage => "Gestion des administrateurs",
AdminPermissions.AuditRead => "Journal d'audit",
AdminPermissions.GamesEdit => "Informations des jeux",
AdminPermissions.ChangelogEdit => "Notes de version",
AdminPermissions.ReleasesPublish => "Publication des versions",
_ => permission
};
/// <summary>Nom court d'une permission intégrée ; « module.action » pour une permission personnalisée.</summary>
public static string Permission(string permission) => AdminPermissions.BuiltInLabel(permission) ?? permission;
public static string Role(string? role) => AdminRoles.DisplayName(role);
/// <summary>Nom affiché d'un groupe : celui donné par le serveur, sinon celui des groupes par défaut.</summary>
public static string Role(string? role, string? roleName = null) =>
string.IsNullOrWhiteSpace(roleName) ? AdminRoles.DisplayName(role) : roleName;
/// <summary>Initiales pour l'avatar : « Nicolas Deville » → « ND ».</summary>
public static string Initials(string? name)
@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
@@ -31,6 +32,50 @@ public sealed record TpmLockoutInfo(int Failures, int MaxTries, TimeSpan Recover
public TimeSpan MaxWait => !IsLockedOut || !HealsWithTime
? TimeSpan.Zero
: RecoveryInterval * Math.Max(1, Failures - MaxTries + 1);
// Propriétés TPM 2.0 lues (TPM_PT_*, partie 2 de la spécification, § 6.13).
private const uint PtPermanent = 0x200, PtLockoutCounter = 0x20E, PtMaxAuthFail = 0x20F, PtLockoutInterval = 0x210;
/// <summary>
/// TPMA_PERMANENT.inLockout (bit 9) : la puce refuse tout code. Ne pas confondre avec le bit 2
/// (lockoutAuthSet), qui indique seulement qu'un mot de passe « lockout » existe — toujours vrai
/// sur un PC dont Windows gère la puce.
/// </summary>
internal const uint PermanentInLockout = 1u << 9;
/// <summary>
/// Lit la réponse à TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES, TPM_PT_PERMANENT…) : en-tête (balise,
/// taille, code retour), moreData, capability, puis la liste (propriété, valeur), en gros-boutiste.
/// Null si la réponse est en erreur, incomplète ou ne contient pas le compteur, le maximum et l'intervalle.
/// </summary>
public static TpmLockoutInfo? FromCapabilityResponse(ReadOnlySpan<byte> response)
{
const int HeaderSize = 10, ListOffset = HeaderSize + 1 + 4 + 4, EntrySize = 8;
if (response.Length < ListOffset) return null;
if (BinaryPrimitives.ReadUInt16BigEndian(response) != 0x8001) return null; // TPM_ST_NO_SESSIONS
if (BinaryPrimitives.ReadUInt32BigEndian(response[6..]) != 0) return null; // code retour TPM
if (BinaryPrimitives.ReadUInt32BigEndian(response[(HeaderSize + 1)..]) != 6) return null; // TPM_CAP_TPM_PROPERTIES
var count = BinaryPrimitives.ReadUInt32BigEndian(response[(HeaderSize + 5)..]);
uint? permanent = null, counter = null, maxTries = null, interval = null;
for (var i = 0L; i < count && ListOffset + (i + 1) * EntrySize <= response.Length; i++)
{
var entry = response.Slice(ListOffset + (int)i * EntrySize, EntrySize);
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
{
case PtPermanent: permanent = value; break;
case PtLockoutCounter: counter = value; break;
case PtMaxAuthFail: maxTries = value; break;
case PtLockoutInterval: interval = value; break; // secondes
}
}
if (counter is null || maxTries is null || interval is null) return null;
var inLockout = permanent is { } attributes && (attributes & PermanentInLockout) != 0;
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
TimeSpan.FromSeconds(interval.Value), inLockout);
}
}
/// <summary>Code PIN refusé par la puce, ou puce bloquée par sa protection anti-essais.</summary>
@@ -0,0 +1,22 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Signing;
/// <summary>Module « Signature » : page du trousseau et des signatures du contenu (propriétaires).</summary>
public sealed class SigningAdminModule : ILauncherModule
{
public string Id => "signing";
public string DisplayName => "Signature";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
SigningPanelViewModel.PanelId,
"Signature",
"Clés autorisées à signer ce que reçoivent les joueurs (trousseau, signé hors ligne par la clé racine) et état des " +
"signatures des versions en ligne et du catalogue.",
"ShieldKey",
AdminPermissions.AdminsManage,
Order: 55,
context => new SigningPanelViewModel(context)));
}
@@ -0,0 +1,373 @@
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Signing;
/// <summary>
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne (versions en
/// ligne, catalogue, formulaires des demandes).
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
/// </summary>
public sealed class SigningPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "signing";
private SigningState? _state;
private List<KeyringKey> _proposed = new();
private IReadOnlyList<KeyringKeyItem> _keys = Array.Empty<KeyringKeyItem>();
private IReadOnlyList<SignedContentItem> _contents = Array.Empty<SignedContentItem>();
private string? _statusMessage;
public SigningPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
ExportRequestCommand = new AsyncRelayCommand(ScrollingToTop(ExportRequestAsync), () => !IsBusy && _state is not null && NeedsNewKeyring);
PublishSignedCommand = new AsyncRelayCommand(ScrollingToTop(PublishSignedAsync), () => !IsBusy && _state is { KeyringPublishingEnabled: true });
}
// ------------------------------------------------------------------ Trousseau publié
public bool HasKeyring => _state?.Keyring is not null;
public bool PublishingEnabled => _state?.KeyringPublishingEnabled == true;
public bool ShowPublishingDisabled => _state is { KeyringPublishingEnabled: false };
public string? KeyringUrl => _state?.KeyringPublicUrl;
public string KeyringText => _state?.Keyring is { } keyring
? $"Trousseau n° {keyring.Sequence} du {AccountTexts.Date(keyring.IssuedAt)} · {keyring.Keys.Count} clé(s)"
: "Aucun trousseau publié : les launchers en mode « require » refusent tout contenu.";
/// <summary>Signature racine vérifiée avec les clés intégrées à CE launcher.</summary>
public string RootText
{
get
{
if (_state?.Keyring is not { } keyring) return "";
if (Context.RootKeys.Count == 0) return "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : vérification impossible.";
return Keyring.VerifyRoot(keyring, Context.RootKeys, out var error)
? $"Signé par la clé racine {keyring.Signature!.RootKeyId} (vérifié par ce launcher)."
: $"Refusé par ce launcher : {error}.";
}
}
public bool RootValid => _state?.Keyring is { } keyring && Keyring.VerifyRoot(keyring, Context.RootKeys, out _);
public string RequireText => _state?.RequireSignatures == true
? "Le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require)."
: "Le serveur accepte encore les publications non signées (LauncherApi:ContentSigning:Require désactivé).";
// ------------------------------------------------------------------ Trousseau proposé
/// <summary>Clés proposées (appareils actifs autorisés à publier) et différences avec le trousseau publié.</summary>
public IReadOnlyList<KeyringKeyItem> Keys
{
get => _keys;
private set => SetProperty(ref _keys, value);
}
public bool HasChanges => _keys.Any(k => k.Change != KeyChange.None);
/// <summary>Nouveau trousseau à signer : clés modifiées, ou trousseau publié refusé par ce launcher (à remplacer).</summary>
public bool NeedsNewKeyring => _proposed.Count > 0 && (HasChanges || (HasKeyring && !RootValid));
public string ChangesText
{
get
{
if (_state is null) return "";
var added = _keys.Count(k => k.Change == KeyChange.Added);
var removed = _keys.Count(k => k.Change == KeyChange.Removed);
var changed = _keys.Count(k => k.Change == KeyChange.ScopesChanged);
if (added + removed + changed == 0)
return HasKeyring && !RootValid
? "Les clés sont à jour, mais le trousseau publié est refusé par ce launcher : exportez une demande pour le remplacer."
: "Le trousseau publié est à jour.";
var parts = new List<string>();
if (added > 0) parts.Add($"{added} clé(s) à ajouter");
if (removed > 0) parts.Add($"{removed} à retirer");
if (changed > 0) parts.Add($"{changed} autorisation(s) à modifier");
return $"À mettre à jour : {string.Join(", ", parts)}.";
}
}
// ------------------------------------------------------------------ Contenu en ligne
public IReadOnlyList<SignedContentItem> Contents
{
get => _contents;
private set => SetProperty(ref _contents, value);
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand ExportRequestCommand { get; }
public AsyncRelayCommand PublishSignedCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
ExportRequestCommand.RaiseCanExecuteChanged();
PublishSignedCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
private async Task ReloadAsync()
{
var state = await Context.Session.GetSigningStateAsync();
var admins = await Context.Session.ListAdminsAsync();
_state = state;
_proposed = Propose(admins);
Keys = Compare(state.Keyring, _proposed, admins);
Contents = BuildContents(state);
foreach (var name in new[] { nameof(HasKeyring), nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(KeyringUrl),
nameof(KeyringText), nameof(RootText), nameof(RootValid), nameof(RequireText), nameof(HasChanges), nameof(NeedsNewKeyring), nameof(ChangesText) })
OnPropertyChanged(name);
ExportRequestCommand.RaiseCanExecuteChanged();
PublishSignedCommand.RaiseCanExecuteChanged();
}
/// <summary>Appareils actifs des comptes actifs dont le groupe permet de publier une version ou le catalogue.</summary>
internal static List<KeyringKey> Propose(IEnumerable<AdminSummary> admins) =>
admins.Where(a => a.Status == AdminStatus.Active)
.SelectMany(a => a.Keys
.Where(k => k.Status == AdminStatus.Active && k.PublicKey is not null)
.Select(k => new KeyringKey
{
KeyId = k.KeyId,
PublicKey = k.PublicKey!,
Name = Truncate($"{a.DisplayName} — {AccountTexts.KeyLabel(k.Label)}"),
// Permissions du groupe, données par le serveur (ancien serveur, sans nom de groupe : celles du rôle par défaut).
Scopes = SigningScopes.FromPermissions(a.RoleName is null ? AdminRoles.PermissionsOf(a.Role) : a.Permissions).ToList()
}))
.Where(k => k.Scopes.Count > 0)
.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>Nom public du trousseau : caractères invisibles ou de mise en forme retirés (refusés par les launchers), longueur bornée.</summary>
internal static string Truncate(string name)
{
var clean = new string(name.Where(c => Keyring.IsDisplaySafe(c.ToString())).ToArray()).Trim();
if (clean.Length > Keyring.MaxNameLength) clean = clean[..Keyring.MaxNameLength].TrimEnd();
return clean.Length == 0 ? "Appareil" : clean;
}
private static List<KeyringKeyItem> Compare(KeyringDocument? published, List<KeyringKey> proposed, List<AdminSummary> admins)
{
var current = published?.Keys.ToDictionary(k => k.KeyId, StringComparer.Ordinal) ?? new Dictionary<string, KeyringKey>();
var items = proposed.Select(k =>
{
if (!current.TryGetValue(k.KeyId, out var old)) return new KeyringKeyItem(k, KeyChange.Added);
return SameScopes(old, k) ? new KeyringKeyItem(k, KeyChange.None) : new KeyringKeyItem(k, KeyChange.ScopesChanged);
}).ToList();
foreach (var old in current.Values.Where(k => proposed.All(p => p.KeyId != k.KeyId)))
items.Add(new KeyringKeyItem(old, KeyChange.Removed));
return items;
}
private static bool SameScopes(KeyringKey a, KeyringKey b) =>
a.Scopes.OrderBy(s => s, StringComparer.Ordinal).SequenceEqual(b.Scopes.OrderBy(s => s, StringComparer.Ordinal));
private List<SignedContentItem> BuildContents(SigningState state)
{
var proposed = new KeyringDocument { Keys = _proposed };
var items = state.Manifests.Select(m => new SignedContentItem(
$"Version en ligne de {m.GameId}" + (string.IsNullOrEmpty(m.Version) ? "" : $" ({m.Version})"),
ContentKinds.Manifest, m.GameId, m.Digest, m.Signature, m.SignerName, state.Keyring, proposed,
"Re-signez-la depuis la page « Publier une version » (dossier de cette version, « Signer la version en ligne »).")).ToList();
if (state.Catalog is { } catalog)
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
if (state.FeedbackForms is { } forms)
items.Add(forms.Unreadable
? SignedContentItem.Failed("Formulaires des demandes",
"Fichier des formulaires illisible sur le serveur (voir ses journaux) : restaurez-le depuis feedback-forms-history/ " +
"dans les données privées du serveur.")
: new SignedContentItem(
forms.Revision > 0 ? $"Formulaires des demandes (révision {forms.Revision})" : "Formulaires des demandes",
ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, forms.Digest, forms.Signature, forms.SignerName, state.Keyring, proposed,
"Republiez-les depuis la page « Formulaires des demandes » (ils sont signés à chaque publication).",
"Formulaires d'origine, intégrés au launcher : rien à signer tant que rien n'est publié."));
return items;
}
// ------------------------------------------------------------------ Actions
private async Task ExportRequestAsync()
{
if (_state is not { } state) return;
var unsignedContents = Contents.Where(c => c.WillBreak).Select(c => c.Title).ToList();
if (unsignedContents.Count > 0 &&
!Context.Dialogs.Confirm("Contenu à signer d'abord",
"Avec ce trousseau, les launchers en mode « require » refuseraient :\n• " + string.Join("\n• ", unsignedContents) +
"\n\nSignez-les d'abord avec une clé qui restera dans le trousseau, ou exportez quand même (en mode « warn », " +
"ils seront seulement signalés).", "Exporter quand même"))
return;
var request = new KeyringDocument
{
Sequence = (state.Keyring?.Sequence ?? 0) + 1,
IssuedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + Context.Session.ClockOffset),
Keys = _proposed
};
var path = Context.Dialogs.PickSaveFile("Enregistrer la demande de trousseau", "Trousseau (*.json)|*.json",
$"keyring-request-{request.Sequence}.json", null);
if (path is null) return;
if (await RunAsync(() => File.WriteAllTextAsync(path, Keyring.Serialize(request))))
StatusMessage = $"Demande n° {request.Sequence} enregistrée ({request.Keys.Count} clé(s)). Signez-la avec tools\\sign-keyring.bat " +
"(clé racine, hors ligne), puis « Publier le trousseau signé… ».";
}
private async Task PublishSignedAsync()
{
if (_state is not { } state) return;
StatusMessage = null;
var path = Context.Dialogs.PickOpenFile("Trousseau signé (produit par tools\\sign-keyring.bat)", "Trousseau (*.json)|*.json", null);
if (path is null) return;
KeyringDocument? keyring;
try
{
var info = new FileInfo(path);
if (info.Length > Keyring.MaxBytes) throw new InvalidDataException("fichier trop volumineux");
keyring = Keyring.Parse(await File.ReadAllBytesAsync(path)) ?? throw new InvalidDataException("fichier vide");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
ErrorMessage = $"Trousseau illisible : {ex.Message}";
return;
}
// Mêmes contrôles que les launchers : un trousseau qu'ils refuseraient n'est pas publié.
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors))
{
ErrorMessage = "Trousseau invalide : " + string.Join(" ", errors.Take(4));
return;
}
if (Context.RootKeys.Count == 0)
{
ErrorMessage = "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : impossible de vérifier le trousseau.";
return;
}
if (!Keyring.VerifyRoot(keyring, Context.RootKeys, out var rootError))
{
ErrorMessage = $"Trousseau refusé : {rootError}. Signez la demande avec tools\\sign-keyring.bat et la clé racine du launcher.";
return;
}
if (state.Keyring is { } current && keyring.Sequence <= current.Sequence)
{
ErrorMessage = $"Trousseau n° {keyring.Sequence} : le n° {current.Sequence} est déjà publié. Exportez une nouvelle demande.";
return;
}
var names = keyring.Keys.Select(k => $"{k.Name} ({string.Join(", ", k.Scopes.Select(SigningScopes.DisplayName))})");
if (!Context.Dialogs.Confirm($"Publier le trousseau n° {keyring.Sequence} ?",
"Les launchers ne feront plus confiance qu'à ces clés :\n• " + string.Join("\n• ", names), "Publier"))
return;
if (await RunAsync(async () =>
{
await Context.Session.PublishKeyringAsync(keyring);
await ReloadAsync();
}))
StatusMessage = $"Trousseau n° {keyring.Sequence} publié. Les launchers l'utilisent dès leur prochain démarrage.";
}
}
public enum KeyChange
{
None,
Added,
Removed,
ScopesChanged
}
/// <summary>Clé du trousseau proposé (ou retirée du trousseau publié).</summary>
public sealed class KeyringKeyItem
{
internal KeyringKeyItem(KeyringKey key, KeyChange change)
{
Key = key;
Change = change;
ScopesText = string.Join(", ", key.Scopes.Select(SigningScopes.DisplayName));
ChangeText = change switch
{
KeyChange.Added => "À ajouter",
KeyChange.Removed => "À retirer",
KeyChange.ScopesChanged => "Autorisations modifiées",
_ => "Dans le trousseau"
};
}
public KeyringKey Key { get; }
public KeyChange Change { get; }
public string Name => Key.Name;
public string KeyId => Key.KeyId;
public string ScopesText { get; }
public string ChangeText { get; }
public bool IsAdded => Change == KeyChange.Added;
public bool IsRemoved => Change == KeyChange.Removed;
public bool IsChanged => Change == KeyChange.ScopesChanged;
}
/// <summary>Contenu en ligne et sa signature.</summary>
public sealed class SignedContentItem
{
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
KeyringDocument? published, KeyringDocument proposed, string howToSign, string nothingPublished = "Rien n'est publié.")
{
Title = title;
if (string.IsNullOrEmpty(digest))
{
Text = nothingPublished;
IsValid = true;
return;
}
var signer = signature is null ? null : $"{signerName ?? $"clé {signature.KeyId}"}, le {AccountTexts.Date(signature.SignedAt)}";
var now = published is null ? ContentCheck.Fail("aucun trousseau publié") : ContentSigning.Verify(kind, id, digest, signature, published);
var next = ContentSigning.Verify(kind, id, digest, signature, proposed);
IsValid = now.Valid;
WillBreak = !next.Valid;
Text = signature is null
? $"Non signé. {howToSign}"
: now.Valid
? $"Signé par {signer} : valide pour les launchers."
: $"Signé par {signer}, mais refusé par les launchers : {now.Reason}. {howToSign}";
if (signature is not null && now.Valid && !next.Valid)
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
}
private SignedContentItem(string title, string text)
{
Title = title;
Text = text;
}
/// <summary>Contenu dont l'état ne peut pas être lu (refusé, à corriger sur le serveur).</summary>
internal static SignedContentItem Failed(string title, string text) => new(title, text);
public string Title { get; }
public string Text { get; }
/// <summary>Accepté par les launchers avec le trousseau publié.</summary>
public bool IsValid { get; }
/// <summary>Serait refusé avec le trousseau proposé.</summary>
public bool WillBreak { get; }
}
@@ -35,6 +35,18 @@ public sealed class AdminEnvironment
public TimeProvider Time { get; init; } = TimeProvider.System;
/// <summary>
/// Clés racine intégrées à l'exe (update-signing.pub) : vérification d'un trousseau signé avant sa publication
/// (page « Signature »). Vide : aucune clé intégrée.
/// </summary>
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; init; } = Array.Empty<(string, byte[])>();
/// <summary>
/// Avertissement avant de publier un contenu signé par une clé que les launchers des joueurs n'accepteront pas
/// (absente du trousseau publié, ou sans l'autorisation voulue). Null : aucun avertissement.
/// </summary>
public PlayerSigningCheck? PlayerSigning { get; init; }
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
public IAdminTpm? Tpm { get; init; }
@@ -47,6 +59,10 @@ public sealed class AdminEnvironment
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
public bool StartTimers { get; init; } = true;
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
/// <summary>
/// Logiciel, pour le journal d'audit du serveur (« GameLauncher 2.5.0 »). Le poste y est identifié par le nom de
/// l'appareil choisi à l'enregistrement de sa clé (invitation), que le serveur connaît : jamais par le nom Windows du
/// PC, qui n'est pas envoyé.
/// </summary>
public string ClientName => $"GameLauncher {LauncherVersion}";
}
@@ -80,7 +80,7 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public string DisplayName => Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(Session.Profile.DisplayName);
public string RoleText => AdminTexts.Role(Session.Profile.Role);
public string RoleText => AdminTexts.Role(Session.Profile.Role, Session.Profile.RoleName);
public string KeyLabel => (Session.Profile.KeyLabel ?? Path.GetFileNameWithoutExtension(_connection.KeyPath)) +
(_connection.IsTpm ? " · puce TPM" : "");
public string ServerHost => _shell.ServerHost;
@@ -125,6 +125,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public IReadOnlyList<string> Themes => _shell.Environment.Themes;
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
@@ -1,4 +1,7 @@
using GameLauncher.Contracts.Security;
using GameLauncher.ApiClient;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
@@ -27,12 +30,71 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(RefreshAsync), () => !IsBusy);
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
OpenFeedbackCommand = new RelayCommand(() => Context.Navigate(FeedbackPanelViewModel.PanelId), () => CanReadFeedback);
}
// ------------------------------------------------------------------ Demandes des joueurs
/// <summary>Carte « Demandes des joueurs » : permission feedback.read et page présente.</summary>
public bool CanReadFeedback => Context.Session.Has(AdminPermissions.FeedbackRead) &&
Context.Panels.Any(p => p.Id == FeedbackPanelViewModel.PanelId);
public RelayCommand OpenFeedbackCommand { get; }
private string _feedbackText = "…";
private bool _hasNewFeedback;
/// <summary>« Exovival : 3 nouvelles · Autre jeu : aucune » (ou la raison d'une indisponibilité).</summary>
public string FeedbackText
{
get => _feedbackText;
private set => SetProperty(ref _feedbackText, value);
}
public bool HasNewFeedback
{
get => _hasNewFeedback;
private set => SetProperty(ref _hasNewFeedback, value);
}
private async Task RefreshFeedbackAsync()
{
try
{
var overview = await Context.Session.GetFeedbackOverviewAsync();
HasNewFeedback = overview.Games.Any(g => g.New > 0);
if (!overview.Enabled)
{
FeedbackText = "Réception désactivée sur le serveur (LauncherApi:Feedback:Enabled).";
return;
}
var names = Context.Games.ToDictionary(g => g.GameId, g => g.GameName, StringComparer.OrdinalIgnoreCase);
var parts = overview.Games.Select(g =>
$"{(names.TryGetValue(g.GameId, out var name) ? name : g.GameId)} : " +
(g.New == 0 ? "aucune nouvelle" : g.New == 1 ? "1 nouvelle" : $"{g.New} nouvelles") + $" ({g.Open} à traiter)").ToList();
FeedbackText = (parts.Count == 0 ? "Aucun jeu ne reçoit de demandes." : string.Join(" · ", parts)) +
(overview.StorageFull ? " · plafond atteint : nouvelles demandes refusées" : "");
}
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
{
FeedbackText = ex.Status == 404 ? "Serveur à mettre à jour pour les demandes des joueurs." : ApiErrorText.Describe(ex);
}
}
/// <summary>Pages « Administrateurs » et « Mes appareils » présentes (modules déclarés, droits).</summary>
public bool CanOpenAdmins => Context.Panels.Any(p => p.Id == AdminsPanelViewModel.PanelId);
public bool CanOpenDevices => Context.Panels.Any(p => p.Id == MyDevicesPanelViewModel.PanelId);
public RelayCommand OpenAdminsCommand { get; }
public RelayCommand OpenDevicesCommand { get; }
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role, Context.Session.Profile.RoleName);
public IReadOnlyList<string> Permissions { get; }
public string KeyText => $"{Context.Session.Profile.KeyLabel ?? "Clé"} · {Context.Session.Profile.KeyId}";
@@ -119,6 +181,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
ActiveInvitations = invitations.Count;
}
if (CanReadFeedback) await RefreshFeedbackAsync();
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
});
}
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff. Show more