Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a491622b | ||
|
|
c210754a18 | ||
|
|
389593a3b4 | ||
|
|
22ee3a6dee | ||
|
|
a72761ccd0 | ||
|
|
62011342ec | ||
|
|
0f93b784f5 | ||
|
|
3ad27965fd | ||
|
|
69e710d3b7 | ||
|
|
d8219acaab | ||
|
|
987dab286b | ||
|
|
6592b09d14 | ||
|
|
35b47b1b9d | ||
|
|
b67636e29b | ||
|
|
db37cd9d46 | ||
|
|
ffbd98d800 | ||
|
|
c281ca1781 | ||
|
|
dbb843b1bb | ||
|
|
745a9f4f63 | ||
|
|
314703d7e5 | ||
|
|
dd661210a5 | ||
|
|
fb0f70a4ff | ||
|
|
735a5c0a3e | ||
|
|
3b8492d5fb | ||
|
|
7529519684 | ||
|
|
1b58860347 | ||
|
|
5647ba0cfb | ||
|
|
be2c4fe17d | ||
|
|
a29f688bdb | ||
|
|
be5c409e2f | ||
|
|
5b090a74ed | ||
|
|
dc55ab4843 | ||
|
|
e6cd32aa15 | ||
|
|
d98bac7eaf | ||
|
|
2da7d11bd0 | ||
|
|
94cf7147e3 | ||
|
|
5ac3d153ab | ||
|
|
7d26a4d59c | ||
|
|
924735a0dc | ||
|
|
d5a992ad62 | ||
|
|
e2523d1bff | ||
|
|
d444940b63 | ||
|
|
8e69ec108e | ||
|
|
84bcb80108 | ||
|
|
e9f650ef9e | ||
|
|
27b57526ee | ||
|
|
3996f580f6 | ||
|
|
ca0eb044e8 | ||
|
|
5b0999e47c | ||
|
|
9f6210598c | ||
|
|
08e1476726 | ||
|
|
818716b7a9 | ||
|
|
4971c926ac | ||
|
|
99502a2003 | ||
|
|
81108699c3 | ||
|
|
7cce8676b4 | ||
|
|
0f8f64f00f | ||
|
|
68ada960cd | ||
|
|
b08cad3e8e | ||
|
|
42fc1e58cc | ||
|
|
b2f1415bff | ||
|
|
d499bfb11a | ||
|
|
bddd46e9bb | ||
|
|
b774ca119f | ||
|
|
fbfb456b5d |
No files matched your search
@@ -5,6 +5,7 @@ Publish/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
Claude outputs/
|
||||
|
||||
# ---> VisualStudio
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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><sha256>.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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><64 hex>.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/<sha256>.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; }
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.<type de la page> » 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%\<Publisher>\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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 <GameId> »).</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)
|
||||
|
||||
+1041
-241
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user