Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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`) |
|
||||
@@ -288,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) :
|
||||
@@ -312,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)
|
||||
```
|
||||
@@ -528,7 +539,9 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
(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.
|
||||
@@ -547,6 +560,13 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
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
|
||||
@@ -561,10 +581,19 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
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**
|
||||
@@ -577,13 +606,15 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
de 200). Filtres par **période** (aujourd'hui, 7 ou 30 derniers jours, ce mois-ci, mois précédent, ou dates
|
||||
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, IP, clé…). La période est demandée au
|
||||
« 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é**
|
||||
@@ -594,14 +625,16 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
* **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,
|
||||
publication, journal d'audit, fichiers du serveur), é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é.
|
||||
@@ -610,6 +643,6 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
|---|---|
|
||||
| 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/` |
|
||||
+143
-5
@@ -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 |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
@@ -275,12 +322,51 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
| `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
|
||||
@@ -384,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 ↑ ↓,
|
||||
@@ -512,6 +604,15 @@ que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste s
|
||||
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é.
|
||||
@@ -548,6 +649,29 @@ serveur »), permission `files.manage`.
|
||||
* **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 :
|
||||
@@ -601,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é
|
||||
@@ -618,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).
|
||||
|
||||
@@ -636,6 +773,7 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
|
||||
| **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,6 +25,7 @@ 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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ public sealed class AuditModule : IApiModule
|
||||
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)
|
||||
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;
|
||||
@@ -43,7 +44,7 @@ public sealed class AuditModule : IApiModule
|
||||
until = parsed;
|
||||
}
|
||||
|
||||
var page = audit.Read(limit ?? 50, cursor, until);
|
||||
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
|
||||
@@ -61,6 +62,25 @@ public sealed class AuditModule : IApiModule
|
||||
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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -325,6 +328,41 @@ Règles appliquées par le serveur (le launcher ne fait qu'afficher) :
|
||||
(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 :
|
||||
@@ -409,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;
|
||||
}
|
||||
@@ -115,6 +115,34 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Groupes et permissions (admins.manage)
|
||||
|
||||
/// <summary>Groupes (permissions, membres) et permissions connues du serveur.</summary>
|
||||
public Task<GroupsState> GetGroupsAsync(CancellationToken ct = default) => GetAsync<GroupsState>(ApiRoutes.Groups, ct);
|
||||
|
||||
public Task<GroupsState> CreateGroupAsync(GroupRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Groups, request, ct);
|
||||
|
||||
/// <summary>Modifie un groupe (409 « revision_conflict » s'il a changé depuis <see cref="GroupRequest.Revision"/>).</summary>
|
||||
public Task<GroupsState> UpdateGroupAsync(string groupId, GroupRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Put, ApiRoutes.Group(CheckGroupId(groupId)), request, ct);
|
||||
|
||||
/// <summary>Supprime un groupe sans membre ni invitation en cours.</summary>
|
||||
public Task<GroupsState> DeleteGroupAsync(string groupId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Group(CheckGroupId(groupId)), (object?)null, ct);
|
||||
|
||||
public Task<GroupsState> CreatePermissionAsync(CreatePermissionRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Permissions, request, ct);
|
||||
|
||||
/// <summary>Supprime une permission personnalisée (elle est retirée de tous les groupes).</summary>
|
||||
public Task<GroupsState> DeletePermissionAsync(string name, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Permission(
|
||||
AdminGroups.IsValidPermissionName(name) ? name : throw new ArgumentException($"Permission invalide : « {name} ».", nameof(name))),
|
||||
(object?)null, ct);
|
||||
|
||||
private static string CheckGroupId(string groupId) =>
|
||||
AdminGroups.IsValidGroupId(groupId) ? groupId : throw new ArgumentException($"Identifiant de groupe invalide : « {groupId} ».", nameof(groupId));
|
||||
|
||||
/// <param name="before">Facultatif : seulement les entrées antérieures à cet instant (filtre « jusqu'au »).</param>
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, DateTimeOffset? before = null, CancellationToken ct = default) =>
|
||||
GetAsync<AuditPage>(ApiRoutes.AuditPage(limit, cursor, before), ct);
|
||||
@@ -151,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,6 +37,16 @@ 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";
|
||||
|
||||
@@ -70,6 +80,14 @@ public static class ApiRoutes
|
||||
// ------------------------------------------------------------------ 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}";
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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
|
||||
@@ -29,4 +32,32 @@ public static class AdminPermissions
|
||||
{
|
||||
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,6 +1,11 @@
|
||||
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>
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
@@ -250,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);
|
||||
|
||||
@@ -428,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);
|
||||
@@ -447,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; }
|
||||
@@ -469,13 +476,19 @@ 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",
|
||||
@@ -518,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,6 +121,12 @@ 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();
|
||||
@@ -146,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 ?? ""); }
|
||||
@@ -192,8 +259,22 @@ public sealed class GameEditor : CatalogEditor
|
||||
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;
|
||||
|
||||
@@ -213,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;
|
||||
@@ -230,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>
|
||||
@@ -314,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,7 +37,7 @@ 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(ScrollingToTop(PublishAsync), () => !IsBusy && HasChanges && PublishingEnabled);
|
||||
@@ -44,6 +48,9 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
|
||||
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,9 +34,9 @@ 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();
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -188,11 +188,14 @@ public sealed class FilesPanelViewModel : AdminPanelViewModel
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
/// <summary>Ouvre un dossier (fil d'Ariane, double-clic, « Remonter »).</summary>
|
||||
/// <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;
|
||||
if (await RunAsync(() => LoadAsync(path))) RequestScrollToTop();
|
||||
await RunAsync(() => LoadAsync(path));
|
||||
}
|
||||
|
||||
private async Task LoadAsync(string path)
|
||||
@@ -205,7 +208,7 @@ public sealed class FilesPanelViewModel : AdminPanelViewModel
|
||||
if (!games.Select(g => g.Value).SequenceEqual(_pruneGames.Select(g => g.Value)))
|
||||
{
|
||||
PruneGames = games;
|
||||
PruneGame = games.FirstOrDefault();
|
||||
PruneGame = games.Count == 1 ? games[0] : null; // plusieurs jeux : choisi par l'administrateur, jamais par défaut
|
||||
}
|
||||
ApplyFolder();
|
||||
OnPropertyChanged(nameof(CurrentPath));
|
||||
|
||||
@@ -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,15 +32,16 @@ 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);
|
||||
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(ScrollingToTop(SignOnlineAsync), () => CanEdit && CanSignOnline);
|
||||
@@ -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,18 +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",
|
||||
AdminPermissions.FilesManage => "Fichiers du serveur",
|
||||
_ => 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,15 @@ 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.
|
||||
@@ -75,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.
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -58,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);
|
||||
@@ -254,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;
|
||||
@@ -356,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;
|
||||
}
|
||||
|
||||
@@ -476,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>
|
||||
|
||||
@@ -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.2.0</Version>
|
||||
<Version>2.5.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+599
-134
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" />
|
||||
@@ -575,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}"
|
||||
@@ -597,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>
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ui:WheelAssist.IsEnabled="True">
|
||||
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).
|
||||
@@ -27,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>
|
||||
@@ -44,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é -->
|
||||
@@ -93,7 +118,7 @@
|
||||
<!-- 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>
|
||||
@@ -102,28 +127,55 @@
|
||||
<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 »…) -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
|
||||
<!-- 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" />
|
||||
@@ -158,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>
|
||||
@@ -167,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;
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -187,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));
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,10 @@ public class FilesPanelTests
|
||||
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.
|
||||
// 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");
|
||||
@@ -119,8 +121,10 @@ public class FilesPanelTests
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,30 @@ public class AuditModuleTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ public static class Program
|
||||
private static async Task<int> LoginTest(AdminSession session, Options o)
|
||||
{
|
||||
var info = await session.RefreshAsync();
|
||||
Success($"Connexion réussie : {info.Admin.DisplayName} ({AdminRoles.DisplayName(info.Admin.Role)})");
|
||||
Success($"Connexion réussie : {info.Admin.DisplayName} ({info.Admin.RoleName ?? AdminRoles.DisplayName(info.Admin.Role)})");
|
||||
Console.WriteLine($" Serveur : {session.ServerId}");
|
||||
Console.WriteLine($" Permissions : {string.Join(", ", info.Admin.Permissions)}");
|
||||
Console.WriteLine($" Session : expire au plus tard le {info.ExpiresAt.ToLocalTime():dd/MM/yyyy HH:mm}");
|
||||
@@ -126,12 +126,12 @@ public static class Program
|
||||
{
|
||||
foreach (var admin in await session.ListAdminsAsync())
|
||||
{
|
||||
Console.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
Console.WriteLine($"{admin.DisplayName} [{admin.RoleName ?? AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
foreach (var key in admin.Keys)
|
||||
Console.WriteLine($" clé {key.KeyId} {key.Fingerprint ?? "(empreinte à vérifier)"} {key.Status,-8} {key.Label}");
|
||||
}
|
||||
foreach (var invitation in await session.ListInvitationsAsync())
|
||||
Console.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.Role}), expire le {invitation.ExpiresAt.ToLocalTime():dd/MM HH:mm}");
|
||||
Console.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.RoleName ?? invitation.Role}), expire le {invitation.ExpiresAt.ToLocalTime():dd/MM HH:mm}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ public static class Program
|
||||
Role = forAdmin is null ? o.Get("role") ?? AdminRoles.Editor : null,
|
||||
ForAdminId = forAdmin
|
||||
});
|
||||
Success($"Invitation créée pour {created.Invitation.DisplayName} ({AdminRoles.DisplayName(created.Invitation.Role)}).");
|
||||
Success($"Invitation créée pour {created.Invitation.DisplayName} ({created.Invitation.RoleName ?? AdminRoles.DisplayName(created.Invitation.Role)}).");
|
||||
Console.WriteLine($" Code (affiché une seule fois) : {created.Code}");
|
||||
Console.WriteLine($" Valable jusqu'au {created.Invitation.ExpiresAt.ToLocalTime():dd/MM/yyyy HH:mm}");
|
||||
return 0;
|
||||
@@ -160,7 +160,7 @@ public static class Program
|
||||
private static async Task<int> Role(AdminSession session, Options o)
|
||||
{
|
||||
var admin = await session.ChangeRoleAsync(Guid.Parse(o.Require("admin")), o.Require("role"));
|
||||
Success($"{admin.DisplayName} : {AdminRoles.DisplayName(admin.Role)}.");
|
||||
Success($"{admin.DisplayName} : {admin.RoleName ?? AdminRoles.DisplayName(admin.Role)}.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -239,10 +239,10 @@ public static class Program
|
||||
Enregistre la clé avec un code d'invitation
|
||||
Gestion (propriétaire) :
|
||||
admins --api <url> [--key …] Administrateurs, clés, invitations
|
||||
invite --api <url> --name <nom> [--role owner|admin|editor]
|
||||
invite --api <url> --name <nom> [--role <id du groupe : owner|admin|editor|…>]
|
||||
invite --api <url> --for <id admin> Invitation « nouvel appareil »
|
||||
approve --api <url> --admin <id> --key-id <clé> --fingerprint "AB12 CD34 EF56 7890 1A2B"
|
||||
role --api <url> --admin <id> --role <rôle>
|
||||
role --api <url> --admin <id> --role <id du groupe>
|
||||
revoke-key --api <url> --admin <id> --key-id <clé>
|
||||
revoke-admin --api <url> --admin <id>
|
||||
audit --api <url> [--limit 30]
|
||||
|
||||
@@ -276,7 +276,8 @@ internal sealed class Capture
|
||||
|
||||
/// <summary>
|
||||
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
|
||||
/// (<scène>.p2.png…, 4 au plus), puis retour en haut.
|
||||
/// (<scène>.p2.png…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
|
||||
/// de la zone (une page très longue n'a pas de pages intermédiaires, mais sa fin est visible).
|
||||
/// </summary>
|
||||
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
|
||||
{
|
||||
@@ -291,7 +292,7 @@ internal sealed class Capture
|
||||
var page = 2;
|
||||
for (var offset = step; page <= 5; offset += step, page++)
|
||||
{
|
||||
var target = Math.Min(offset, scroller.ScrollableHeight);
|
||||
var target = page == 5 ? scroller.ScrollableHeight : Math.Min(offset, scroller.ScrollableHeight);
|
||||
scroller.ScrollToVerticalOffset(target);
|
||||
await SettleAsync(160);
|
||||
Save(Render(root), $"{record.Id}.p{page}.png", record);
|
||||
|
||||
@@ -109,6 +109,13 @@ internal sealed class FakeApi : HttpMessageHandler
|
||||
? Json(admin)
|
||||
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Administrateur introuvable.");
|
||||
|
||||
// ---- Groupes et permissions
|
||||
case ["v1", "groups"] when get:
|
||||
return Json(_world.Groups());
|
||||
case ["v1", "groups", ..]:
|
||||
case ["v1", "permissions", ..]:
|
||||
return Json(_world.Groups());
|
||||
|
||||
// ---- Journal d'audit
|
||||
case ["v1", "audit"] when get:
|
||||
return Json(_world.Audit());
|
||||
@@ -118,6 +125,8 @@ internal sealed class FakeApi : HttpMessageHandler
|
||||
// ---- Catalogue
|
||||
case ["v1", "catalog"] when get:
|
||||
return Json(_world.Catalog());
|
||||
case ["v1", "catalog", "images", var kind]:
|
||||
return Json(_world.UploadedImage(kind, body));
|
||||
case ["v1", "catalog"]:
|
||||
var update = Read<CatalogUpdateRequest>(body);
|
||||
return Json(_world.Catalog() with { Catalog = update.Catalog with { Revision = update.BaseRevision + 1, UpdatedAt = DateTimeOffset.Now } });
|
||||
@@ -414,6 +423,52 @@ internal sealed class SampleWorld
|
||||
|
||||
public MyAccount MyAccount() => new() { Admin = Owner(), Invitations = new[] { NewDeviceInvitation() } };
|
||||
|
||||
// ------------------------------------------------------------------ Groupes et permissions
|
||||
|
||||
private const string ModeratorsId = "moderateurs";
|
||||
|
||||
public GroupsState Groups()
|
||||
{
|
||||
var custom = new PermissionSummary
|
||||
{
|
||||
Name = "news.edit", Label = "Actualités", Description = "Rédiger les actualités affichées dans le launcher (module à venir).",
|
||||
BuiltIn = false, GroupCount = 2, CreatedAt = _now.AddDays(-3), CreatedBy = OwnerName
|
||||
};
|
||||
var all = AdminPermissions.All.Append(custom.Name).ToList();
|
||||
GroupSummary Group(string id, string name, IReadOnlyList<string> permissions, int members, int invitations = 0) => new()
|
||||
{
|
||||
Id = id, Name = name, Description = AdminGroups.DefaultDescription(id) ?? "Modération de la communauté et des actualités.",
|
||||
Permissions = permissions, System = AdminGroups.IsSystem(id), Revision = 3, MemberCount = members, InvitationCount = invitations,
|
||||
UpdatedAt = _now.AddDays(-2), UpdatedBy = OwnerName
|
||||
};
|
||||
var groups = new List<GroupSummary>
|
||||
{
|
||||
Group(AdminRoles.Owner, "Propriétaire", all, 1),
|
||||
Group(AdminRoles.Admin, "Administrateur", AdminRoles.PermissionsOf(AdminRoles.Admin), 1),
|
||||
Group(AdminRoles.Editor, "Éditeur", AdminRoles.PermissionsOf(AdminRoles.Editor), 1, 1),
|
||||
Group(ModeratorsId, "Modérateurs", new[] { AdminPermissions.ChangelogEdit, custom.Name }, 0)
|
||||
};
|
||||
var permissions = AdminPermissions.All.Select(p => new PermissionSummary
|
||||
{
|
||||
Name = p, Label = AdminPermissions.BuiltInLabel(p)!, Description = AdminPermissions.BuiltInDescription(p), BuiltIn = true,
|
||||
GroupCount = groups.Count(g => g.Permissions.Contains(p))
|
||||
}).Append(custom).ToList();
|
||||
return new GroupsState { Groups = groups, Permissions = permissions };
|
||||
}
|
||||
|
||||
/// <summary>Image « envoyée » : adresse calculée comme le ferait le serveur (empreinte du contenu).</summary>
|
||||
public CatalogImageUploaded UploadedImage(string kind, byte[] body)
|
||||
{
|
||||
CatalogImages.Check(kind, body, out var info);
|
||||
var sha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(body)).ToLowerInvariant();
|
||||
var name = $"{sha}.{info?.Format ?? "png"}";
|
||||
return new CatalogImageUploaded
|
||||
{
|
||||
Url = new Uri(new Uri(Catalog().PublicUrl ?? "https://launcher.example/catalog/catalog.json"), "images/" + name).AbsoluteUri,
|
||||
FileName = name, Sha256 = sha, Width = info?.Width ?? 0, Height = info?.Height ?? 0, Bytes = body.Length
|
||||
};
|
||||
}
|
||||
|
||||
public InvitationCreated CreateInvitation(CreateInvitationRequest request) => new()
|
||||
{
|
||||
Invitation = new InvitationSummary
|
||||
|
||||
@@ -42,6 +42,7 @@ internal sealed class AdminScenes : ISceneSet
|
||||
("admin-login-error", "Administration · connexion refusée"),
|
||||
("admin-enroll", "Administration · « J'ai une invitation »"),
|
||||
("admin-enroll-done", "Administration · clé créée, empreinte à communiquer"),
|
||||
(PagePrefix + "changelog-notes-game", "Administration · notes de version (jeu choisi)"),
|
||||
(PagePrefix + "releases-publish-ready", "Administration · version prête à publier"),
|
||||
("admin-login-locked", "Administration · session verrouillée")
|
||||
};
|
||||
@@ -171,6 +172,21 @@ internal sealed class AdminScenes : ISceneSet
|
||||
await ShootAllThemesAsync(id, "Administration · " + item.Title);
|
||||
}
|
||||
|
||||
// ---- Notes de version, jeu choisi (plusieurs jeux : la page s'ouvre sur le choix du jeu)
|
||||
var changelogId = PagePrefix + "changelog-notes-game";
|
||||
if (capture.WantsAny(capture.Themes.Select(t => changelogId + Capture.Suffix(t))) &&
|
||||
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == ChangelogPanelViewModel.PanelId) is { } changelogItem)
|
||||
{
|
||||
workspace.SelectedPanel = changelogItem;
|
||||
await WaitForPanelAsync(capture, changelogItem, api);
|
||||
if (changelogItem.ViewModel is ChangelogPanelViewModel { SelectedGame: null } changelog && MainChoice(changelog.GameChoices) is { } choice)
|
||||
{
|
||||
changelog.SelectedGame = choice;
|
||||
await WaitForPanelAsync(capture, changelogItem, api);
|
||||
}
|
||||
await ShootAllThemesAsync(changelogId, "Administration · notes de version (jeu choisi)");
|
||||
}
|
||||
|
||||
// ---- Version prête à publier (dossier de publication factice)
|
||||
var publishId = PagePrefix + "releases-publish-ready";
|
||||
if (capture.WantsAny(capture.Themes.Select(t => publishId + Capture.Suffix(t))) &&
|
||||
@@ -180,6 +196,11 @@ internal sealed class AdminScenes : ISceneSet
|
||||
await WaitForPanelAsync(capture, publishItem, api);
|
||||
if (publishItem.ViewModel is PublishPanelViewModel publish)
|
||||
{
|
||||
if (publish.SelectedGame is null && MainChoice(publish.GameChoices) is { } choice)
|
||||
{
|
||||
publish.SelectedGame = choice; // plusieurs jeux : choisi comme le ferait l'administrateur
|
||||
await WaitForPanelAsync(capture, publishItem, api);
|
||||
}
|
||||
var gameId = publish.SelectedGame?.Value ?? _fixture.MainGame.GameId;
|
||||
var manifest = SampleData.CreateRelease(Path.Combine(_fixture.WorkDirectory, "publication"), gameId,
|
||||
_fixture.Catalog.Find(gameId)?.GameExecutable ?? "Jeu.exe");
|
||||
@@ -195,6 +216,10 @@ internal sealed class AdminScenes : ISceneSet
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Jeu principal de la démonstration parmi les choix d'une page (sinon le premier).</summary>
|
||||
private Choice? MainChoice(IReadOnlyList<Choice> choices) =>
|
||||
choices.FirstOrDefault(c => string.Equals(c.Value, _fixture.MainGame.GameId, StringComparison.OrdinalIgnoreCase)) ?? choices.FirstOrDefault();
|
||||
|
||||
private static void SelectKey(LoginViewModel login, string keyPath) =>
|
||||
login.SelectedKey = login.Keys.FirstOrDefault(k => string.Equals(Path.GetFullPath(k.Path), Path.GetFullPath(keyPath), StringComparison.OrdinalIgnoreCase))
|
||||
?? login.Keys.FirstOrDefault();
|
||||
|
||||
+70
-85
@@ -1,118 +1,103 @@
|
||||
# Launcher 2.2.0
|
||||
# Launcher 2.5.0
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Menu d'administration : fichiers du serveur et journal d'audit - Mise à jour du 7 novembre 2026
|
||||
Plein écran et nom des appareils dans l'audit - Mise à jour du 8 novembre 2026
|
||||
==================================================================
|
||||
|
||||
Version consacrée au menu d'administration : une nouvelle page « Fichiers du serveur » pour libérer de la place en toute sécurité, un journal d'audit filtrable par date et qui trace ses propres consultations et exports, et une navigation plus confortable dans toutes les pages.
|
||||
La fenêtre du launcher et le menu d'administration peuvent maintenant s'agrandir sur tout l'écran, la barre des tâches restant visible. Leur mise en page s'adapte à la place disponible, sur grand comme sur petit écran.
|
||||
|
||||
Pour les joueurs, rien ne change : le jeu, les mises à jour et le launcher fonctionnent comme avant.
|
||||
Dans le menu d'administration, le journal d'audit indique désormais chaque appareil sous le nom choisi lors de l'invitation, et non plus sous le nom Windows du PC. Le serveur est à redéployer pour en profiter.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
EN BREF
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : page « Fichiers du serveur » : parcourir le site,
|
||||
supprimer les fichiers inutilisés, nettoyer le dossier
|
||||
« objects » d'un jeu.
|
||||
- NOUVEAU : journal d'audit : filtre par période, aussi appliqué à
|
||||
l'export CSV.
|
||||
- NOUVEAU : chaque consultation et chaque export du journal d'audit
|
||||
sont inscrits au journal.
|
||||
- AMÉLIORÉ : boutons d'action toujours visibles en bas de chaque
|
||||
page du menu d'administration.
|
||||
- AMÉLIORÉ : retour automatique en haut de la page après
|
||||
« Recharger », « Publier »… pour voir le résultat.
|
||||
- CORRIGÉ : la molette de la souris ne change plus une liste
|
||||
déroulante par erreur en faisant défiler la page.
|
||||
- AMÉLIORÉ : liens d'un jeu : en-têtes de colonnes et textes
|
||||
indicatifs dans les champs.
|
||||
- QUALITÉ : 18 nouveaux tests automatisés (253 au total).
|
||||
- NOUVEAU : plein écran pour la fenêtre du launcher et le menu
|
||||
d'administration (bouton entre « Réduire » et « Fermer », ou F11).
|
||||
- NOUVEAU : le mode choisi (plein écran ou fenêtré) est retenu pour
|
||||
la fois suivante.
|
||||
- AMÉLIORÉ : mise en page adaptée à la taille de l'écran, sans
|
||||
texte ni bouton coupé.
|
||||
- CORRIGÉ : le journal d'audit affichait le nom Windows du PC au
|
||||
lieu du nom de l'appareil choisi à l'invitation.
|
||||
- SERVEUR : à redéployer (nom des appareils dans le journal
|
||||
d'audit).
|
||||
- QUALITÉ : 1 nouveau test automatisé (282 au total).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
NOUVELLE PAGE « FICHIERS DU SERVEUR »
|
||||
PLEIN ÉCRAN
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : parcours des dossiers du site public (fil d'Ariane), avec
|
||||
pour chaque fichier sa taille, sa date et son état :
|
||||
* Utilisé : référencé par un manifest ou par le catalogue ;
|
||||
* Inutilisé : supprimable (par exemple une ancienne archive .zip) ;
|
||||
* Protégé : fichier de description (manifest, catalogue...),
|
||||
jamais supprimable ;
|
||||
* Inconnu : vérification impossible, donc jamais supprimable.
|
||||
- NOUVEAU : espace disque libre et utilisé du serveur, et filtre
|
||||
« Fichiers inutilisés seulement ».
|
||||
- NOUVEAU : suppression d'un fichier inutilisé, après confirmation.
|
||||
- NOUVEAU : nettoyage du dossier « objects » d'un jeu :
|
||||
* une analyse montre les fichiers que la version en ligne
|
||||
n'utilise plus et la place récupérable ;
|
||||
* le nettoyage les supprime après confirmation.
|
||||
- SÉCURITÉ : le serveur revérifie tout au moment de supprimer :
|
||||
* tous les manifests et le catalogue sont relus juste avant ;
|
||||
* un fichier encore référencé n'est jamais supprimé ;
|
||||
* les manifests ne peuvent jamais être supprimés ;
|
||||
* suppression refusée pendant une publication, ou si le fichier
|
||||
ou le manifest a changé depuis l'affichage ;
|
||||
* un fichier encore utilisé par un autre manifest n'est pas
|
||||
nettoyé ;
|
||||
* chaque suppression et chaque nettoyage sont inscrits au journal
|
||||
d'audit (requêtes signées).
|
||||
- Accès : propriétaires et administrateurs (nouvelle permission
|
||||
« Fichiers du serveur »).
|
||||
- Concerne la fenêtre principale du launcher et le menu
|
||||
d'administration ; les autres fenêtres (paramètres, notes de
|
||||
version agrandies, messages) gardent leur taille.
|
||||
- Bascule : bouton entre « Réduire » et « Fermer », ou touche F11.
|
||||
- La fenêtre s'agrandit comme une fenêtre Windows : elle occupe tout
|
||||
l'écran où elle se trouve, la barre des tâches reste visible (même
|
||||
en masquage automatique).
|
||||
- Le mode de chaque fenêtre est retenu pour la fois suivante.
|
||||
- Les fenêtres ne se redimensionnent toujours pas par les bords ou
|
||||
les coins : deux modes seulement, fenêtré ou plein écran.
|
||||
- Une fenêtre réduite dans la barre des tâches ou près de l'horloge
|
||||
revient dans son mode.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
JOURNAL D'AUDIT
|
||||
MISE EN PAGE ADAPTÉE À L'ÉCRAN
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : filtre par période : aujourd'hui, 7 ou 30 derniers jours,
|
||||
ce mois-ci, mois précédent, ou dates « du / au » (jj/mm/aaaa).
|
||||
- L'export CSV suit la période choisie, et son nom de fichier
|
||||
l'indique.
|
||||
- NOUVEAU : chaque consultation du journal (ouverture, Actualiser,
|
||||
période changée) est inscrite : auteur, appareil, adresse IP et
|
||||
nombre d'entrées lues.
|
||||
- NOUVEAU : chaque export CSV est inscrit avant l'écriture du
|
||||
fichier : nombre d'entrées, période et filtres. Si le serveur ne
|
||||
peut pas l'inscrire, le fichier n'est pas écrit.
|
||||
- NOUVEAU : catégories de filtre « Fichiers du serveur » et
|
||||
« Consultations et exports du journal ».
|
||||
- Plein écran : les notes de version s'élargissent, les pages du
|
||||
menu d'administration utilisent toute la largeur.
|
||||
- Petit écran ou mise à l'échelle Windows 125-150 % : la fenêtre ne
|
||||
dépasse plus de l'écran ; si la place manque, le contenu est
|
||||
réduit d'un bloc, sans rien couper.
|
||||
- Présentation du jeu : elle défile si elle ne tient pas en hauteur
|
||||
(description, liens et bandeau à la fois).
|
||||
- Menu d'administration :
|
||||
* messages d'erreur et de résultat sur toute la largeur ;
|
||||
* pages en deux colonnes (Jeux, Notes de version, Groupes) : sur
|
||||
un très petit écran, la page entière défile, rien n'est coupé ;
|
||||
* libellés trop longs abrégés proprement (navigation, titres).
|
||||
- Paramètres, notes de version agrandies et messages : taille
|
||||
limitée à l'écran de la fenêtre du launcher (utile avec plusieurs
|
||||
écrans) ; un message long défile.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
MENU D'ADMINISTRATION : CONFORT
|
||||
JOURNAL D'AUDIT : NOM DES APPAREILS
|
||||
------------------------------------------------------------------
|
||||
- AMÉLIORÉ : les boutons d'action (Recharger, Publier, Enregistrer,
|
||||
Actualiser, Exporter...) restent affichés en bas de la fenêtre,
|
||||
quelle que soit la position de l'ascenseur.
|
||||
- AMÉLIORÉ : après une action ou une erreur, la page remonte en haut
|
||||
pour afficher le bandeau de résultat. Elle remonte aussi quand on
|
||||
change de page.
|
||||
- CORRIGÉ : la molette fait défiler ce qui se trouve sous la souris.
|
||||
Une liste déroulante fermée ne change plus de valeur pendant le
|
||||
défilement de la page ; une liste ouverte défile normalement.
|
||||
- AMÉLIORÉ : page « Jeux », liens : en-têtes « Type (icône) / Texte
|
||||
du bouton / Adresse du lien », textes indicatifs dans les champs
|
||||
vides, info-bulles et ligne d'aide.
|
||||
- Chaque entrée indique l'appareil sous le nom choisi lors de
|
||||
l'invitation (« J'ai une invitation »), jamais le nom Windows du
|
||||
PC.
|
||||
- Les anciennes entrées en profitent aussi ; seules les lignes
|
||||
« Connexion » déjà écrites gardent leur texte d'origine (le
|
||||
journal n'est jamais réécrit).
|
||||
- Export CSV : nouvelle colonne « Appareil ».
|
||||
- Recherche : le nom de l'appareil est pris en compte.
|
||||
- SÉCURITÉ :
|
||||
* le nom Windows du PC n'est plus envoyé au serveur ni conservé ;
|
||||
le serveur retire celui qu'envoient les launchers plus anciens ;
|
||||
* le nom de l'appareil est ajouté par le serveur à la lecture, il
|
||||
n'est jamais écrit dans les fichiers du journal.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SERVEUR (LAUNCHER API)
|
||||
SERVEUR
|
||||
------------------------------------------------------------------
|
||||
- À REDÉPLOYER : la page « Fichiers du serveur », le filtre par date
|
||||
et le suivi des consultations du journal nécessitent la nouvelle
|
||||
version du serveur. Sans elle, l'export CSV est refusé.
|
||||
- Dossier du site : détecté automatiquement (dossier commun aux
|
||||
manifests et au catalogue), ou réglé avec « Files:Root ».
|
||||
- Les suppressions demandent les droits d'écriture du service sur
|
||||
ces dossiers (voir server/deploy/LISEZMOI.md, section 3 sexies).
|
||||
- À redéployer : server/deploy/LISEZMOI.md, étape 8 (étapes 1 et
|
||||
2). Aucun réglage nouveau.
|
||||
- Sans redéploiement, la colonne « Appareil » reste vide.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
DÉVELOPPEMENT
|
||||
------------------------------------------------------------------
|
||||
- Outil de captures d'écran : la page « Fichiers du serveur » et les
|
||||
nouvelles lignes du journal sont simulées.
|
||||
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md et
|
||||
server/deploy/LISEZMOI.md mis à jour.
|
||||
- Nouveaux fichiers : Controls/WindowModeAssist.cs (plein écran,
|
||||
F11, mode retenu), Controls/ScaleToFitDecorator.cs (mise en page
|
||||
adaptée), Platform/WindowModeStore.cs (window-modes.json).
|
||||
- Une future fenêtre ou page d'administration en profite sans code
|
||||
supplémentaire (règles dans docs/ADMIN-ARCHITECTURE.md, § 5.3).
|
||||
- Tests : AuditModuleTests.cs et AuditPanelTests.cs complétés.
|
||||
- Documentation : README.md et docs/ADMIN-ARCHITECTURE.md mis à
|
||||
jour.
|
||||
|
||||
|
||||
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
|
||||
Reference in new issue
Block a user