Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb843b1bb | ||
|
|
745a9f4f63 | ||
|
|
314703d7e5 | ||
|
|
dd661210a5 | ||
|
|
fb0f70a4ff | ||
|
|
735a5c0a3e | ||
|
|
3b8492d5fb | ||
|
|
7529519684 | ||
|
|
1b58860347 | ||
|
|
5647ba0cfb | ||
|
|
be2c4fe17d | ||
|
|
a29f688bdb | ||
|
|
be5c409e2f | ||
|
|
5b090a74ed | ||
|
|
dc55ab4843 | ||
|
|
e6cd32aa15 | ||
|
|
d98bac7eaf | ||
|
|
2da7d11bd0 | ||
|
|
94cf7147e3 |
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`) |
|
||||
@@ -528,7 +528,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.
|
||||
@@ -568,10 +570,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**
|
||||
@@ -601,14 +612,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é.
|
||||
@@ -617,6 +630,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/` |
|
||||
@@ -264,7 +264,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 +280,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
|
||||
@@ -554,6 +598,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 :
|
||||
@@ -624,7 +691,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).
|
||||
|
||||
@@ -642,6 +710,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);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,8 @@ 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));
|
||||
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,
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -469,13 +469,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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -204,6 +211,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)
|
||||
{
|
||||
|
||||
@@ -108,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>
|
||||
@@ -194,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();
|
||||
}
|
||||
@@ -263,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -38,6 +38,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.
|
||||
|
||||
@@ -73,3 +73,46 @@ 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();
|
||||
}
|
||||
@@ -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.3.0</Version>
|
||||
<Version>2.4.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
xmlns:aacc="clr-namespace:GameLauncher.Modules.Admin.Accounts;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:aaud="clr-namespace:GameLauncher.Modules.Admin.Audit;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin">
|
||||
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:conv="clr-namespace:GameLauncher.Converters">
|
||||
|
||||
<!--
|
||||
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
|
||||
@@ -19,6 +20,10 @@
|
||||
qu'une fois l'application chargée.
|
||||
-->
|
||||
|
||||
<!-- Aperçu d'une image envoyée (octets en mémoire, aucun fichier verrouillé) ; élément facultatif -->
|
||||
<conv:BytesToImageConverter x:Key="AdminBytesToImage" />
|
||||
<conv:NullToVisibilityConverter x:Key="AdminNullToVisibility" />
|
||||
|
||||
<!-- ============================================================ Cartes et textes -->
|
||||
<Style x:Key="AdminCardStyle" TargetType="Border">
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
@@ -136,7 +141,7 @@
|
||||
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Étiquette (rôle, permission) -->
|
||||
<!-- Étiquette (groupe, permission) -->
|
||||
<Style x:Key="AdminChipStyle" TargetType="Border">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="10,4" />
|
||||
@@ -556,7 +561,10 @@
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type acat:GamesPanelViewModel}">
|
||||
<StackPanel>
|
||||
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : la liste de gauche reste visible quand on descend dans
|
||||
la fiche de droite. Bandeaux et choix du jeu restent en haut, hors des zones qui défilent. -->
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
|
||||
<!-- Erreur (chargement, publication refusée par le serveur…) -->
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
@@ -599,14 +607,17 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- Saisie suspendue pendant un chargement ou une publication -->
|
||||
<Grid IsEnabled="{Binding IsEditable}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="240" />
|
||||
<ColumnDefinition Width="248" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Launcher, puis les jeux dans l'ordre d'affichage -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
|
||||
<StackPanel>
|
||||
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding Selected, Mode=TwoWay}"
|
||||
Style="{StaticResource AdminListBoxStyle}"
|
||||
@@ -623,11 +634,15 @@
|
||||
<TextBlock Text="Ordre d'affichage" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Élément sélectionné (modèles LauncherEditor / GameEditor ci-dessous) -->
|
||||
<ContentControl Grid.Column="1" Margin="18,0,0,0" Content="{Binding Selected}" Focusable="False" />
|
||||
<!-- Élément sélectionné (modèles LauncherEditor / GameEditor ci-dessous) : remonte en haut à chaque changement -->
|
||||
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
|
||||
ui:ScrollAssist.ResetOn="{Binding Selected}">
|
||||
<ContentControl Content="{Binding Selected}" Focusable="False" />
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Réglages du launcher -->
|
||||
@@ -747,11 +762,80 @@
|
||||
SelectedItem="{Binding Theme, Mode=TwoWay}" DisplayMemberPath="Label" />
|
||||
<TextBlock Text="Thèmes intégrés au launcher uniquement." Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" />
|
||||
|
||||
<!-- Icône : « Parcourir… » (explorateur Windows) → contrôle sur le PC → envoi signé → adresse renseignée -->
|
||||
<TextBlock Text="Icône" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Icon, UpdateSourceTrigger=PropertyChanged}" MaxLength="500" />
|
||||
<TextBlock Text="Image de fond" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,0" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Background, UpdateSourceTrigger=PropertyChanged}" MaxLength="500" />
|
||||
<TextBlock Text="{Binding ImageHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" TextWrapping="Wrap" />
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Parcourir…" VerticalAlignment="Center"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding BrowseIconCommand}"
|
||||
Visibility="{Binding CanBrowseImages, Converter={StaticResource BoolToVisibility}}"
|
||||
ToolTip="Choisir une image .png ou .jpg sur ce PC : elle est contrôlée puis envoyée au serveur du catalogue (requête signée par votre clé)" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="9" Margin="6,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding ClearIconCommand}"
|
||||
ToolTip="Vider le champ : icône de launcher.json" />
|
||||
</StackPanel>
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Icon, UpdateSourceTrigger=PropertyChanged}" MaxLength="500"
|
||||
ui:TextAssist.Placeholder="Parcourir… ou https://…/catalog/images/…" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding IconBoundsHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0" />
|
||||
<Border CornerRadius="12" Padding="10" Margin="0,8,0,0" BorderThickness="1" Background="{DynamicResource SurfaceAltBrush}"
|
||||
BorderBrush="{DynamicResource SuccessBrush}" HorizontalAlignment="Left"
|
||||
Visibility="{Binding IconPreview, Converter={StaticResource AdminNullToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="56" Height="56" CornerRadius="12" BorderThickness="1" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<Border.Background>
|
||||
<ImageBrush Stretch="Uniform"
|
||||
ImageSource="{Binding IconPreview.Content, Converter={StaticResource AdminBytesToImage}, ConverterParameter=128}" />
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center" MaxWidth="420">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CloudUpload}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Aperçu de l'icône envoyée" FontSize="12.5" FontWeight="SemiBold" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding IconPreview.Text}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Image de fond -->
|
||||
<TextBlock Text="Image de fond" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,14,0,6" />
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Parcourir…" VerticalAlignment="Center"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding BrowseBackgroundCommand}"
|
||||
Visibility="{Binding CanBrowseImages, Converter={StaticResource BoolToVisibility}}"
|
||||
ToolTip="Choisir une image .png ou .jpg sur ce PC : elle est contrôlée puis envoyée au serveur du catalogue (requête signée par votre clé)" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="9" Margin="6,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding ClearBackgroundCommand}"
|
||||
ToolTip="Vider le champ : image de fond de launcher.json" />
|
||||
</StackPanel>
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Background, UpdateSourceTrigger=PropertyChanged}" MaxLength="500"
|
||||
ui:TextAssist.Placeholder="Parcourir… ou https://…/catalog/images/…" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding BackgroundBoundsHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0" />
|
||||
<Border CornerRadius="12" Padding="10" Margin="0,8,0,0" BorderThickness="1" Background="{DynamicResource SurfaceAltBrush}"
|
||||
BorderBrush="{DynamicResource SuccessBrush}" HorizontalAlignment="Left"
|
||||
Visibility="{Binding BackgroundPreview, Converter={StaticResource AdminNullToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="160" Height="90" CornerRadius="10" BorderThickness="1" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<Border.Background>
|
||||
<ImageBrush Stretch="UniformToFill"
|
||||
ImageSource="{Binding BackgroundPreview.Content, Converter={StaticResource AdminBytesToImage}, ConverterParameter=480}" />
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center" MaxWidth="340">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CloudUpload}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Aperçu de l'image envoyée" FontSize="12.5" FontWeight="SemiBold" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding BackgroundPreview.Text}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding ImageHint}" Style="{StaticResource AdminHintStyle}" Margin="2,10,0,14" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Recherche de mise à jour (minutes)" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Width="140" HorizontalAlignment="Left" MaxLength="5"
|
||||
@@ -908,7 +992,10 @@
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type achl:ChangelogPanelViewModel}">
|
||||
<StackPanel>
|
||||
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : la liste de gauche reste visible quand on descend dans
|
||||
la fiche de droite. Bandeaux et choix du jeu restent en haut, hors des zones qui défilent. -->
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
|
||||
<!-- Erreur (chargement, jeu non configuré sur le serveur, refus…) -->
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
@@ -955,22 +1042,29 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- Saisie suspendue pendant un chargement ou un enregistrement -->
|
||||
<Grid IsEnabled="{Binding IsEditable}" Visibility="{Binding HasVersions, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="158" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="VERSIONS" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
|
||||
<ListBox ItemsSource="{Binding Versions}" SelectedItem="{Binding SelectedVersion, Mode=TwoWay}"
|
||||
Style="{StaticResource AdminListBoxStyle}"
|
||||
ItemContainerStyle="{StaticResource AdminVersionItemStyle}" />
|
||||
</StackPanel>
|
||||
<!-- Notes de la version sélectionnée (modèle VersionEditor ci-dessous) -->
|
||||
<ContentControl Grid.Column="1" Margin="18,0,0,0" Content="{Binding SelectedVersion}" Focusable="False" />
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="VERSIONS" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
|
||||
<ListBox ItemsSource="{Binding Versions}" SelectedItem="{Binding SelectedVersion, Mode=TwoWay}"
|
||||
Style="{StaticResource AdminListBoxStyle}"
|
||||
ItemContainerStyle="{StaticResource AdminVersionItemStyle}" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
<!-- Notes de la version sélectionnée (modèle VersionEditor ci-dessous) : remonte en haut à chaque changement -->
|
||||
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
|
||||
ui:ScrollAssist.ResetOn="{Binding SelectedVersion}">
|
||||
<ContentControl Content="{Binding SelectedVersion}" Focusable="False" />
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Notes d'une version : modification ou aperçu -->
|
||||
@@ -1452,11 +1546,11 @@
|
||||
<Border x:Name="Card" Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<!-- Rôle et révocation -->
|
||||
<!-- Groupe et révocation -->
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding CanChangeRole, Converter={StaticResource BoolToVisibility}}">
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="170" ItemsSource="{Binding RoleChoices}"
|
||||
SelectedItem="{Binding SelectedRole, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Rôle" />
|
||||
SelectedItem="{Binding SelectedRole, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Groupe (page « Groupes et permissions »)" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Appliquer" Margin="8,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding ChangeRoleCommand}"
|
||||
Visibility="{Binding RoleChanged, Converter={StaticResource BoolToVisibility}}" />
|
||||
@@ -1550,7 +1644,7 @@
|
||||
Text="{Binding NewName, UpdateSourceTrigger=PropertyChanged}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Margin="10,0,0,0">
|
||||
<TextBlock Text="Rôle" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBlock Text="Groupe" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding RoleChoices}" IsEnabled="{Binding CanEdit}"
|
||||
SelectedItem="{Binding NewRole, Mode=TwoWay}" DisplayMemberPath="Label" />
|
||||
</StackPanel>
|
||||
@@ -1587,6 +1681,261 @@
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Groupes et permissions » -->
|
||||
|
||||
<!-- Case d'une permission dans la fiche d'un groupe -->
|
||||
<DataTemplate DataType="{x:Type aacc:PermissionToggle}">
|
||||
<Border x:Name="Bd" CornerRadius="12" Padding="12,9" Margin="0,0,0,8" BorderThickness="1"
|
||||
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}"
|
||||
ToolTip="{Binding DisabledReason}" ToolTipService.ShowOnDisabled="True">
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding IsChecked, Mode=TwoWay}" IsEnabled="{Binding IsEnabled}"
|
||||
FontSize="13">
|
||||
<StackPanel>
|
||||
<WrapPanel>
|
||||
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Border Style="{StaticResource AdminChipStyle}" Margin="0,0,6,0" Padding="7,1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="10.5" FontFamily="Cascadia Mono, Consolas, Courier New"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</Border>
|
||||
<Border x:Name="KindChip" Style="{StaticResource AdminChipStyle}" Margin="0" Padding="7,1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="KindText" Text="{Binding KindText}" FontSize="10.5" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</Border>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="{Binding Description}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0"
|
||||
Visibility="{Binding HasDescription, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsChecked}" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsBuiltIn}" Value="False">
|
||||
<Setter TargetName="KindText" Property="Foreground" Value="{DynamicResource Accent2Brush}" />
|
||||
</DataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsSensitive}" Value="True" />
|
||||
<Condition Binding="{Binding IsChecked}" Value="True" />
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
|
||||
</MultiDataTrigger>
|
||||
<DataTrigger Binding="{Binding IsEnabled}" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.7" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Fiche d'un groupe (à droite de la liste) -->
|
||||
<DataTemplate DataType="{x:Type aacc:GroupEditor}">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<DockPanel Margin="0,0,0,14">
|
||||
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
|
||||
<Path x:Name="HeaderIcon" Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.AccountGroup}" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Header}" Style="{StaticResource AdminCardTitleStyle}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding IdText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0" />
|
||||
<TextBlock Text="{Binding MembersText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0"
|
||||
Visibility="{Binding MembersText, Converter={StaticResource StringToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Groupe système, ou votre propre groupe -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding HasNotice, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Lock}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding Notice}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="560" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="Nom du groupe" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="40" IsEnabled="{Binding CanEditText}"
|
||||
Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" ui:TextAssist.Placeholder="Ex. Modérateurs" />
|
||||
<TextBlock Text="Description" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,6" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="200" IsEnabled="{Binding CanEditText}"
|
||||
Text="{Binding Description, UpdateSourceTrigger=PropertyChanged}"
|
||||
ui:TextAssist.Placeholder="Ce que font les membres de ce groupe (facultatif)" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0"
|
||||
Text="Affichée dans la page « Administrateurs » quand on choisit le groupe d'un compte ou d'une invitation." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Permissions du groupe" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,14"
|
||||
Text="Les membres reçoivent exactement ces droits, immédiatement (sessions ouvertes comprises). Vous ne pouvez accorder que des permissions que vous avez vous-même." />
|
||||
<ItemsControl ItemsSource="{Binding Toggles}" Focusable="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Suppression -->
|
||||
<DockPanel Margin="2,0,0,16" Visibility="{Binding IsSystem, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
|
||||
<Button DockPanel.Dock="Left" Style="{StaticResource SmallButtonStyle}" Content="{Binding DeleteText}"
|
||||
Foreground="{DynamicResource DangerBrush}" ui:IconAssist.Icon="{StaticResource Icon.Delete}"
|
||||
Command="{Binding DeleteCommand}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding DeleteHint}" Style="{StaticResource AdminHintStyle}" Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding DeleteHint, Converter={StaticResource StringToVisibility}}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSystem}" Value="True">
|
||||
<Setter TargetName="HeaderIcon" Property="Data" Value="{DynamicResource Icon.ShieldAccount}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsNew}" Value="True">
|
||||
<Setter TargetName="HeaderIcon" Property="Data" Value="{DynamicResource Icon.Plus}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Permission de la liste générale -->
|
||||
<DataTemplate DataType="{x:Type aacc:PermissionItem}">
|
||||
<Border CornerRadius="12" Padding="12,9" Margin="0,0,0,8" BorderThickness="1"
|
||||
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" Foreground="{DynamicResource DangerBrush}" ui:IconAssist.Icon="{StaticResource Icon.Delete}"
|
||||
Command="{Binding DeleteCommand}" ToolTip="Supprimer cette permission personnalisée (retirée de tous les groupes)"
|
||||
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Border DockPanel.Dock="Left" Width="30" Height="30" CornerRadius="9" Background="{DynamicResource BadgeBrush}" VerticalAlignment="Top">
|
||||
<Path x:Name="KindIcon" Width="14" Height="14" Stretch="Uniform" Fill="{DynamicResource TextSecondaryBrush}"
|
||||
Data="{StaticResource Icon.Lock}" />
|
||||
</Border>
|
||||
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
|
||||
<WrapPanel>
|
||||
<TextBlock Text="{Binding Label}" FontSize="13" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Border Style="{StaticResource AdminChipStyle}" Margin="0,0,6,0" Padding="7,1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="10.5" FontFamily="Cascadia Mono, Consolas, Courier New"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</Border>
|
||||
<Border Style="{StaticResource AdminChipStyle}" Margin="0" Padding="7,1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="KindText" Text="{Binding KindText}" FontSize="10.5" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</Border>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="{Binding Description}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0"
|
||||
Visibility="{Binding HasDescription, Converter={StaticResource BoolToVisibility}}" />
|
||||
<TextBlock Text="{Binding UsageText}" FontSize="11.5" Margin="0,3,0,0" Foreground="{DynamicResource TextMutedBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsBuiltIn}" Value="False">
|
||||
<Setter TargetName="KindIcon" Property="Data" Value="{DynamicResource Icon.ShieldKey}" />
|
||||
<Setter TargetName="KindIcon" Property="Fill" Value="{DynamicResource Accent2Brush}" />
|
||||
<Setter TargetName="KindText" Property="Foreground" Value="{DynamicResource Accent2Brush}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Page « Groupes et permissions » -->
|
||||
<DataTemplate DataType="{x:Type aacc:GroupsPanelViewModel}">
|
||||
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : la liste de gauche reste visible quand on descend dans
|
||||
la fiche de droite. Bandeaux et choix du jeu restent en haut, hors des zones qui défilent. -->
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
|
||||
VerticalAlignment="Top" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
|
||||
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- Groupes : liste à gauche ; à droite, fiche du groupe choisi puis liste des permissions -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="258" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
|
||||
<StackPanel IsEnabled="{Binding IsEditable}">
|
||||
<ListBox ItemsSource="{Binding Groups}" SelectedItem="{Binding Selected, Mode=TwoWay}"
|
||||
Style="{StaticResource AdminListBoxStyle}" ItemContainerStyle="{StaticResource AdminCatalogItemStyle}" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Nouveau groupe" HorizontalAlignment="Left" Margin="0,4,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding NewGroupCommand}"
|
||||
ToolTip="Prépare un nouveau groupe : nom, description, permissions, puis « Enregistrer »" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,10,0,0"
|
||||
Text="Chaque administrateur appartient à un groupe. Point orange : modifications non enregistrées." />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
|
||||
ui:ScrollAssist.ResetOn="{Binding Selected}">
|
||||
<StackPanel IsEnabled="{Binding IsEditable}">
|
||||
<ContentControl Content="{Binding Selected.Editor}" Focusable="False" />
|
||||
|
||||
<!-- Permissions -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<DockPanel Margin="0,0,0,12">
|
||||
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
|
||||
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.ShieldKey}" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="Permissions" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0"
|
||||
Text="Intégrées : vérifiées par le serveur et le launcher, elles ne se suppriment pas. Personnalisées : à attribuer aux groupes en prévision d'un module qui les vérifiera ; seules elles peuvent être supprimées." />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<!-- Nouvelle permission personnalisée : en haut de la carte, toujours accessible sans descendre sous la liste -->
|
||||
<!-- Disposition sur trois lignes : la colonne de droite est étroite (nom et libellé, puis description,
|
||||
puis bouton), aucun champ n'est écrasé quelle que soit la largeur de la fenêtre. -->
|
||||
<TextBlock Text="Créer une permission" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,0,8" FontSize="13" />
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Nom (module.action)" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="57" FontFamily="Cascadia Mono, Consolas, Courier New"
|
||||
Text="{Binding NewPermissionName, UpdateSourceTrigger=PropertyChanged}" ui:TextAssist.Placeholder="news.edit" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Margin="10,0,0,0">
|
||||
<TextBlock Text="Libellé" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="40"
|
||||
Text="{Binding NewPermissionLabel, UpdateSourceTrigger=PropertyChanged}" ui:TextAssist.Placeholder="Actualités" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Text="Description (facultatif)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,6" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="200"
|
||||
Text="{Binding NewPermissionDescription, UpdateSourceTrigger=PropertyChanged}"
|
||||
ui:TextAssist.Placeholder="Ce que permet cette permission (affiché sous son nom)" />
|
||||
<DockPanel Margin="0,12,0,0">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Créer la permission" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding CreatePermissionCommand}"
|
||||
ToolTip="Crée la permission (requête signée par votre clé) ; le groupe « Propriétaire » la reçoit automatiquement" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
|
||||
Text="Minuscules, chiffres, « - » ou « _ », un seul point : ex. « news.edit », « community.moderate »." />
|
||||
</DockPanel>
|
||||
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource BorderBrush}" />
|
||||
<ItemsControl ItemsSource="{Binding Permissions}" Focusable="False" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Journal d'audit » -->
|
||||
|
||||
<DataTemplate DataType="{x:Type aaud:AuditItem}">
|
||||
@@ -2188,6 +2537,25 @@
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="AdminFooter.GroupsPanelViewModel">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}"
|
||||
ToolTip="Afficher les groupes enregistrés sur le serveur" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer le groupe"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding SaveCommand}"
|
||||
ToolTip="Contrôle puis enregistre le groupe affiché (requête signée par votre clé, inscrite au journal d'audit)" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding SummaryText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="AdminFooter.AuditPanelViewModel">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
|
||||
|
||||
@@ -102,10 +102,24 @@
|
||||
<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 fenêtre ne fait plus défiler la page entière ; la page reçoit
|
||||
la hauteur disponible et chaque colonne a son propre ascenseur (Themes/Admin.xaml). -->
|
||||
<ScrollViewer Focusable="False"
|
||||
ui:ScrollAssist.ResetOn="{Binding SelectedPanel}"
|
||||
ui:ScrollAssist.ScrollToTopToken="{Binding SelectedPanel.ViewModel.ScrollToTopToken}">
|
||||
<ScrollViewer.Style>
|
||||
<Style TargetType="ScrollViewer">
|
||||
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
|
||||
<Setter Property="Padding" Value="0,0,14,0" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
|
||||
<Setter Property="VerticalScrollBarVisibility" Value="Disabled" />
|
||||
<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" />
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -49,6 +49,7 @@ 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
|
||||
@@ -168,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,68 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Tests.Infrastructure;
|
||||
|
||||
/// <summary>Images PNG et JPEG fabriquées pour les tests (sans bibliothèque graphique).</summary>
|
||||
public static class TestImages
|
||||
{
|
||||
/// <summary>PNG valide (RVB 8 bits, une couleur unie) de la taille demandée.</summary>
|
||||
public static byte[] Png(int width, int height, byte shade = 0x40)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
output.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
|
||||
|
||||
var header = new byte[13];
|
||||
BinaryPrimitives.WriteInt32BigEndian(header, width);
|
||||
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), height);
|
||||
header[8] = 8; // profondeur
|
||||
header[9] = 2; // RVB
|
||||
Chunk(output, "IHDR", header);
|
||||
|
||||
using var raw = new MemoryStream();
|
||||
using (var zlib = new ZLibStream(raw, CompressionLevel.Fastest, leaveOpen: true))
|
||||
{
|
||||
var row = new byte[1 + width * 3];
|
||||
for (var i = 1; i < row.Length; i++) row[i] = shade;
|
||||
for (var y = 0; y < height; y++) zlib.Write(row);
|
||||
}
|
||||
Chunk(output, "IDAT", raw.ToArray());
|
||||
Chunk(output, "IEND", Array.Empty<byte>());
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Structure JPEG minimale (SOI, APP0, SOF0 avec les dimensions, EOI) : suffit au contrôle des en-têtes.</summary>
|
||||
public static byte[] Jpeg(int width, int height)
|
||||
{
|
||||
var bytes = new List<byte> { 0xFF, 0xD8 };
|
||||
bytes.AddRange(new byte[] { 0xFF, 0xE0, 0x00, 0x10, (byte)'J', (byte)'F', (byte)'I', (byte)'F', 0, 1, 1, 0, 0, 1, 0, 1, 0, 0 });
|
||||
bytes.AddRange(new byte[] { 0xFF, 0xC0, 0x00, 0x11, 8, (byte)(height >> 8), (byte)height, (byte)(width >> 8), (byte)width, 3,
|
||||
1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1 });
|
||||
bytes.AddRange(new byte[] { 0xFF, 0xD9 });
|
||||
return bytes.ToArray();
|
||||
}
|
||||
|
||||
private static void Chunk(Stream output, string type, byte[] data)
|
||||
{
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, data.Length);
|
||||
output.Write(length);
|
||||
var typeAndData = Encoding.ASCII.GetBytes(type).Concat(data).ToArray();
|
||||
output.Write(typeAndData);
|
||||
var crc = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(typeAndData));
|
||||
output.Write(crc);
|
||||
}
|
||||
|
||||
private static uint Crc32(byte[] data)
|
||||
{
|
||||
var crc = 0xFFFFFFFFu;
|
||||
foreach (var b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? 0xEDB88320u ^ (crc >> 1) : crc >> 1;
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
|
||||
+100
-56
@@ -1,85 +1,129 @@
|
||||
# Launcher 2.3.0
|
||||
# Launcher 2.4.0
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Menu d'administration : plusieurs jeux - Mise à jour du 7 novembre 2026
|
||||
Menu d'administration : groupes, permissions et images - Mise à jour du 7 novembre 2026
|
||||
==================================================================
|
||||
|
||||
Version consacrée à la gestion de plusieurs jeux dans le menu d'administration : les pages « Notes de version » et « Publier une version » demandent désormais de choisir le jeu avant d'afficher quoi que ce soit, pour ne jamais modifier ou publier le mauvais jeu par erreur.
|
||||
Version consacrée au menu d'administration : les rôles fixes deviennent des groupes modifiables avec leurs permissions, l'icône et l'image de fond d'un jeu s'envoient directement depuis le launcher (plus besoin de client FTP), et les pages en deux colonnes défilent séparément.
|
||||
|
||||
Pour les joueurs, rien ne change : le jeu, les mises à jour et le launcher fonctionnent comme avant.
|
||||
Pour les joueurs, rien ne change visiblement : le jeu, les mises à jour et le launcher fonctionnent comme avant. Les images du catalogue envoyées depuis le menu sont désormais vérifiées au téléchargement.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
EN BREF
|
||||
------------------------------------------------------------------
|
||||
- MODIFIÉ : « Notes de version » et « Publier une version » :
|
||||
choix du jeu obligatoire quand plusieurs jeux sont déclarés.
|
||||
- NOUVEAU : carte « Choisir le jeu » (une tuile par jeu), puis
|
||||
liste « Jeu » pour en changer.
|
||||
- CORRIGÉ : changer de jeu dans « Publier une version » efface
|
||||
tout ce qui concernait le jeu précédent.
|
||||
- CORRIGÉ : un envoi interrompu reste attaché à son jeu.
|
||||
- AMÉLIORÉ : les confirmations et les résultats de publication
|
||||
nomment le jeu.
|
||||
- MODIFIÉ : « Fichiers du serveur » : plus de jeu choisi par défaut
|
||||
pour le nettoyage du dossier « objects ».
|
||||
- QUALITÉ : 2 nouveaux tests automatisés (255 au total).
|
||||
- NOUVEAU : page « Groupes et permissions » : créer, modifier et
|
||||
supprimer des groupes d'administrateurs et choisir leurs
|
||||
permissions.
|
||||
- NOUVEAU : permissions personnalisées (« module.action »), en
|
||||
prévision de modules futurs.
|
||||
- NOUVEAU : page « Jeux » : bouton « Parcourir... » pour envoyer
|
||||
l'icône et l'image de fond sans client FTP, avec aperçu.
|
||||
- SÉCURITÉ : les images envoyées depuis le menu sont vérifiées
|
||||
par les launchers des joueurs (empreinte SHA-256).
|
||||
- AMÉLIORÉ : pages « Jeux », « Notes de version » et « Groupes et
|
||||
permissions » : la liste de gauche et la fiche de droite
|
||||
défilent séparément.
|
||||
- CORRIGÉ : « Fichiers du serveur » : l'ascenseur ne remonte plus
|
||||
à chaque changement de dossier.
|
||||
- MODIFIÉ : « Administrateurs » : « rôle » devient « groupe ».
|
||||
- SERVEUR : nouvelles routes : à redéployer (voir plus bas).
|
||||
- QUALITÉ : 21 nouveaux tests automatisés (276 au total).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CHOIX DU JEU
|
||||
GROUPES ET PERMISSIONS
|
||||
------------------------------------------------------------------
|
||||
- MODIFIÉ : avec plusieurs jeux, les pages « Notes de version » et
|
||||
« Publier une version » s'ouvrent sur la carte « Choisir le
|
||||
jeu » :
|
||||
* une tuile par jeu : nom, identifiant et serveur ;
|
||||
* aucun jeu choisi par défaut ;
|
||||
* rien n'est chargé ni affiché avant le choix.
|
||||
- NOUVEAU : une fois le jeu choisi, la liste « Jeu » en haut de la
|
||||
page permet d'en changer :
|
||||
* désactivée pendant un chargement ou un envoi ;
|
||||
* confirmation demandée si des notes ne sont pas enregistrées.
|
||||
- Avec un seul jeu déclaré, il est choisi automatiquement : rien
|
||||
ne change par rapport à la version précédente.
|
||||
- NOUVEAU : les rôles Propriétaire, Administrateur et Éditeur
|
||||
deviennent des groupes, créés automatiquement avec les mêmes
|
||||
droits qu'avant : rien ne change pour les comptes existants.
|
||||
- NOUVEAU : page « Groupes et permissions » (réservée à la gestion
|
||||
des administrateurs) :
|
||||
* créer un groupe (nom, description, permissions à cocher) ;
|
||||
* renommer un groupe, changer ses permissions ;
|
||||
* supprimer un groupe sans membre ni invitation en cours ;
|
||||
* créer et supprimer des permissions personnalisées ;
|
||||
* point orange sur un groupe modifié non enregistré.
|
||||
- Les changements s'appliquent immédiatement, y compris aux
|
||||
sessions déjà ouvertes.
|
||||
- SÉCURITÉ :
|
||||
* le groupe Propriétaire a toujours toutes les permissions : il
|
||||
ne peut être ni modifié ni supprimé ;
|
||||
* on ne peut accorder que des permissions que l'on a soi-même ;
|
||||
* on ne modifie pas les permissions de son propre groupe ;
|
||||
* seul un propriétaire donne ou retire le groupe Propriétaire,
|
||||
ou gère un autre propriétaire ;
|
||||
* les permissions intégrées (vérifiées par le launcher et le
|
||||
serveur) ne peuvent pas être supprimées ;
|
||||
* deux administrateurs qui modifient le même groupe en même
|
||||
temps : la seconde modification est refusée, rien n'est
|
||||
écrasé ;
|
||||
* chaque action est signée par la clé de l'administrateur et
|
||||
inscrite au journal d'audit.
|
||||
- MODIFIÉ : page « Administrateurs » : le groupe remplace le rôle ;
|
||||
seuls les groupes que vous pouvez attribuer sont proposés.
|
||||
- MODIFIÉ : page « Signature » : les autorisations proposées pour
|
||||
le trousseau suivent les permissions du groupe de chaque
|
||||
administrateur.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
PUBLIER UNE VERSION
|
||||
IMAGES DES JEUX (ICÔNE, FOND)
|
||||
------------------------------------------------------------------
|
||||
- CORRIGÉ : au changement de jeu, rien du jeu précédent ne reste
|
||||
affiché ni utilisable :
|
||||
* dossier de publication choisi et version prête ;
|
||||
* version en ligne et sa signature ;
|
||||
* avancement de l'envoi.
|
||||
- CORRIGÉ : une réponse du serveur arrivée après un changement de
|
||||
jeu est ignorée.
|
||||
- CORRIGÉ : un envoi interrompu reste attaché à son jeu : le
|
||||
bandeau « Reprendre / Abandonner » réapparaît quand on revient à
|
||||
ce jeu.
|
||||
- AMÉLIORÉ : les confirmations (publier, signer la version en
|
||||
ligne) et le message de résultat nomment le jeu.
|
||||
- SÉCURITÉ : un dossier de publication d'un autre jeu reste refusé
|
||||
(identifiant du manifest contrôlé).
|
||||
- NOUVEAU : page « Jeux » : bouton « Parcourir... » à côté de
|
||||
l'icône et de l'image de fond :
|
||||
* choix de l'image dans l'explorateur Windows ;
|
||||
* contrôle sur le PC, puis envoi au serveur ;
|
||||
* adresse renseignée automatiquement et aperçu de l'image ;
|
||||
* bouton pour vider le champ.
|
||||
- L'image est appliquée aux joueurs après « Publier », au
|
||||
prochain démarrage de leur launcher.
|
||||
- Formats : PNG ou JPEG, 4 Mo au plus ; icône de 32 à 2048 px,
|
||||
image de fond de 640 x 360 à 7680 x 4320 px.
|
||||
- SÉCURITÉ :
|
||||
* image reconnue à son contenu, pas à son extension ;
|
||||
* fichier refusé s'il est incomplet ou suivi de données
|
||||
étrangères à l'image ;
|
||||
* chaque image est enregistrée sous son empreinte SHA-256 et
|
||||
vérifiée par les launchers des joueurs au téléchargement ;
|
||||
* envoi signé par la clé de l'administrateur, inscrit au
|
||||
journal d'audit.
|
||||
- AMÉLIORÉ : l'aide sous le champ « Icône » indique où elle
|
||||
s'affiche (liste des jeux à gauche, visible seulement avec
|
||||
plusieurs jeux).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
FICHIERS DU SERVEUR
|
||||
NAVIGATION DANS LE MENU D'ADMINISTRATION
|
||||
------------------------------------------------------------------
|
||||
- MODIFIÉ : nettoyage du dossier « objects » : avec plusieurs jeux,
|
||||
aucun jeu n'est choisi par défaut ; la liste affiche « Choisir le
|
||||
jeu à nettoyer... ».
|
||||
- AMÉLIORÉ : les listes déroulantes du menu d'administration
|
||||
peuvent afficher un texte indicatif quand rien n'est choisi.
|
||||
- AMÉLIORÉ : pages « Jeux », « Notes de version » et « Groupes et
|
||||
permissions » : chaque colonne a son propre ascenseur ; la
|
||||
liste de gauche reste visible quand on descend dans la fiche.
|
||||
- AMÉLIORÉ : les bandeaux (erreur, résultat) et le choix du jeu
|
||||
restent en haut de la page.
|
||||
- CORRIGÉ : « Fichiers du serveur » : se déplacer dans les
|
||||
dossiers (fil d'Ariane, double-clic, dossier parent) ne fait
|
||||
plus remonter l'ascenseur.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SERVEUR (LAUNCHER API) : À REDÉPLOYER
|
||||
------------------------------------------------------------------
|
||||
- Avant la mise à jour : copier admins.json (il passe au format 2,
|
||||
qu'une ancienne version du serveur refuse).
|
||||
- nginx : ajouter le bloc d'envoi des images (nginx-api.conf).
|
||||
- Vérifier que le dossier catalog/images existe.
|
||||
- Détails : server/deploy/LISEZMOI.md, étape 3 septies.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
DÉVELOPPEMENT
|
||||
------------------------------------------------------------------
|
||||
- Outil de captures d'écran : nouvelle capture « Notes de version
|
||||
(jeu choisi) » ; la capture « version prête à publier » choisit
|
||||
le jeu si besoin.
|
||||
- Serveur (Launcher API) inchangé : rien à redéployer.
|
||||
- Documentation : README.md et docs/ADMIN-ARCHITECTURE.md mis à
|
||||
jour.
|
||||
- Outil de captures d'écran : la dernière capture d'une page
|
||||
longue montre toujours le bas de la page ; nouvelles routes
|
||||
simulées.
|
||||
- AdminKeyTool : affiche le nom des groupes.
|
||||
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md et
|
||||
server/deploy/LISEZMOI.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