Compare commits

...
65 Commits
Author SHA1 Message Date
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
106 changed files with 9368 additions and 1333 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
+76 -13
View File
@@ -35,7 +35,7 @@ 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`) |
@@ -191,6 +191,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 |
|---|---|
@@ -283,6 +288,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) :
@@ -307,6 +322,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)
```
@@ -492,7 +508,13 @@ Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitatio
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
servi aux joueurs (page **Signature**, voir plus bas).
servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la page **Fichiers du serveur**.
* **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.
* **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é,
@@ -513,15 +535,22 @@ servi aux joueurs (page **Signature**, voir plus bas).
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),
@@ -531,6 +560,13 @@ servi aux joueurs (page **Signature**, voir plus bas).
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
@@ -545,10 +581,19 @@ servi aux joueurs (page **Signature**, voir plus bas).
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` : propriétaires) : inviter (nom, rôle → code
* **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 un rôle, révoquer un appareil ou un compte, supprimer une invitation.
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
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**
@@ -560,18 +605,36 @@ servi aux joueurs (page **Signature**, voir plus bas).
`"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 catégorie, résultat et texte (nom, IP, clé…), export CSV des entrées filtrées.
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é.
@@ -580,6 +643,6 @@ servi aux joueurs (page **Signature**, voir plus bas).
|---|---|
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| 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/` |
+187 -7
View File
@@ -139,6 +139,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,7 +306,12 @@ 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 |
|---|:-:|:-:|:-:|---|
@@ -273,13 +320,53 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
| `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 » |
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.
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
@@ -383,6 +470,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 ↑ ↓,
@@ -499,14 +592,86 @@ que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste s
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) ; export CSV des entrées filtrées, toutes les cellules entre
guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
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`).
### 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 ».
## 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 :
@@ -560,6 +725,18 @@ et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire to
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é
@@ -577,7 +754,8 @@ démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou mod
« 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 non signées ; un launcher qui n'a jamais vu de trousseau accepte tout
**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).
@@ -594,6 +772,8 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
| **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) | ✅ |
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.
+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,11 +25,13 @@ 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.Signing.SigningModule()
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule()
};
}
@@ -45,6 +45,8 @@ public sealed class LauncherApiOptions
public ContentSigningSettings ContentSigning { get; set; } = new();
public FilesSettings Files { 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.
@@ -106,6 +108,17 @@ public sealed class LauncherApiOptions
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>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -168,6 +181,9 @@ public sealed class LauncherApiOptions
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
public string KeyringPublicUrl { 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);
@@ -201,6 +217,7 @@ public sealed class LauncherApiOptions
ValidateCatalog();
ValidateKeyring();
ValidateGames();
ValidateFiles();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
@@ -308,6 +325,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,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
{
@@ -48,8 +51,11 @@ 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
@@ -63,17 +69,18 @@ public sealed class AdminsModule : IApiModule
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
return Results.Json(new MyAccount
{
Admin = ToSummary(admin),
Admin = ToSummary(admin, db),
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
.Select(ToSummary).ToList()
.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
@@ -108,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
@@ -128,7 +136,7 @@ public sealed class AdminsModule : IApiModule
});
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
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)
@@ -172,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))
@@ -194,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,
@@ -208,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();
@@ -217,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
@@ -226,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,
@@ -257,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;
@@ -272,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
@@ -290,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 }
@@ -319,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
@@ -339,11 +359,12 @@ public sealed class AdminsModule : IApiModule
}).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,
@@ -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();
@@ -17,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) =>
@@ -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)";
}
@@ -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)));
}
@@ -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);
+10 -3
View File
@@ -145,10 +145,11 @@ 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.
/// 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)
public AuditPage Read(int limit, string? cursor, DateTimeOffset? before = null)
{
limit = Math.Clamp(limit, 1, MaxPageSize);
var months = Directory.Exists(_directory)
@@ -169,17 +170,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
};
}
@@ -3,7 +3,7 @@ using System.Text;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichier PUBLIC servi par nginx (catalogue, trousseau) : écrit atomiquement (fichier temporaire caché du
/// 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
@@ -13,7 +13,12 @@ internal static class PublicFileWriter
/// <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)
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");
@@ -23,7 +28,7 @@ internal static class PublicFileWriter
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
using (var stream = new FileStream(temp, options))
{
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text));
stream.Write(content);
stream.Flush(flushToDisk: true);
}
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
@@ -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);
}
}
@@ -45,6 +45,12 @@
// 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": ""
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
+78 -4
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).
@@ -290,6 +293,76 @@ La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans
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.
## 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 :
@@ -374,6 +447,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
+22 -1
View File
@@ -7,7 +7,7 @@
#
# 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 DEUX blocs ci-dessous ; le second, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
# =============================================================================
@@ -32,3 +32,24 @@ 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;
}
+63 -6
View File
@@ -3,6 +3,7 @@ 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.Security;
@@ -114,12 +115,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)
@@ -131,6 +179,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>
+45
View File
@@ -37,12 +37,57 @@ public static class ApiRoutes
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}";
@@ -48,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; }
}
+14 -1
View File
@@ -26,7 +26,16 @@ 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>();
@@ -44,7 +53,7 @@ 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>
@@ -56,6 +65,9 @@ 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; }
@@ -82,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; }
}
@@ -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 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,39 @@ 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";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage
};
/// <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",
_ => 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 ».",
_ => 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,7 @@ public static class AdminRoles
[Admin] = new[]
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -39,7 +44,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)
};
}
@@ -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),
@@ -15,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;
@@ -144,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);
@@ -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; }
@@ -22,7 +22,7 @@ public static class AccountTexts
_ => status
};
/// <summary>Ce que permet chaque rôle (liste des rôles proposés).</summary>
/// <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",
@@ -48,7 +48,7 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
protected AccountsPanelBase(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
}
public AsyncRelayCommand RefreshCommand { get; }
@@ -62,7 +62,9 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
get => _statusMessage;
protected set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
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
}
}
@@ -248,7 +250,7 @@ public sealed class InvitationItem
IsDevice = invitation.ForAdminId is not null;
Title = IsDevice
? $"Nouvel appareil de {invitation.DisplayName}"
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role)}";
: $"{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;
@@ -3,8 +3,8 @@ using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Module « Comptes » : page « Mes appareils » (tout administrateur) et page « Administrateurs »
/// (gestion des administrateurs : propriétaires).
/// 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
{
@@ -26,10 +26,19 @@ public sealed class AccountsAdminModule : ILauncherModule
registry.AddAdminPanel(new AdminPanelDescriptor(
AdminsPanelViewModel.PanelId,
"Administrateurs",
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son rôle ou révoquez-le.",
"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)));
}
}
@@ -1,3 +1,4 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
@@ -8,9 +9,10 @@ namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Administrateurs » (admins.manage) : comptes et appareils, invitations, approbation par
/// empreinte, rôles, révocation. Le serveur reste seul juge : cette page ne propose que ce qu'il
/// accepte (pas de changement de son propre rôle, pas d'appareil approuvé au nom d'un autre
/// administrateur actif…) et affiche ses refus tels quels.
/// 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
{
@@ -22,15 +24,27 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
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)
{
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));
// 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);
}
public IReadOnlyList<Choice> RoleChoices { get; }
/// <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
{
@@ -94,11 +108,26 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
get => _newRole;
set
{
if (value is not null && SetProperty(ref _newRole, value)) OnPropertyChanged(nameof(NewRoleHint));
if (value is null || !SetProperty(ref _newRole, value)) return;
OnPropertyChanged(nameof(NewRoleHint));
InviteCommand.RaiseCanExecuteChanged();
}
}
public string NewRoleHint => $"{AdminTexts.Role(_newRole.Value)} : {AccountTexts.RoleHint(_newRole.Value ?? "")}.";
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; }
@@ -113,6 +142,11 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
$"{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 () =>
@@ -120,7 +154,7 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
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)})",
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.");
@@ -133,16 +167,18 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
{
var role = admin.SelectedRole.Value!;
if (role == admin.Role) return;
var message = $"{admin.DisplayName} : {AdminTexts.Role(admin.Role)} → {AdminTexts.Role(role)}. Le changement s'applique " +
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.";
if (!Context.Dialogs.Confirm("Changer le rôle ?", message, "Changer le rôle", danger: role == AdminRoles.Owner))
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 {AdminTexts.Role(role).ToLowerInvariant()}."))
$"{admin.DisplayName} est maintenant dans le groupe « {GroupName(role)} »."))
admin.ResetRole();
}
@@ -160,6 +196,7 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
protected override async Task ReloadAsync()
{
await LoadGroupsAsync();
var admins = await Context.Session.ListAdminsAsync();
var invitations = await Context.Session.ListInvitationsAsync();
_summaries = admins;
@@ -169,6 +206,32 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
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;
@@ -178,7 +241,8 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
.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))
.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();
}
}
@@ -196,6 +260,7 @@ public sealed class AdminAccountItem : ObservableObject
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)}";
@@ -226,7 +291,7 @@ public sealed class AdminAccountItem : ObservableObject
public string DisplayName { get; }
public string Initials { get; }
public string Role { get; }
public string RoleText => AdminTexts.Role(Role);
public string RoleText { get; }
public string Status { get; }
public string StatusText => AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
@@ -235,7 +300,7 @@ public sealed class AdminAccountItem : ObservableObject
public string CreatedText { get; }
public IReadOnlyList<AdminKeyItem> Keys { get; }
/// <summary>On ne change ni son propre rôle ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
/// <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;
@@ -253,7 +318,7 @@ public sealed class AdminAccountItem : ObservableObject
}
}
/// <summary>Rôle choisi différent du rôle actuel : bouton « Appliquer » affiché.</summary>
/// <summary>Groupe choisi différent du groupe actuel : bouton « Appliquer » affiché.</summary>
public bool RoleChanged => _selectedRole.Value != Role;
public AsyncRelayCommand ChangeRoleCommand { get; }
@@ -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; }
}
@@ -24,7 +24,7 @@ public sealed class MyDevicesPanelViewModel : AccountsPanelBase
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);
/// <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);
@@ -8,8 +8,10 @@ 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 (texte, catégorie, résultat) appliqués aux entrées
/// chargées ; export CSV de ce qui est affiché.
/// 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
{
@@ -33,6 +35,14 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
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)
{
@@ -45,6 +55,8 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("catalog.", "Informations des jeux"),
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("files.", "Fichiers du serveur"),
new("audit.", "Consultations et exports du journal"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
@@ -53,9 +65,20 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
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];
RefreshCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: true)), () => !IsBusy);
_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);
@@ -63,6 +86,130 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
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
{
@@ -118,7 +265,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
}
}
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null;
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
@@ -128,7 +275,8 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
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)";
return $"{shown}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
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."
: ".");
}
@@ -153,6 +301,18 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
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>
@@ -160,10 +320,18 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
private async Task LoadAsync(bool reset)
{
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor);
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));
@@ -179,13 +347,16 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
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))
@@ -202,14 +373,45 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
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-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
$"journal-audit{period}-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
if (path is null) return;
var entries = _filtered;
if (await RunAsync(() => File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true))))
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>
@@ -226,7 +428,10 @@ public sealed class AuditItem
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);
@@ -245,6 +450,10 @@ public sealed class AuditItem
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; }
@@ -267,18 +476,28 @@ public static class AuditTexts
"admins.invite_deleted" => "Invitation supprimée",
"admins.key_approve" => "Appareil approuvé",
"admins.key_revoke" => "Appareil révoqué",
"admins.role" => "Rôle modifié",
"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",
"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",
_ => action
};
@@ -291,11 +510,15 @@ public static class AuditTexts
_ => result
};
/// <summary>Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées.</summary>
/// <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",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
@@ -308,14 +531,14 @@ public static class AuditTexts
public static string ToCsv(IEnumerable<AuditItem> items)
{
var csv = new StringBuilder();
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
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.Ip, e.KeyId, e.Target, e.Details
item.ActionText, e.Action, item.ResultText, e.ActorName, e.KeyLabel, e.Ip, e.KeyId, e.Target, e.Details
}.Select(Cell)));
}
return csv.ToString();
@@ -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,4 +1,5 @@
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
@@ -16,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
{
@@ -33,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();
@@ -184,6 +191,8 @@ 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
{
@@ -204,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,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;
}
@@ -5,6 +5,7 @@ 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;
@@ -14,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
{
@@ -29,19 +32,20 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
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);
SignOnlineCommand = new AsyncRelayCommand(SignOnlineAsync, () => CanEdit && CanSignOnline);
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
@@ -49,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();
}
}
@@ -114,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();
}
@@ -201,7 +246,9 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
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;
@@ -218,14 +265,17 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
private async Task RefreshSignatureAsync(string gameId)
{
ManifestSignatureState? online;
try
{
_online = await Context.Session.GetManifestSignatureAsync(gameId);
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
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();
}
@@ -240,6 +290,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
@@ -276,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;
@@ -299,7 +352,7 @@ 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();
@@ -353,22 +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)}";
}
@@ -407,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));
@@ -439,5 +502,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
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;
}
@@ -14,7 +14,7 @@ public sealed class AdminLoginService
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>
/// <param name="rootKeys">
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
@@ -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>
@@ -25,9 +25,9 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
public SigningPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
ExportRequestCommand = new AsyncRelayCommand(ExportRequestAsync, () => !IsBusy && _state is not null && NeedsNewKeyring);
PublishSignedCommand = new AsyncRelayCommand(PublishSignedAsync, () => !IsBusy && _state is { KeyringPublishingEnabled: true });
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é
@@ -141,7 +141,7 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
PublishSignedCommand.RaiseCanExecuteChanged();
}
/// <summary>Appareils actifs des comptes actifs dont le rôle permet de publier une version ou le catalogue.</summary>
/// <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
@@ -151,7 +151,8 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
KeyId = k.KeyId,
PublicKey = k.PublicKey!,
Name = Truncate($"{a.DisplayName} — {AccountTexts.KeyLabel(k.Label)}"),
Scopes = SigningScopes.FromPermissions(AdminRoles.PermissionsOf(a.Role)).ToList()
// 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)
@@ -41,6 +41,12 @@ public sealed class AdminEnvironment
/// </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; }
@@ -53,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;
@@ -126,6 +126,7 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
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();
@@ -28,7 +28,7 @@ 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);
}
@@ -43,7 +43,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
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}";
@@ -0,0 +1,45 @@
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>
/// Choix du jeu d'une page qui ne concerne qu'un jeu à la fois (« Notes de version », « Publier une version ») :
/// une tuile par jeu déclaré dans launcher.json. Avec plusieurs jeux, la page n'affiche rien d'autre tant qu'aucune
/// tuile n'a été choisie (pas de jeu par défaut : pas de modification ni de publication sur le mauvais jeu par mégarde).
/// </summary>
public sealed class GameTile
{
private GameTile(Choice choice, string name, string detail, Action<Choice> select)
{
Choice = choice;
Name = name;
Detail = detail;
SelectCommand = new RelayCommand(() => select(choice));
}
public Choice Choice { get; }
public string Name { get; }
/// <summary>« Identifiant EZR · serveur cdn.exemple.fr ».</summary>
public string Detail { get; }
public RelayCommand SelectCommand { get; }
/// <summary>Choix « Nom (identifiant) » des jeux déclarés.</summary>
public static IReadOnlyList<Choice> Choices(IAdminContext context) =>
context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
/// <summary>Jeu choisi d'office : le seul déclaré, sinon aucun (l'administrateur choisit).</summary>
public static Choice? Initial(IReadOnlyList<Choice> choices) => choices.Count == 1 ? choices[0] : null;
public static IReadOnlyList<GameTile> For(IAdminContext context, IReadOnlyList<Choice> choices, Action<Choice> select) =>
choices.Select(choice =>
{
var game = context.Games.FirstOrDefault(g => string.Equals(g.GameId, choice.Value, StringComparison.OrdinalIgnoreCase));
var host = game is not null && Uri.TryCreate(game.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : null;
return new GameTile(choice, game?.GameName ?? choice.Label,
$"Identifiant {choice.Value}" + (string.IsNullOrEmpty(host) ? "" : $" · serveur {host}"), select);
}).ToList();
}
@@ -1,3 +1,4 @@
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin;
@@ -38,6 +39,42 @@ public abstract class AdminPanelViewModel : ObservableObject
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
/// <summary>
/// Page en deux colonnes (liste à gauche, fiche à droite) qui défilent séparément : la fenêtre ne fait alors plus
/// défiler la page entière, elle lui donne la hauteur disponible (la liste reste visible quand on descend dans la
/// fiche). Indication d'affichage seulement ; une page qui en change signale <see cref="SplitLayoutChanged"/>.
/// </summary>
public virtual bool HasSplitLayout => false;
protected void SplitLayoutChanged() => OnPropertyChanged(nameof(HasSplitLayout));
/// <summary>
/// Change à chaque demande de retour en haut de la page (la fenêtre fait défiler la page jusqu'en haut) :
/// après « Recharger », « Publier »… le bandeau du résultat, en haut, est visible sans remonter à la main.
/// </summary>
public int ScrollToTopToken
{
get => _scrollToTopToken;
private set => SetProperty(ref _scrollToTopToken, value);
}
private int _scrollToTopToken;
protected void RequestScrollToTop() => ScrollToTopToken = unchecked(_scrollToTopToken + 1);
/// <summary>Action d'un bouton « Recharger », « Publier »… : retour en haut de la page au clic et une fois terminée.</summary>
protected Func<Task> ScrollingToTop(Func<Task> action) => async () =>
{
RequestScrollToTop();
try
{
await action();
}
finally
{
RequestScrollToTop();
}
};
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
public Task ActivateAsync()
{
@@ -48,6 +85,38 @@ public abstract class AdminPanelViewModel : ObservableObject
protected virtual Task OnActivatedAsync(bool firstTime) => Task.CompletedTask;
/// <summary>
/// Avant une publication signée (catalogue, version d'un jeu) : si les launchers des joueurs n'accepteront pas la
/// clé de ce PC (absente du trousseau publié, ou sans l'autorisation voulue), avertissement avec la possibilité de
/// continuer. Vrai : publier ; faux : annulé. Simple avertissement : le serveur, lui, accepte la publication.
/// </summary>
/// <param name="kind">ContentKinds.Catalog ou ContentKinds.Manifest.</param>
/// <param name="confirmText">Bouton pour continuer malgré l'avertissement.</param>
protected async Task<bool> ConfirmPlayerSigningAsync(string kind, string confirmText = "Publier quand même")
{
if (Context.PlayerSigning is not { } check) return true;
string? warning;
var wasBusy = IsBusy;
IsBusy = true; // contrôle réseau (quelques secondes au plus) : boutons désactivés
try
{
warning = await check.WarningAsync(kind, Context.Session.PublicKey);
}
catch (Exception ex)
{
// Contrôle indicatif : une erreur imprévue ne bloque pas la publication.
Log.Warn($"Administration : contrôle du trousseau des joueurs impossible ({ex.Message}).");
return true;
}
finally
{
IsBusy = wasBusy;
}
if (warning is null) return true;
Log.Warn($"Administration : avertissement avant publication ({kind}) : {warning.Split('\n')[0]}");
return Context.Dialogs.Confirm("Clé non reconnue par les launchers des joueurs", warning, confirmText);
}
/// <summary>
/// Exécute une action réseau : « occupé » pendant l'action, erreur affichée en cas d'échec,
/// retour à la connexion si la session a expiré. Vrai si l'action a réussi.
@@ -67,7 +136,10 @@ public abstract class AdminPanelViewModel : ObservableObject
if (ApiErrorText.IsSessionLost(ex))
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
else
{
ErrorMessage = ApiErrorText.Describe(ex);
RequestScrollToTop(); // bandeau d'erreur en haut de la page
}
return false;
}
finally
@@ -30,6 +30,12 @@ public interface IAdminContext
/// <summary>Clés racine intégrées à l'exe (vérification du trousseau avant sa publication).</summary>
IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; }
/// <summary>
/// Contrôle avant publication : la clé de ce PC est-elle acceptée par les launchers des joueurs (trousseau publié) ?
/// Null : pas de contrôle (signatures non vérifiées par ce launcher, ou tests).
/// </summary>
PlayerSigningCheck? PlayerSigning => null;
/// <summary>Pages visibles pour cet administrateur.</summary>
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
@@ -0,0 +1,122 @@
using System.Text.Json;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Trust;
namespace GameLauncher.Modules.Admin;
/// <summary>
/// Avertissement avant une publication signée (catalogue, version d'un jeu) : la clé de ce PC figure-t-elle dans le
/// trousseau publié (celui que téléchargent les launchers des joueurs), avec l'autorisation voulue ?
/// Contrôle côté administration uniquement, sans effet sur le serveur, qui accepte la publication dans tous les cas :
/// c'est le launcher des joueurs qui ignore un contenu signé par une clé absente du trousseau (contentSigning « require »).
/// Le trousseau est relu à chaque contrôle (pas de cache) : une clé ajoutée par un propriétaire est vue aussitôt.
/// </summary>
public sealed class PlayerSigningCheck
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(15);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri? _keyringUrl;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
/// <param name="download">Téléchargement d'un petit fichier (https, taille limitée, sans cache).</param>
/// <param name="keyringUrl">« keyringUrl » du launcher.json intégré (le même que celui des joueurs).</param>
/// <param name="mode">« contentSigning » du launcher.json intégré.</param>
/// <param name="minKeyringSequence">« keyringMinSequence » : un trousseau plus ancien est refusé par les launchers.</param>
/// <param name="rootKeys">Clés racine intégrées à l'exe (vérification du trousseau, comme chez les joueurs).</param>
public PlayerSigningCheck(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? keyringUrl, ContentSigningMode mode,
long minKeyringSequence, IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys)
{
_download = download;
_keyringUrl = keyringUrl;
Mode = mode;
MinKeyringSequence = Math.Max(0, minKeyringSequence);
_rootKeys = rootKeys;
}
public ContentSigningMode Mode { get; }
public long MinKeyringSequence { get; }
/// <summary>
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
/// </summary>
/// <param name="kind"><see cref="ContentKinds.Catalog"/> ou <see cref="ContentKinds.Manifest"/>.</param>
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return null;
var keyId = AdminKeys.ComputeKeyId(publicKey);
var scope = ContentKinds.ScopeOf(kind);
string problem;
if (_keyringUrl is null)
{
problem = "Aucun trousseau n'est configuré dans ce launcher (« keyringUrl ») : les launchers des joueurs ne peuvent vérifier aucune signature.";
}
else
{
KeyringDocument keyring;
try
{
keyring = await DownloadAsync(_keyringUrl, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
problem = $"Le trousseau publié ({_keyringUrl}) est refusé par ce launcher : {ex.Message}.";
return Compose(kind, problem);
}
catch (Exception ex)
{
problem = $"Le trousseau publié ({_keyringUrl}) est injoignable ({ex.Message}) : impossible de vérifier que la clé de ce PC y figure.";
return Compose(kind, problem);
}
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
if (key is null)
problem = $"La clé de ce PC ({keyId}) n'est pas dans le trousseau publié (n° {keyring.Sequence}).";
else if (!key.Scopes.Contains(scope, StringComparer.Ordinal))
problem = $"La clé de ce PC ({keyId}, « {key.Name} ») est dans le trousseau publié (n° {keyring.Sequence}), " +
$"mais sans l'autorisation « {SigningScopes.DisplayName(scope)} ».";
else
return null;
}
return Compose(kind, problem);
}
/// <summary>Trousseau publié, vérifié comme le font les launchers des joueurs (format, clé racine, numéro minimal).</summary>
private async Task<KeyringDocument> DownloadAsync(Uri url, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
var bytes = await _download(url, 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].TrimEnd('.'));
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error.TrimEnd('.'));
if (keyring.Sequence < MinKeyringSequence)
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
return keyring;
}
private string Compose(string kind, string problem)
{
var catalog = kind == ContentKinds.Catalog;
var consequence = Mode == ContentSigningMode.Require
? catalog
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
"(celui intégré au launcher pour une installation neuve)."
: "Les launchers des joueurs (signatures obligatoires) refuseront cette version : ni installation, ni mise à jour " +
"(le jeu déjà installé reste jouable)."
: "Les launchers des joueurs l'accepteront, mais inscriront une alerte de signature dans leur journal (vérification en mode « warn »).";
return problem + "\n\n" + consequence + "\n\n" +
"Le serveur accepte la publication quand même. Pour régler ce point, un propriétaire ajoute la clé de ce PC au trousseau " +
"(page « Signature »), le fait signer avec la clé racine puis le publie : les launchers accepteront alors ce contenu " +
"sans nouvelle publication.";
}
}
+11 -3
View File
@@ -18,6 +18,7 @@ using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Platform;
@@ -57,6 +58,7 @@ public partial class App : Application, ILauncherRestartHost
private AdminWindowService? _admin;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
private PlayerSigningCheck? _playerSigning;
private Task<CatalogDocument?>? _catalogFetch;
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -253,6 +255,9 @@ public partial class App : Application, ILauncherRestartHost
var version = VersionUtil.Display(_version);
Log.Info($"Démarrage du launcher {version} ({catalog.Games.Count} jeu(x))");
// Mode d'affichage (plein écran ou fenêtré) mémorisé pour chaque fenêtre qui le permet. Pas en mode --capture.
Controls.WindowModeAssist.Store = readOnly ? null : new WindowModeStore(Path.Combine(catalog.DataDirectory, WindowModeStore.FileName));
var window = new Views.MainWindow();
MainWindow = window;
_mainWindow = window;
@@ -355,7 +360,7 @@ public partial class App : Application, ILauncherRestartHost
{
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
ThemeManager.BuiltInThemes.ToList());
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
window.AdminRequested += _admin.Open;
}
@@ -475,8 +480,10 @@ public partial class App : Application, ILauncherRestartHost
Log.Error("Signatures du contenu : aucune clé racine intégrée (Security\\update-signing.pub) : aucun trousseau ne peut être vérifié.");
Log.Info($"Signatures du contenu : mode « {mode.ToString().ToLowerInvariant()} », trousseau {keyringUrl ?? "non configuré"}" +
(minSequence > 0 ? $" (n° {minSequence} au minimum)." : "."));
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUrl is null ? null : new Uri(keyringUrl), roots, mode,
minSequence);
var keyringUri = keyringUrl is null ? null : new Uri(keyringUrl);
// Menu d'administration : avertissement avant de publier avec une clé que ces launchers n'accepteraient pas.
_playerSigning = new PlayerSigningCheck(_http!.GetBytesAsync, keyringUri, mode, minSequence, roots);
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUri, roots, mode, minSequence);
}
/// <summary>
@@ -568,6 +575,7 @@ public partial class App : Application, ILauncherRestartHost
new CatalogAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
new AccountsAdminModule(),
new SigningAdminModule(),
new AuditAdminModule()
+143
View File
@@ -0,0 +1,143 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
namespace GameLauncher.Controls;
/// <summary>
/// Texte indicatif d'un champ vide (« https://… », « jj/mm/aaaa »…), affiché par les styles
/// ModernTextBoxStyle / ModernMultilineTextBoxStyle (Themes/Controls.xaml) tant que le champ est vide.
/// </summary>
public static class TextAssist
{
public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached(
"Placeholder", typeof(string), typeof(TextAssist), new FrameworkPropertyMetadata(null));
public static string? GetPlaceholder(DependencyObject element) => (string?)element.GetValue(PlaceholderProperty);
public static void SetPlaceholder(DependencyObject element, string? value) => element.SetValue(PlaceholderProperty, value);
}
/// <summary>
/// Retour en haut d'un <see cref="ScrollViewer"/> : chaque changement de <c>ScrollToTopToken</c> (demandé par la page
/// après « Recharger », « Publier »…) ou de <c>ResetOn</c> (page affichée) remonte la page, une fois la mise en page faite.
/// </summary>
public static class ScrollAssist
{
public static readonly DependencyProperty ScrollToTopTokenProperty = DependencyProperty.RegisterAttached(
"ScrollToTopToken", typeof(int), typeof(ScrollAssist), new PropertyMetadata(0, OnScrollRequested));
public static int GetScrollToTopToken(DependencyObject element) => (int)element.GetValue(ScrollToTopTokenProperty);
public static void SetScrollToTopToken(DependencyObject element, int value) => element.SetValue(ScrollToTopTokenProperty, value);
public static readonly DependencyProperty ResetOnProperty = DependencyProperty.RegisterAttached(
"ResetOn", typeof(object), typeof(ScrollAssist), new PropertyMetadata(null, OnScrollRequested));
public static object? GetResetOn(DependencyObject element) => element.GetValue(ResetOnProperty);
public static void SetResetOn(DependencyObject element, object? value) => element.SetValue(ResetOnProperty, value);
private static void OnScrollRequested(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not ScrollViewer viewer) return;
viewer.ScrollToTop();
// Contenu remplacé au même moment (rechargement) : encore une fois après la mise en page.
viewer.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(viewer.ScrollToTop));
}
}
/// <summary>
/// Molette de la souris dans le menu d'administration : elle fait défiler ce qui est SOUS le curseur, jamais une
/// liste déroulante fermée (pas de paramètre changé par mégarde en faisant défiler la page), ni un élément qui
/// garderait la molette sans pouvoir défiler (liste, champ texte) : la page défile alors à sa place.
/// Une liste déroulante OUVERTE garde son comportement normal (défilement de ses choix).
/// </summary>
public static class WheelAssist
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled", typeof(bool), typeof(WheelAssist), new PropertyMetadata(false, OnIsEnabledChanged));
public static bool GetIsEnabled(DependencyObject element) => (bool)element.GetValue(IsEnabledProperty);
public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value);
/// <summary>Défilement d'un cran de molette (comme WPF : 3 lignes de 16 pixels par défaut).</summary>
private static double PixelsPerNotch => Math.Max(1, SystemParameters.WheelScrollLines) * 16.0;
private static void OnIsEnabledChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not UIElement root) return;
root.PreviewMouseWheel -= OnPreviewMouseWheel;
if (e.NewValue is true) root.PreviewMouseWheel += OnPreviewMouseWheel;
}
private static void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (e.Handled || sender is not UIElement root || e.Delta == 0) return;
var source = e.OriginalSource as DependencyObject;
// Liste déroulante ouverte (son contenu est dans une fenêtre surgissante) : comportement normal.
if (source is Visual sourceVisual && PresentationSource.FromVisual(sourceVisual) is { } sourcePresentation &&
!ReferenceEquals(sourcePresentation, PresentationSource.FromVisual(root)))
return;
if (Ancestor<ComboBox>(source) is { IsDropDownOpen: true }) return;
// Élément sous le curseur (pas forcément celui qui a le focus clavier).
var hit = root.InputHitTest(e.GetPosition(root)) as DependencyObject;
if (hit is null) return;
if (Ancestor<ComboBox>(hit) is { IsDropDownOpen: true }) return;
var target = ScrollableAncestor(hit, e.Delta);
var overClosedComboBox = Ancestor<ComboBox>(source) is { IsDropDownOpen: false } || Ancestor<ComboBox>(hit) is { IsDropDownOpen: false };
if (!overClosedComboBox && ReferenceEquals(Ancestor<ScrollViewer>(source), target)) return; // comportement normal
e.Handled = true; // la liste déroulante fermée ou l'élément qui ne peut pas défiler ne reçoit pas la molette
if (target is null) return;
if (target.CanContentScroll)
{
for (var i = 0; i < Math.Max(1, SystemParameters.WheelScrollLines); i++)
if (e.Delta > 0) target.LineUp();
else target.LineDown();
}
else
{
target.ScrollToVerticalOffset(target.VerticalOffset - e.Delta * PixelsPerNotch / Mouse.MouseWheelDeltaForOneLine);
}
}
/// <summary>Premier ScrollViewer parent qui peut encore défiler dans le sens de la molette.</summary>
private static ScrollViewer? ScrollableAncestor(DependencyObject? element, int delta)
{
for (var current = element; current is not null; current = Parent(current))
if (current is ScrollViewer viewer && viewer.ScrollableHeight > 0 &&
(delta > 0 ? viewer.VerticalOffset > 0 : viewer.VerticalOffset < viewer.ScrollableHeight))
return viewer;
return null;
}
private static T? Ancestor<T>(DependencyObject? element) where T : DependencyObject
{
for (var current = element; current is not null; current = Parent(current))
if (current is T match)
return match;
return null;
}
private static DependencyObject? Parent(DependencyObject element) =>
element is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(element) ?? LogicalTreeHelper.GetParent(element)
: LogicalTreeHelper.GetParent(element); // Run, Hyperlink… (éléments de texte)
}
/// <summary>
/// Barre d'actions d'une page du menu d'administration, affichée SOUS la zone qui défile (toujours visible) :
/// modèle « AdminFooter.&lt;type de la page&gt; » de Themes/Admin.xaml, sinon rien.
/// </summary>
public sealed class AdminFooterTemplateSelector : DataTemplateSelector
{
public override DataTemplate? SelectTemplate(object? item, DependencyObject container)
{
if (item is null || container is not FrameworkElement element) return null;
return element.TryFindResource("AdminFooter." + item.GetType().Name) as DataTemplate
?? element.TryFindResource("AdminFooter.None") as DataTemplate;
}
}
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace GameLauncher.Controls;
/// <summary>
/// Mise en page responsive d'une fenêtre à deux modes (fenêtré / plein écran, voir <see cref="WindowModeAssist"/>) :
/// <list type="bullet">
/// <item>place disponible plus GRANDE que la taille minimale de mise en page (<see cref="DesignWidth"/> ×
/// <see cref="DesignHeight"/>, un peu en dessous de la taille fenêtrée : le contenu défile déjà verticalement) : le contenu
/// occupe toute la place (colonnes, listes et ascenseurs s'adaptent) ;</item>
/// <item>place plus PETITE (petit écran, mise à l'échelle Windows 125-150 %) : le contenu est mis en page à la taille de
/// conception puis réduit d'un bloc, sans rien rogner ni masquer : tous les boutons restent accessibles, tous les textes
/// visibles.</item>
/// </list>
/// Le contenu n'est jamais agrandi : en plein écran sur un grand écran, il gagne de la place, pas des pixels flous.
/// </summary>
public sealed class ScaleToFitDecorator : Decorator
{
public static readonly DependencyProperty DesignWidthProperty = DependencyProperty.Register(
nameof(DesignWidth), typeof(double), typeof(ScaleToFitDecorator),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
public static readonly DependencyProperty DesignHeightProperty = DependencyProperty.Register(
nameof(DesignHeight), typeof(double), typeof(ScaleToFitDecorator),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
/// <summary>Largeur minimale de mise en page du contenu (0 : jamais réduit en largeur).</summary>
public double DesignWidth
{
get => (double)GetValue(DesignWidthProperty);
set => SetValue(DesignWidthProperty, value);
}
/// <summary>Hauteur minimale de mise en page du contenu (0 : jamais réduit en hauteur).</summary>
public double DesignHeight
{
get => (double)GetValue(DesignHeightProperty);
set => SetValue(DesignHeightProperty, value);
}
/// <summary>Échelle appliquée (1 : taille réelle ; en dessous, contenu réduit).</summary>
public double Scale { get; private set; } = 1;
/// <summary>Plus petite échelle acceptée : en dessous, les ascenseurs du contenu prennent le relais.</summary>
private const double MinScale = 0.5;
/// <summary>Écart négligeable (arrondis) : pas de réduction, qui rendrait le texte un peu flou pour rien.</summary>
private const double Tolerance = 0.995;
private static bool IsValidSize(object value) => value is double d && !double.IsNaN(d) && !double.IsInfinity(d) && d >= 0;
private double ScaleFor(Size available)
{
var scale = 1.0;
if (DesignWidth > 0 && !double.IsInfinity(available.Width) && available.Width < DesignWidth)
scale = Math.Min(scale, available.Width / DesignWidth);
if (DesignHeight > 0 && !double.IsInfinity(available.Height) && available.Height < DesignHeight)
scale = Math.Min(scale, available.Height / DesignHeight);
return scale >= Tolerance ? 1 : Math.Max(MinScale, scale);
}
protected override Size MeasureOverride(Size constraint)
{
if (Child is not { } child) return default;
var scale = ScaleFor(constraint);
child.Measure(new Size(constraint.Width / scale, constraint.Height / scale));
var desired = child.DesiredSize;
return new Size(Math.Min(desired.Width * scale, constraint.Width), Math.Min(desired.Height * scale, constraint.Height));
}
protected override Size ArrangeOverride(Size arrangeSize)
{
if (Child is not { } child) return arrangeSize;
var scale = ScaleFor(arrangeSize);
Scale = scale;
// Mis en page à la taille « non réduite », puis réduit à l'affichage (le clic et les infobulles suivent la réduction).
child.Arrange(new Rect(0, 0, arrangeSize.Width / scale, arrangeSize.Height / scale));
if (scale >= 1)
{
if (child.RenderTransform is not MatrixTransform { Matrix.IsIdentity: true }) child.RenderTransform = Transform.Identity;
}
else if (child.RenderTransform is not ScaleTransform current || Math.Abs(current.ScaleX - scale) > 0.0001)
{
child.RenderTransform = new ScaleTransform(scale, scale);
}
return arrangeSize;
}
}
@@ -0,0 +1,363 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using GameLauncher.Platform;
namespace GameLauncher.Controls;
/// <summary>
/// Deux modes d'affichage seulement pour une fenêtre sans bordure : FENÊTRÉ (taille d'origine, jamais redimensionnable
/// par les bords ou les coins) ou 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). Ce n'est pas le plein écran
/// exclusif d'un jeu.
/// <para>
/// Une fenêtre s'y inscrit en XAML, sans code : <c>ui:WindowModeAssist.AllowFullScreen="True"</c> (et, pour retrouver
/// le mode au prochain affichage, <c>ui:WindowModeAssist.PersistenceKey="clé"</c>). Elle reçoit alors :
/// </para>
/// <list type="bullet">
/// <item>la touche F11 et la commande <see cref="ToggleFullScreenCommand"/> (bouton prêt à l'emploi : style
/// « WindowModeButtonStyle » de Themes/Controls.xaml, icône et infobulle selon le mode) ;</item>
/// <item>les ressources « WindowChrome.* » de la fenêtre (CornerRadius, Thickness : arrondis, marge de l'ombre) mises à 0
/// en plein écran, puis restaurées : les éléments qui les utilisent en DynamicResource suivent le mode ;</item>
/// <item>en mode fenêtré, une taille limitée à la zone utile de SON écran (petits écrans, mise à l'échelle 125-150 %).</item>
/// </list>
/// Les fenêtres qui ne s'y inscrivent pas (boîtes de dialogue, paramètres…) gardent leur taille fixe.
/// </summary>
public static class WindowModeAssist
{
/// <summary>Bascule fenêtré / plein écran de la fenêtre qui contient l'élément (F11).</summary>
public static readonly RoutedUICommand ToggleFullScreenCommand =
new("Plein écran", nameof(ToggleFullScreenCommand), typeof(WindowModeAssist));
/// <summary>Préfixe des ressources de la fenêtre (CornerRadius, Thickness) remises à zéro en plein écran.</summary>
public const string ChromeResourcePrefix = "WindowChrome.";
/// <summary>
/// Modes mémorisés d'une fenêtre à l'autre (<see cref="PersistenceKeyProperty"/>). Null : rien n'est mémorisé
/// (mode --capture, tests). Fixé par l'application avant l'affichage des fenêtres.
/// </summary>
public static WindowModeStore? Store { get; set; }
// ------------------------------------------------------------------ Propriétés attachées
public static readonly DependencyProperty AllowFullScreenProperty = DependencyProperty.RegisterAttached(
"AllowFullScreen", typeof(bool), typeof(WindowModeAssist), new PropertyMetadata(false, OnAllowFullScreenChanged));
public static bool GetAllowFullScreen(DependencyObject element) => (bool)element.GetValue(AllowFullScreenProperty);
public static void SetAllowFullScreen(DependencyObject element, bool value) => element.SetValue(AllowFullScreenProperty, value);
/// <summary>Mode courant de la fenêtre (liable ; les déclencheurs des styles s'en servent).</summary>
public static readonly DependencyProperty IsFullScreenProperty = DependencyProperty.RegisterAttached(
"IsFullScreen", typeof(bool), typeof(WindowModeAssist),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsFullScreenChanged));
public static bool GetIsFullScreen(DependencyObject element) => (bool)element.GetValue(IsFullScreenProperty);
public static void SetIsFullScreen(DependencyObject element, bool value) => element.SetValue(IsFullScreenProperty, value);
/// <summary>Clé sous laquelle le mode de la fenêtre est mémorisé (« main », « admin »…) ; vide : non mémorisé.</summary>
public static readonly DependencyProperty PersistenceKeyProperty = DependencyProperty.RegisterAttached(
"PersistenceKey", typeof(string), typeof(WindowModeAssist), new PropertyMetadata(null));
public static string? GetPersistenceKey(DependencyObject element) => (string?)element.GetValue(PersistenceKeyProperty);
public static void SetPersistenceKey(DependencyObject element, string? value) => element.SetValue(PersistenceKeyProperty, value);
// État interne d'une fenêtre inscrite.
private static readonly DependencyProperty StateProperty = DependencyProperty.RegisterAttached(
"State", typeof(WindowModeState), typeof(WindowModeAssist), new PropertyMetadata(null));
private sealed class WindowModeState
{
public readonly Dictionary<object, object> WindowedChrome = new();
public bool Syncing;
}
// ------------------------------------------------------------------ API pour le code
/// <summary>Bascule le mode d'une fenêtre inscrite (sans effet sinon).</summary>
public static void Toggle(Window window)
{
if (GetAllowFullScreen(window)) SetIsFullScreen(window, !GetIsFullScreen(window));
}
/// <summary>
/// Réaffiche une fenêtre réduite dans SON mode (plein écran ou fenêtré) : à utiliser à la place de
/// <c>WindowState = Normal</c>, qui ferait quitter le plein écran.
/// </summary>
public static void RestoreFromMinimized(Window window)
{
if (window.WindowState != WindowState.Minimized) return;
window.WindowState = GetAllowFullScreen(window) && GetIsFullScreen(window) ? WindowState.Maximized : WindowState.Normal;
}
/// <summary>Déplacement par la barre de titre : seulement en mode fenêtré (en plein écran, la fenêtre reste en place).</summary>
public static void DragMove(Window window)
{
if (window.WindowState != WindowState.Normal) return;
try
{
window.DragMove();
}
catch (InvalidOperationException)
{
// bouton de la souris déjà relâché
}
}
/// <summary>Zone utile (hors barre des tâches) de l'écran où se trouve l'élément, en pixels indépendants (DIP).</summary>
public static Rect WorkArea(Visual? reference)
{
if (reference is not null && PresentationSource.FromVisual(reference) is HwndSource { Handle: var handle } source &&
handle != IntPtr.Zero && source.CompositionTarget is { } target)
{
var monitor = MonitorFromWindow(handle, MonitorDefaultToNearest);
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (monitor != IntPtr.Zero && GetMonitorInfo(monitor, ref info))
{
var fromDevice = target.TransformFromDevice;
var topLeft = fromDevice.Transform(new Point(info.Work.Left, info.Work.Top));
var bottomRight = fromDevice.Transform(new Point(info.Work.Right, info.Work.Bottom));
return new Rect(topLeft, bottomRight);
}
}
return SystemParameters.WorkArea;
}
// ------------------------------------------------------------------ Mise en place
private static void OnAllowFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not Window window) return;
if (e.NewValue is true)
{
if (window.GetValue(StateProperty) is not null) return;
window.SetValue(StateProperty, new WindowModeState());
window.CommandBindings.Add(new CommandBinding(ToggleFullScreenCommand, (_, args) =>
{
Toggle(window);
args.Handled = true;
}, (_, args) =>
{
args.CanExecute = true;
args.Handled = true;
}));
window.InputBindings.Add(new KeyBinding(ToggleFullScreenCommand, Key.F11, ModifierKeys.None));
window.SourceInitialized += OnSourceInitialized;
window.StateChanged += OnStateChanged;
}
else
{
SetIsFullScreen(window, false);
}
}
/// <summary>Juste avant le premier affichage : taille adaptée à l'écran, puis mode mémorisé.</summary>
private static void OnSourceInitialized(object? sender, EventArgs e)
{
if (sender is not Window window) return;
// Une fenêtre sans bordure agrandie couvrirait par défaut tout l'écran, barre des tâches comprise :
// sa taille agrandie est ramenée à la zone utile de son écran (WM_GETMINMAXINFO).
if (PresentationSource.FromVisual(window) is HwndSource source) source.AddHook(LimitMaximizedToWorkArea);
FitToWorkArea(window);
if (GetPersistenceKey(window) is { Length: > 0 } key && Store?.Get(key) == true) SetIsFullScreen(window, true);
}
/// <summary>
/// Mode fenêtré : jamais plus grande que la zone utile de son écran, et entièrement visible. Une fenêtre placée
/// volontairement hors des écrans (captures de tools\UiSnapshot) n'est pas déplacée.
/// </summary>
private static void FitToWorkArea(Window window)
{
var work = WorkArea(window);
if (work.Width < 1 || work.Height < 1) return;
if (window.Width > work.Width) window.Width = work.Width;
if (window.Height > work.Height) window.Height = work.Height;
if (double.IsNaN(window.Left) || double.IsNaN(window.Top)) return;
if (!work.IntersectsWith(new Rect(window.Left, window.Top, window.Width, window.Height))) return;
if (window.Left + window.Width > work.Right) window.Left = work.Right - window.Width;
if (window.Top + window.Height > work.Bottom) window.Top = work.Bottom - window.Height;
if (window.Left < work.Left) window.Left = work.Left;
if (window.Top < work.Top) window.Top = work.Top;
}
// ------------------------------------------------------------------ Changement de mode
private static void OnIsFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not Window window || window.GetValue(StateProperty) is not WindowModeState state) return;
var fullScreen = e.NewValue is true;
if (fullScreen && !GetAllowFullScreen(window))
{
SetIsFullScreen(window, false);
return;
}
ApplyChrome(window, state, fullScreen);
if (!state.Syncing)
{
state.Syncing = true;
try
{
// Fenêtre réduite : le mode s'appliquera à sa réapparition (RestoreFromMinimized).
if (window.WindowState != WindowState.Minimized)
window.WindowState = fullScreen ? WindowState.Maximized : WindowState.Normal;
}
finally
{
state.Syncing = false;
}
}
if (GetPersistenceKey(window) is { Length: > 0 } key) Store?.Set(key, fullScreen);
}
/// <summary>
/// État changé par Windows (Win+Flèche bas, réduction depuis la barre des tâches…) : le mode suit. Une fenêtre réduite
/// garde son mode ; une fenêtre agrandie n'existe qu'en plein écran (pas d'agrandissement partiel).
/// </summary>
private static void OnStateChanged(object? sender, EventArgs e)
{
if (sender is not Window window || window.GetValue(StateProperty) is not WindowModeState state || state.Syncing) return;
if (window.WindowState == WindowState.Minimized) return;
var fullScreen = window.WindowState == WindowState.Maximized;
if (fullScreen == GetIsFullScreen(window)) return;
state.Syncing = true;
try
{
SetIsFullScreen(window, fullScreen);
}
finally
{
state.Syncing = false;
}
}
/// <summary>Ressources « WindowChrome.* » de la fenêtre : 0 en plein écran, valeurs d'origine en mode fenêtré.</summary>
private static void ApplyChrome(Window window, WindowModeState state, bool fullScreen)
{
if (fullScreen)
{
foreach (var key in window.Resources.Keys.OfType<string>().Where(k => k.StartsWith(ChromeResourcePrefix, StringComparison.Ordinal)).ToList())
{
var value = window.Resources[key];
object? flat = value switch
{
CornerRadius => new CornerRadius(0),
Thickness => new Thickness(0),
_ => null
};
if (flat is null) continue;
state.WindowedChrome.TryAdd(key, value);
window.Resources[key] = flat;
}
}
else
{
foreach (var (key, value) in state.WindowedChrome) window.Resources[key] = value;
state.WindowedChrome.Clear();
}
}
// ------------------------------------------------------------------ Agrandie = zone utile de l'écran
private const int WmGetMinMaxInfo = 0x0024;
/// <summary>Marge laissée sur le bord d'une barre des tâches masquée automatiquement, pour qu'elle puisse réapparaître.</summary>
private const int AutoHideMargin = 2;
/// <summary>
/// Taille et position de la fenêtre agrandie : la zone utile de l'écran qui la contient (hors barre des tâches et autres
/// barres d'application). Barre des tâches en masquage automatique : un liseré de 2 pixels lui est laissé sur son bord,
/// sinon la souris ne pourrait plus la faire apparaître.
/// </summary>
private static IntPtr LimitMaximizedToWorkArea(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg != WmGetMinMaxInfo || lParam == IntPtr.Zero) return IntPtr.Zero;
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (monitor == IntPtr.Zero || !GetMonitorInfo(monitor, ref info)) return IntPtr.Zero;
var work = info.Work;
if (work.Left == info.Monitor.Left && work.Top == info.Monitor.Top &&
work.Right == info.Monitor.Right && work.Bottom == info.Monitor.Bottom)
{
// Aucune place réservée : barre des tâches absente de cet écran, ou masquée automatiquement.
if (HasAutoHideBar(AppBarEdgeLeft, info.Monitor)) work.Left += AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeTop, info.Monitor)) work.Top += AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeRight, info.Monitor)) work.Right -= AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeBottom, info.Monitor)) work.Bottom -= AutoHideMargin;
}
var minMax = Marshal.PtrToStructure<MinMaxInfo>(lParam);
// Position relative au coin de l'écran (Windows l'applique à l'écran où la fenêtre est agrandie).
minMax.MaxPosition = new NativePoint { X = work.Left - info.Monitor.Left, Y = work.Top - info.Monitor.Top };
minMax.MaxSize = new NativePoint { X = work.Right - work.Left, Y = work.Bottom - work.Top };
Marshal.StructureToPtr(minMax, lParam, false);
handled = true;
return IntPtr.Zero;
}
private static bool HasAutoHideBar(int edge, NativeRect monitor)
{
var data = new AppBarData { Size = Marshal.SizeOf<AppBarData>(), Edge = edge, Rect = monitor };
return SHAppBarMessage(AppBarGetAutoHideBarEx, ref data) != IntPtr.Zero;
}
// ------------------------------------------------------------------ Win32
private const int MonitorDefaultToNearest = 2;
private const int AppBarGetAutoHideBarEx = 0x0000000B;
private const int AppBarEdgeLeft = 0, AppBarEdgeTop = 1, AppBarEdgeRight = 2, AppBarEdgeBottom = 3;
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X, Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct MinMaxInfo
{
public NativePoint Reserved;
public NativePoint MaxSize;
public NativePoint MaxPosition;
public NativePoint MinTrackSize;
public NativePoint MaxTrackSize;
}
[StructLayout(LayoutKind.Sequential)]
private struct AppBarData
{
public int Size;
public IntPtr Window;
public uint CallbackMessage;
public int Edge;
public NativeRect Rect;
public IntPtr Param;
}
[DllImport("shell32.dll")]
private static extern IntPtr SHAppBarMessage(int message, ref AppBarData data);
[StructLayout(LayoutKind.Sequential)]
private struct NativeRect
{
public int Left, Top, Right, Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MonitorInfo
{
public int Size;
public NativeRect Monitor;
public NativeRect Work;
public uint Flags;
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info);
}
+61
View File
@@ -73,3 +73,64 @@ public sealed class KindToResourceConverter : IValueConverter
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>
/// Contenu d'une image (octets PNG/JPEG déjà contrôlés) → image affichable, décodée en mémoire (aucun fichier
/// verrouillé) et réduite : paramètre = largeur de décodage en pixels (480 par défaut). Null si illisible.
/// </summary>
public sealed class BytesToImageConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not byte[] { Length: > 0 } bytes) return null;
try
{
var image = new System.Windows.Media.Imaging.BitmapImage();
image.BeginInit();
image.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
image.CreateOptions = System.Windows.Media.Imaging.BitmapCreateOptions.IgnoreColorProfile;
image.DecodePixelWidth = parameter is string text && int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var width)
? width
: 480;
image.StreamSource = new System.IO.MemoryStream(bytes, writable: false);
image.EndInit();
image.Freeze();
return image;
}
catch (Exception ex) when (ex is NotSupportedException or System.IO.IOException or ArgumentException or InvalidOperationException)
{
return null;
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>null → Collapsed, sinon Visible.</summary>
public sealed class NullToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is null ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>
/// Hauteur d'une page « en deux colonnes » du menu d'administration : toute la zone visible (arrondie à l'inférieur, pour
/// ne jamais faire apparaître d'ascenseur inutile), mais jamais moins que le paramètre (pixels) : sur un très petit écran,
/// la page entière défile alors, et rien n'est rogné.
/// </summary>
public sealed class ViewportHeightConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var viewport = value is double d && !double.IsNaN(d) && !double.IsInfinity(d) ? Math.Floor(d) : 0;
var minimum = double.TryParse(parameter as string, NumberStyles.Float, CultureInfo.InvariantCulture, out var m) ? m : 0;
return Math.Max(viewport, minimum);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>2.0.0</Version>
<Version>2.5.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
+2 -22
View File
@@ -245,28 +245,8 @@ public sealed class TpmKeyStore : IAdminTpm
var response = new byte[512];
var length = (uint)response.Length;
if (Tbsip_Submit_Command(context, 0, 200, command, (uint)command.Length, response, ref length) != 0) return null;
var data = response.AsSpan(0, (int)Math.Min(length, (uint)response.Length));
if (data.Length < 19 || BinaryPrimitives.ReadUInt32BigEndian(data[6..]) != 0) return null; // code retour TPM
if (BinaryPrimitives.ReadUInt32BigEndian(data[11..]) != 6) return null;
var count = BinaryPrimitives.ReadUInt32BigEndian(data[15..]);
uint? permanent = null, counter = null, maxTries = null, interval = null;
for (var i = 0; i < count && 19 + (i + 1) * 8 <= data.Length; i++)
{
var entry = data.Slice(19 + i * 8, 8);
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
{
case 0x200: permanent = value; break; // TPM_PT_PERMANENT
case 0x20E: counter = value; break; // TPM_PT_LOCKOUT_COUNTER
case 0x20F: maxTries = value; break; // TPM_PT_MAX_AUTH_FAIL
case 0x210: interval = value; break; // TPM_PT_LOCKOUT_INTERVAL (secondes)
}
}
if (counter is null || maxTries is null || interval is null) return null;
var inLockout = permanent is { } attributes && (attributes & (1u << 2)) != 0; // TPMA_PERMANENT.inLockout
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
TimeSpan.FromSeconds(interval.Value), inLockout);
// Analyse dans TpmLockoutInfo (testée) : compteur, maximum, intervalle et TPMA_PERMANENT.inLockout.
return TpmLockoutInfo.FromCapabilityResponse(response.AsSpan(0, (int)Math.Min(length, (uint)response.Length)));
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentOutOfRangeException)
{
@@ -0,0 +1,78 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
using GameLauncher.Core.Utils;
namespace GameLauncher.Platform;
/// <summary>
/// Mode d'affichage mémorisé de chaque fenêtre qui le permet (plein écran ou fenêtré) :
/// %AppData%\&lt;Publisher&gt;\GameLauncher\window-modes.json, ex. { "main": true, "admin": false }.
/// Simple préférence d'affichage, sans donnée personnelle ; fichier illisible ou modifié à la main : ignoré.
/// </summary>
public sealed partial class WindowModeStore
{
public const string FileName = "window-modes.json";
private const int MaxFileSize = 4 * 1024;
private const int MaxEntries = 32;
private readonly string _path;
private readonly Dictionary<string, bool> _modes;
private readonly object _lock = new();
public WindowModeStore(string path)
{
_path = path;
_modes = Load(path);
}
/// <summary>Plein écran mémorisé pour cette fenêtre (null : jamais choisi).</summary>
public bool? Get(string key)
{
lock (_lock) return _modes.TryGetValue(key, out var fullScreen) ? fullScreen : null;
}
public void Set(string key, bool fullScreen)
{
if (!IsValidKey(key)) return;
Dictionary<string, bool> snapshot;
lock (_lock)
{
if (_modes.TryGetValue(key, out var current) && current == fullScreen) return;
if (!_modes.ContainsKey(key) && _modes.Count >= MaxEntries) return;
_modes[key] = fullScreen;
snapshot = new Dictionary<string, bool>(_modes);
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path))!);
FileUtil.WriteAllTextAtomic(_path, JsonSerializer.Serialize(snapshot, JsonDefaults.Options));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Mode d'affichage non mémorisé ({_path}) : {ex.Message}");
}
}
private static Dictionary<string, bool> Load(string path)
{
var modes = new Dictionary<string, bool>(StringComparer.Ordinal);
try
{
if (!File.Exists(path) || new FileInfo(path).Length > MaxFileSize) return modes;
var loaded = JsonSerializer.Deserialize<Dictionary<string, bool>>(File.ReadAllText(path), JsonDefaults.Options);
foreach (var (key, value) in loaded ?? new Dictionary<string, bool>())
if (IsValidKey(key) && modes.Count < MaxEntries) modes[key] = value;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"Modes d'affichage illisibles ({path}) : {ex.Message}");
}
return modes;
}
private static bool IsValidKey(string key) => KeyPattern().IsMatch(key);
[GeneratedRegex("^[a-z0-9][a-z0-9.-]{0,31}$")]
private static partial Regex KeyPattern();
}
+1 -1
View File
@@ -40,7 +40,7 @@ public sealed class WindowService : IWindowService
public void Restore()
{
if (!_window.IsVisible) _window.Show(); // l'icône de la zone de notification disparaît (voir App)
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
_window.Activate();
}
@@ -4,6 +4,7 @@ using GameLauncher.ApiClient;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Platform;
using GameLauncher.Views;
@@ -26,6 +27,7 @@ public sealed class AdminWindowService : IDisposable
private readonly string _launcherVersion;
private readonly Uri? _apiUrl;
private readonly string? _apiUrlError;
private readonly PlayerSigningCheck? _playerSigning;
private LauncherApiClient? _client;
private AdminWindow? _window;
@@ -34,8 +36,12 @@ public sealed class AdminWindowService : IDisposable
/// <param name="catalog">Configuration active (dossier des données du launcher).</param>
/// <param name="declared">Jeux déclarés dans launcher.json, avant le catalogue (valeurs par défaut de la page « Jeux »).</param>
/// <param name="themes">Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</param>
/// <param name="playerSigning">
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
/// </param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes)
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
PlayerSigningCheck? playerSigning = null)
{
_owner = owner;
_modules = modules;
@@ -45,6 +51,7 @@ public sealed class AdminWindowService : IDisposable
_launcherVersion = launcherVersion;
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
_apiUrlError = apiUrlError;
_playerSigning = playerSigning;
}
public bool IsAvailable => _apiUrl is not null;
@@ -54,7 +61,7 @@ public sealed class AdminWindowService : IDisposable
{
if (_window is not null)
{
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
_window.Activate();
return;
}
@@ -85,7 +92,8 @@ public sealed class AdminWindowService : IDisposable
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
SettingsPath = _settingsPath,
LauncherVersion = _launcherVersion,
RootKeys = LauncherUpdateService.LoadRootKeys()
RootKeys = LauncherUpdateService.LoadRootKeys(),
PlayerSigning = _playerSigning
});
window = new AdminWindow(shell);
if (_owner.IsVisible) window.Owner = _owner;
+17 -1
View File
@@ -86,6 +86,9 @@ public sealed record DesktopShortcut(string FilePath, string TargetPath, bool Po
/// <summary>Icône du raccourci (« chemin,index »), telle qu'enregistrée.</summary>
public string? IconLocation { get; init; }
/// <summary>Commentaire du raccourci : nom du jeu posé par le launcher à sa création.</summary>
public string? Description { get; init; }
}
public interface IShortcutService
@@ -93,6 +96,9 @@ public interface IShortcutService
/// <summary>Emplacement du launcher en cours d'exécution.</summary>
string LauncherPath { get; }
/// <summary>Nom donné au raccourci (sans « .lnk ») : nom affiché du jeu (catalogue), sinon celui de launcher.json.</summary>
string ShortcutName { get; }
/// <summary>Crée le raccourci du Bureau (icône du jeu si installé). Faux en cas d'échec (journalisé).</summary>
bool CreateDesktopShortcut();
@@ -106,8 +112,18 @@ public interface IShortcutService
/// <summary>Raccourcis du Bureau (dossier de l'utilisateur) qui ouvrent ce jeu.</summary>
IReadOnlyList<DesktopShortcut> FindDesktopShortcuts();
/// <summary>Redirige le raccourci vers le launcher actuel. Faux en cas d'échec (journalisé).</summary>
/// <summary>
/// Redirige le raccourci vers le launcher actuel (et lui donne le nom affiché du jeu s'il porte un
/// nom posé par le launcher). Faux en cas d'échec (journalisé).
/// </summary>
bool RetargetDesktopShortcut(DesktopShortcut shortcut);
/// <summary>
/// Raccourcis de ce jeu vers ce launcher, nommés par le launcher : renommés selon le nom affiché
/// actuel du jeu (jamais par-dessus un fichier existant). Renvoie le nombre de raccourcis renommés.
/// </summary>
int RenameDesktopShortcuts();
/// <summary>Supprime les raccourcis de ce jeu nommés par le launcher (ceux renommés par le joueur restent).</summary>
void RemoveDesktopShortcut();
}
+126 -10
View File
@@ -15,6 +15,11 @@ namespace GameLauncher.Services;
/// ses raccourcis sont redirigés vers le nouvel emplacement (au démarrage si l'ancien exécutable
/// a disparu, ou depuis « Réparer »).
///
/// Nom : celui AFFICHÉ du jeu (catalogue publié depuis le menu d'administration), sinon celui de
/// launcher.json. Un raccourci nommé par le launcher suit ce nom (démarrage, bouton « Raccourci »,
/// « Réparer ») ; un raccourci renommé par le joueur garde son nom. Un fichier existant n'est jamais
/// remplacé par un nom venu du catalogue (au pire, le nom de launcher.json est conservé).
///
/// Icône : celle de l'exécutable du jeu (emplacement lu dans le registre), sinon celle du
/// launcher (jeu pas encore installé, exécutable absent). Une icône choisie par le joueur est
/// conservée ; celle que le launcher a posée suit le jeu s'il est déplacé.
@@ -43,7 +48,15 @@ public sealed class ShortcutService : IShortcutService
private static string DesktopDirectory => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
private string ShortcutPath => Path.Combine(DesktopDirectory, PathHelper.SanitizeFolderName(_config.GameName) + ".lnk");
/// <summary>Nom du raccourci (sans « .lnk ») : nom affiché du jeu, sinon celui de launcher.json.</summary>
public string ShortcutName => PathHelper.SanitizeFolderName(_config.ShownName);
/// <summary>Nom tiré de launcher.json (raccourcis créés avant le catalogue, repli).</summary>
private string DeclaredShortcutName => PathHelper.SanitizeFolderName(_config.GameName);
private string ShortcutPath => LinkPath(ShortcutName);
private static string LinkPath(string name) => Path.Combine(DesktopDirectory, name + ".lnk");
/// <summary>
/// Exécutable du jeu installé (registre : dossier d'installation + exécutable), s'il existe ;
@@ -81,18 +94,27 @@ public sealed class ShortcutService : IShortcutService
try
{
var icon = DesiredIconLocation;
WithShell(shell => WithLink(shell, ShortcutPath, link =>
var path = ShortcutPath;
var declaredPath = LinkPath(DeclaredShortcutName);
// Nom venu du catalogue : jamais au détriment d'un raccourci qui n'est pas celui de ce jeu.
if (!SamePath(path, declaredPath) && File.Exists(path) && !IsShortcutForThisGame(path))
{
Log.Warn($"Un autre raccourci « {Path.GetFileName(path)} » existe déjà sur le Bureau : nom de launcher.json utilisé.");
path = declaredPath;
}
WithShell(shell => WithLink(shell, path, link =>
{
link.TargetPath = _launcherPath;
link.Arguments = Arguments;
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
link.IconLocation = icon;
link.Description = _config.GameName;
link.Description = _config.ShownName; // nom posé par le launcher (voir IsLauncherNamed)
link.Save();
return true;
}));
NotifyShellUpdated(ShortcutPath);
Log.Info($"Raccourci créé : {ShortcutPath} (icône : {icon})");
NotifyShellUpdated(path);
Log.Info($"Raccourci créé : {path} (icône : {icon})");
return true;
}
catch (Exception ex)
@@ -118,9 +140,21 @@ public sealed class ShortcutService : IShortcutService
// Icône posée par le launcher : celle du jeu, à son emplacement actuel.
RefreshIcon(shortcut);
// Nom affiché du jeu modifié depuis le menu d'administration : le raccourci le suit.
if (shortcut.PointsToThisLauncher) RenameIfLauncherNamed(shortcut);
}
}
public int RenameDesktopShortcuts()
{
var renamed = 0;
foreach (var shortcut in FindDesktopShortcuts())
if (shortcut.PointsToThisLauncher && RenameIfLauncherNamed(shortcut))
renamed++;
return renamed;
}
public IReadOnlyList<DesktopShortcut> FindDesktopShortcuts()
{
var found = new List<DesktopShortcut>();
@@ -150,10 +184,11 @@ public sealed class ShortcutService : IShortcutService
string? target;
string? arguments;
string? icon;
string? description;
try
{
(target, arguments, icon) = WithLink(shell, file,
link => ((string?)link.TargetPath, (string?)link.Arguments, (string?)link.IconLocation));
(target, arguments, icon, description) = WithLink(shell, file,
link => ((string?)link.TargetPath, (string?)link.Arguments, (string?)link.IconLocation, (string?)link.Description));
}
catch (Exception ex)
{
@@ -164,7 +199,8 @@ public sealed class ShortcutService : IShortcutService
if (string.IsNullOrWhiteSpace(target) || !IsForThisGame(arguments)) continue;
found.Add(new DesktopShortcut(file, target, SamePath(target, _launcherPath), File.Exists(target))
{
IconLocation = icon
IconLocation = icon,
Description = description
});
}
return true;
@@ -196,16 +232,96 @@ public sealed class ShortcutService : IShortcutService
}));
NotifyShellUpdated(shortcut.FilePath);
Log.Info($"Raccourci « {shortcut.Name} » mis à jour : {shortcut.TargetPath} → {_launcherPath}");
return true;
}
catch (Exception ex)
{
Log.Error($"Mise à jour du raccourci impossible ({shortcut.FilePath})", ex);
return false;
}
RenameIfLauncherNamed(shortcut); // non bloquant : le raccourci fonctionne déjà
return true;
}
public void RemoveDesktopShortcut() => FileUtil.TryDeleteFile(ShortcutPath);
public void RemoveDesktopShortcut()
{
// Nom de launcher.json : comportement historique. Puis les raccourcis de ce jeu nommés par le
// launcher (nom affiché du catalogue…) ; ceux renommés par le joueur sont conservés.
FileUtil.TryDeleteFile(LinkPath(DeclaredShortcutName));
foreach (var shortcut in FindDesktopShortcuts())
if (IsLauncherNamed(shortcut))
FileUtil.TryDeleteFile(shortcut.FilePath);
}
/// <summary>
/// Vrai si le nom du raccourci a été posé par le launcher : nom de launcher.json, nom affiché actuel,
/// ou nom enregistré dans son commentaire lors de sa création. Faux s'il a été renommé par le joueur.
/// </summary>
private bool IsLauncherNamed(DesktopShortcut shortcut)
{
var name = shortcut.Name;
return string.Equals(name, DeclaredShortcutName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, ShortcutName, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrWhiteSpace(shortcut.Description) &&
string.Equals(name, PathHelper.SanitizeFolderName(shortcut.Description), StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Raccourci nommé par le launcher : renommé selon le nom affiché actuel du jeu. Jamais par-dessus
/// un fichier existant. Vrai si le raccourci a été renommé.
/// </summary>
private bool RenameIfLauncherNamed(DesktopShortcut shortcut)
{
var desired = ShortcutPath;
if (SamePath(shortcut.FilePath, desired) || !IsLauncherNamed(shortcut)) return false;
if (File.Exists(desired))
{
Log.Info($"Raccourci « {shortcut.Name} » non renommé : « {Path.GetFileName(desired)} » existe déjà sur le Bureau.");
return false;
}
try
{
File.Move(shortcut.FilePath, desired);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Raccourci « {shortcut.Name} » non renommé : {ex.Message}");
return false;
}
try
{
// Commentaire = nom posé par le launcher : le prochain changement de nom le reconnaîtra.
WithShell(shell => WithLink(shell, desired, link =>
{
link.Description = _config.ShownName;
link.Save();
return true;
}));
}
catch (Exception ex)
{
Log.Warn($"Commentaire du raccourci « {Path.GetFileName(desired)} » non mis à jour : {ex.Message}");
}
NotifyShellUpdated(desired);
Log.Info($"Raccourci renommé : « {shortcut.Name} » → « {ShortcutName} »");
return true;
}
/// <summary>Vrai si le fichier .lnk ouvre ce jeu (arguments « --game &lt;GameId&gt; »).</summary>
private bool IsShortcutForThisGame(string path)
{
try
{
return WithShell(shell => WithLink(shell, path, link => IsForThisGame((string?)link.Arguments)));
}
catch (Exception ex)
{
Log.Warn($"Lecture du raccourci impossible ({path}) : {ex.Message}");
return false;
}
}
/// <summary>Icône posée par le launcher remplacée par celle du jeu (à son emplacement actuel).</summary>
private void RefreshIcon(DesktopShortcut shortcut)
File diff suppressed because it is too large. Load diff
+53 -5
View File
@@ -12,6 +12,7 @@
<conv:StringToVisibilityConverter x:Key="StringToVisibility" />
<conv:IconKeyConverter x:Key="IconKey" />
<conv:KindToResourceConverter x:Key="KindToResource" />
<conv:ViewportHeightConverter x:Key="ViewportHeight" />
<!-- ============================================================ Focus clavier discret -->
<Style x:Key="FocusVisualStyle">
@@ -69,6 +70,26 @@
<Setter Property="ui:IconAssist.HoverBrush" Value="{DynamicResource DangerBrush}" />
</Style>
<!--
Plein écran / mode fenêtré (F11), pour toute fenêtre inscrite avec ui:WindowModeAssist.AllowFullScreen="True" :
icône et infobulle suivent le mode ; bouton masqué dans une fenêtre qui ne le permet pas.
Usage : <Button Style="{StaticResource WindowModeButtonStyle}" />, entre « Réduire » et « Fermer ».
-->
<Style x:Key="WindowModeButtonStyle" TargetType="Button" BasedOn="{StaticResource IconButtonStyle}">
<Setter Property="Command" Value="{x:Static ui:WindowModeAssist.ToggleFullScreenCommand}" />
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.Fullscreen}" />
<Setter Property="ToolTip" Value="Plein écran (F11)" />
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.FullscreenExit}" />
<Setter Property="ToolTip" Value="Quitter le plein écran (F11)" />
</DataTrigger>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.AllowFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ============================================================ Bouton principal (JOUER / INSTALLER…) -->
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Height" Value="60" />
@@ -442,11 +463,21 @@
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" Focusable="False"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
<Grid>
<!-- Texte indicatif (ui:TextAssist.Placeholder), visible tant que le champ est vide -->
<TextBlock x:Name="Placeholder" Margin="{TemplateBinding Padding}" Padding="2,0,0,0" VerticalAlignment="Center"
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
TextTrimming="CharacterEllipsis" IsHitTestVisible="False" Visibility="Collapsed" />
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" Focusable="False"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="Text" Value="">
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
</Trigger>
@@ -473,10 +504,19 @@
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
<Grid>
<TextBlock x:Name="Placeholder" Margin="{TemplateBinding Padding}" Padding="2,0,0,0" VerticalAlignment="Top"
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
TextWrapping="Wrap" IsHitTestVisible="False" Visibility="Collapsed" />
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="Text" Value="">
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
</Trigger>
@@ -556,6 +596,11 @@
<Grid>
<ToggleButton x:Name="Toggle" Style="{StaticResource ModernComboBoxToggleStyle}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />
<!-- Texte indicatif (ui:TextAssist.Placeholder), visible tant que rien n'est choisi -->
<TextBlock x:Name="Placeholder" Margin="14,0,34,0" VerticalAlignment="Center" IsHitTestVisible="False"
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
TextTrimming="CharacterEllipsis" Visibility="Collapsed" />
<ContentPresenter Margin="12,0,34,0" VerticalAlignment="Center" HorizontalAlignment="Left" IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
@@ -578,6 +623,9 @@
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="SelectedItem" Value="{x:Null}">
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
+5
View File
@@ -17,6 +17,9 @@
<Geometry x:Key="Icon.Cog">M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z</Geometry>
<Geometry x:Key="Icon.Close">M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z</Geometry>
<Geometry x:Key="Icon.Minimize">M20,14H4V10H20</Geometry>
<!-- Plein écran (F11) / quitter le plein écran : bouton des fenêtres qui le permettent (WindowModeAssist) -->
<Geometry x:Key="Icon.Fullscreen">M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z</Geometry>
<Geometry x:Key="Icon.FullscreenExit">M14,14H19V16H16V19H14V14M5,14H10V19H8V16H5V14M8,5H10V10H5V8H8V5M19,8V10H14V5H16V8H19Z</Geometry>
<Geometry x:Key="Icon.WindowRestore">M4,8H8V4H20V16H16V20H4V8M16,8V14H18V6H10V8H16M6,12V18H14V12H6Z</Geometry>
<Geometry x:Key="Icon.ExitToApp">M19,3H5C3.89,3 3,3.89 3,5V9H5V5H19V19H5V15H3V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10.08,15.58L11.5,17L16.5,12L11.5,7L10.08,8.41L12.67,11H3V13H12.67L10.08,15.58Z</Geometry>
<Geometry x:Key="Icon.Folder">M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z</Geometry>
@@ -92,5 +95,7 @@
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
<Geometry x:Key="Icon.AccountRemove">M15,14C17.67,14 23,15.33 23,18V20H7V18C7,15.33 12.33,14 15,14M15,12A4,4 0 0,1 11,8A4,4 0 0,1 15,4A4,4 0 0,1 19,8A4,4 0 0,1 15,12M5,9.59L7.12,7.46L8.54,8.88L6.41,11L8.54,13.12L7.12,14.54L5,12.41L2.88,14.54L1.46,13.12L3.59,11L1.46,8.88L2.88,7.46L5,9.59Z</Geometry>
<Geometry x:Key="Icon.Broom">M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z</Geometry>
<Geometry x:Key="Icon.CalendarRange">M9,10H7V12H9V10M13,10H11V12H13V10M17,10H15V12H17V10M19,3H18V1H16V3H8V1H6V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M19,19H5V8H19V19Z</Geometry>
</ResourceDictionary>
+14 -2
View File
@@ -887,12 +887,22 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (_shortcuts.RetargetDesktopShortcut(shortcut)) repaired++;
}
return repaired switch
// Nom affiché du jeu modifié depuis le menu d'administration : les raccourcis nommés par le launcher le suivent.
var renamed = _shortcuts.RenameDesktopShortcuts();
var moved = repaired switch
{
0 => null,
1 => "Le raccourci du Bureau a été mis à jour vers l'emplacement actuel du launcher.",
_ => $"{repaired} raccourcis du Bureau ont été mis à jour vers l'emplacement actuel du launcher."
};
var named = renamed switch
{
0 => null,
1 => $"Le raccourci du Bureau a été renommé « {_shortcuts.ShortcutName} ».",
_ => $"{renamed} raccourcis du Bureau ont été renommés « {_shortcuts.ShortcutName} »."
};
return moved is null ? named : named is null ? moved : $"{moved} {named}";
}
catch (Exception ex)
{
@@ -1431,10 +1441,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
var existing = _shortcuts.FindDesktopShortcuts();
if (existing.Count > 0)
{
// Redirigés vers ce launcher, icône du jeu, et nom affiché du jeu s'ils portent un nom posé par le launcher.
var updated = existing.Count(_shortcuts.RetargetDesktopShortcut);
var name = existing.Count == 1 ? _shortcuts.FindDesktopShortcuts().FirstOrDefault()?.Name ?? existing[0].Name : null;
return updated == existing.Count
? new ShortcutActionResult(true, existing.Count == 1
? $"Le raccourci « {existing[0].Name} » existait déjà : il a été mis à jour."
? $"Le raccourci « {name} » existait déjà : il a été mis à jour."
: $"{existing.Count} raccourcis existaient déjà : ils ont été mis à jour.")
: new ShortcutActionResult(false, "La mise à jour du raccourci a échoué. Consultez le dossier des journaux.");
}
+77 -14
View File
@@ -16,7 +16,10 @@
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal">
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True"
ui:WindowModeAssist.AllowFullScreen="True"
ui:WindowModeAssist.PersistenceKey="admin">
<!--
Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo).
@@ -26,6 +29,29 @@
• AdminWorkspaceViewModel → navigation entre les pages des modules + page affichée.
-->
<Window.Resources>
<!--
Mode fenêtré : ombre et coins arrondis. En plein écran (F11, bouton de la barre de titre), WindowModeAssist met ces
ressources « WindowChrome.* » à 0. Toute page d'un module (présente ou future) s'affiche dans cette fenêtre et
profite donc du plein écran sans rien déclarer.
-->
<Thickness x:Key="WindowChrome.ShadowMargin">20</Thickness>
<CornerRadius x:Key="WindowChrome.Radius">20</CornerRadius>
<CornerRadius x:Key="WindowChrome.NavRadius">0,0,0,20</CornerRadius>
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
<Style x:Key="WindowShadowStyle" TargetType="Border">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="Effect" Value="{x:Null}" />
</DataTrigger>
</Style.Triggers>
</Style>
<DataTemplate DataType="{x:Type avm:LoginViewModel}">
<local:LoginView />
</DataTemplate>
@@ -43,7 +69,7 @@
</Grid.ColumnDefinitions>
<!-- Navigation -->
<Border Background="{DynamicResource RailBrush}" CornerRadius="0,0,0,20"
<Border Background="{DynamicResource RailBrush}" CornerRadius="{DynamicResource WindowChrome.NavRadius}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0">
<DockPanel Margin="16,20,16,18">
<!-- Compte connecté -->
@@ -83,9 +109,8 @@
<TextBlock DockPanel.Dock="Top" Text="ADMINISTRATION" Style="{StaticResource SectionTitleStyle}" Margin="6,0,0,10" />
<ListBox ItemsSource="{Binding PanelItems}" SelectedItem="{Binding SelectedPanel, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}"
ItemContainerStyle="{StaticResource AdminNavItemStyle}"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Border>
@@ -93,29 +118,64 @@
<!-- Page affichée -->
<DockPanel Grid.Column="1" Margin="32,22,18,18">
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,18">
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" />
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding PanelDescription}" FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0">
<!-- Barre d'actions de la page (« Recharger », « Publier »…) : toujours visible, sous la zone qui défile
(modèle « AdminFooter.<type de la page> » de Themes/Admin.xaml) -->
<ContentControl DockPanel.Dock="Bottom" Margin="0,0,14,0" Focusable="False"
Content="{Binding SelectedPanel.ViewModel}"
ContentTemplateSelector="{StaticResource AdminFooterSelector}" />
<!-- Retour en haut : page changée, ou demandé par la page (après « Recharger », « Publier »…).
Page en deux colonnes (HasSplitLayout) : la page reçoit la hauteur visible et chaque colonne a son propre
ascenseur (Themes/Admin.xaml) ; sur un très petit écran (moins de 360 px de haut), la page entière défile
en plus, pour que rien ne soit jamais rogné (bandeaux en haut compris). -->
<ScrollViewer Focusable="False" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
ui:ScrollAssist.ResetOn="{Binding SelectedPanel}"
ui:ScrollAssist.ScrollToTopToken="{Binding SelectedPanel.ViewModel.ScrollToTopToken}">
<ScrollViewer.Style>
<Style TargetType="ScrollViewer">
<Setter Property="Padding" Value="0,0,14,0" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
<Setter Property="Padding" Value="0" />
</DataTrigger>
</Style.Triggers>
</Style>
</ScrollViewer.Style>
<!-- Modèle choisi selon le type de la page (Themes/Admin.xaml) -->
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False" />
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False">
<ContentControl.Style>
<Style TargetType="ContentControl">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
<Setter Property="Height"
Value="{Binding ViewportHeight, RelativeSource={RelativeSource AncestorType=ScrollViewer}, Converter={StaticResource ViewportHeight}, ConverterParameter=360}" />
</DataTrigger>
</Style.Triggers>
</Style>
</ContentControl.Style>
</ContentControl>
</ScrollViewer>
</DockPanel>
</Grid>
</DataTemplate>
</Window.Resources>
<Grid Margin="20">
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
<!-- Ombre portée -->
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Border.Effect>
</Border>
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
Style="{StaticResource WindowShadowStyle}" />
<Border CornerRadius="20" Background="{DynamicResource SurfaceSolidBrush}"
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource SurfaceSolidBrush}"
BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1">
<!--
Responsive (ScaleToFitDecorator) : 1038 × 678 en mode fenêtré. Plus grand (plein écran) : les pages s'étalent.
Plus petit : jusqu'à 960 × 560 la mise en page s'adapte (les pages défilent) ; en dessous (petit écran, mise à
l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
-->
<ui:ScaleToFitDecorator DesignWidth="960" DesignHeight="560">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="64" />
@@ -150,6 +210,8 @@
</Border>
<Button Style="{StaticResource IconButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
ToolTip="Réduire" Click="OnMinimize" />
<!-- Plein écran / mode fenêtré (F11) -->
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="9" Margin="4,0,0,0" />
<Button Style="{StaticResource CloseButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Close}"
ToolTip="Fermer (déconnexion)" Margin="4,0,0,0" Click="OnClose" />
</StackPanel>
@@ -159,6 +221,7 @@
<!-- Écran courant -->
<ContentControl Grid.Row="1" Content="{Binding Screen}" Focusable="False" />
</Grid>
</ui:ScaleToFitDecorator>
</Border>
</Grid>
</Window>
@@ -29,7 +29,7 @@ public partial class AdminWindow : Window
PreviewMouseDown += (_, _) => _shell.NotifyActivity();
PreviewMouseWheel += (_, _) => _shell.NotifyActivity();
PreviewKeyDown += OnPreviewKeyDown;
Loaded += (_, _) => FitToScreen();
// Taille adaptée à l'écran (mode fenêtré) et plein écran : ui:WindowModeAssist (AdminWindow.xaml).
// Session Windows verrouillée (Win+L, mise en veille) : l'administration se verrouille aussi.
SystemEvents.SessionSwitch += OnSessionSwitch;
@@ -54,16 +54,6 @@ public partial class AdminWindow : Window
_shell.NotifyActivity();
}
/// <summary>Jamais plus haute que la zone utile de l'écran (petits écrans, mise à l'échelle 125-150 %).</summary>
private void FitToScreen()
{
var work = SystemParameters.WorkArea;
if (Height > work.Height) Height = work.Height;
if (Width > work.Width) Width = work.Width;
if (Top < work.Top) Top = work.Top;
if (Left < work.Left) Left = work.Left;
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
_shell.NotifyActivity();
@@ -87,10 +77,11 @@ public partial class AdminWindow : Window
});
}
/// <summary>Déplacement par la barre de titre (mode fenêtré seulement).</summary>
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
DragMove();
Controls.WindowModeAssist.DragMove(this);
}
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
+1 -2
View File
@@ -41,9 +41,8 @@
</DockPanel>
<ListBox ItemsSource="{Binding Keys}" SelectedItem="{Binding SelectedKey, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}"
ItemContainerStyle="{StaticResource AdminKeyItemStyle}" MaxHeight="216"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto"
Visibility="{Binding HasKeys, Converter={StaticResource BoolToVisibility}}" />
@@ -13,14 +13,17 @@ public partial class ChangelogWindow : Window
InitializeComponent();
SubtitleText.Text = string.IsNullOrWhiteSpace(gameName) ? "Nouveautés et correctifs" : $"{gameName} · nouveautés et correctifs";
Entries.ItemsSource = entries;
FitToScreen();
SourceInitialized += (_, _) => FitToScreen();
}
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran : au-delà, les notes défilent.</summary>
/// <summary>
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (celle-ci peut être en plein écran sur
/// un autre écran que l'écran principal) : au-delà, les notes défilent.
/// </summary>
private void FitToScreen()
{
const double chrome = 250; // en-tête, bouton, marges et ombre
EntriesScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
EntriesScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
+52 -18
View File
@@ -14,9 +14,34 @@
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal">
TextOptions.TextFormattingMode="Ideal"
ui:WindowModeAssist.AllowFullScreen="True"
ui:WindowModeAssist.PersistenceKey="main">
<Window.Resources>
<!--
Mode fenêtré : ombre autour de la fenêtre et coins arrondis. En plein écran (F11, bouton de la barre de titre),
WindowModeAssist met ces ressources « WindowChrome.* » à 0 : la fenêtre occupe toute la zone utile de l'écran
(barre des tâches visible), sans marge ni arrondi.
-->
<Thickness x:Key="WindowChrome.ShadowMargin">16</Thickness>
<CornerRadius x:Key="WindowChrome.Radius">22</CornerRadius>
<CornerRadius x:Key="WindowChrome.RailRadius">22,0,0,22</CornerRadius>
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
<Style x:Key="WindowShadowStyle" TargetType="Border">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="Effect" Value="{x:Null}" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="ChipStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Background" Value="{DynamicResource ChipBrush}" />
@@ -166,24 +191,21 @@
</Style>
</Window.Resources>
<Grid Margin="16">
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
<Grid x:Name="Root">
<!-- ===================================================== Fond (thème) : ombre, image, lueur, dégradés -->
<Border CornerRadius="22" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
</Border.Effect>
</Border>
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
Style="{StaticResource WindowShadowStyle}" />
<!-- Illustration du thème (Default : Assets/background.png, Exovival : Assets/Themes/exovival-background.jpg) -->
<Border x:Name="BackgroundLayer" CornerRadius="22">
<Border x:Name="BackgroundLayer" CornerRadius="{DynamicResource WindowChrome.Radius}">
<Border.Background>
<ImageBrush ImageSource="{DynamicResource BackgroundImage}" Stretch="UniformToFill" AlignmentX="Right" />
</Border.Background>
</Border>
<Border CornerRadius="22" Background="{DynamicResource AmbientGlowBrush}" />
<Border CornerRadius="22" Background="{DynamicResource HeroOverlayBrush}" />
<Border CornerRadius="22" Background="{DynamicResource VignetteBrush}" />
<Border CornerRadius="22" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource AmbientGlowBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource HeroOverlayBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource VignetteBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
<Grid>
<Grid.ColumnDefinitions>
@@ -192,7 +214,7 @@
</Grid.ColumnDefinitions>
<!-- ===================================================== Liste des jeux (si plusieurs jeux) -->
<Border Width="88" CornerRadius="22,0,0,22" Background="{DynamicResource RailBrush}"
<Border Width="88" CornerRadius="{DynamicResource WindowChrome.RailRadius}" Background="{DynamicResource RailBrush}"
MouseLeftButtonDown="OnTitleBarMouseDown"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0"
Visibility="{Binding IsMultiGame, Converter={StaticResource BoolToVisibility}}">
@@ -208,7 +230,13 @@
</DockPanel>
</Border>
<Grid Grid.Column="1" DataContext="{Binding SelectedGame}">
<!--
Responsive (ScaleToFitDecorator) : zone du jeu de 1168 × 708 en mode fenêtré. Plus grand (plein écran) : le contenu
s'étale (colonnes fluides). Plus petit : jusqu'à 1100 × 620 la mise en page s'adapte (la présentation et les notes
défilent) ; en dessous (petit écran, mise à l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
-->
<ui:ScaleToFitDecorator Grid.Column="1" DesignWidth="1100" DesignHeight="620">
<Grid DataContext="{Binding SelectedGame}">
<Grid.RowDefinitions>
<RowDefinition Height="76" />
<RowDefinition Height="*" />
@@ -255,6 +283,8 @@
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Réduire" Padding="12"
ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
Command="{Binding DataContext.MinimizeCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
<!-- Plein écran / mode fenêtré (F11) -->
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="11" Margin="4,0,0,0" />
<Button Style="{StaticResource CloseButtonStyle}" ToolTip="{Binding DataContext.CloseToolTip, RelativeSource={RelativeSource AncestorType=Window}}" Padding="12" Margin="4,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Close}"
Command="{Binding DataContext.CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
@@ -265,11 +295,13 @@
<Grid Grid.Row="1" Margin="58,6,34,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="370" />
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
<ColumnDefinition Width="0.5*" MinWidth="370" MaxWidth="560" />
</Grid.ColumnDefinitions>
<!-- Présentation du jeu -->
<StackPanel VerticalAlignment="Center" Margin="0,0,40,0">
<!-- Présentation du jeu : défile si elle ne tient pas en hauteur (description, liens et bandeau à la fois) -->
<ScrollViewer Margin="0,0,24,0" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
<StackPanel VerticalAlignment="Center" Margin="0,0,16,0">
<!-- Badge d'état -->
<Border HorizontalAlignment="Left" CornerRadius="14" Padding="12,6"
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
@@ -404,6 +436,7 @@
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Notes de version -->
<Border Grid.Column="1" Margin="0,4" Padding="22,20,10,18" CornerRadius="20"
@@ -537,6 +570,7 @@
</Grid>
</Border>
</Grid>
</ui:ScaleToFitDecorator>
</Grid>
</Grid>
@@ -544,6 +578,6 @@
<Image x:Name="TransitionImage" IsHitTestVisible="False" Visibility="Collapsed" Stretch="Fill" />
<!-- Voile sombre affiché derrière les boîtes de dialogue -->
<Border x:Name="DimOverlay" CornerRadius="22" Background="#A6000000" Visibility="Collapsed" />
<Border x:Name="DimOverlay" CornerRadius="{DynamicResource WindowChrome.Radius}" Background="#A6000000" Visibility="Collapsed" />
</Grid>
</Window>
+2 -2
View File
@@ -54,11 +54,11 @@ public partial class MainWindow : Window, IThemeTransition
AdminRequested?.Invoke();
}
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre.</summary>
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre (mode fenêtré seulement).</summary>
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
DragMove();
Controls.WindowModeAssist.DragMove(this);
}
/// <summary>Ouvre le menu "…" au-dessus du bouton.</summary>
+6 -2
View File
@@ -33,8 +33,12 @@
FontSize="19" FontWeight="SemiBold" TextWrapping="Wrap" />
</StackPanel>
<TextBlock x:Name="MessageText" Margin="0,18,0,0" FontSize="14" LineHeight="21"
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
<!-- Message long (détail d'une erreur…) : il défile, la fenêtre ne dépasse jamais l'écran -->
<ScrollViewer x:Name="MessageScroll" Margin="0,18,0,0" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" Focusable="False">
<TextBlock x:Name="MessageText" FontSize="14" LineHeight="21"
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
</ScrollViewer>
<Border x:Name="OptionPanel" Margin="0,20,0,0" Padding="16,12" CornerRadius="14"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
@@ -12,6 +12,12 @@ public partial class ModernDialog : Window
private ModernDialog()
{
InitializeComponent();
// Jamais plus haute que la zone utile de l'écran de sa fenêtre parente (au-delà, le message défile).
SourceInitialized += (_, _) =>
{
const double chrome = 280; // titre, option, boutons, marges et ombre
MessageScroll.MaxHeight = Math.Max(120, Controls.WindowModeAssist.WorkArea(Owner ?? (Visual)this).Height - chrome);
};
}
public static void ShowMessage(Window? owner, string title, string message, DialogKind kind)
@@ -10,17 +10,17 @@ public partial class SettingsWindow : Window
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
Loaded += (_, _) => FitToScreen();
SourceInitialized += (_, _) => FitToScreen(); // avant le centrage sur la fenêtre parente
}
/// <summary>
/// La fenêtre ne dépasse jamais la zone utile de l'écran (mise à l'échelle 125-150 %) :
/// au-delà, les sections défilent.
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (mise à l'échelle 125-150 %, autre
/// écran) : au-delà, les sections défilent.
/// </summary>
private void FitToScreen()
{
const double chrome = 260; // en-tête, boutons, marges et ombre
SectionsScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
SectionsScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
}
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
@@ -23,12 +23,15 @@ public sealed class FakeDialogs : IModuleDialogs
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) => Messages.Add($"{title} : {message}");
/// <summary>Réponse aux confirmations (vrai par défaut).</summary>
public bool ConfirmAnswer { get; set; } = true;
/// <summary>Réponse selon le titre de la confirmation (null : <see cref="ConfirmAnswer"/>).</summary>
public Func<string, bool?>? AnswerFor { get; set; }
public List<string> Confirmations { get; } = new();
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false)
{
Confirmations.Add($"{title} : {message}");
return ConfirmAnswer;
return AnswerFor?.Invoke(title) ?? ConfirmAnswer;
}
public string? PickOpenFile(string title, string filter, string? initialDirectory) => NextOpenFile;
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => NextSaveFile;
@@ -85,9 +88,11 @@ public static class AdminTestKit
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
IAdminTpm? tpm = null, IReadOnlyList<LauncherConfig>? games = null,
IReadOnlyList<string>? themes = null,
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null) => new()
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null,
PlayerSigningCheck? playerSigning = null) => new()
{
RootKeys = rootKeys ?? Array.Empty<(string, byte[])>(),
PlayerSigning = playerSigning,
Tpm = tpm,
Client = api.CreateClient(),
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
@@ -377,4 +377,59 @@ public class AdminTpmLoginScreenTests
Assert.Equal(TimeSpan.Zero, new TpmLockoutInfo(5, 32, TimeSpan.FromMinutes(10), false).MaxWait);
Assert.False(new TpmLockoutInfo(40, 32, TimeSpan.Zero, true).HealsWithTime);
}
/// <summary>Réponse TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES) avec les propriétés données.</summary>
private static byte[] CapabilityResponse(uint responseCode, params (uint Property, uint Value)[] properties)
{
var data = new byte[19 + properties.Length * 8];
System.Buffers.Binary.BinaryPrimitives.WriteUInt16BigEndian(data, 0x8001);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(2), (uint)data.Length);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(6), responseCode);
data[10] = 0; // moreData
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(11), 6);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(15), (uint)properties.Length);
for (var i = 0; i < properties.Length; i++)
{
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(19 + i * 8), properties[i].Property);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(23 + i * 8), properties[i].Value);
}
return data;
}
[Fact]
public void Chip_managed_by_windows_is_not_reported_locked()
{
// PC ordinaire : Windows a défini l'autorisation « lockout » (lockoutAuthSet, bit 2) et ownerAuthSet (bit 0),
// aucune erreur de code. Ce n'est PAS un blocage (bug : la puce était vue comme toujours bloquée).
var info = TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0,
(0x200, (1u << 0) | (1u << 1) | (1u << 2)), (0x20E, 0), (0x20F, 32), (0x210, 7200), (0x211, 86400)));
Assert.NotNull(info);
Assert.False(info!.IsLockedOut);
Assert.Equal(32, info.RemainingTries);
Assert.Equal(TimeSpan.FromHours(2), info.RecoveryInterval);
}
[Fact]
public void Chip_lockout_is_read_from_the_in_lockout_bit_or_the_counter()
{
var flagged = TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0,
(0x200, (1u << 2) | (1u << 9)), (0x20E, 3), (0x20F, 32), (0x210, 600)));
Assert.True(flagged!.IsLockedOut);
var counted = TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0,
(0x200, 1u << 2), (0x20E, 32), (0x20F, 32), (0x210, 600)));
Assert.True(counted!.IsLockedOut);
Assert.Equal(TimeSpan.FromMinutes(10), counted.MaxWait);
}
[Fact]
public void Unreadable_chip_answers_give_no_lockout_state()
{
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0x9A2))); // erreur TPM
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0, (0x200, 0), (0x20E, 0)))); // incomplète
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(new byte[12])); // tronquée
var full = CapabilityResponse(0, (0x200, 0), (0x20E, 0), (0x20F, 32), (0x210, 600));
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(full.AsSpan(0, full.Length - 4))); // dernière valeur coupée
}
}
@@ -6,6 +6,7 @@ using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Admin;
@@ -81,6 +82,95 @@ public class AuditPanelTests
Assert.StartsWith("Date;Action;Code;Résultat", lines[0]);
Assert.True(lines.Length - 1 > AuditPanelViewModel.MaxDisplayed, $"{lines.Length - 1} lignes exportées");
Assert.Contains(dialogs.Messages, m => m.StartsWith("Journal exporté"));
// Consultation et export inscrits au journal par le serveur.
var journal = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>().Read(50, null).Entries;
Assert.Contains(journal, e => e.Action == "audit.view");
var export = journal.First(e => e.Action == "audit.export");
Assert.StartsWith($"{lines.Length - 1} entrée(s) ; toutes les dates", export.Details);
Assert.Equal("Journal d'audit exporté (CSV)", AuditTexts.Action(export.Action));
Assert.True(AuditTexts.InCategory("audit.export", "audit."));
Assert.False(AuditTexts.InCategory("audit.suppressed", "audit."));
}
/// <summary>Attend la fin des rechargements déclenchés par la période (un second peut suivre le premier).</summary>
private static async Task SettledAsync(AuditPanelViewModel page)
{
for (var i = 0; i < 50; i++)
{
var reload = page.PeriodReload;
await reload;
if (ReferenceEquals(reload, page.PeriodReload) && !page.IsBusy) return;
await Task.Delay(10);
}
}
[Fact]
public async Task The_journal_is_filtered_by_period_and_the_export_follows()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
// Trois entrées datées de dix jours plus tôt (horloge du serveur reculée le temps de les écrire).
var audit = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>();
api.Time.Advance(TimeSpan.FromDays(-10));
var oldDay = api.Time.GetUtcNow().ToLocalTime().Date;
for (var i = 0; i < 3; i++) audit.Write("test.old", AuditResults.Ok, details: $"ancienne {i}");
api.Time.Advance(TimeSpan.FromDays(10));
var today = api.Time.GetUtcNow().ToLocalTime().Date;
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(AuditPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<AuditPanelViewModel>(workspace.SelectedPanel!.ViewModel);
page.Today = () => today;
Assert.Contains(page.Entries, e => e.ActionCode == "test.old");
// « Aujourd'hui » : les entrées d'il y a dix jours disparaissent (rechargées depuis le serveur).
page.Period = page.Periods.First(p => p.Label == "Aujourd'hui");
await SettledAsync(page);
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(today.ToString("dd/MM/yyyy"), page.FromText);
Assert.DoesNotContain(page.Entries, e => e.ActionCode == "test.old");
Assert.Contains(page.Entries, e => e.ActionText == "Connexion");
Assert.True(page.HasFilters);
// Dates choisies : le jour des anciennes entrées uniquement.
page.FromText = oldDay.ToString("dd/MM/yyyy");
page.ToText = oldDay.ToString("dd/MM/yyyy");
await SettledAsync(page);
Assert.Equal("custom", page.Period.Value);
Assert.Equal(3, page.Entries.Count);
Assert.All(page.Entries, e => Assert.Equal("test.old", e.ActionCode));
// L'export suit la période.
dialogs.NextSaveFile = folder.File("periode.csv");
await page.ExportCommand.ExecuteAsync();
Assert.Equal(1 + 3, File.ReadAllLines(dialogs.NextSaveFile).Length);
var announced = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>().Read(50, null).Entries.First(e => e.Action == "audit.export");
Assert.Equal("3 entrée(s) ; du " + oldDay.ToString("dd/MM/yyyy", System.Globalization.CultureInfo.InvariantCulture) + " au " + oldDay.ToString("dd/MM/yyyy", System.Globalization.CultureInfo.InvariantCulture), announced.Details);
// Date invalide : signalée, la période précédente reste appliquée.
page.ToText = "32/13/2026";
Assert.True(page.HasDateError);
Assert.Equal(3, page.Entries.Count);
page.FromText = today.ToString("dd/MM/yyyy");
page.ToText = oldDay.ToString("dd/MM/yyyy");
Assert.Contains("postérieure", page.DateError);
// Effacer les filtres : toutes les dates.
page.ClearFiltersCommand.Execute(null);
await SettledAsync(page);
Assert.False(page.HasFilters);
Assert.False(page.HasDateError);
Assert.Contains(page.Entries, e => e.ActionCode == "test.old");
Assert.Contains(page.Entries, e => e.ActionText == "Connexion");
}
[Fact]
@@ -97,7 +187,12 @@ public class AuditPanelTests
ActorName = "Paul,=1+1",
Details = "nom ; avec \"guillemets\"\nsur deux lignes"
};
var line = AuditTexts.ToCsv(new[] { new AuditItem(entry) }).Split(Environment.NewLine)[1];
var csv = AuditTexts.ToCsv(new[] { new AuditItem(entry), new AuditItem(entry with { KeyLabel = "PC bureau" }) }).Split(Environment.NewLine);
Assert.Equal("Date;Action;Code;Résultat;Administrateur;Appareil;IP;Clé;Cible;Détails", csv[0]);
Assert.Contains(";\"PC bureau\";\"'@SUM(A1)\";", csv[2]);
Assert.Equal("Appareil « PC bureau »", new AuditItem(entry with { KeyLabel = "PC bureau" }).DeviceText);
Assert.StartsWith("Appareil « PC bureau » · IP", new AuditItem(entry with { KeyLabel = "PC bureau" }).OriginText);
var line = csv[1];
Assert.Contains(";\"'@SUM(A1)\";", line);
Assert.Contains(";\"'=HYPERLINK(\"\"http://x\"\")\";", line);
Assert.Contains(";\"' -2+3\";", line);
@@ -49,8 +49,10 @@ public class ChangelogPanelTests
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
Assert.True(page.HasSplitLayout); // versions et notes défilent séparément
Assert.StartsWith("Version publiée : 1.1 · 2 versions", page.ManifestText);
Assert.False(page.HasSeveralGames);
Assert.False(page.ShowGamePicker); // un seul jeu déclaré : choisi d'office
Assert.False(page.HasChanges);
Assert.False(page.SaveCommand.CanExecute(null));
@@ -167,6 +169,7 @@ public class ChangelogPanelTests
Assert.True(page.HasError);
Assert.Contains("non configuré", page.ErrorMessage);
Assert.False(page.HasVersions);
Assert.False(page.HasSplitLayout); // rien à afficher en deux colonnes : la page défile normalement
Assert.False(page.SaveCommand.CanExecute(null));
}
}
@@ -0,0 +1,138 @@
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Fichiers du serveur » du menu d'administration.</summary>
public class FilesPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new FilesAdminModule() });
private static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
[Fact]
public async Task Files_are_browsed_deleted_and_objects_pruned_from_the_page()
{
// Site : un jeu (manifest 1.1 : une archive, un objet), une ancienne archive et un objet orphelin.
var root = Path.Combine(Path.GetTempPath(), "launcher-files-panel-tests", Guid.NewGuid().ToString("N"), "www");
var game = Path.Combine(root, "exovivalzombie");
Directory.CreateDirectory(game);
var used = RandomNumberGenerator.GetBytes(500);
var orphan = RandomNumberGenerator.GetBytes(700);
foreach (var data in new[] { used, orphan })
{
var path = Path.Combine(game, ManifestCheck.ObjectPath(Sha(data)));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, data);
}
File.WriteAllBytes(Path.Combine(game, "EZR-1.1.zip"), new byte[100]);
File.WriteAllBytes(Path.Combine(game, "EZR-1.0.zip"), new byte[2048]);
var manifest = Path.Combine(game, "manifest.json");
File.WriteAllText(manifest, JsonSerializer.Serialize(new GameManifest
{
GameId = "EZR",
Version = "1.1",
Executable = "Game.exe",
Archive = new ArchiveInfo { Url = "EZR-1.1.zip", Size = 100, Sha256 = new string('a', 64) },
Files = new List<ManifestFile> { new() { Path = "Game.exe", Size = used.Length, Sha256 = Sha(used) } }
}, JsonDefaults.Options));
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = manifest,
["LauncherApi:Files:Root"] = root
});
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(FilesPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<FilesPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Racine : un dossier ; espace disque ; jeu proposé au nettoyage.
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal("www", page.RootName);
var directory = Assert.Single(page.Entries);
Assert.True(directory.IsDirectory);
Assert.Contains("Espace libre", page.DiskText);
Assert.Equal("EZR", Assert.Single(page.PruneGames).Value);
Assert.False(page.UpCommand.CanExecute(null));
// Ouvrir le dossier du jeu : états des fichiers, fil d'Ariane ; l'ascenseur ne bouge pas.
var scrollToken = page.ScrollToTopToken;
await directory.OpenCommand.ExecuteAsync();
Assert.Equal(scrollToken, page.ScrollToTopToken);
Assert.Equal("exovivalzombie", page.CurrentPath);
Assert.Equal(new[] { "www", "exovivalzombie" }, page.Crumbs.Select(c => c.Label));
var oldArchive = page.Entries.Single(e => e.Name == "EZR-1.0.zip");
Assert.True(oldArchive.CanDelete);
Assert.Equal("Inutilisé", oldArchive.StatusText);
var current = page.Entries.Single(e => e.Name == "EZR-1.1.zip");
Assert.False(current.CanDelete);
Assert.Equal("Utilisé", current.StatusText);
Assert.Contains("version 1.1", current.ToolTipText);
Assert.Equal("Protégé", page.Entries.Single(e => e.Name == "manifest.json").StatusText);
Assert.False(current.DeleteCommand.CanExecute(null));
Assert.Contains("inutilisé", page.SummaryText);
// Filtre : seulement les fichiers supprimables (et les dossiers).
page.ShowOnlyUnused = true;
Assert.Equal(new[] { "objects", "EZR-1.0.zip" }, page.Entries.Select(e => e.Name));
page.ShowOnlyUnused = false;
// Suppression annulée, puis confirmée.
dialogs.ConfirmAnswer = false;
await oldArchive.DeleteCommand.ExecuteAsync();
Assert.True(File.Exists(Path.Combine(game, "EZR-1.0.zip")));
dialogs.ConfirmAnswer = true;
await page.Entries.Single(e => e.Name == "EZR-1.0.zip").DeleteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(File.Exists(Path.Combine(game, "EZR-1.0.zip")));
Assert.Contains("2 Ko libéré", page.StatusMessage);
Assert.DoesNotContain(page.Entries, e => e.Name == "EZR-1.0.zip");
// Nettoyage d'« objects » : analyse, puis suppression de l'orphelin.
await page.AnalyzeCommand.ExecuteAsync();
Assert.True(page.HasPrune);
Assert.True(page.CanPrune);
Assert.Contains("1 fichier(s)", page.PruneText);
await page.PruneCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("1 fichier(s) supprimé(s)", page.StatusMessage);
Assert.False(File.Exists(Path.Combine(game, ManifestCheck.ObjectPath(Sha(orphan)))));
Assert.True(File.Exists(Path.Combine(game, ManifestCheck.ObjectPath(Sha(used)))));
await page.AnalyzeCommand.ExecuteAsync();
Assert.False(page.CanPrune);
Assert.Contains("Rien à nettoyer", page.PruneText);
// Retour à la racine par le fil d'Ariane.
scrollToken = page.ScrollToTopToken; // (une suppression, elle, remonte en haut pour afficher son résultat)
await page.Crumbs[0].OpenCommand.ExecuteAsync();
Assert.Equal("", page.CurrentPath);
Assert.Equal(scrollToken, page.ScrollToTopToken);
Assert.Equal(ServerFileStatus.Directory, Assert.Single(page.Entries).Status);
}
[Theory]
[InlineData(0, "0 o")]
[InlineData(812, "812 o")]
[InlineData(2048, "2 Ko")]
[InlineData(1_400_000, "1,3 Mo")]
[InlineData(21_400_000_000, "19,9 Go")]
public void Sizes_are_readable(long bytes, string expected) => Assert.Equal(expected, FilesPanelViewModel.Bytes(bytes));
}
@@ -0,0 +1,211 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Groupes et permissions », choix du groupe dans « Administrateurs », images de la page « Jeux ».</summary>
public class GroupsPanelTests
{
private static readonly ModuleCatalog Modules =
new(new ILauncherModule[] { new AdminModule(), new AccountsAdminModule(), new CatalogAdminModule() });
private static async Task<(AdminShellViewModel Shell, AdminWorkspaceViewModel Workspace)> OpenAsync(TestApi api, string folder, FakeDialogs dialogs)
{
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
return (shell, Assert.IsType<AdminWorkspaceViewModel>(shell.Screen));
}
private static async Task<T> PageAsync<T>(AdminWorkspaceViewModel workspace, string panelId) where T : AdminPanelViewModel
{
workspace.Navigate(panelId);
await workspace.Activation;
return Assert.IsType<T>(workspace.SelectedPanel!.ViewModel);
}
[Fact]
public async Task An_owner_edits_creates_and_deletes_groups_and_permissions()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
Assert.Contains(workspace.PanelItems, p => p.Descriptor.Id == GroupsPanelViewModel.PanelId);
var page = await PageAsync<GroupsPanelViewModel>(workspace, GroupsPanelViewModel.PanelId);
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.HasSplitLayout);
Assert.Equal(new[] { "Propriétaire", "Administrateur", "Éditeur" }, page.Groups.Select(g => g.Title));
var system = page.Groups[0];
Assert.True(system.IsSystem);
Assert.True(system.Editor.IsYourGroup);
Assert.All(system.Editor.Toggles, t => Assert.True(t.IsChecked && !t.IsEnabled));
Assert.False(system.Editor.CanSave);
Assert.False(system.Editor.CanDelete);
// Éditeur : on lui ajoute le journal d'audit.
var editor = page.Groups.Single(g => g.Id == AdminRoles.Editor);
page.Selected = editor;
Assert.False(page.SaveCommand.CanExecute(null));
editor.Editor.Toggles.Single(t => t.Name == AdminPermissions.AuditRead).IsChecked = true;
Assert.True(editor.IsDirty);
await page.SaveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("enregistré", page.StatusMessage);
var saved = page.Groups.Single(g => g.Id == AdminRoles.Editor);
Assert.Same(saved, page.Selected);
Assert.False(saved.IsDirty);
Assert.Contains(AdminPermissions.AuditRead, saved.Summary.Permissions);
// Nouveau groupe, avec la gestion des administrateurs : confirmation renforcée demandée.
page.NewGroupCommand.Execute(null);
Assert.False(page.NewGroupCommand.CanExecute(null)); // un seul brouillon à la fois
var draft = Assert.IsType<GroupItem>(page.Selected);
Assert.True(draft.IsNew);
draft.Editor.Name = "Gestion";
draft.Editor.Toggles.Single(t => t.Name == AdminPermissions.AdminsManage).IsChecked = true;
dialogs.Confirmations.Clear();
await page.SaveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith("Donner la gestion des administrateurs"));
var gestion = Assert.IsType<GroupItem>(page.Selected);
Assert.Equal("gestion", gestion.Id);
Assert.False(gestion.IsNew);
Assert.True(gestion.Editor.CanDelete);
// Permission personnalisée : création, puis suppression (retirée des groupes).
page.NewPermissionName = "actualites";
page.NewPermissionLabel = "Actualités";
await page.CreatePermissionCommand.ExecuteAsync();
Assert.Contains(dialogs.Messages, m => m.StartsWith("Valeurs à corriger"));
Assert.DoesNotContain(page.Permissions, p => p.Name == "actualites");
page.NewPermissionName = " News.Edit "; // mis en minuscules
page.NewPermissionLabel = "Actualités";
page.NewPermissionLabel = "Actualités";
await page.CreatePermissionCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var custom = page.Permissions.Single(p => p.Name == "news.edit");
Assert.True(custom.CanDelete);
Assert.False(page.Permissions.Single(p => p.Name == AdminPermissions.GamesEdit).CanDelete);
Assert.Contains(page.Groups.Single(g => g.Id == "gestion").Editor.Toggles, t => t.Name == "news.edit" && t.IsEnabled);
await custom.DeleteCommand.ExecuteAsync();
Assert.DoesNotContain(page.Permissions, p => p.Name == "news.edit");
// Suppression d'un groupe sans membre.
page.Selected = page.Groups.Single(g => g.Id == "gestion");
await page.Selected!.Editor.DeleteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.DoesNotContain(page.Groups, g => g.Id == "gestion");
// Page « Administrateurs » : les groupes du serveur sont proposés, avec leur description.
var admins = await PageAsync<AdminsPanelViewModel>(workspace, AdminsPanelViewModel.PanelId);
Assert.Equal(new[] { "Propriétaire", "Administrateur", "Éditeur" }, admins.RoleChoices.Select(c => c.Label));
Assert.Equal(AdminRoles.Editor, admins.NewRole.Value);
Assert.StartsWith("Éditeur : ", admins.NewRoleHint);
}
[Fact]
public async Task A_group_manager_only_sees_what_he_may_grant()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var gestion = (await owner.CreateGroupAsync(new GroupRequest
{
Name = "Gestion", Permissions = new[] { AdminPermissions.AdminsManage, AdminPermissions.GamesEdit }
})).Groups.Single(g => g.Name == "Gestion");
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = gestion.Id });
var enrolled = await api.CreateClient().EnrollAsync(invitation.Code, signer, "PC de Paul");
await owner.ApproveKeyAsync(enrolled.AdminId, signer.KeyId, enrolled.Fingerprint);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
var page = await PageAsync<GroupsPanelViewModel>(workspace, GroupsPanelViewModel.PanelId);
var mine = page.Groups.Single(g => g.Id == gestion.Id);
Assert.True(mine.Editor.IsYourGroup);
Assert.All(mine.Editor.Toggles, t => Assert.False(t.IsEnabled));
Assert.False(mine.Editor.CanDelete);
var editor = page.Groups.Single(g => g.Id == AdminRoles.Editor);
Assert.True(editor.Editor.Toggles.Single(t => t.Name == AdminPermissions.GamesEdit).IsEnabled);
Assert.True(editor.Editor.Toggles.Single(t => t.Name == AdminPermissions.ChangelogEdit).IsEnabled); // déjà cochée : peut être retirée
Assert.False(editor.Editor.Toggles.Single(t => t.Name == AdminPermissions.AuditRead).IsEnabled); // il ne l'a pas : ne peut la donner
var admins = await PageAsync<AdminsPanelViewModel>(workspace, AdminsPanelViewModel.PanelId);
Assert.Equal(new[] { "Gestion" }, admins.RoleChoices.Select(c => c.Label)); // ni Propriétaire, ni groupes au-dessus de ses droits
}
[Fact]
public async Task Browse_uploads_a_checked_image_and_fills_the_field()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-catalog-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(directory, CatalogImages.FolderName));
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
["LauncherApi:Catalog:PublicUrl"] = "https://launcher.example/catalog/catalog.json"
});
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
var page = await PageAsync<GamesPanelViewModel>(workspace, GamesPanelViewModel.PanelId);
var game = page.Games.Single();
Assert.True(game.CanBrowseImages);
// Fichier refusé sur le PC (pas une image) : rien n'est envoyé.
var text = Path.Combine(folder.Path, "faux.png");
File.WriteAllText(text, "pas une image");
dialogs.NextOpenFile = text;
await game.BrowseIconCommand.ExecuteAsync();
Assert.Contains(dialogs.Messages, m => m.StartsWith("Image refusée"));
Assert.Equal("", game.Icon);
// Image valide : envoyée, adresse renseignée, aperçu, publication possible.
var png = TestImages.Png(256, 256);
var file = Path.Combine(folder.Path, "icone.png");
File.WriteAllBytes(file, png);
dialogs.NextOpenFile = file;
await game.BrowseIconCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var sha = Convert.ToHexString(SHA256.HashData(png)).ToLowerInvariant();
Assert.Equal($"https://launcher.example/catalog/images/{sha}.png", game.Icon);
Assert.NotNull(game.IconPreview);
Assert.Equal(png, game.IconPreview!.Content);
Assert.True(File.Exists(Path.Combine(directory, CatalogImages.FolderName, sha + ".png")));
Assert.True(page.HasChanges);
Assert.Contains("Publier", page.StatusMessage);
// Adresse modifiée à la main : l'aperçu disparaît ; vider le champ.
game.Icon = "https://launcher.example/catalog/images/autre.png";
Assert.Null(game.IconPreview);
game.ClearIconCommand.Execute(null);
Assert.Equal("", game.Icon);
// Annulation de l'explorateur : rien ne change.
dialogs.NextOpenFile = null;
await game.BrowseBackgroundCommand.ExecuteAsync();
Assert.Equal("", game.Background);
}
}
@@ -0,0 +1,172 @@
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Admin;
/// <summary>
/// Pages qui ne concernent qu'un jeu à la fois (« Notes de version », « Publier une version ») avec plusieurs jeux
/// déclarés : rien n'est chargé ni affiché avant le choix du jeu, et rien d'un jeu ne reste affiché pour un autre.
/// </summary>
public class MultiGamePanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new ChangelogAdminModule(), new PublishAdminModule() });
private sealed class Context : IAsyncDisposable
{
public required TestApi Api { get; init; }
public required TempFolder Folder { get; init; }
public required FakeDialogs Dialogs { get; init; }
public required AdminShellViewModel Shell { get; init; }
public required AdminWorkspaceViewModel Workspace { get; init; }
public required string EzrManifest { get; init; }
public required string OtherManifest { get; init; }
public async ValueTask DisposeAsync()
{
await Shell.DisposeAsync();
await Api.DisposeAsync();
Folder.Dispose();
}
public async Task<T> OpenAsync<T>(string panelId) where T : AdminPanelViewModel
{
Workspace.Navigate(panelId);
await Workspace.Activation;
return Assert.IsType<T>(Workspace.SelectedPanel!.ViewModel);
}
}
private static async Task<Context> StartAsync()
{
var ezr = ChangelogModuleTests.PublishedManifest("EZR");
var other = ChangelogModuleTests.PublishedManifest("ZR2");
var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = ezr,
["LauncherApi:Games:ZR2:ManifestFile"] = other
});
var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
AdminTestKit.RegisterOwner(api, signer);
signer.Dispose();
var dialogs = new FakeDialogs();
var games = new[] { AdminTestKit.Game(), AdminTestKit.Game("ZR2", "Zombie Reborn 2") };
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules, games: games));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
return new Context
{
Api = api, Folder = folder, Dialogs = dialogs, Shell = shell,
Workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen),
EzrManifest = ezr, OtherManifest = other
};
}
[Fact]
public async Task Release_notes_wait_for_the_game_to_be_chosen()
{
await using var context = await StartAsync();
var page = await context.OpenAsync<ChangelogPanelViewModel>(ChangelogPanelViewModel.PanelId);
// Aucun jeu par défaut : choix du jeu seulement, rien de chargé.
Assert.False(page.HasError, page.ErrorMessage);
Assert.Null(page.SelectedGame);
Assert.True(page.ShowGamePicker);
Assert.False(page.ShowGameSwitcher);
Assert.Empty(page.Versions);
Assert.False(page.ShowNoVersions);
Assert.Equal("", page.ManifestText);
Assert.False(page.ReloadCommand.CanExecute(null));
Assert.False(page.SaveCommand.CanExecute(null));
Assert.Equal(new[] { "Exovival Zombie Reborn", "Zombie Reborn 2" }, page.GameTiles.Select(t => t.Name));
Assert.Contains("ZR2", page.GameTiles[1].Detail);
// Choix par la tuile : notes du jeu choisi, liste pour changer de jeu.
page.GameTiles[1].SelectCommand.Execute(null);
await page.GameLoad;
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal("ZR2", page.SelectedGame?.Value);
Assert.False(page.ShowGamePicker);
Assert.True(page.ShowGameSwitcher);
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
// Modification non enregistrée : changement de jeu refusé tant qu'elle n'est pas abandonnée.
page.SelectedVersion!.Title = "Notes de ZR2";
Assert.True(page.HasChanges);
var ezr = page.GameChoices.Single(c => c.Value == "EZR");
context.Dialogs.ConfirmAnswer = false;
page.SelectedGame = ezr;
Assert.Equal("ZR2", page.SelectedGame?.Value);
Assert.True(page.HasChanges);
context.Dialogs.ConfirmAnswer = true;
page.SelectedGame = ezr;
await page.GameLoad;
Assert.Equal("EZR", page.SelectedGame?.Value);
Assert.False(page.HasChanges);
Assert.Equal("Version 1.1", page.SelectedVersion!.Title);
// Enregistrement : seul le manifest du jeu choisi change.
var otherBefore = await File.ReadAllBytesAsync(context.OtherManifest);
page.SelectedVersion!.Title = "Notes d'EZR";
await page.SaveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Notes d'EZR", await File.ReadAllTextAsync(context.EzrManifest));
Assert.Equal(otherBefore, await File.ReadAllBytesAsync(context.OtherManifest));
}
[Fact]
public async Task Publishing_waits_for_the_game_and_forgets_the_previous_one()
{
await using var context = await StartAsync();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 2, bigFileBytes: 1024, archiveBytes: 2000);
var page = await context.OpenAsync<PublishPanelViewModel>(PublishPanelViewModel.PanelId);
// Aucun jeu par défaut : rien à choisir ni à publier avant le choix du jeu.
Assert.Null(page.SelectedGame);
Assert.True(page.ShowGamePicker);
Assert.False(page.HasSelectedGame);
Assert.Equal("", page.PublishedText);
Assert.False(page.ChooseCommand.CanExecute(null));
Assert.False(page.PublishCommand.CanExecute(null));
Assert.Equal(2, page.GameTiles.Count);
// Jeu choisi : version en ligne de ce jeu, dossier contrôlé pour ce jeu.
page.GameTiles[0].SelectCommand.Execute(null);
await page.GameLoad;
Assert.Equal("EZR", page.SelectedGame?.Value);
Assert.True(page.ShowGameSwitcher);
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
context.Dialogs.NextOpenFile = release.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.HasRelease);
Assert.True(page.PublishCommand.CanExecute(null));
// Autre jeu : rien du premier ne reste (dossier, version à publier) ; le dossier d'EZR y est refusé.
page.SelectedGame = page.GameChoices.Single(c => c.Value == "ZR2");
await page.GameLoad;
Assert.Equal("Zombie Reborn 2 (ZR2)", page.SelectedGameLabel);
Assert.False(page.HasRelease);
Assert.Null(page.ManifestPath);
Assert.False(page.PublishCommand.CanExecute(null));
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
await page.InspectAsync(release.ManifestPath);
Assert.True(page.HasError);
Assert.False(page.HasRelease);
// Retour à EZR, publication : la confirmation et le résultat nomment le jeu.
page.SelectedGame = page.GameChoices.Single(c => c.Value == "EZR");
await page.GameLoad;
Assert.False(page.HasError);
await page.InspectAsync(release.ManifestPath);
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Jeu : Exovival Zombie Reborn (EZR)", context.Dialogs.Confirmations.Last());
Assert.Contains("Version 1.2 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
}
}
@@ -0,0 +1,240 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Trust;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Server;
using GameLauncher.Tests.Signing;
namespace GameLauncher.Tests.Admin;
/// <summary>
/// Avertissement avant une publication signée par une clé que les launchers des joueurs n'accepteront pas
/// (absente du trousseau publié, ou sans l'autorisation voulue). Le serveur, lui, accepte la publication.
/// </summary>
public class PlayerSigningCheckTests
{
private const string WarningTitle = "Clé non reconnue par les launchers des joueurs";
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
/// <summary>Trousseau servi (modifiable entre deux contrôles) et nombre de téléchargements.</summary>
private sealed class KeyringServer
{
public byte[] Bytes { get; set; } = Array.Empty<byte>();
public Exception? Failure { get; set; }
public int Downloads { get; private set; }
public void Serve(KeyringDocument keyring) => Bytes = Encoding.UTF8.GetBytes(Keyring.Serialize(keyring));
public Task<byte[]> DownloadAsync(Uri url, long maxBytes, CancellationToken ct)
{
Downloads++;
Assert.Equal(KeyringUrl, url);
Assert.Equal(Keyring.MaxBytes, maxBytes);
return Failure is null ? Task.FromResult(Bytes) : Task.FromException<byte[]>(Failure);
}
}
private static KeyringKey EntryFor(byte[] publicKey, string name, params string[] scopes) => new()
{
KeyId = GameLauncher.Contracts.Security.AdminKeys.ComputeKeyId(publicKey),
PublicKey = Convert.ToBase64String(publicKey),
Name = name,
Scopes = scopes.Length == 0 ? SigningScopes.All.ToList() : scopes.ToList()
};
[Fact]
public async Task The_published_keyring_decides_whether_a_warning_is_shown()
{
using var root = SigningKit.NewKey();
using var mine = SigningKit.NewKey();
using var other = SigningKit.NewKey();
var publicKey = mine.ExportSubjectPublicKeyInfo();
var server = new KeyringServer();
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 2, new[] { SigningKit.Root(root) });
// Clé présente, toutes autorisations : aucun avertissement.
server.Serve(SigningKit.Keyring(root, 3, SigningKit.Entry(mine, "PC de Nicolas"), SigningKit.Entry(other, "PC de Paul")));
Assert.Null(await check.WarningAsync(ContentKinds.Catalog, publicKey));
Assert.Null(await check.WarningAsync(ContentKinds.Manifest, publicKey));
// Clé absente : catalogue ignoré par les launchers (« require »), le serveur l'accepte quand même.
server.Serve(SigningKit.Keyring(root, 4, SigningKit.Entry(other, "PC de Paul")));
var warning = await check.WarningAsync(ContentKinds.Catalog, publicKey);
Assert.NotNull(warning);
Assert.Contains($"La clé de ce PC ({SigningKit.KeyId(mine)}) n'est pas dans le trousseau publié (n° 4)", warning);
Assert.Contains("ignoreront ce catalogue", warning);
Assert.Contains("Le serveur accepte la publication quand même", warning);
Assert.Contains("page « Signature »", warning);
// Clé présente sans l'autorisation des versions : seul le manifest est concerné.
server.Serve(SigningKit.Keyring(root, 5, SigningKit.Entry(mine, "PC de Nicolas", SigningScopes.Catalog)));
Assert.Null(await check.WarningAsync(ContentKinds.Catalog, publicKey));
warning = await check.WarningAsync(ContentKinds.Manifest, publicKey);
Assert.Contains("est dans le trousseau publié (n° 5), mais sans l'autorisation", warning);
Assert.Contains($"« {SigningScopes.DisplayName(SigningScopes.Releases)} »", warning);
Assert.Contains("refuseront cette version", warning);
// Trousseau relu à chaque contrôle : la clé ajoutée par un propriétaire est vue aussitôt.
server.Serve(SigningKit.Keyring(root, 6, SigningKit.Entry(mine, "PC de Nicolas")));
Assert.Null(await check.WarningAsync(ContentKinds.Manifest, publicKey));
Assert.Equal(6, server.Downloads);
}
[Fact]
public async Task An_unverifiable_keyring_is_reported_like_the_players_launchers_would()
{
using var root = SigningKit.NewKey();
using var forged = SigningKit.NewKey();
using var mine = SigningKit.NewKey();
var publicKey = mine.ExportSubjectPublicKeyInfo();
var server = new KeyringServer();
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 2, new[] { SigningKit.Root(root) });
// Signé par une autre clé racine que celle intégrée à l'exe.
server.Serve(SigningKit.Keyring(forged, 3, SigningKit.Entry(mine, "PC de Nicolas")));
Assert.Contains("est refusé par ce launcher", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Plus ancien que le numéro minimal du launcher.
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(mine, "PC de Nicolas")));
Assert.Contains("plus ancien que le minimum n° 2", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Illisible.
server.Bytes = Encoding.UTF8.GetBytes("{ pas du json");
Assert.Contains("est refusé par ce launcher", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Injoignable : l'avertissement le dit (vérification impossible), sans bloquer.
server.Failure = new HttpRequestException("délai dépassé");
Assert.Contains("est injoignable (délai dépassé)", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Aucun trousseau configuré alors que les signatures sont vérifiées.
var unconfigured = new PlayerSigningCheck(server.DownloadAsync, null, ContentSigningMode.Require, 0, new[] { SigningKit.Root(root) });
Assert.Contains("Aucun trousseau n'est configuré", await unconfigured.WarningAsync(ContentKinds.Catalog, publicKey));
}
[Fact]
public async Task Warn_mode_only_mentions_the_journal_and_off_checks_nothing()
{
using var root = SigningKit.NewKey();
using var mine = SigningKit.NewKey();
using var other = SigningKit.NewKey();
var publicKey = mine.ExportSubjectPublicKeyInfo();
var server = new KeyringServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(other, "PC de Paul")));
var warn = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Warn, 0, new[] { SigningKit.Root(root) });
var warning = await warn.WarningAsync(ContentKinds.Catalog, publicKey);
Assert.Contains("l'accepteront, mais inscriront une alerte", warning);
Assert.DoesNotContain("ignoreront", warning);
var off = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Off, 0, new[] { SigningKit.Root(root) });
Assert.Null(await off.WarningAsync(ContentKinds.Catalog, publicKey));
Assert.Equal(1, server.Downloads); // mode « off » : pas de téléchargement
}
[Fact]
public async Task Games_page_warns_before_publishing_and_the_server_still_accepts()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-catalog-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var publicFile = Path.Combine(directory, "catalog.json");
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
using var root = SigningKit.NewKey();
using var other = SigningKit.NewKey();
var server = new KeyringServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(other, "PC de Paul")));
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 0, new[] { SigningKit.Root(root) });
var dialogs = new FakeDialogs();
var modules = new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new CatalogAdminModule() });
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, modules, playerSigning: check));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(GamesPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<GamesPanelViewModel>(workspace.SelectedPanel!.ViewModel);
page.Games.Single().Tagline = "Survivez à l'école.";
// Avertissement refusé : rien n'est signé ni publié.
dialogs.AnswerFor = title => title == WarningTitle ? false : null;
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith(WarningTitle) && c.Contains(signer.KeyId) && c.Contains("ignoreront ce catalogue"));
Assert.False(File.Exists(publicFile));
Assert.True(page.HasChanges);
Assert.False(page.IsBusy);
// « Publier quand même » : le serveur accepte (comportement inchangé).
dialogs.AnswerFor = null;
dialogs.Confirmations.Clear();
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith(WarningTitle));
Assert.Contains("révision 1", page.StatusMessage);
Assert.True(File.Exists(publicFile));
// Clé ajoutée au trousseau : plus d'avertissement.
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(other, "PC de Paul"), EntryFor(signer.PublicKey, "PC de Nicolas")));
page.Games.Single().Tagline = "Survivez au collège.";
dialogs.Confirmations.Clear();
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.DoesNotContain(dialogs.Confirmations, c => c.StartsWith(WarningTitle));
Assert.Contains("révision 2", page.StatusMessage);
}
[Fact]
public async Task Publish_page_warns_before_sending_a_release()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 2, bigFileBytes: 4096, archiveBytes: 2000);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
using var root = SigningKit.NewKey();
var server = new KeyringServer();
server.Serve(SigningKit.Keyring(root, 1, EntryFor(signer.PublicKey, "PC de Nicolas", SigningScopes.Catalog)));
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 0, new[] { SigningKit.Root(root) });
var dialogs = new FakeDialogs();
var modules = new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new PublishAdminModule() });
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, modules, playerSigning: check));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(PublishPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
dialogs.NextOpenFile = release.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
// Clé sans l'autorisation des versions : avertissement refusé, rien n'est envoyé (ni la confirmation habituelle).
dialogs.AnswerFor = title => title == WarningTitle ? false : null;
await page.PublishCommand.ExecuteAsync();
var asked = Assert.Single(dialogs.Confirmations);
Assert.StartsWith(WarningTitle, asked);
Assert.Contains("refuseront cette version", asked);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
Assert.False(page.IsBusy);
// « Publier quand même » puis confirmation habituelle : version publiée, le serveur l'accepte.
dialogs.AnswerFor = null;
dialogs.Confirmations.Clear();
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(2, dialogs.Confirmations.Count);
Assert.StartsWith("Publier la version 1.2", dialogs.Confirmations[1]);
Assert.Contains("Version 1.2 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
}
}
@@ -35,6 +35,10 @@ public class PublishPanelTests
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Un seul jeu déclaré : choisi d'office, pas de choix du jeu affiché.
Assert.Equal("EZR", page.SelectedGame?.Value);
Assert.False(page.ShowGamePicker);
Assert.False(page.ShowGameSwitcher);
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
Assert.False(page.PublishCommand.CanExecute(null));
@@ -55,7 +59,7 @@ public class PublishPanelTests
dialogs.ConfirmAnswer = true;
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.1 publiée", page.StatusMessage);
Assert.Contains("Version 1.1 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
Assert.False(page.IsPublishing);
Assert.False(page.HasInterrupted);
Assert.Equal("Nouveautés.", JsonNode.Parse(File.ReadAllText(manifest))!["changelog"]![0]!["changes"]![0]!.GetValue<string>());
@@ -105,7 +109,7 @@ public class PublishPanelTests
Assert.True(page.HasRelease, page.ErrorMessage);
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.2 publiée", page.StatusMessage);
Assert.Contains("Version 1.2 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
Assert.False(page.HasInterrupted);
});
@@ -0,0 +1,68 @@
using System.Buffers.Binary;
using System.IO.Compression;
using System.Text;
namespace GameLauncher.Tests.Infrastructure;
/// <summary>Images PNG et JPEG fabriquées pour les tests (sans bibliothèque graphique).</summary>
public static class TestImages
{
/// <summary>PNG valide (RVB 8 bits, une couleur unie) de la taille demandée.</summary>
public static byte[] Png(int width, int height, byte shade = 0x40)
{
using var output = new MemoryStream();
output.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
var header = new byte[13];
BinaryPrimitives.WriteInt32BigEndian(header, width);
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), height);
header[8] = 8; // profondeur
header[9] = 2; // RVB
Chunk(output, "IHDR", header);
using var raw = new MemoryStream();
using (var zlib = new ZLibStream(raw, CompressionLevel.Fastest, leaveOpen: true))
{
var row = new byte[1 + width * 3];
for (var i = 1; i < row.Length; i++) row[i] = shade;
for (var y = 0; y < height; y++) zlib.Write(row);
}
Chunk(output, "IDAT", raw.ToArray());
Chunk(output, "IEND", Array.Empty<byte>());
return output.ToArray();
}
/// <summary>Structure JPEG minimale (SOI, APP0, SOF0 avec les dimensions, EOI) : suffit au contrôle des en-têtes.</summary>
public static byte[] Jpeg(int width, int height)
{
var bytes = new List<byte> { 0xFF, 0xD8 };
bytes.AddRange(new byte[] { 0xFF, 0xE0, 0x00, 0x10, (byte)'J', (byte)'F', (byte)'I', (byte)'F', 0, 1, 1, 0, 0, 1, 0, 1, 0, 0 });
bytes.AddRange(new byte[] { 0xFF, 0xC0, 0x00, 0x11, 8, (byte)(height >> 8), (byte)height, (byte)(width >> 8), (byte)width, 3,
1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1 });
bytes.AddRange(new byte[] { 0xFF, 0xD9 });
return bytes.ToArray();
}
private static void Chunk(Stream output, string type, byte[] data)
{
var length = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, data.Length);
output.Write(length);
var typeAndData = Encoding.ASCII.GetBytes(type).Concat(data).ToArray();
output.Write(typeAndData);
var crc = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(typeAndData));
output.Write(crc);
}
private static uint Crc32(byte[] data)
{
var crc = 0xFFFFFFFFu;
foreach (var b in data)
{
crc ^= b;
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? 0xEDB88320u ^ (crc >> 1) : crc >> 1;
}
return ~crc;
}
}
@@ -0,0 +1,119 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Modules.Audit;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>Consultations et exports du journal d'audit, eux-mêmes inscrits au journal.</summary>
public class AuditModuleTests
{
private static List<AuditEntry> Journal(TestApi api, string action) =>
api.Services.GetRequiredService<AuditLog>().Read(200, null).Entries.Where(e => e.Action == action).ToList();
[Fact]
public async Task Each_consultation_is_recorded_without_one_line_per_page()
{
await using var api = await TestApi.StartAsync();
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var audit = api.Services.GetRequiredService<AuditLog>();
for (var i = 0; i < 5; i++) audit.Write("test.entry", AuditResults.Ok, details: $"entrée {i}");
// Première page : inscrite APRÈS la lecture (absente de la page lue), avec l'auteur et la clé.
var first = await owner.GetAuditAsync(3);
Assert.DoesNotContain(first.Entries, e => e.Action == "audit.view");
var view = Assert.Single(Journal(api, "audit.view"));
Assert.Equal(AuditResults.Ok, view.Result);
Assert.Equal(owner.Profile.DisplayName, view.ActorName);
Assert.Equal(key.KeyId, view.KeyId);
Assert.Contains("3 entrée(s) lue(s)", view.Details);
// Pages suivantes (« Charger plus ») juste après : pas de nouvelle ligne.
var next = await owner.GetAuditAsync(3, first.NextCursor);
Assert.NotEmpty(next.Entries);
Assert.Single(Journal(api, "audit.view"));
// Nouvelle consultation (ouverture, « Actualiser », période) : toujours inscrite.
var limit = api.Time.GetUtcNow().AddMinutes(1);
await owner.GetAuditAsync(3, before: limit);
var views = Journal(api, "audit.view");
Assert.Equal(2, views.Count);
Assert.Contains("antérieures au", views[0].Details);
// Page suivante longtemps après : inscrite à nouveau.
api.Time.Advance(AuditConsultations.ContinuationWindow + TimeSpan.FromMinutes(1));
await owner.GetAuditAsync(3, first.NextCursor);
views = Journal(api, "audit.view");
Assert.Equal(3, views.Count);
Assert.Contains("entrées plus anciennes", views[0].Details);
}
[Fact]
public async Task Entries_name_the_device_chosen_at_enrollment_never_the_windows_name()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var (editorKey, _) = await api.AddAdminAsync(owner, "Éditrice", AdminRoles.Editor); // appareil « poste de test »
// Launcher 2.4.1 ou plus ancien : nom Windows du PC entre parenthèses dans la description du logiciel.
await using (await api.CreateClient().LoginAsync(editorKey, "GameLauncher 2.4.1 (DESKTOP-ABC123)"))
{
}
var login = (await owner.GetAuditAsync(50)).Entries.First(e => e.Action == "auth.login" && e.KeyId == editorKey.KeyId);
Assert.Equal("poste de test", login.KeyLabel);
Assert.Equal("appareil « poste de test » · GameLauncher 2.4.1", login.Details);
Assert.DoesNotContain(Journal(api, "auth.login"), e => e.Details?.Contains("DESKTOP-ABC123") == true);
// Le nom de l'appareil est ajouté à la lecture : il n'est jamais écrit dans le fichier du journal.
Assert.All(api.Services.GetRequiredService<AuditLog>().Read(200, null).Entries, e => Assert.Null(e.KeyLabel));
// Entrée sans clé : pas d'appareil.
Assert.All((await owner.GetAuditAsync(50)).Entries.Where(e => e.KeyId is null), e => Assert.Null(e.KeyLabel));
}
[Fact]
public async Task Exports_are_recorded_and_checked()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
await owner.RecordAuditExportAsync(new AuditExportRequest
{
Count = 245,
From = new DateOnly(2026, 11, 1),
To = new DateOnly(2026, 11, 7),
Filters = "catégorie « Connexions »"
});
var export = Assert.Single(Journal(api, "audit.export"));
Assert.Equal(owner.Profile.DisplayName, export.ActorName);
Assert.Equal(ownerKey.KeyId, export.KeyId);
Assert.Equal("fichier CSV", export.Target);
Assert.Equal("245 entrée(s) ; du 01/11/2026 au 07/11/2026 ; filtres : catégorie « Connexions »", export.Details);
await owner.RecordAuditExportAsync(new AuditExportRequest { Count = 12 });
Assert.Equal("12 entrée(s) ; toutes les dates", Journal(api, "audit.export")[0].Details);
// Annonces invalides : refusées, rien d'inscrit.
foreach (var invalid in new[]
{
new AuditExportRequest { Count = -1 },
new AuditExportRequest { Count = 1, From = new DateOnly(2026, 11, 8), To = new DateOnly(2026, 11, 7) },
new AuditExportRequest { Count = 1, From = new DateOnly(1900, 1, 1) },
new AuditExportRequest { Count = 1, Filters = new string('x', AuditExportLimits.MaxFiltersLength + 1) }
})
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.RecordAuditExportAsync(invalid))).Status);
Assert.Equal(2, Journal(api, "audit.export").Count);
// Sans la permission de lire le journal : refusé.
var (editorKey, _) = await api.AddAdminAsync(owner, "Éditeur", AdminRoles.Editor);
await using var editor = await api.LoginAsync(editorKey);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.RecordAuditExportAsync(new AuditExportRequest { Count = 1 }))).Status);
Assert.Equal(2, Journal(api, "audit.export").Count);
}
}
@@ -0,0 +1,207 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Catalog;
using GameLauncher.Tests.Admin;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
/// <summary>Images du catalogue envoyées depuis le menu d'administration (sans client FTP).</summary>
public class CatalogImageTests
{
private static (string PublicFile, string ImagesDirectory) PublicFolder(bool withImages = true)
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-catalog-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var images = Path.Combine(directory, CatalogImages.FolderName);
if (withImages) Directory.CreateDirectory(images);
return (Path.Combine(directory, "catalog.json"), images);
}
private static string Sha256(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
[Fact]
public async Task An_image_is_checked_stored_under_its_hash_and_usable_in_the_published_catalog()
{
var (publicFile, images) = PublicFolder();
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var png = TestImages.Png(256, 256);
var uploaded = await owner.UploadCatalogImageAsync(CatalogImages.Icon, png);
Assert.Equal($"{Sha256(png)}.png", uploaded.FileName);
Assert.Equal((256, 256), (uploaded.Width, uploaded.Height));
Assert.False(uploaded.AlreadyPresent);
Assert.EndsWith($"/catalog/images/{uploaded.FileName}", uploaded.Url);
var stored = Path.Combine(images, uploaded.FileName);
Assert.Equal(png, File.ReadAllBytes(stored));
if (!OperatingSystem.IsWindows())
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead, File.GetUnixFileMode(stored));
Assert.Empty(Directory.GetFiles(images, ".*")); // aucun fichier temporaire laissé
// Même image : rien n'est réécrit.
Assert.True((await owner.UploadCatalogImageAsync(CatalogImages.Icon, png)).AlreadyPresent);
// Image de fond JPEG, puis catalogue publié avec ces adresses (même hôte que le catalogue : accepté).
var jpeg = TestImages.Jpeg(1920, 1080);
var background = await owner.UploadCatalogImageAsync(CatalogImages.Background, jpeg);
Assert.EndsWith(".jpg", background.FileName);
// (Le serveur de test écoute en http : en production, l'adresse publique du catalogue est en https.)
static string Https(string url) => "https" + url["http".Length..];
var catalog = new CatalogDocument
{
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Icon = Https(uploaded.Url), Background = Https(background.Url) } }
};
var state = await owner.PublishCatalogAsync(catalog, 0);
Assert.Equal(Https(uploaded.Url), state.Catalog.Games["EZR"].Icon);
var audit = (await owner.GetAuditAsync(limit: 50)).Entries.Where(e => e.Action == "catalog.image_upload").ToList();
Assert.Equal(3, audit.Count);
Assert.Contains(audit, e => e.Details!.Contains("déjà présente"));
}
[Theory]
[InlineData("texte")]
[InlineData("tronquee")]
[InlineData("suivie")]
[InlineData("petite")]
[InlineData("immense")]
public async Task Invalid_images_are_refused_before_anything_is_written(string kind)
{
var (publicFile, images) = PublicFolder();
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var png = TestImages.Png(128, 128);
var content = kind switch
{
"texte" => "<?php echo 1; ?>"u8.ToArray(),
"tronquee" => png[..^12],
"suivie" => png.Concat("<script>alert(1)</script>"u8.ToArray()).ToArray(),
"petite" => TestImages.Png(16, 16),
_ => TestImages.Jpeg(9000, 9000)
};
var imageKind = kind == "immense" ? CatalogImages.Background : CatalogImages.Icon;
var error = await Assert.ThrowsAsync<ApiException>(() => owner.UploadCatalogImageAsync(imageKind, content));
Assert.Equal(400, error.Status);
Assert.Empty(Directory.GetFiles(images));
Assert.Contains((await owner.GetAuditAsync(limit: 20)).Entries, e => e.Action == "catalog.image_upload" && e.Result == AuditResults.Denied);
}
[Fact]
public async Task Uploads_require_games_edit_a_signature_a_configured_folder_and_a_bounded_size()
{
var (publicFile, _) = PublicFolder(withImages: false);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var png = TestImages.Png(64, 64);
// Dossier « images » absent : refus explicite (créé à l'installation, jamais par le service).
var missing = await Assert.ThrowsAsync<ApiException>(() => owner.UploadCatalogImageAsync(CatalogImages.Icon, png));
Assert.Equal(503, missing.Status);
Assert.Equal(ApiErrorCodes.NotConfigured, missing.Code);
Directory.CreateDirectory(Path.Combine(Path.GetDirectoryName(publicFile)!, CatalogImages.FolderName));
// Groupe sans games.edit : refusé.
var auditors = (await owner.CreateGroupAsync(new GroupRequest { Name = "Audit", Permissions = new[] { AdminPermissions.AuditRead } }))
.Groups.Single(g => g.Name == "Audit");
var (key, _) = await api.AddAdminAsync(owner, "Paul", auditors.Id);
using var __ = key;
await using var paul = await api.LoginAsync(key);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.UploadCatalogImageAsync(CatalogImages.Icon, png))).Status);
// Sans signature : refusé ; type inconnu : refusé.
var login = await api.RawLoginAsync(ownerKey);
using (var request = new HttpRequestMessage(HttpMethod.Put, ApiRoutes.CatalogImage(CatalogImages.Icon)))
{
request.Content = new ByteArrayContent(png);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(CatalogImages.ContentType);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", login.Token);
using var response = await api.Http.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
Assert.Throws<ArgumentException>(() => { _ = owner.UploadCatalogImageAsync("../catalog", png); });
// Ordinaire : accepté.
Assert.False((await owner.UploadCatalogImageAsync(CatalogImages.Icon, png)).AlreadyPresent);
// Corps plus grand que la limite de la route : refusé avant lecture complète (session à part : connexion coupée).
await using var oversize = await api.LoginAsync(ownerKey);
var huge = new byte[CatalogImages.MaxRequestBytes + 1];
var tooLarge = await Assert.ThrowsAsync<ApiException>(() => oversize.UploadCatalogImageAsync(CatalogImages.Background, huge));
Assert.True(tooLarge.Status is 413 or 0, tooLarge.Message); // 413, ou connexion coupée par Kestrel pendant l'envoi
}
[Fact]
public async Task Without_a_public_catalog_uploads_are_not_configured()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var error = await Assert.ThrowsAsync<ApiException>(() => owner.UploadCatalogImageAsync(CatalogImages.Icon, TestImages.Png(64, 64)));
Assert.Equal(503, error.Status);
}
[Fact]
public void Image_checks_read_formats_and_dimensions_from_the_content()
{
Assert.Null(CatalogImages.Check(CatalogImages.Icon, TestImages.Png(512, 512), out var png));
Assert.Equal(("png", 512, 512), (png!.Format, png.Width, png.Height));
Assert.Null(CatalogImages.Check(CatalogImages.Background, TestImages.Jpeg(3840, 2160), out var jpg));
Assert.Equal(("jpg", 3840, 2160), (jpg!.Format, jpg.Width, jpg.Height));
Assert.NotNull(CatalogImages.Check(CatalogImages.Background, TestImages.Png(512, 512), out _)); // trop petite pour un fond
Assert.NotNull(CatalogImages.Check(CatalogImages.Icon, TestImages.Jpeg(4000, 4000), out _)); // trop grande pour une icône
Assert.NotNull(CatalogImages.Check(CatalogImages.Icon, Array.Empty<byte>(), out _));
Assert.NotNull(CatalogImages.Check(CatalogImages.Icon, "GIF89a"u8.ToArray(), out _));
Assert.NotNull(CatalogImages.Check("autre", TestImages.Png(64, 64), out _));
var jpeg = TestImages.Jpeg(800, 600);
Assert.NotNull(CatalogImages.Check(CatalogImages.Icon, jpeg[..^2], out _)); // sans marqueur de fin
var name = new string('a', 64);
Assert.Equal(name, CatalogImages.ExpectedSha256(new Uri($"https://h/catalog/images/{name}.png")));
Assert.Null(CatalogImages.ExpectedSha256(new Uri("https://h/catalog/images/fond.jpg")));
Assert.Null(CatalogImages.ExpectedSha256(new Uri($"https://h/catalog/images/{name.ToUpperInvariant()}.png")));
}
[Fact]
public void Group_rules_are_strict()
{
Assert.Equal("moderateurs-forum", AdminGroups.Slug("Modérateurs forum !"));
Assert.Equal("groupe-42", AdminGroups.Slug("42"));
Assert.True(AdminGroups.IsValidGroupId(AdminGroups.Slug("Équipe « son » & musique")));
Assert.True(AdminGroups.IsValidPermissionName("news.edit"));
Assert.True(AdminGroups.IsValidPermissionName("community.moderate-chat"));
Assert.False(AdminGroups.IsValidPermissionName("news"));
Assert.False(AdminGroups.IsValidPermissionName("news.edit.all"));
Assert.False(AdminGroups.IsValidPermissionName("News.edit"));
Assert.False(AdminGroups.IsValidPermissionName("news.édit"));
Assert.True(AdminGroups.IsSystem(AdminRoles.Owner));
Assert.All(AdminPermissions.All, p => Assert.NotNull(AdminPermissions.BuiltInLabel(p)));
}
[Fact]
public async Task Player_launcher_rejects_an_image_whose_content_does_not_match_its_hashed_name()
{
using var folder = new TempFolder();
var png = TestImages.Png(64, 64);
var other = TestImages.Png(64, 64, shade: 0x99);
var good = new Uri($"https://launcher.example/catalog/images/{Sha256(png)}.png");
var swapped = new Uri($"https://launcher.example/catalog/images/{Sha256(other)}.png");
Func<Uri, long, CancellationToken, Task<byte[]>> download = (_, _, _) => Task.FromResult(png);
var service = new CatalogService(download, folder.Path, new Uri("https://launcher.example/catalog/catalog.json"));
Assert.NotNull(await service.GetImageAsync(good.AbsoluteUri));
Assert.Null(await service.GetImageAsync(swapped.AbsoluteUri)); // le serveur a servi un autre contenu
Assert.NotNull(await service.GetImageAsync("https://launcher.example/catalog/images/fond.png")); // nom libre : pas d'empreinte attendue
}
}
@@ -0,0 +1,404 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>Page « Fichiers du serveur » : consultation du site, suppression d'un fichier inutilisé, nettoyage d'« objects ».</summary>
public class FilesModuleTests
{
/// <summary>
/// Site de test :
/// www/exovivalzombie/manifest.json (version 1.1 : EZR-1.1.zip + 2 objets)
/// www/exovivalzombie/EZR-1.0.zip (ancienne archive : inutilisée)
/// www/exovivalzombie/objects/xx/…bin (2 objets utilisés + 1 orphelin + 1 fichier étranger)
/// www/catalog/catalog.json, keyring.json, images/icon.png (utilisée), images/old.png (inutilisée)
/// www/launcher/launcher-update.json → GameLauncher-2.0.0.exe (utilisé), GameLauncher-1.9.0.exe (inutilisé)
/// </summary>
private sealed class Site
{
public string Root { get; } = Path.Combine(Path.GetTempPath(), "launcher-files-tests", Guid.NewGuid().ToString("N"), "www");
public string Game => Path.Combine(Root, "exovivalzombie");
public string Manifest => Path.Combine(Game, "manifest.json");
public string Catalog => Path.Combine(Root, "catalog", "catalog.json");
public byte[] Exe { get; } = RandomNumberGenerator.GetBytes(3000);
public byte[] Data { get; } = RandomNumberGenerator.GetBytes(2000);
public byte[] Orphan { get; } = RandomNumberGenerator.GetBytes(4096);
public string ObjectFile(byte[] data) => Path.Combine(Game, ManifestCheck.ObjectPath(Sha(data)));
public static Site Create(string catalogHost)
{
var site = new Site();
Directory.CreateDirectory(site.Game);
foreach (var data in new[] { site.Exe, site.Data, site.Orphan })
{
Directory.CreateDirectory(Path.GetDirectoryName(site.ObjectFile(data))!);
File.WriteAllBytes(site.ObjectFile(data), data);
}
File.WriteAllText(Path.Combine(site.Game, "objects", "readme.txt"), "pas un objet");
File.WriteAllBytes(Path.Combine(site.Game, "EZR-1.1.zip"), new byte[1234]);
File.WriteAllBytes(Path.Combine(site.Game, "EZR-1.0.zip"), new byte[777]);
site.WriteManifest("1.1", "EZR-1.1.zip", site.Exe, site.Data);
var catalog = Path.GetDirectoryName(site.Catalog)!;
Directory.CreateDirectory(Path.Combine(catalog, "images"));
File.WriteAllBytes(Path.Combine(catalog, "images", "icon.png"), new byte[100]);
File.WriteAllBytes(Path.Combine(catalog, "images", "old.png"), new byte[50]);
File.WriteAllText(site.Catalog, JsonSerializer.Serialize(new
{
revision = 3,
games = new Dictionary<string, object> { ["EZR"] = new { icon = $"http://{catalogHost}/catalog/images/icon.png" } }
}));
File.WriteAllText(Path.Combine(catalog, "keyring.json"), "{}");
var launcher = Path.Combine(site.Root, "launcher");
Directory.CreateDirectory(launcher);
File.WriteAllBytes(Path.Combine(launcher, "GameLauncher-2.0.0.exe"), new byte[300]);
File.WriteAllBytes(Path.Combine(launcher, "GameLauncher-1.9.0.exe"), new byte[200]);
var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { product = "x", version = "2.0.0", file = "GameLauncher-2.0.0.exe" }));
File.WriteAllText(Path.Combine(launcher, "launcher-update.json"),
JsonSerializer.Serialize(new { format = 1, payload = Convert.ToBase64String(payload), signature = "" }));
return site;
}
public void WriteManifest(string version, string archive, params byte[][] files)
{
var manifest = new GameManifest
{
GameId = "EZR",
Version = version,
Executable = "Game.exe",
Archive = new ArchiveInfo { Url = archive, Size = 1234, Sha256 = new string('a', 64) },
Files = files.Select((f, i) => new ManifestFile { Path = i == 0 ? "Game.exe" : $"Data/{i}.pak", Size = f.Length, Sha256 = Sha(f) }).ToList()
};
File.WriteAllText(Manifest, JsonSerializer.Serialize(manifest, JsonDefaults.Options));
}
public Dictionary<string, string?> Settings(bool explicitRoot = true)
{
var settings = new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = Manifest,
["LauncherApi:Catalog:PublicFile"] = Catalog
};
if (explicitRoot) settings["LauncherApi:Files:Root"] = Root;
return settings;
}
}
private static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
private sealed class Context : IAsyncDisposable
{
public required TestApi Api { get; init; }
public required AdminSession Owner { get; init; }
public required Site Site { get; init; }
public async ValueTask DisposeAsync()
{
await Owner.DisposeAsync();
await Api.DisposeAsync();
}
}
private static async Task<Context> StartAsync(bool explicitRoot = true)
{
// L'hôte du catalogue (images) est celui de l'API de test (le port n'est connu qu'au démarrage : seul l'hôte compte).
var site = Site.Create("127.0.0.1");
var api = await TestApi.StartAsync(site.Settings(explicitRoot));
var key = api.AddOwner();
return new Context { Api = api, Owner = await api.LoginAsync(key), Site = site };
}
private static ServerFileEntry Entry(ServerFolder folder, string name) => folder.Entries.Single(e => e.Name == name);
[Fact]
public async Task The_site_is_listed_with_the_status_of_each_file()
{
await using var context = await StartAsync(explicitRoot: false); // racine par défaut : parent commun du jeu et du catalogue
var (owner, site) = (context.Owner, context.Site);
var root = await owner.GetServerFolderAsync("");
Assert.Equal("www", root.RootName);
Assert.Equal(new[] { "catalog", "exovivalzombie", "launcher" }, root.Entries.Select(e => e.Name));
Assert.All(root.Entries, e => Assert.Equal(ServerFileStatus.Directory, e.Status));
Assert.Equal(new[] { "EZR" }, root.PrunableGames);
Assert.Equal(1234 + 777 + site.Exe.Length + site.Data.Length + site.Orphan.Length + "pas un objet".Length +
new FileInfo(site.Manifest).Length, Entry(root, "exovivalzombie").Size);
Assert.True(root.FreeBytes > 0);
var game = await owner.GetServerFolderAsync("exovivalzombie");
Assert.Equal(ServerFileStatus.Protected, Entry(game, "manifest.json").Status);
Assert.Equal(ServerFileStatus.InUse, Entry(game, "EZR-1.1.zip").Status);
Assert.Contains("version 1.1", Entry(game, "EZR-1.1.zip").Reason);
Assert.Equal(ServerFileStatus.Unused, Entry(game, "EZR-1.0.zip").Status);
var prefix = Sha(site.Orphan)[..2];
var objects = await owner.GetServerFolderAsync($"exovivalzombie/objects/{prefix}");
Assert.Equal(ServerFileStatus.Unused, Entry(objects, Sha(site.Orphan) + ".bin").Status);
var used = await owner.GetServerFolderAsync($"exovivalzombie/objects/{Sha(site.Exe)[..2]}");
Assert.Equal(ServerFileStatus.InUse, Entry(used, Sha(site.Exe) + ".bin").Status);
var images = await owner.GetServerFolderAsync("catalog/images");
Assert.Equal(ServerFileStatus.InUse, Entry(images, "icon.png").Status);
Assert.Equal(ServerFileStatus.Unused, Entry(images, "old.png").Status);
var catalog = await owner.GetServerFolderAsync("catalog");
Assert.Equal(ServerFileStatus.Protected, Entry(catalog, "catalog.json").Status);
Assert.Equal(ServerFileStatus.Protected, Entry(catalog, "keyring.json").Status);
var launcher = await owner.GetServerFolderAsync("launcher");
Assert.Equal(ServerFileStatus.InUse, Entry(launcher, "GameLauncher-2.0.0.exe").Status);
Assert.Equal(ServerFileStatus.Unused, Entry(launcher, "GameLauncher-1.9.0.exe").Status);
Assert.Equal(ServerFileStatus.Protected, Entry(launcher, "launcher-update.json").Status);
}
[Fact]
public async Task Only_unused_files_can_be_deleted()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var result = await owner.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777);
Assert.Equal(777, result.FreedBytes);
Assert.False(File.Exists(Path.Combine(site.Game, "EZR-1.0.zip")));
var audit = (await owner.GetAuditAsync(5)).Entries.First();
Assert.Equal("files.delete", audit.Action);
Assert.Equal("exovivalzombie/EZR-1.0.zip", audit.Target);
// Référencés ou protégés : refusés, rien n'est supprimé.
async Task RefusedAsync(string path, long size, int status)
{
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync(path, size));
Assert.Equal(status, refused.Status);
}
await RefusedAsync("exovivalzombie/EZR-1.1.zip", 1234, 409);
await RefusedAsync("exovivalzombie/manifest.json", new FileInfo(site.Manifest).Length, 409);
await RefusedAsync($"exovivalzombie/objects/{Sha(site.Exe)[..2]}/{Sha(site.Exe)}.bin", site.Exe.Length, 409);
await RefusedAsync("catalog/images/icon.png", 100, 409);
await RefusedAsync("catalog/catalog.json", new FileInfo(site.Catalog).Length, 409);
await RefusedAsync("launcher/GameLauncher-2.0.0.exe", 300, 409);
Assert.True(File.Exists(Path.Combine(site.Game, "EZR-1.1.zip")));
Assert.True(File.Exists(site.Manifest));
Assert.True(File.Exists(site.ObjectFile(site.Exe)));
// Chemins refusés : en dehors du site, cachés, dossiers ; taille changée depuis l'affichage.
await RefusedAsync("../outside.txt", 1, 400);
await RefusedAsync("exovivalzombie/../../outside.txt", 1, 400);
await RefusedAsync("/etc/passwd", 1, 404); // lu comme « etc/passwd » DANS le site : introuvable
await RefusedAsync("exovivalzombie\\EZR-1.1.zip", 1234, 400);
await RefusedAsync("exovivalzombie/.publish-x/manifest.json", 1, 400);
await RefusedAsync("exovivalzombie/objects", 0, 400);
await RefusedAsync("catalog/images/old.png", 49, 409);
await RefusedAsync("catalog/images/absent.png", 1, 404);
Assert.True(File.Exists(Path.Combine(site.Root, "catalog", "images", "old.png")));
// L'ancienne image et l'ancien exécutable, eux, sont supprimés.
await owner.DeleteServerFileAsync("catalog/images/old.png", 50);
await owner.DeleteServerFileAsync("launcher/GameLauncher-1.9.0.exe", 200);
}
[Fact]
public async Task Nothing_is_deleted_next_to_an_unreadable_manifest_or_during_a_publication()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
// Publication en cours (dépôt « .publish-… ») : refus, même pour un fichier inutilisé.
var staging = Path.Combine(site.Game, ".publish-0123456789abcdef0123456789abcdef");
Directory.CreateDirectory(staging);
var during = await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777));
Assert.Equal(409, during.Status);
Assert.Equal(ApiErrorCodes.PublicationInProgress, during.Code);
Assert.DoesNotContain((await owner.GetServerFolderAsync("exovivalzombie")).Entries, e => e.Name.StartsWith('.'));
Directory.Delete(staging);
// Manifest illisible : impossible de vérifier, donc rien n'est supprimable dans le dossier du jeu.
File.WriteAllText(site.Manifest, "{ pas du json");
var game = await owner.GetServerFolderAsync("exovivalzombie");
Assert.Equal(ServerFileStatus.Unknown, Entry(game, "EZR-1.0.zip").Status);
var unknown = await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777));
Assert.Equal(409, unknown.Status);
Assert.True(File.Exists(Path.Combine(site.Game, "EZR-1.0.zip")));
}
[Fact]
public async Task Objects_no_longer_used_by_the_manifest_are_pruned()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var preview = await owner.GetObjectsPruneAsync("EZR");
Assert.Equal("1.1", preview.Version);
Assert.Equal(2, preview.ReferencedCount);
Assert.Equal(0, preview.MissingCount);
Assert.Equal(1, preview.OrphanCount);
Assert.Equal(site.Orphan.Length, preview.OrphanBytes);
Assert.Equal(new[] { ManifestCheck.ObjectPath(Sha(site.Orphan)) }, preview.Examples);
Assert.False(preview.PublicationInProgress);
// Manifest changé depuis l'analyse : refus.
var stale = await Assert.ThrowsAsync<ApiException>(() => owner.PruneObjectsAsync("EZR", new string('0', 64)));
Assert.Equal(ApiErrorCodes.RevisionConflict, stale.Code);
Assert.True(File.Exists(site.ObjectFile(site.Orphan)));
// Publication en cours : refus.
var staging = Path.Combine(site.Game, ".publish-0123456789abcdef0123456789abcdef");
Directory.CreateDirectory(staging);
Assert.True((await owner.GetObjectsPruneAsync("EZR")).PublicationInProgress);
var during = await Assert.ThrowsAsync<ApiException>(() => owner.PruneObjectsAsync("EZR", preview.ManifestSha256));
Assert.Equal(ApiErrorCodes.PublicationInProgress, during.Code);
Directory.Delete(staging);
var result = await owner.PruneObjectsAsync("EZR", preview.ManifestSha256);
Assert.Equal(1, result.Deleted);
Assert.Equal(site.Orphan.Length, result.FreedBytes);
Assert.False(File.Exists(site.ObjectFile(site.Orphan)));
Assert.True(File.Exists(site.ObjectFile(site.Exe)));
Assert.True(File.Exists(site.ObjectFile(site.Data)));
Assert.True(File.Exists(Path.Combine(site.Game, "objects", "readme.txt"))); // pas un objet : jamais touché
Assert.Contains((await owner.GetAuditAsync(5)).Entries, e => e.Action == "files.prune" && e.Target == "EZR objects");
// Nouvelle version qui n'utilise plus l'exécutable : il devient orphelin.
site.WriteManifest("1.2", "EZR-1.1.zip", site.Data);
var after = await owner.GetObjectsPruneAsync("EZR");
Assert.Equal(1, after.OrphanCount);
await owner.PruneObjectsAsync("EZR", after.ManifestSha256);
Assert.False(File.Exists(site.ObjectFile(site.Exe)));
Assert.True(File.Exists(site.ObjectFile(site.Data)));
}
[Fact]
public async Task Files_used_by_any_manifest_of_the_site_are_kept()
{
await using var context = await StartAsync();
var (owner, site) = (context.Owner, context.Site);
// Un autre jeu (non configuré sur le serveur) : archive dans un dossier voisin (« ../ »), fichiers servis depuis le
// dossier « objects » du premier jeu par une adresse complète de ce serveur.
var other = Path.Combine(site.Root, "autrejeu");
var shared = Path.Combine(site.Root, "partage");
Directory.CreateDirectory(other);
Directory.CreateDirectory(shared);
File.WriteAllBytes(Path.Combine(shared, "commun.zip"), new byte[10]);
File.WriteAllBytes(Path.Combine(shared, "libre.zip"), new byte[11]);
File.WriteAllText(Path.Combine(other, "manifest.json"), JsonSerializer.Serialize(new
{
gameId = "AUTRE",
version = "3.0",
archive = new { url = "../partage/commun.zip", size = 10, sha256 = new string('d', 64) },
objectsBaseUrl = "http://127.0.0.1/exovivalzombie/objects/",
files = new[] { new { path = "a.bin", size = site.Orphan.Length, sha256 = Sha(site.Orphan) } }
}));
var folder = await owner.GetServerFolderAsync("partage");
Assert.Equal(ServerFileStatus.InUse, Entry(folder, "commun.zip").Status);
Assert.Contains("autrejeu/", Entry(folder, "commun.zip").Reason);
Assert.Equal(ServerFileStatus.Unused, Entry(folder, "libre.zip").Status);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("partage/commun.zip", 10))).Status);
// L'objet « orphelin » du premier jeu est utilisé par l'autre : ni supprimable, ni nettoyé.
var orphan = $"exovivalzombie/objects/{Sha(site.Orphan)[..2]}/{Sha(site.Orphan)}.bin";
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync(orphan, site.Orphan.Length))).Status);
var preview = await owner.GetObjectsPruneAsync("EZR");
Assert.Equal(0, preview.OrphanCount);
Assert.True(File.Exists(site.ObjectFile(site.Orphan)));
// Adresse complète d'un AUTRE serveur : rien à protéger ici. Adresse de ce serveur hors du site : idem.
File.WriteAllText(Path.Combine(other, "manifest.json"), JsonSerializer.Serialize(new
{
version = "3.1",
archive = new { url = "https://cdn.example.org/commun.zip" },
files = Array.Empty<object>()
}));
Assert.Equal(ServerFileStatus.Unused, Entry(await owner.GetServerFolderAsync("partage"), "commun.zip").Status);
Assert.Equal(1, (await owner.GetObjectsPruneAsync("EZR")).OrphanCount);
}
[Fact]
public async Task Symbolic_links_are_neither_followed_nor_deleted()
{
if (OperatingSystem.IsWindows()) return; // liens symboliques : droits particuliers sous Windows
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var outside = Path.Combine(Path.GetTempPath(), "launcher-files-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outside);
File.WriteAllText(Path.Combine(outside, "secret.txt"), "secret");
Directory.CreateSymbolicLink(Path.Combine(site.Root, "lien"), outside);
File.CreateSymbolicLink(Path.Combine(site.Root, "lien.txt"), Path.Combine(outside, "secret.txt"));
var root = await owner.GetServerFolderAsync("");
Assert.Equal(ServerFileStatus.Link, Entry(root, "lien").Status);
Assert.Equal(ServerFileStatus.Link, Entry(root, "lien.txt").Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.GetServerFolderAsync("lien"))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("lien/secret.txt", 6))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("lien.txt", 6))).Status);
Assert.True(File.Exists(Path.Combine(outside, "secret.txt")));
}
[Fact]
public async Task Editors_cannot_see_nor_delete_files()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var editorAccount = await api.AddAdminAsync(owner, "Éditrice", AdminRoles.Editor);
await using var editor = await api.LoginAsync(editorAccount.Key);
var adminAccount = await api.AddAdminAsync(owner, "Admin", AdminRoles.Admin);
await using var admin = await api.LoginAsync(adminAccount.Key);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetServerFolderAsync(""))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetObjectsPruneAsync("EZR"))).Status);
Assert.True(File.Exists(Path.Combine(site.Game, "EZR-1.0.zip")));
await admin.GetServerFolderAsync(""); // administrateur : autorisé
Assert.True(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.FilesManage));
}
[Fact]
public async Task Without_any_public_folder_the_page_reports_it_is_not_configured()
{
await using var api = await TestApi.StartAsync();
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.GetServerFolderAsync(""));
Assert.Equal(503, refused.Status);
Assert.Equal(ApiErrorCodes.NotConfigured, refused.Code);
}
[Fact]
public async Task The_audit_can_be_read_up_to_a_date()
{
await using var api = await TestApi.StartAsync();
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var limit = api.Time.GetUtcNow().AddMinutes(1);
// Entrée écrite trois jours plus tard (horloge du serveur avancée le temps de l'écrire).
var audit = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>();
api.Time.Advance(TimeSpan.FromDays(3));
audit.Write("test.later", AuditResults.Ok, details: "plus tard");
api.Time.Advance(TimeSpan.FromDays(-3));
var all = await owner.GetAuditAsync(50);
Assert.Contains(all.Entries, e => e.Action == "test.later");
var early = await owner.GetAuditAsync(50, before: limit);
Assert.DoesNotContain(early.Entries, e => e.Action == "test.later");
Assert.Contains(early.Entries, e => e.Action == "auth.login");
Assert.All(early.Entries, e => Assert.True(e.Timestamp < limit));
// Date invalide : refus explicite.
var invalid = await Assert.ThrowsAsync<ApiException>(() => owner.GetAsync<AuditPage>("v1/audit?before=pas-une-date"));
Assert.Equal(400, invalid.Status);
}
}
@@ -0,0 +1,250 @@
using System.Net;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
/// <summary>Groupes d'administrateurs et permissions (module serveur « Groups »).</summary>
public class GroupsModuleTests
{
private static GroupRequest Request(string name, params string[] permissions) =>
new() { Name = name, Description = "Test", Permissions = permissions };
[Fact]
public async Task Default_groups_reproduce_the_former_roles()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.GetGroupsAsync();
Assert.Equal(new[] { AdminRoles.Owner, AdminRoles.Admin, AdminRoles.Editor }, state.Groups.Select(g => g.Id));
var ownerGroup = state.Groups[0];
Assert.True(ownerGroup.System);
Assert.Equal("Propriétaire", ownerGroup.Name);
Assert.Equal(1, ownerGroup.MemberCount);
Assert.Equal(AdminPermissions.All.OrderBy(p => p), ownerGroup.Permissions.OrderBy(p => p));
foreach (var role in new[] { AdminRoles.Admin, AdminRoles.Editor })
Assert.Equal(AdminRoles.PermissionsOf(role).OrderBy(p => p), state.Groups.Single(g => g.Id == role).Permissions.OrderBy(p => p));
Assert.All(state.Permissions, p => Assert.True(p.BuiltIn));
Assert.Equal(AdminPermissions.All.Count, state.Permissions.Count);
Assert.Equal("Propriétaire", owner.Profile.RoleName);
}
[Fact]
public async Task A_custom_group_gives_exactly_its_permissions_and_changes_apply_immediately()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.CreateGroupAsync(Request("Modérateurs forum", AdminPermissions.AuditRead));
var group = state.Groups.Single(g => g.Name == "Modérateurs forum");
Assert.Equal("moderateurs-forum", group.Id);
Assert.Equal(new[] { AdminPermissions.AuditRead }, group.Permissions);
Assert.Equal(1, group.Revision);
var (key, adminId) = await api.AddAdminAsync(owner, "Paul", group.Id);
using var _ = key;
await using var paul = await api.LoginAsync(key);
Assert.Equal(group.Id, paul.Profile.Role);
Assert.Equal("Modérateurs forum", paul.Profile.RoleName);
Assert.Equal(new[] { AdminPermissions.AuditRead }, paul.Profile.Permissions);
await paul.GetAuditAsync();
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.ListAdminsAsync())).Status);
var summary = (await owner.ListAdminsAsync()).Single(a => a.Id == adminId);
Assert.Equal("Modérateurs forum", summary.RoleName);
Assert.Equal(new[] { AdminPermissions.AuditRead }, summary.Permissions);
// Permissions modifiées : effet immédiat sur la session ouverte de Paul.
state = await owner.UpdateGroupAsync(group.Id, Request("Modérateurs", AdminPermissions.ChangelogEdit) with { Revision = group.Revision });
group = state.Groups.Single(g => g.Id == group.Id);
Assert.Equal("Modérateurs", group.Name);
Assert.Equal(2, group.Revision);
Assert.Equal(1, group.MemberCount);
Assert.Equal(new[] { AdminPermissions.ChangelogEdit }, (await paul.RefreshAsync()).Admin.Permissions);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.GetAuditAsync())).Status);
// Modification préparée sur une révision dépassée : refusée, rien n'est écrasé.
var conflict = await Assert.ThrowsAsync<ApiException>(() =>
owner.UpdateGroupAsync(group.Id, Request("Autre nom") with { Revision = 1 }));
Assert.Equal(409, conflict.Status);
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
Assert.Equal("Modérateurs", (await owner.GetGroupsAsync()).Groups.Single(g => g.Id == group.Id).Name);
// Audit : création et modification inscrites.
var actions = (await owner.GetAuditAsync(limit: 50)).Entries.Select(e => e.Action).ToList();
Assert.Contains("admins.group_create", actions);
Assert.Contains("admins.group_update", actions);
}
[Fact]
public async Task System_group_and_groups_in_use_are_protected()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var system = await Assert.ThrowsAsync<ApiException>(() => owner.UpdateGroupAsync(AdminRoles.Owner, Request("Chefs") with { Revision = 1 }));
Assert.Equal(409, system.Status);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteGroupAsync(AdminRoles.Owner))).Status);
// Groupe avec un membre, puis avec une invitation en cours : non supprimable.
var (key, adminId) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
using var _ = key;
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteGroupAsync(AdminRoles.Editor))).Status);
await owner.ChangeRoleAsync(adminId, AdminRoles.Admin);
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Léa", Role = AdminRoles.Editor });
Assert.Contains("invitation", (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteGroupAsync(AdminRoles.Editor))).Message);
await owner.DeleteInvitationAsync(invitation.Invitation.Id);
var state = await owner.DeleteGroupAsync(AdminRoles.Editor);
Assert.DoesNotContain(state.Groups, g => g.Id == AdminRoles.Editor);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() =>
owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Léa", Role = AdminRoles.Editor }))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.ChangeRoleAsync(adminId, "inexistant"))).Status);
// Noms : obligatoires, uniques, sans caractère invisible.
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.CreateGroupAsync(Request("administrateur")))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.CreateGroupAsync(Request(" ")))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.CreateGroupAsync(Request("Nom‮")))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.CreateGroupAsync(Request("Inconnue", "root.all")))).Status);
// Identifiant d'un nouveau groupe : d'après son nom (minuscules, sans accent).
state = await owner.CreateGroupAsync(Request("Éditeur"));
Assert.Equal("editeur", state.Groups.Single(g => g.Name == "Éditeur").Id);
}
[Fact]
public async Task A_group_manager_cannot_give_more_than_his_own_rights()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var managers = (await owner.CreateGroupAsync(Request("Gestion", AdminPermissions.AdminsManage, AdminPermissions.GamesEdit)))
.Groups.Single(g => g.Name == "Gestion");
var (key, _) = await api.AddAdminAsync(owner, "Paul", managers.Id);
using var __ = key;
await using var paul = await api.LoginAsync(key);
// Permissions qu'il n'a pas : refusées ; sous-ensemble des siennes : accepté.
var denied = await Assert.ThrowsAsync<ApiException>(() => paul.CreateGroupAsync(Request("Auditeurs", AdminPermissions.AuditRead)));
Assert.Equal(403, denied.Status);
Assert.Contains(AdminPermissions.AuditRead, denied.Message);
var created = (await paul.CreateGroupAsync(Request("Rédaction", AdminPermissions.GamesEdit))).Groups.Single(g => g.Name == "Rédaction");
// Son propre groupe : nom modifiable, pas ses permissions ; ni suppression.
var self = await Assert.ThrowsAsync<ApiException>(() =>
paul.UpdateGroupAsync(managers.Id, Request("Gestion", AdminPermissions.AdminsManage) with { Revision = managers.Revision }));
Assert.Equal(ApiErrorCodes.SelfAction, self.Code);
await paul.UpdateGroupAsync(managers.Id, Request("Gestionnaires", AdminPermissions.AdminsManage, AdminPermissions.GamesEdit)
with { Revision = managers.Revision });
Assert.Equal(ApiErrorCodes.SelfAction, (await Assert.ThrowsAsync<ApiException>(() => paul.DeleteGroupAsync(managers.Id))).Code);
// Groupes au-dessus de ses droits : ni invitation, ni changement de groupe ; un propriétaire ne se gère pas.
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() =>
paul.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "X", Role = AdminRoles.Owner }))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() =>
paul.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "X", Role = AdminRoles.Admin }))).Status);
await paul.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Y", Role = created.Id });
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.ChangeRoleAsync(owner.Profile.Id, created.Id))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.RevokeAdminAsync(owner.Profile.Id))).Status);
var ownerKeyId = owner.Profile.KeyId;
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.RevokeKeyAsync(owner.Profile.Id, ownerKeyId))).Status);
// Un éditeur (sans admins.manage) n'accède pas aux groupes.
var (editorKey, _) = await api.AddAdminAsync(owner, "Éd", AdminRoles.Editor);
using var ___ = editorKey;
await using var editor = await api.LoginAsync(editorKey);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetGroupsAsync())).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.CreateGroupAsync(Request("Pirates")))).Status);
}
[Fact]
public async Task Custom_permissions_are_created_assigned_and_deleted_but_built_in_ones_are_not()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.CreatePermissionAsync(new CreatePermissionRequest { Name = "news.edit", Label = "Actualités", Description = "Rédiger" });
var permission = state.Permissions.Single(p => p.Name == "news.edit");
Assert.False(permission.BuiltIn);
Assert.Equal(1, permission.GroupCount); // le groupe « Propriétaire » l'a d'office
Assert.Contains("news.edit", (await owner.RefreshAsync()).Admin.Permissions);
var editor = state.Groups.Single(g => g.Id == AdminRoles.Editor);
state = await owner.UpdateGroupAsync(AdminRoles.Editor, new GroupRequest
{
Name = editor.Name, Description = editor.Description, Revision = editor.Revision,
Permissions = editor.Permissions.Append("news.edit").ToList()
});
Assert.Contains("news.edit", state.Groups.Single(g => g.Id == AdminRoles.Editor).Permissions);
// Formats, doublons, permissions intégrées.
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() =>
owner.CreatePermissionAsync(new CreatePermissionRequest { Name = "News.Edit", Label = "X" }))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() =>
owner.CreatePermissionAsync(new CreatePermissionRequest { Name = "news", Label = "X" }))).Status);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() =>
owner.CreatePermissionAsync(new CreatePermissionRequest { Name = "news.edit", Label = "X" }))).Status);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() =>
owner.CreatePermissionAsync(new CreatePermissionRequest { Name = AdminPermissions.GamesEdit, Label = "X" }))).Status);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.DeletePermissionAsync(AdminPermissions.AuditRead))).Status);
// Suppression : retirée de tous les groupes (révision des groupes concernés augmentée).
state = await owner.DeletePermissionAsync("news.edit");
Assert.DoesNotContain(state.Permissions, p => p.Name == "news.edit");
var after = state.Groups.Single(g => g.Id == AdminRoles.Editor);
Assert.DoesNotContain("news.edit", after.Permissions);
Assert.Equal(editor.Revision + 2, after.Revision);
Assert.Equal(404, (await Assert.ThrowsAsync<ApiException>(() => owner.DeletePermissionAsync("news.edit"))).Status);
var actions = (await owner.GetAuditAsync(limit: 50)).Entries.Select(e => e.Action).ToList();
Assert.Contains("admins.permission_create", actions);
Assert.Contains("admins.permission_delete", actions);
}
[Fact]
public async Task Group_changes_require_a_signed_request()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
var login = await api.RawLoginAsync(ownerKey);
using var unsigned = await api.SendSignedRawAsync(login, ownerKey, HttpMethod.Post, ApiRoutes.Groups, Request("Sans signature"),
includeSignature: false);
Assert.Equal(HttpStatusCode.Unauthorized, unsigned.StatusCode);
Assert.Equal(ApiErrorCodes.SignatureRequired, (await TestApi.ReadErrorAsync(unsigned)).Code);
using var signed = await api.SendSignedRawAsync(login, ownerKey, HttpMethod.Post, ApiRoutes.Groups, Request("Signé"));
Assert.Equal(HttpStatusCode.OK, signed.StatusCode);
}
[Fact]
public async Task A_format_1_admin_file_is_migrated_to_default_groups()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
// Fichier écrit par un serveur antérieur (format 1, sans groupes).
var path = Path.Combine(api.DataDirectory, AdminStore.FileName);
var json = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(path))!.AsObject();
json["formatVersion"] = 1;
json.Remove("groups");
json.Remove("customPermissions");
File.WriteAllText(path, json.ToJsonString());
await using var owner = await api.LoginAsync(ownerKey);
Assert.Equal(AdminPermissions.All.Count, owner.Profile.Permissions.Count);
Assert.Equal(3, (await owner.GetGroupsAsync()).Groups.Count);
await owner.CreateGroupAsync(Request("Nouveau"));
var written = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.Equal(2, written.GetProperty("formatVersion").GetInt32());
Assert.Equal(4, written.GetProperty("groups").GetArrayLength());
}
}
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more