Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df48ee142e | ||
|
|
39a0088ed2 | ||
|
|
3a2d87b726 | ||
|
|
eeb4818df4 | ||
|
|
3772b339e6 | ||
|
|
a9f5f39f36 | ||
|
|
86c98eef99 | ||
|
|
4bdc627d6b | ||
|
|
a13b1841f5 | ||
|
|
efbe9b03f9 | ||
|
|
6207147202 | ||
|
|
c9a491622b | ||
|
|
c210754a18 | ||
|
|
389593a3b4 | ||
|
|
22ee3a6dee | ||
|
|
a72761ccd0 | ||
|
|
62011342ec | ||
|
|
0f93b784f5 | ||
|
|
3ad27965fd | ||
|
|
69e710d3b7 | ||
|
|
d8219acaab | ||
|
|
987dab286b | ||
|
|
6592b09d14 | ||
|
|
35b47b1b9d | ||
|
|
b67636e29b | ||
|
|
db37cd9d46 | ||
|
|
ffbd98d800 | ||
|
|
c281ca1781 | ||
|
|
dbb843b1bb | ||
|
|
745a9f4f63 | ||
|
|
314703d7e5 | ||
|
|
dd661210a5 | ||
|
|
fb0f70a4ff | ||
|
|
735a5c0a3e | ||
|
|
3b8492d5fb | ||
|
|
7529519684 | ||
|
|
1b58860347 | ||
|
|
5647ba0cfb | ||
|
|
be2c4fe17d | ||
|
|
a29f688bdb | ||
|
|
be5c409e2f | ||
|
|
5b090a74ed | ||
|
|
dc55ab4843 | ||
|
|
e6cd32aa15 | ||
|
|
d98bac7eaf | ||
|
|
2da7d11bd0 | ||
|
|
94cf7147e3 | ||
|
|
5ac3d153ab | ||
|
|
7d26a4d59c | ||
|
|
924735a0dc | ||
|
|
d5a992ad62 | ||
|
|
e2523d1bff | ||
|
|
d444940b63 | ||
|
|
8e69ec108e | ||
|
|
84bcb80108 | ||
|
|
e9f650ef9e | ||
|
|
27b57526ee | ||
|
|
3996f580f6 | ||
|
|
ca0eb044e8 | ||
|
|
5b0999e47c | ||
|
|
9f6210598c | ||
|
|
08e1476726 | ||
|
|
818716b7a9 | ||
|
|
4971c926ac | ||
|
|
99502a2003 | ||
|
|
81108699c3 | ||
|
|
7cce8676b4 | ||
|
|
0f8f64f00f |
No files matched your search
@@ -68,6 +68,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -164,6 +166,14 @@ Global
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.Build.0 = Debug|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.ActiveCfg = Release|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.Build.0 = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
|
||||
@@ -189,6 +199,7 @@ Global
|
||||
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
|
||||
@@ -35,10 +35,11 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
|
||||
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
|
||||
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
|
||||
@@ -52,13 +53,15 @@ src/GameLauncher/
|
||||
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow,
|
||||
│ FeedbackWindow (bouton « Aide »)
|
||||
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
|
||||
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
|
||||
│ GameViewModel (machine à états d'un jeu),
|
||||
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
|
||||
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
|
||||
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
|
||||
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration),
|
||||
│ FeedbackWindowService (bouton « Aide »), interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
|
||||
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
|
||||
```
|
||||
@@ -288,6 +291,16 @@ Deux fenêtres distinctes :
|
||||
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
|
||||
sont repris de ceux du jeu affiché par défaut.
|
||||
|
||||
### Plein écran
|
||||
|
||||
La fenêtre principale et le menu d'administration ont deux modes : **fenêtré** (taille d'origine) ou
|
||||
**plein écran** (fenêtre agrandie sur tout l'écran, la barre des tâches restant visible). Bascule : bouton entre « Réduire » et
|
||||
« Fermer », ou **F11**. Le mode de chaque fenêtre est retenu pour la fois suivante. Les fenêtres ne se
|
||||
redimensionnent jamais par les bords ou les coins ; la mise en page s'adapte à la place disponible
|
||||
(plein écran : colonnes plus larges ; petit écran ou mise à l'échelle 125-150 % : contenu réduit d'un
|
||||
bloc, sans rien rogner). Les autres fenêtres (paramètres, notes de version agrandies, boîtes de
|
||||
dialogue) gardent leur taille fixe.
|
||||
|
||||
### Zone de notification (icônes cachées, près de l'horloge)
|
||||
|
||||
Deux options des *Paramètres du launcher* (désactivées par défaut) :
|
||||
@@ -312,6 +325,7 @@ referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) o
|
||||
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
|
||||
├─ self-update.json mise à jour du launcher (§7)
|
||||
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
|
||||
├─ window-modes.json plein écran ou fenêtré, par fenêtre (« main », « admin »)
|
||||
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
|
||||
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
|
||||
```
|
||||
@@ -497,8 +511,26 @@ Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitatio
|
||||
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
|
||||
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
|
||||
servi aux joueurs (page **Signature**, voir plus bas).
|
||||
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
|
||||
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
|
||||
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
|
||||
page ; après « Recharger » ou « Publier », la page remonte en haut pour montrer le bandeau du résultat. La
|
||||
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
|
||||
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
|
||||
|
||||
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
|
||||
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
|
||||
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
|
||||
l'envoi avec les règles du serveur ; informations techniques facultatives (versions, Windows, langue) montrées avant
|
||||
l'envoi, cochées par défaut pour un bug. Demande **anonyme** : aucun compte, aucune adresse IP enregistrée ; le
|
||||
joueur reçoit une référence `FB-XXXX-XXXX`. En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
|
||||
sans `apiUrl` ; réception à activer sur le serveur (`server/deploy/LISEZMOI.md`, étape 3 octies).
|
||||
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
|
||||
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
|
||||
En cours, Traitée, Rejetée, Doublon), note interne, suppression. Chaque consultation est inscrite au journal d'audit.
|
||||
Purge automatique : 12 mois après la clôture, 24 mois au plus ; le journal d'audit est purgé au-delà de 12 mois.
|
||||
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
|
||||
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
|
||||
vérifiée par le serveur, protège l'administration.
|
||||
@@ -522,7 +554,9 @@ servi aux joueurs (page **Signature**, voir plus bas).
|
||||
(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.
|
||||
@@ -541,6 +575,13 @@ servi aux joueurs (page **Signature**, voir plus bas).
|
||||
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
|
||||
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
|
||||
`server/deploy/LISEZMOI.md`, étape 3 ter.
|
||||
* **Plusieurs jeux** (pages « Notes de version » et « Publier une version ») : la page s'ouvre sur le
|
||||
**choix du jeu** (une tuile par jeu de `launcher.json`, aucun jeu par défaut) et n'affiche rien d'autre
|
||||
avant ce choix ; une liste « Jeu » permet ensuite d'en changer (pas pendant un chargement, un envoi, ni
|
||||
avec des notes non enregistrées sans confirmation). Rien d'un jeu ne reste affiché pour un autre
|
||||
(dossier choisi, version en ligne, signature) ; un envoi interrompu reste attaché à son jeu. Les
|
||||
confirmations et résultats de publication nomment le jeu. Avec un seul jeu, il est choisi d'office.
|
||||
Page « Fichiers du serveur » : le jeu à nettoyer se choisit de la même façon.
|
||||
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
|
||||
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
|
||||
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
|
||||
@@ -555,10 +596,19 @@ servi aux joueurs (page **Signature**, voir plus bas).
|
||||
fois) ; sur le nouveau PC, « J'ai une invitation » ; de retour ici, l'appareil en attente s'approuve
|
||||
en saisissant l'empreinte affichée sur le nouveau PC. Personne d'autre ne peut approuver un appareil
|
||||
à votre nom. Révoquer un appareil perdu : propriétaires (sinon, demandez à un propriétaire).
|
||||
* **Page « Administrateurs »** (permission `admins.manage` : propriétaires) : inviter (nom, rôle → code
|
||||
* **Page « Administrateurs »** (permission `admins.manage`) : inviter (nom, groupe → code
|
||||
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
|
||||
qu'il vous dicte, changer un rôle, révoquer un appareil ou un compte, supprimer une invitation.
|
||||
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
|
||||
qu'il vous dicte, changer le groupe d'un compte, révoquer un appareil ou un compte, supprimer une invitation.
|
||||
Impossible de changer son propre groupe, de se révoquer, ou de retirer le dernier propriétaire actif ;
|
||||
seuls les groupes dont vous avez toutes les permissions sont proposés.
|
||||
* **Page « Groupes et permissions »** (permission `admins.manage`) : créer, renommer, décrire et supprimer des
|
||||
groupes d'administrateurs, cocher leurs permissions ; créer des permissions **personnalisées**
|
||||
(« module.action », pour un module futur) et les supprimer (retirées de tous les groupes). Les permissions
|
||||
**intégrées** (vérifiées par le code) ne se suppriment pas. Le groupe **Propriétaire** a toujours toutes les
|
||||
permissions (ni modifiable ni supprimable). On ne modifie pas les permissions de son propre groupe, on n'accorde
|
||||
que des permissions que l'on a soi-même, et un groupe qui a des membres ou des invitations ne se supprime pas.
|
||||
Effet immédiat (sessions ouvertes comprises), requêtes signées, journal d'audit. Mise en place :
|
||||
`server/deploy/LISEZMOI.md`, étape 3 septies.
|
||||
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
|
||||
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
|
||||
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
|
||||
@@ -570,18 +620,36 @@ servi aux joueurs (page **Signature**, voir plus bas).
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
de 200). Filtres par catégorie, résultat et texte (nom, IP, clé…), export CSV des entrées filtrées.
|
||||
de 200). Filtres par **période** (aujourd'hui, 7 ou 30 derniers jours, ce mois-ci, mois précédent, ou dates
|
||||
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, appareil, IP, clé…). La période est demandée au
|
||||
serveur (paramètre `before`) et les pages sont chargées jusqu'à son début (5000 entrées au plus) ; l'**export
|
||||
CSV** reprend toutes les entrées filtrées, période comprise. Le journal se surveille lui-même : chaque
|
||||
**consultation** (ouverture, « Actualiser », période changée) est inscrite par le serveur (« Journal d'audit
|
||||
consulté » : auteur, appareil, IP, nombre d'entrées lues), et chaque **export** l'est avant l'écriture du
|
||||
fichier (« Journal d'audit exporté (CSV) » : nombre d'entrées, période, filtres) ; si le serveur ne peut pas
|
||||
l'inscrire, le fichier n'est pas écrit. Catégorie de filtre « Consultations et exports du journal ».
|
||||
Chaque entrée indique l'**appareil** sous le nom choisi à l'enregistrement de sa clé (« J'ai une
|
||||
invitation »), jamais le nom Windows du PC (colonne « Appareil » de l'export CSV).
|
||||
* **Page « Fichiers du serveur »** (permission `files.manage` : propriétaires et administrateurs) : contenu du
|
||||
site public (dossiers, tailles, dates, espace libre du disque) et état de chaque fichier : **Utilisé** (archive,
|
||||
fichier d'une version, exécutable du launcher, image du catalogue), **Protégé** (`.json`), **Inutilisé**
|
||||
(supprimable) ou **Non vérifiable**. Un fichier inutilisé se supprime après confirmation ; « Nettoyer le
|
||||
dossier objects » supprime les fichiers d'un jeu que la version en ligne n'utilise plus (analyse d'abord). Le
|
||||
serveur relit tous les manifests du site avant chaque suppression et refuse tout fichier encore désigné, ainsi
|
||||
que pendant une publication. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 sexies.
|
||||
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
|
||||
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
|
||||
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
|
||||
PNG/JPEG du même serveur, déposées dans `catalog/images/` (nouveau nom à chaque changement).
|
||||
PNG/JPEG du même serveur, dans `catalog/images/` (envoyées depuis la page Jeux sous le nom de leur
|
||||
empreinte SHA-256, vérifiée par le launcher au téléchargement ; ou déposées à la main sous un nouveau nom).
|
||||
Installation sur le VPS : `server/deploy/LISEZMOI.md`, étape 3 bis.
|
||||
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
|
||||
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
|
||||
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
|
||||
la clé (un jeton volé ne permet que de lire). Journal d'audit de chaque action.
|
||||
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu et
|
||||
publication), éditeur (contenu).
|
||||
* **Groupes** (anciennement « rôles ») : propriétaire (tout, dont la gestion des administrateurs ; groupe
|
||||
système), administrateur (contenu, publication, journal d'audit, fichiers du serveur), éditeur (contenu) par
|
||||
défaut ; modifiables et complétables depuis la page « Groupes et permissions ».
|
||||
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
|
||||
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
|
||||
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
|
||||
@@ -590,6 +658,6 @@ servi aux joueurs (page **Signature**, voir plus bas).
|
||||
|---|---|
|
||||
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
|
||||
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
+270
-10
@@ -67,6 +67,7 @@ Conséquences :
|
||||
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
|
||||
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
|
||||
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
|
||||
@@ -77,8 +78,8 @@ Conséquences :
|
||||
|
||||
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
|
||||
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
|
||||
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `GameLauncher` → `Core`, `Modules`,
|
||||
`Modules.Admin`.
|
||||
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
|
||||
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
|
||||
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
|
||||
|
||||
## 5. Modules
|
||||
@@ -139,6 +140,48 @@ public sealed record AdminPanelDescriptor(
|
||||
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
|
||||
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
|
||||
|
||||
### 5.3 Affichage : mode fenêtré ou plein écran, mise en page responsive (✅)
|
||||
|
||||
Deux modes seulement, pour la fenêtre principale et le menu d'administration (les autres fenêtres —
|
||||
paramètres, notes agrandies, boîtes de dialogue — gardent leur taille fixe) : **fenêtré** (taille
|
||||
d'origine, jamais redimensionnable par les bords ou les coins) et **plein écran** au sens d'une fenêtre
|
||||
Windows agrandie (toute la zone utile de l'écran où se trouve la fenêtre, la barre des tâches restant visible,
|
||||
même en masquage automatique ; pas de plein écran exclusif comme un jeu). Bascule : bouton entre « Réduire » et « Fermer », ou **F11**.
|
||||
Le mode est mémorisé par fenêtre (`%AppData%\…\GameLauncher\window-modes.json`, ex.
|
||||
`{ "main": true, "admin": false }` ; simple préférence, sans donnée personnelle).
|
||||
|
||||
* `Controls/WindowModeAssist` (propriétés attachées, aucun code dans la fenêtre) :
|
||||
`ui:WindowModeAssist.AllowFullScreen="True"` + `ui:WindowModeAssist.PersistenceKey="clé"` sur la
|
||||
fenêtre ; bouton `<Button Style="{StaticResource WindowModeButtonStyle}" />` (icône et infobulle
|
||||
selon le mode, masqué si la fenêtre ne le permet pas). En plein écran, les ressources de la fenêtre
|
||||
nommées `WindowChrome.*` (CornerRadius, Thickness : arrondis, marge de l'ombre) passent à 0 ; elles
|
||||
sont utilisées en `DynamicResource`. En mode fenêtré, la taille est limitée à la zone utile de
|
||||
l'écran de la fenêtre. Déplacement par la barre de titre : `WindowModeAssist.DragMove(window)` (sans
|
||||
effet en plein écran) ; réaffichage d'une fenêtre réduite : `WindowModeAssist.RestoreFromMinimized`
|
||||
(jamais `WindowState = Normal`, qui ferait quitter le plein écran).
|
||||
* `Controls/ScaleToFitDecorator` (responsive) : `DesignWidth` × `DesignHeight` = taille du contenu
|
||||
en mode fenêtré. Plus de place (plein écran) : le contenu s'étale (colonnes fluides, listes et
|
||||
ascenseurs) ; moins de place (petit écran, mise à l'échelle Windows 125-150 %) : il est mis en page
|
||||
à cette taille puis réduit d'un bloc. Rien n'est jamais rogné : tous les boutons restent
|
||||
accessibles, tous les textes visibles. Le contenu n'est jamais agrandi.
|
||||
* **Nouvelle page d'administration** (module futur) : elle s'affiche dans la fenêtre d'administration
|
||||
et profite donc du plein écran sans rien déclarer. Règles de mise en page pour qu'elle reste
|
||||
lisible dans les deux modes :
|
||||
* ne pas fixer la largeur d'un bloc de texte (`MaxWidth` sur un bandeau…) : texte en
|
||||
`TextWrapping="Wrap"` dans un `DockPanel` ou une colonne `*` d'un `Grid`, jamais dans un
|
||||
`StackPanel` horizontal (il ne revient alors jamais à la ligne) ;
|
||||
* listes de tuiles : `WrapPanel` ; libellés sur une ligne : `TextTrimming="CharacterEllipsis"` +
|
||||
infobulle ;
|
||||
* colonnes de largeur fixe : seulement pour une liste de navigation (≤ 260) ; la partie
|
||||
principale en `*` ;
|
||||
* page en deux colonnes qui défilent séparément : `HasSplitLayout => true` ; la page reçoit la
|
||||
hauteur visible (au moins 360 px, sinon la page entière défile en plus) ;
|
||||
* bandeaux d'erreur et de résultat : reprendre ceux des pages existantes (`AdminErrorBannerStyle`,
|
||||
`AdminInfoBannerStyle`, icône à gauche dans un `DockPanel`).
|
||||
* **Futur module du launcher** affiché dans sa propre fenêtre : `AllowFullScreen` seulement si la fenêtre
|
||||
contient assez d'informations pour le mériter, avec une `PersistenceKey` propre et un
|
||||
`ScaleToFitDecorator` autour du contenu.
|
||||
|
||||
## 6. Authentification par clé publique/privée
|
||||
|
||||
### 6.1 Clés
|
||||
@@ -264,7 +307,12 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
| Erreur de manipulation | Impossible de se révoquer soi-même, de changer son propre rôle, ou de supprimer le dernier propriétaire |
|
||||
| Perte de toutes les clés | Récupération par l'outil en ligne de commande, sur le VPS (accès SSH administrateur) |
|
||||
|
||||
## 7. Rôles et permissions
|
||||
## 7. Groupes et permissions
|
||||
|
||||
Chaque administrateur appartient à **un groupe** (anciennement « rôle » : le champ `role` des comptes et des
|
||||
invitations contient l'identifiant du groupe). Ses droits sont les permissions de ce groupe, **relues à chaque
|
||||
requête** (une modification s'applique immédiatement, sessions ouvertes comprises). Groupes par défaut, créés au
|
||||
premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les droits des anciens rôles :
|
||||
|
||||
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
@@ -273,13 +321,59 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
|
||||
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
|
||||
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
|
||||
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
|
||||
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
|
||||
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
|
||||
|
||||
Les permissions sont des chaînes « module.action » : un module futur déclare les siennes
|
||||
(`news.edit`, `community.moderate`…) et les rôles qui les reçoivent.
|
||||
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
|
||||
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
|
||||
d'un propriétaire, page « Groupes et permissions »). Les groupes par défaut créés **après** l'ajout la reçoivent.
|
||||
|
||||
Les permissions sont des chaînes « module.action ». **Intégrées** : déclarées dans le code
|
||||
(`AdminPermissions.All`), vérifiées par les routes du serveur et les pages du launcher, jamais supprimables.
|
||||
**Personnalisées** : créées depuis la page « Groupes et permissions » (`news.edit`, `community.moderate`…),
|
||||
attribuables aux groupes en prévision d'un module futur ; supprimées, elles sont retirées de tous les groupes.
|
||||
|
||||
### 7.1 Page « Groupes et permissions » (✅)
|
||||
|
||||
Module serveur `Groups` (`GroupsModule`, données dans `admins.json` : `groups`, `customPermissions`) et page
|
||||
launcher `GroupsPanelViewModel` (module `AccountsAdminModule`), permission `admins.manage`.
|
||||
|
||||
| Route | Signée | Rôle |
|
||||
|---|:-:|---|
|
||||
| `GET v1/groups` | | Groupes (permissions effectives, membres, invitations, révision) et permissions connues |
|
||||
| `POST v1/groups` | ✔ | Crée un groupe (nom, description, permissions) ; identifiant tiré du nom (`moderateurs-forum`) |
|
||||
| `PUT v1/groups/{id}` | ✔ | Modifie un groupe ; révision attendue (409 `revision_conflict` sinon) |
|
||||
| `DELETE v1/groups/{id}` | ✔ | Supprime un groupe sans membre (actif ou en attente) ni invitation en cours |
|
||||
| `POST v1/permissions` | ✔ | Crée une permission personnalisée (« module.action », libellé, description) |
|
||||
| `DELETE v1/permissions/{name}` | ✔ | Supprime une permission personnalisée (retirée de tous les groupes) |
|
||||
|
||||
Garde-fous (serveur, sous le verrou d'`admins.json`, auteur et droits revérifiés au moment d'écrire —
|
||||
`AdminRules`) :
|
||||
* le groupe **Propriétaire** (`owner`) est un groupe système : toujours **toutes** les permissions, y compris
|
||||
les personnalisées créées plus tard ; ni modifiable ni supprimable ; seul un propriétaire le donne, le
|
||||
retire ou gère (groupe, révocation, appareils) un autre propriétaire ;
|
||||
* **pas d'élévation** : on n'accorde à un groupe, et on ne place quelqu'un (invitation, changement de groupe,
|
||||
approbation d'un compte en attente) que dans un groupe dont on a soi-même toutes les permissions ; on ne gère
|
||||
pas un administrateur qui a des permissions que l'on n'a pas ;
|
||||
* on ne modifie pas les permissions de **son propre groupe** (comme on ne change pas son propre groupe) ;
|
||||
* identifiants : `[a-z][a-z0-9-]{1,31}`, jamais réutilisés tant qu'un compte (même révoqué) ou une invitation
|
||||
les désigne ; noms uniques, une ligne, sans caractère invisible ; 50 groupes, 40 permissions personnalisées ;
|
||||
* audit : `admins.group_create`, `admins.group_update` (permissions ajoutées/retirées), `admins.group_delete`,
|
||||
`admins.permission_create`, `admins.permission_delete`.
|
||||
|
||||
`admins.manage` donnée à un autre groupe que Propriétaire équivaut presque aux droits d'un propriétaire (gestion
|
||||
des comptes et des groupes, dans la limite de ses propres permissions) : la page demande une confirmation
|
||||
renforcée. Trousseau de signature (phase 7) : les autorisations `releases`/`catalog` d'une clé suivent désormais les
|
||||
permissions **de son groupe** ; la page Signature signale un trousseau à refaire après un changement.
|
||||
|
||||
Côté launcher : `AdminProfile`, `AdminSummary` et `InvitationSummary` portent `RoleName` (nom du groupe) et
|
||||
`AdminSummary.Permissions` ; la page « Administrateurs » ne propose que les groupes attribuables. La page ne
|
||||
propose que ce que le serveur accepte (cases grisées avec la raison en info-bulle) ; le serveur reste seul juge.
|
||||
|
||||
## 8. Ajouter un administrateur
|
||||
|
||||
1. Un **propriétaire** crée une invitation (nom, rôle) : le serveur renvoie **une seule fois** un
|
||||
1. Un **propriétaire** crée une invitation (nom, groupe) : le serveur renvoie **une seule fois** un
|
||||
code à usage unique (160 bits, `ABCD-EFGH-…`, valable 24 h ; seule son empreinte est stockée).
|
||||
2. Il transmet ce code au futur administrateur.
|
||||
3. Le futur administrateur ouvre le menu d'administration › « J'ai une invitation » : le launcher
|
||||
@@ -383,6 +477,12 @@ publication n'est pas configurée.
|
||||
|
||||
## 10. Notes de version (phase 4 ✅)
|
||||
|
||||
* **Plusieurs jeux** (ici et dans « Publier une version ») : la page s'ouvre sur le choix du jeu
|
||||
(`GameTile`, une tuile par jeu de `launcher.json`, aucun jeu par défaut ; un seul jeu : choisi d'office)
|
||||
et ne charge rien avant ; liste « Jeu » ensuite (désactivée pendant un chargement ou un envoi). Une
|
||||
réponse du serveur arrivée après un changement de jeu est ignorée ; dossier, version en ligne et
|
||||
signature sont effacés au changement ; un envoi interrompu est mémorisé par jeu. « Fichiers du
|
||||
serveur » : jeu à nettoyer sans choix par défaut s'il y en a plusieurs.
|
||||
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
|
||||
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
|
||||
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
|
||||
@@ -499,14 +599,158 @@ que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste s
|
||||
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
|
||||
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
|
||||
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
|
||||
texte ; limite des 5 000 entrées signalée) ; export CSV des entrées filtrées, toutes les cellules entre
|
||||
guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
|
||||
texte ; limite des 5 000 entrées signalée) ; **période** (« du / au », raccourcis) : `GET v1/audit?before=`
|
||||
(ISO 8601, les mois postérieurs ne sont pas lus) puis pages suivantes jusqu'au début de la période, filtre
|
||||
local en plus ; **consultations inscrites** : chaque `GET v1/audit` sans curseur (ouverture, « Actualiser »,
|
||||
période) écrit `audit.view` APRÈS la lecture (auteur, clé, IP, entrées lues, borne `before`) ; les pages
|
||||
suivantes (curseur) n'en réécrivent une que si la dernière consultation inscrite de la même clé date de plus
|
||||
de 10 minutes (un curseur forgé ne permet donc pas de lire sans trace) ; l'outil `AdminKeyTool` est tracé
|
||||
de la même façon ; **exports inscrits** : `POST v1/audit/export` (signé, `audit.read` ; nombre d'entrées,
|
||||
période, filtres en clair, bornés) écrit `audit.export` AVANT que le launcher n'écrive le fichier, qui n'est
|
||||
pas écrit en cas de refus ou d'erreur (le contenu du fichier reste celui déjà lu : un client modifié pourrait
|
||||
sauter l'annonce, mais pas la lecture, elle-même tracée) ; export CSV des entrées filtrées (période
|
||||
comprise), toutes les cellules entre guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
|
||||
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
|
||||
* **Appareil** : chaque entrée liée à une clé affiche le **nom de l'appareil choisi à l'enregistrement de
|
||||
la clé** (formulaire « J'ai une invitation »), jamais le nom Windows du PC. Le serveur l'ajoute à la
|
||||
lecture (`AuditEntry.KeyLabel`, d'après `admins.json`) : il n'est jamais écrit dans les fichiers du
|
||||
journal (ajout seul), et les anciennes entrées en profitent aussi ; colonne « Appareil » dans l'export
|
||||
CSV, recherche comprise. À la connexion (`auth.login`), le détail est « appareil « Nom » · GameLauncher
|
||||
x.y.z » : le launcher n'envoie plus le nom Windows du PC (`LoginRequest.ClientName` = logiciel et
|
||||
version) et le serveur retire celui qu'ajoutaient les launchers jusqu'à la version 2.4.1 (il n'est ni
|
||||
journalisé ni conservé dans la session). Les lignes déjà écrites par un ancien serveur gardent leur texte
|
||||
d'origine (journal en ajout seul).
|
||||
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
|
||||
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
|
||||
tant qu'un code non transmis est affiché.
|
||||
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
|
||||
|
||||
### 11 ter. Fichiers du serveur
|
||||
|
||||
Module serveur `Files` (`FilesModule`, `ServerFilesStore`) et module launcher `FilesAdminModule` (page « Fichiers du
|
||||
serveur »), permission `files.manage`.
|
||||
|
||||
| Route | Signée | Rôle |
|
||||
|---|:-:|---|
|
||||
| `GET v1/files?path=` | | Contenu d'un dossier du site (2 000 éléments au plus, tailles des sous-dossiers dans la limite de 200 000 fichiers parcourus), espace disque, jeux nettoyables |
|
||||
| `POST v1/files/delete` | ✔ | Supprime UN fichier (`path`, `size` vue dans la liste : refus s'il a changé) |
|
||||
| `GET v1/games/{id}/objects/prune` | | Analyse : objets du dossier `objects` que plus aucun manifest n'utilise (nombre, taille, empreinte du manifest analysé) |
|
||||
| `POST v1/games/{id}/objects/prune` | ✔ | Nettoyage, refusé si le manifest a changé depuis l'analyse (`revision_conflict`) |
|
||||
|
||||
* **Racine** : `LauncherApi:Files:Root`, sinon le parent commun des dossiers des jeux et du catalogue ; jamais la
|
||||
racine du disque ni un dossier mêlé aux données privées. Chemins relatifs uniquement (ni `..`, ni `\`, ni `:`, ni
|
||||
élément caché) ; aucun lien symbolique sur le chemin ; un dossier n'est jamais supprimé.
|
||||
* **Références** : à chaque requête, tous les `manifest.json` et `launcher-update.json` du site (dossiers cachés et
|
||||
liens exclus, 50 000 dossiers au plus) et le catalogue configuré sont relus ; les adresses sont résolues comme le
|
||||
fait le launcher (relatives au fichier, `..` compris ; adresse complète du même serveur rattachée au site grâce à
|
||||
l'emplacement et à l'adresse du catalogue). État d'un fichier : `protected` (`.json`), `in_use`, `unused`,
|
||||
`unknown` (fichier de description illisible dans le dossier, ou adresse du même serveur impossible à rattacher :
|
||||
tout le site), `link`.
|
||||
* **Suppression** : verrou d'écriture du manifest de chaque jeu configuré qui contient le fichier, refus pendant une
|
||||
publication (dossier `.publish-…` à côté d'un manifest parent), références relues sous le verrou, seul un fichier
|
||||
`unused` est supprimé. Audit `files.delete` (chemin relatif, taille libérée).
|
||||
* **Nettoyage** : jeux configurés uniquement, `objectsBaseUrl` = `objects/` ; seuls les fichiers
|
||||
`objects/<2 hex>/<empreinte>.bin` au nom exact, ni liens, que le manifest du jeu n'utilise pas ET qu'aucun autre
|
||||
fichier de description du site ne désigne ni ne rend invérifiable. Sous le verrou du jeu, refus pendant une
|
||||
publication. Audit `files.prune`.
|
||||
* **Erreurs** : refus métier en 409 (`publication_in_progress`, `revision_conflict`) ; droits du dossier
|
||||
(`ReadWritePaths` de systemd) ou fichier occupé en 503 sans chemin du serveur (détail dans journalctl).
|
||||
|
||||
### 11 quater. Images du catalogue envoyées depuis le menu (✅)
|
||||
|
||||
Page « Jeux » › icône ou image de fond › **« Parcourir… »** (explorateur Windows) : plus besoin de client FTP.
|
||||
|
||||
1. Le launcher lit le fichier (taille contrôlée avant la lecture) et applique **les mêmes contrôles que le
|
||||
serveur** (`Contracts/Catalog/CatalogImages.Check`) : PNG ou JPEG reconnu à son **contenu**, 4 Mo au plus,
|
||||
fichier complet sans données ajoutées après l'image (bloc `IEND` / marqueur `EOI`), dimensions lues dans
|
||||
l'en-tête et bornées (icône 32–2048 px, fond 640 × 360 à 7680 × 4320 px, 40 mégapixels au plus).
|
||||
2. `PUT v1/catalog/images/{icon|background}` (`games.edit`, **requête signée** : la signature couvre l'empreinte
|
||||
du contenu) ; corps binaire, seule route du catalogue à 4 Mo + 64 Ko (Kestrel `WithBodyLimit`, nginx
|
||||
`location ^~ /api/v1/catalog/images/`, 5 Mo).
|
||||
3. Serveur (`Storage/CatalogImageStore`) : contrôle complet avant toute écriture, dossier `catalog/images` à côté
|
||||
du catalogue public (créé à l'installation, jamais par le service ; refusé s'il est un lien), nom
|
||||
`<sha256>.png|.jpg` (même contenu déjà présent : rien n'est écrit), écriture atomique (fichier temporaire caché
|
||||
puis renommage) en 0644, 300 images / 300 Mo au plus, espace disque vérifié (507). Audit
|
||||
`catalog.image_upload` (refus compris).
|
||||
4. Le launcher vérifie que l'adresse renvoyée est en https, sur l'hôte du catalogue, et porte l'empreinte du
|
||||
fichier envoyé, puis la met dans le champ (aperçu décodé en mémoire). L'image n'est visible des joueurs
|
||||
qu'après **« Publier »** (catalogue signé).
|
||||
5. Joueurs (`CatalogService`) : une image dont le nom est une empreinte est **vérifiée** au téléchargement ; son
|
||||
adresse figurant dans le catalogue signé, l'image est couverte par cette signature. Les images inutilisées se
|
||||
suppriment depuis « Fichiers du serveur ».
|
||||
|
||||
### 11 quinquies. Demandes des joueurs : bouton « Aide » (7d ✅)
|
||||
|
||||
**Besoin** : depuis le launcher, un joueur envoie, **sans compte**, un **avis**, une **demande d'évolution**, un **bug**
|
||||
ou **autre chose**, rattaché au **jeu sélectionné** ; les administrateurs les lisent par jeu dans le menu
|
||||
d'administration, sans pouvoir répondre (un futur module « comptes » permettra l'échange ; les demandes anonymes
|
||||
actuelles resteront sans propriétaire : aucun secret local n'est conservé pour les rattacher plus tard).
|
||||
|
||||
**Formulaires partagés** (`Contracts/Feedback`) : `FeedbackForms` décrit une fois chaque type et ses champs
|
||||
prédéfinis (texte, choix, note 1 à 5 ; obligatoires ou non) ; le launcher génère le formulaire, le serveur valide
|
||||
avec `FeedbackRules` (mêmes règles des deux côtés), le menu affiche les libellés. Titre 5 à 120 caractères, textes
|
||||
2 000 caractères / 40 lignes, total 8 000, corps 16 Ko ; caractères de contrôle, invisibles, de mise en forme,
|
||||
d'inversion du sens d'écriture, d'usage privé et demi-caractères refusés ; fins de ligne normalisées.
|
||||
|
||||
**Route anonyme** (première route d'écriture ouverte à tous ; module serveur `Feedback`) :
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `POST v1/feedback/challenge` | anonyme | | Défi anti-spam sans état (HMAC, secret en mémoire) |
|
||||
| `POST v1/feedback` | anonyme | | Dépôt : `{ reference }` seulement |
|
||||
| `GET v1/feedback/overview` | `feedback.read` | | Compteurs par jeu, réception activée, plafond atteint, durées de conservation |
|
||||
| `GET v1/games/{id}/feedback?kind=&status=&cursor=&limit=` | `feedback.read` | | Liste (100 par page, curseur stable, filtres `open`/`closed`/statut) |
|
||||
| `GET v1/games/{id}/feedback/{id}` | `feedback.read` | | Détail |
|
||||
| `PUT v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Statut et note interne (révision attendue, 409 sinon) |
|
||||
| `DELETE v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Suppression définitive |
|
||||
|
||||
Défenses, dans l'ordre de traitement : réception désactivée par défaut (`LauncherApi:Feedback:Enabled`, 503) ;
|
||||
limite de débit ASP.NET (`feedback`, 10/min par IP) et nginx (`feedback_par_ip`, 6/min) ; corps 16 Ko ; **preuve de
|
||||
travail** (`FeedbackProof` : SHA-256 avec 18 bits à zéro par défaut, défi à usage unique valable 10 min) ; jeu parmi
|
||||
ceux configurés (`Feedback:Games`, sinon `Games` ; l'identifiant **configuré** sert seul à construire un chemin, `.`
|
||||
et `..` refusés) ; contenu ; 3 demandes / 10 min et 10 / jour par IP (IPv6 : /64), **comptées en mémoire seulement**
|
||||
(429 + `Retry-After`) ; plafonds globaux (300 / jour / jeu, 5 000 / jeu, 100 Mo, 500 Mo libres : 503
|
||||
`feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
|
||||
et le statut.
|
||||
|
||||
**Anonymat** : ni adresse IP ni identifiant du PC dans la demande ; audit `feedback.submit` **sans IP** (référence,
|
||||
jeu, type) ; refus résumés par `WriteAnonymousFailure` sans IP ; nginx `access_log off` sur les deux routes ;
|
||||
informations techniques facultatives (versions du launcher et du jeu, Windows, langue), cochées par défaut pour un
|
||||
bug seulement, contenu exact montré au joueur avant l'envoi. Agent utilisateur générique (`GameLauncher/<version>`).
|
||||
Le launcher ne contacte l'API qu'à l'envoi.
|
||||
|
||||
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` dans les
|
||||
données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
|
||||
et signalé), verrou en mémoire (seul le service écrit ici). **SQLite évalué et écarté pour ce module** : volumes bornés,
|
||||
aucune relation, une demande = un document modifié isolément, purge = suppression de fichiers ; SQLite ajouterait une
|
||||
dépendance native (paquet NuGet + bibliothèque `e_sqlite3`) à maintenir sur le VPS et une sauvegarde spécifique, sans
|
||||
gain de sécurité. À réévaluer avec le module « comptes + échanges » (relations comptes ⇄ demandes ⇄ messages,
|
||||
requêtes croisées, volumes non bornés), où SQLite serait légitime (requêtes paramétrées uniquement, fichier 0600 dans
|
||||
les données privées, mode WAL, sauvegarde par l'API de sauvegarde).
|
||||
|
||||
**Consultations et traitement** : `feedback.view` inscrit à chaque ouverture de liste (première page) et à chaque
|
||||
lecture d'une demande (une fois par demande et par clé toutes les 10 min), sans le contenu ; `feedback.update`
|
||||
(ancien → nouveau statut, note modifiée oui/non), `feedback.delete` (référence). Statuts : Nouvelle, En cours, Traitée,
|
||||
Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur.
|
||||
|
||||
**Conservation** (`Hosting/RetentionService`, au démarrage puis toutes les 24 h) : demande close supprimée
|
||||
`RetentionClosedMonths` (12) après sa clôture, toute demande au plus `RetentionMaxMonths` (24) après réception
|
||||
(`feedback.purge`) ; **journal d'audit** : fichiers mensuels au-delà de `Audit:RetentionMonths` (12) supprimés
|
||||
(`audit.purge`) — corrige l'écart relevé par la fiche RGPD.
|
||||
|
||||
**Launcher (joueur)** : bouton **Aide** (bulle avec point d'interrogation) dans la barre de titre, absent sans
|
||||
`apiUrl` (même règle que le menu : `launcher.json` intégré en Release). Fenêtre modale `FeedbackWindow` (le jeu est
|
||||
figé à l'ouverture) : type (4 tuiles) › formulaire (contrôle local, compteurs de caractères, étoiles, saisie conservée
|
||||
en cas d'échec, Échap sans effet pendant la saisie) › référence (copie). ViewModel `FeedbackFormViewModel`
|
||||
(`GameLauncher.Modules.Feedback`), envoi par `LauncherApiClient.SendFeedbackAsync` (défi résolu hors du fil de
|
||||
l'interface).
|
||||
|
||||
**Menu d'administration** : module `FeedbackAdminModule`, page **« Demandes des joueurs »** (`feedback.read`) : choix
|
||||
du jeu (tuiles avec compteurs ; un seul jeu : choisi d'office), filtres type / statut (« À traiter » par défaut) /
|
||||
recherche, liste et fiche en deux colonnes (texte brut, sélectionnable, jamais interprété), statut et note
|
||||
(`feedback.manage`), suppression confirmée ; bandeaux « réception désactivée » et « plafond atteint ». Tableau de bord :
|
||||
carte « Demandes des joueurs » (nouvelles demandes par jeu).
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
|
||||
|
||||
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
|
||||
@@ -560,6 +804,18 @@ et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire to
|
||||
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
|
||||
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
|
||||
|
||||
**Avertissement avant publication** (`Modules/Admin/PlayerSigningCheck`, côté administration uniquement) : avant de
|
||||
publier le catalogue (page Jeux), une version ou de signer la version en ligne (page « Publier une version »), le
|
||||
launcher télécharge le trousseau publié (`keyringUrl` du launcher.json intégré, sans cache, 15 s au plus) et le
|
||||
vérifie comme les launchers des joueurs (structure, racine intégrée, `keyringMinSequence`). Si la clé de ce PC
|
||||
(identifiant calculé localement) en est absente, n'a pas l'autorisation voulue (`catalog` / `releases`), ou si le
|
||||
trousseau est injoignable ou refusé : confirmation « Clé non reconnue par les launchers des joueurs » qui explique la
|
||||
conséquence selon `contentSigning` (« require » : catalogue ignoré / version refusée ; « warn » : alerte au journal)
|
||||
et le remède (page Signature), avec « Publier quand même » / « Signer quand même ». Aucun avertissement en
|
||||
`contentSigning` « off ». **Le serveur n'est pas modifié** : il accepte toujours ces publications (il ne contrôle que
|
||||
la clé de la session, la date et la validité de la signature) ; une erreur imprévue du contrôle ne bloque pas la
|
||||
publication.
|
||||
|
||||
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
|
||||
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
|
||||
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
|
||||
@@ -577,7 +833,8 @@ démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou mod
|
||||
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
|
||||
pas les versions suivantes).
|
||||
|
||||
**Limites assumées** : images du catalogue non signées ; un launcher qui n'a jamais vu de trousseau accepte tout
|
||||
**Limites assumées** : images du catalogue déposées à la main (nom libre) non signées — celles envoyées depuis la
|
||||
page Jeux le sont par leur empreinte (§ 11 quater) ; un launcher qui n'a jamais vu de trousseau accepte tout
|
||||
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
|
||||
(la page Signature le signale : re-signer d'abord).
|
||||
|
||||
@@ -594,6 +851,9 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
|
||||
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
|
||||
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
|
||||
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
|
||||
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
|
||||
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
|
||||
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
|
||||
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
|
||||
@@ -605,5 +865,5 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
|
||||
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
|
||||
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
|
||||
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) |
|
||||
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
|
||||
@@ -150,14 +150,17 @@ public static class AdminCli
|
||||
if (db.Admins.Count == 0) o.WriteLine("Aucun administrateur.");
|
||||
foreach (var admin in db.Admins.OrderBy(a => a.CreatedAt))
|
||||
{
|
||||
o.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
o.WriteLine($"{admin.DisplayName} [{db.GroupName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
foreach (var key in admin.Keys)
|
||||
o.WriteLine($" clé {key.KeyId} {AdminKeys.ComputeFingerprint(key.PublicKeyBytes())} {key.Status,-8} {key.Label}" +
|
||||
(key.LastUsedAt is { } used ? $" (dernière connexion {used:yyyy-MM-dd HH:mm} UTC)" : ""));
|
||||
}
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var invitation in db.Invitations.Where(i => i.ExpiresAt > now))
|
||||
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.Role}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
|
||||
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({db.GroupName(invitation.Role)}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
|
||||
foreach (var group in db.Groups)
|
||||
o.WriteLine($"Groupe {group.Id} : {group.Name} — " +
|
||||
(Contracts.Security.AdminGroups.IsSystem(group.Id) ? "toutes les permissions" : string.Join(", ", db.PermissionsOf(group.Id))));
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,14 @@ public static class ApiModules
|
||||
new Modules.System.SystemModule(),
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Admins.GroupsModule(),
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule(),
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule()
|
||||
new Modules.Signing.SigningModule(),
|
||||
new Modules.Files.FilesModule(),
|
||||
new Modules.Feedback.FeedbackModule()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,4 +47,7 @@ public static class RateLimitPolicies
|
||||
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
|
||||
/// </summary>
|
||||
public const string PublicationUpload = "publication-upload";
|
||||
|
||||
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
|
||||
public const string Feedback = "feedback";
|
||||
}
|
||||
@@ -45,6 +45,14 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public ContentSigningSettings ContentSigning { get; set; } = new();
|
||||
|
||||
public FilesSettings Files { get; set; } = new();
|
||||
|
||||
/// <summary>Demandes des joueurs (bouton « Aide » du launcher) : réception anonyme, consultation dans le menu.</summary>
|
||||
public FeedbackSettings Feedback { get; set; } = new();
|
||||
|
||||
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
|
||||
public AuditSettings Audit { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
|
||||
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
|
||||
@@ -106,6 +114,69 @@ public sealed class LauncherApiOptions
|
||||
public bool Require { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Page « Fichiers du serveur » du menu d'administration.</summary>
|
||||
public sealed class FilesSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Racine du site public (chemin absolu, ex. « /srv/exovival/www ») : seul dossier consultable depuis le menu.
|
||||
/// Vide : dossier parent commun des jeux configurés et du catalogue. La suppression n'est possible que là où
|
||||
/// systemd autorise l'écriture (ReadWritePaths : dossiers des jeux et du catalogue).
|
||||
/// </summary>
|
||||
public string Root { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demandes des joueurs (avis, évolutions, bugs…), envoyées SANS compte depuis le bouton « Aide » du launcher.
|
||||
/// Route anonyme : preuve de travail, limites par adresse IP (en mémoire uniquement) et plafonds globaux. Aucune
|
||||
/// adresse IP n'est enregistrée avec une demande ni dans l'audit de son envoi.
|
||||
/// </summary>
|
||||
public sealed class FeedbackSettings
|
||||
{
|
||||
/// <summary>Réception activée (faux par défaut : les routes répondent 503 « not_configured »).</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifiants des jeux acceptés (GameId de launcher.json). Vide : les jeux de <see cref="LauncherApiOptions.Games"/>.
|
||||
/// Une demande pour un autre jeu est refusée.
|
||||
/// </summary>
|
||||
public string[] Games { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Difficulté du défi anti-spam (bits à zéro) : 18 ≈ une fraction de seconde sur un PC. À relever en cas d'attaque.</summary>
|
||||
public int ProofDifficulty { get; set; } = 18;
|
||||
|
||||
/// <summary>Durée de validité d'un défi.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 600;
|
||||
|
||||
/// <summary>Demandes acceptées par adresse IP (IPv6 : par /64) sur 10 minutes, et sur 24 heures.</summary>
|
||||
public int PerIpPer10Minutes { get; set; } = 3;
|
||||
public int PerIpPerDay { get; set; } = 10;
|
||||
|
||||
/// <summary>Défis et envois par minute et par adresse IP (limite de débit ASP.NET Core, avant tout traitement).</summary>
|
||||
public int RequestsPerMinute { get; set; } = 10;
|
||||
|
||||
/// <summary>Plafonds : demandes reçues par jeu sur 24 heures, demandes conservées par jeu, taille totale (Mo).</summary>
|
||||
public int MaxPerGamePerDay { get; set; } = 300;
|
||||
public int MaxPerGame { get; set; } = 5000;
|
||||
public int MaxTotalMegabytes { get; set; } = 100;
|
||||
|
||||
/// <summary>Espace disque libre minimal (Mo) : en dessous, les nouvelles demandes sont refusées.</summary>
|
||||
public int MinFreeDiskMegabytes { get; set; } = 500;
|
||||
|
||||
/// <summary>Conservation : demande close supprimée N mois après sa clôture ; toute demande au plus M mois après réception.</summary>
|
||||
public int RetentionClosedMonths { get; set; } = 12;
|
||||
public int RetentionMaxMonths { get; set; } = 24;
|
||||
}
|
||||
|
||||
/// <summary>Journal d'audit (fichiers audit-AAAA-MM.jsonl).</summary>
|
||||
public sealed class AuditSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Mois conservés : un fichier mensuel est supprimé une fois tous ses jours plus anciens que cette durée
|
||||
/// (CNIL : 6 mois à 1 an pour les journaux de sécurité). Vérifié au démarrage puis chaque jour.
|
||||
/// </summary>
|
||||
public int RetentionMonths { get; set; } = 12;
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
@@ -168,10 +239,16 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
|
||||
public string KeyringPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
|
||||
public string? FilesRootFullPath { get; private set; }
|
||||
|
||||
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
@@ -201,6 +278,8 @@ public sealed class LauncherApiOptions
|
||||
ValidateCatalog();
|
||||
ValidateKeyring();
|
||||
ValidateGames();
|
||||
ValidateFiles();
|
||||
ValidateFeedback();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
@@ -212,6 +291,33 @@ public sealed class LauncherApiOptions
|
||||
Limits.PublicationUploadsPerMinute >= 1, "Limits");
|
||||
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
|
||||
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
|
||||
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
|
||||
}
|
||||
|
||||
private void ValidateFeedback()
|
||||
{
|
||||
var feedback = Feedback;
|
||||
Require(feedback.ProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
|
||||
$"Feedback:ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
|
||||
Require(feedback.ChallengeLifetimeSeconds is >= 60 and <= 3600, "Feedback:ChallengeLifetimeSeconds (60 à 3600)");
|
||||
Require(feedback.PerIpPer10Minutes is >= 1 and <= 1000 && feedback.PerIpPerDay is >= 1 and <= 10_000 &&
|
||||
feedback.RequestsPerMinute is >= 1 and <= 10_000, "Feedback : limites par adresse IP");
|
||||
Require(feedback.MaxPerGamePerDay is >= 1 and <= 100_000, "Feedback:MaxPerGamePerDay");
|
||||
Require(feedback.MaxPerGame is >= 1 and <= 50_000, "Feedback:MaxPerGame (1 à 50000)");
|
||||
Require(feedback.MaxTotalMegabytes is >= 1 and <= 10_000, "Feedback:MaxTotalMegabytes");
|
||||
Require(feedback.MinFreeDiskMegabytes is >= 0 and <= 1_000_000, "Feedback:MinFreeDiskMegabytes");
|
||||
Require(feedback.RetentionClosedMonths is >= 1 and <= 120, "Feedback:RetentionClosedMonths (1 à 120)");
|
||||
Require(feedback.RetentionMaxMonths is >= 1 and <= 120 && feedback.RetentionMaxMonths >= feedback.RetentionClosedMonths,
|
||||
"Feedback:RetentionMaxMonths (1 à 120, au moins RetentionClosedMonths)");
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var id in feedback.Games is { Length: > 0 } configured ? configured : Games.Keys.ToArray())
|
||||
{
|
||||
if (!Contracts.Catalog.CatalogRules.IsValidGameId(id))
|
||||
throw new InvalidOperationException($"{Section}:Feedback:Games : identifiant de jeu invalide « {id} ».");
|
||||
ids.Add(id);
|
||||
}
|
||||
FeedbackGameIds = ids;
|
||||
}
|
||||
|
||||
private void ValidateCatalog()
|
||||
@@ -308,6 +414,55 @@ public sealed class LauncherApiOptions
|
||||
GameManifestFiles = files;
|
||||
}
|
||||
|
||||
private void ValidateFiles()
|
||||
{
|
||||
FilesRootFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Files.Root))
|
||||
{
|
||||
var root = Files.Root.Trim();
|
||||
if (!Path.IsPathRooted(root))
|
||||
throw new InvalidOperationException($"{Section}:Files:Root doit être un chemin absolu (racine du site, ex. « /srv/exovival/www »).");
|
||||
var full = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
|
||||
if (!Directory.Exists(full))
|
||||
throw new InvalidOperationException($"{Section}:Files:Root : le dossier « {full} » n'existe pas.");
|
||||
if (IsFileSystemRoot(full))
|
||||
throw new InvalidOperationException($"{Section}:Files:Root ne peut pas être la racine du disque.");
|
||||
EnsureSeparatedFromData(full, "Files:Root");
|
||||
FilesRootFullPath = full;
|
||||
return;
|
||||
}
|
||||
|
||||
// Par défaut : dossier parent commun des jeux et du catalogue (ex. /srv/exovival/www). Rien de configuré,
|
||||
// racine du disque ou mélange avec les données privées : page désactivée (sans empêcher le démarrage).
|
||||
var folders = GameManifestFiles.Values.Select(Path.GetDirectoryName)
|
||||
.Concat(new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Where(f => f is not null).Select(Path.GetDirectoryName))
|
||||
.OfType<string>().Select(d => Path.GetFullPath(d).TrimEnd(Path.DirectorySeparatorChar)).Distinct().ToList();
|
||||
if (folders.Count == 0) return;
|
||||
var common = folders.Count == 1 ? folders[0] : CommonParent(folders);
|
||||
if (common is null || IsFileSystemRoot(common) || !Directory.Exists(common)) return;
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
var realCommon = RealPath(common);
|
||||
if (IsSameOrInside(realCommon, dataDirectory) || IsSameOrInside(dataDirectory, realCommon)) return;
|
||||
FilesRootFullPath = common;
|
||||
}
|
||||
|
||||
private static string? CommonParent(IReadOnlyList<string> folders)
|
||||
{
|
||||
string? common = folders[0];
|
||||
foreach (var folder in folders.Skip(1))
|
||||
while (common is not null && !IsSameOrInside(folder, common))
|
||||
common = Path.GetDirectoryName(common);
|
||||
return common;
|
||||
}
|
||||
|
||||
private static bool IsFileSystemRoot(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return string.Equals(Path.GetPathRoot(full)?.TrimEnd(Path.DirectorySeparatorChar), full.TrimEnd(Path.DirectorySeparatorChar),
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ||
|
||||
full == "/";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
|
||||
/// dans l'autre (liens symboliques résolus).
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Durées de conservation (RGPD, recommandations de la CNIL) appliquées automatiquement, au démarrage du service
|
||||
/// puis toutes les 24 heures :
|
||||
/// • journal d'audit : fichiers mensuels plus anciens que <c>LauncherApi:Audit:RetentionMonths</c> (12 mois) ;
|
||||
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
|
||||
/// <c>Feedback:RetentionMaxMonths</c> (24).
|
||||
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
|
||||
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
|
||||
/// </summary>
|
||||
public sealed class RetentionService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly AuditLog _audit;
|
||||
private readonly FeedbackStore _feedback;
|
||||
private readonly LauncherApiOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<RetentionService> _logger;
|
||||
|
||||
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
|
||||
ILogger<RetentionService> logger)
|
||||
{
|
||||
_audit = audit;
|
||||
_feedback = feedback;
|
||||
_options = options.Value;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
RunOnce();
|
||||
try
|
||||
{
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Une passe de purge (appelée aussi par les tests).</summary>
|
||||
public void RunOnce()
|
||||
{
|
||||
try
|
||||
{
|
||||
var months = _audit.Purge(_options.Audit.RetentionMonths);
|
||||
if (months.Count > 0)
|
||||
_audit.Write("audit.purge", AuditResults.Ok,
|
||||
details: $"{months.Count} mois supprimé(s) ({string.Join(", ", months)}) ; conservation {_options.Audit.RetentionMonths} mois");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Purge du journal d'audit impossible");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var removed = _feedback.Purge(_time.GetUtcNow());
|
||||
if (removed > 0)
|
||||
_audit.Write("feedback.purge", AuditResults.Ok,
|
||||
details: $"{removed} demande(s) supprimée(s) ; conservation {_options.Feedback.RetentionClosedMonths} mois après clôture, " +
|
||||
$"{_options.Feedback.RetentionMaxMonths} mois au plus");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,8 @@ public static class ServerApp
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
});
|
||||
|
||||
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
|
||||
@@ -98,6 +100,8 @@ public static class ServerApp
|
||||
builder.Services.AddSingleton<AdminStore>();
|
||||
builder.Services.AddAdminAuthentication();
|
||||
foreach (var module in ApiModules.All) module.ConfigureServices(builder.Services, options);
|
||||
// Conservation (RGPD) : purge du journal d'audit et des demandes des joueurs, au démarrage puis chaque jour.
|
||||
builder.Services.AddHostedService<RetentionService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Admins;
|
||||
|
||||
/// <summary>
|
||||
/// Règles communes aux modifications des administrateurs et des groupes, appliquées DANS la modification
|
||||
/// (sous le verrou d'admins.json, sur les données relues) : l'auteur et ses droits sont revérifiés au moment
|
||||
/// d'écrire, et personne ne peut se donner (ni donner à un autre) plus de droits qu'il n'en a.
|
||||
/// </summary>
|
||||
internal static class AdminRules
|
||||
{
|
||||
/// <summary>
|
||||
/// L'auteur est toujours actif, sa clé aussi, et il a encore <paramref name="requiredPermission"/> (null : simple
|
||||
/// administrateur connecté). Sinon : 403, rien n'est écrit. Renvoie ses permissions actuelles.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, string? requiredPermission)
|
||||
{
|
||||
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
|
||||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Vos droits ont changé entre-temps : action refusée.");
|
||||
var permissions = db.PermissionsOf(current.Admin.Role);
|
||||
if (requiredPermission is not null && !permissions.Contains(requiredPermission, StringComparer.Ordinal))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/// <summary>L'auteur est propriétaire (groupe système), d'après les données relues.</summary>
|
||||
public static bool IsOwner(AdminDatabase db, AuthenticatedAdmin actor) =>
|
||||
db.FindAdmin(actor.Admin.Id) is { } admin && AdminGroups.IsSystem(admin.Role);
|
||||
|
||||
/// <summary>
|
||||
/// Placer quelqu'un dans ce groupe (invitation, changement de groupe, approbation d'un compte en attente) :
|
||||
/// seul un propriétaire donne le groupe « Propriétaire », et personne ne donne des permissions qu'il n'a pas.
|
||||
/// </summary>
|
||||
public static void EnsureCanAssign(AdminDatabase db, AuthenticatedAdmin actor, string groupId)
|
||||
{
|
||||
if (AdminGroups.IsSystem(groupId) && !IsOwner(db, actor))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Seul un propriétaire peut donner le groupe « Propriétaire ».");
|
||||
EnsureWithinOwnRights(db, actor, db.PermissionsOf(groupId),
|
||||
missing => $"Ce groupe a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas y placer un administrateur.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gérer un autre administrateur (changer son groupe, le révoquer, révoquer un de ses appareils) : seul un
|
||||
/// propriétaire gère un propriétaire, et personne ne gère un administrateur qui a des permissions qu'il n'a pas.
|
||||
/// </summary>
|
||||
public static void EnsureCanManage(AdminDatabase db, AuthenticatedAdmin actor, AdminRecord target)
|
||||
{
|
||||
if (AdminGroups.IsSystem(target.Role) && !IsOwner(db, actor))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Seul un propriétaire peut gérer un autre propriétaire.");
|
||||
EnsureWithinOwnRights(db, actor, db.PermissionsOf(target.Role),
|
||||
missing => $"{target.DisplayName} a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas gérer son compte.");
|
||||
}
|
||||
|
||||
/// <summary>Toutes ces permissions sont aussi celles de l'auteur (sinon 403, en les nommant).</summary>
|
||||
public static void EnsureWithinOwnRights(AdminDatabase db, AuthenticatedAdmin actor, IEnumerable<string> permissions,
|
||||
Func<string, string> message)
|
||||
{
|
||||
var own = db.FindAdmin(actor.Admin.Id) is { } admin ? db.PermissionsOf(admin.Role) : Array.Empty<string>();
|
||||
var missing = permissions.Where(p => !own.Contains(p, StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, message(string.Join(", ", missing.Take(5))));
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,11 @@ namespace GameLauncher.Server.Modules.Admins;
|
||||
/// pourrait sinon révoquer ses clés puis y ajouter la sienne). Compte sans aucun appareil actif :
|
||||
/// restauration sur le VPS (outil en ligne de commande, « add-key »).
|
||||
///
|
||||
/// Garde-fous : on ne change pas son propre rôle, on ne se révoque pas soi-même, on ne révoque pas
|
||||
/// Garde-fous : on ne change pas son propre groupe, on ne se révoque pas soi-même, on ne révoque pas
|
||||
/// la clé de sa propre session, et il reste toujours au moins un propriétaire capable de se connecter.
|
||||
/// Groupes (voir <see cref="AdminRules"/>) : on ne place personne dans un groupe qui a des permissions que
|
||||
/// l'on n'a pas soi-même, on ne gère pas un administrateur plus privilégié que soi, et seul un propriétaire
|
||||
/// donne, retire ou révoque le groupe « Propriétaire ».
|
||||
/// </summary>
|
||||
public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
@@ -48,8 +51,11 @@ public sealed class AdminsModule : IApiModule
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private static IResult List(AdminStore store) =>
|
||||
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
|
||||
private static IResult List(AdminStore store)
|
||||
{
|
||||
var db = store.Read();
|
||||
return Results.Json(db.Admins.OrderBy(a => a.CreatedAt).Select(a => ToSummary(a, db)).ToList(), ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
|
||||
@@ -63,17 +69,18 @@ public sealed class AdminsModule : IApiModule
|
||||
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
|
||||
return Results.Json(new MyAccount
|
||||
{
|
||||
Admin = ToSummary(admin),
|
||||
Admin = ToSummary(admin, db),
|
||||
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(ToSummary).ToList()
|
||||
.Select(i => ToSummary(i, db)).ToList()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult ListInvitations(AdminStore store, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
return Results.Json(store.Read().Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(ToSummary).ToList(), ApiJson.Options);
|
||||
var db = store.Read();
|
||||
return Results.Json(db.Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(i => ToSummary(i, db)).ToList(), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Invitations
|
||||
@@ -108,7 +115,8 @@ public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
displayName = ApiText.CleanLine(request.DisplayName, ApiText.MaxDisplayNameLength)
|
||||
?? throw ApiRejection.BadRequest($"Nom obligatoire ({ApiText.MaxDisplayNameLength} caractères au plus).");
|
||||
role = AdminRoles.IsValid(request.Role) ? request.Role! : throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
role = db.FindGroup(request.Role)?.Id ?? throw ApiRejection.BadRequest("Groupe inconnu : rechargez la page.");
|
||||
AdminRules.EnsureCanAssign(db, actor, role);
|
||||
}
|
||||
|
||||
var record = new InvitationRecord
|
||||
@@ -128,7 +136,7 @@ public sealed class AdminsModule : IApiModule
|
||||
});
|
||||
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
|
||||
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
|
||||
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation, store.Read()), Code = code }, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
|
||||
@@ -172,7 +180,13 @@ public sealed class AdminsModule : IApiModule
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
$"Ce nouvel appareil doit être approuvé par {admin.DisplayName} lui-même, depuis un de ses appareils déjà actifs.");
|
||||
}
|
||||
else EnsureActor(db, actor);
|
||||
else
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
// Premier appareil d'un compte en attente : son groupe a été choisi à l'invitation, peut-être par
|
||||
// quelqu'un d'autre ; l'approbateur ne fait entrer personne au-dessus de ses propres droits.
|
||||
if (admin.Status == AdminStatus.Pending) AdminRules.EnsureCanAssign(db, actor, admin.Role);
|
||||
}
|
||||
if (key.Status != AdminStatus.Pending) throw ApiRejection.Conflict("Cette clé n'est pas en attente d'approbation.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
if (!AdminKeys.FingerprintMatches(key.PublicKeyBytes(), request.Fingerprint))
|
||||
@@ -194,7 +208,7 @@ public sealed class AdminsModule : IApiModule
|
||||
return admin.DisplayName;
|
||||
});
|
||||
Audit(audit, context, "admins.key_approve", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
private static IResult RevokeKey(Guid adminId, string keyId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
@@ -208,6 +222,7 @@ public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
if (admin.Id != actor.Admin.Id) AdminRules.EnsureCanManage(db, actor, admin);
|
||||
if (key.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cette clé est déjà révoquée.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = time.GetUtcNow();
|
||||
@@ -217,7 +232,7 @@ public sealed class AdminsModule : IApiModule
|
||||
});
|
||||
sessions.RemoveWhere(s => s.KeyId == keyId);
|
||||
Audit(audit, context, "admins.key_revoke", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
@@ -226,22 +241,24 @@ public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre rôle.");
|
||||
if (!AdminRoles.IsValid(request.Role)) throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre groupe.");
|
||||
|
||||
var (name, previous) = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var group = db.FindGroup(request.Role) ?? throw ApiRejection.BadRequest("Groupe inconnu : rechargez la page.");
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
AdminRules.EnsureCanManage(db, actor, admin);
|
||||
AdminRules.EnsureCanAssign(db, actor, group.Id);
|
||||
var old = admin.Role;
|
||||
admin.Role = request.Role;
|
||||
admin.Role = group.Id;
|
||||
EnsureUsableOwner(db);
|
||||
return (admin.DisplayName, old);
|
||||
});
|
||||
// Les droits sont relus à chaque requête : le nouveau rôle s'applique immédiatement.
|
||||
// Les droits sont relus à chaque requête : le nouveau groupe s'applique immédiatement.
|
||||
Audit(audit, context, "admins.role", name, $"{previous} → {request.Role}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
private static IResult RevokeAdmin(Guid adminId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
@@ -257,6 +274,7 @@ public sealed class AdminsModule : IApiModule
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est déjà révoqué.");
|
||||
AdminRules.EnsureCanManage(db, actor, admin);
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor.Admin.DisplayName;
|
||||
@@ -272,7 +290,13 @@ public sealed class AdminsModule : IApiModule
|
||||
});
|
||||
sessions.RemoveWhere(s => s.AdminId == adminId);
|
||||
Audit(audit, context, "admins.revoke", name);
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
private static IResult Summary(AdminStore store, Guid adminId)
|
||||
{
|
||||
var db = store.Read();
|
||||
return Results.Json(ToSummary(db.FindAdmin(adminId)!, db), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
@@ -290,14 +314,8 @@ public sealed class AdminsModule : IApiModule
|
||||
/// l'authentification de sa requête et l'écriture (autre propriétaire, outil en ligne de commande),
|
||||
/// rien n'est écrit.
|
||||
/// </summary>
|
||||
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage)
|
||||
{
|
||||
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
|
||||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active ||
|
||||
(required == AdminPermission.Manage && !AdminRoles.Has(current.Admin.Role, AdminPermissions.AdminsManage)))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
required == AdminPermission.Manage ? "Vos droits ne permettent pas cette action." : "Vos droits ont changé entre-temps : action refusée.");
|
||||
}
|
||||
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage) =>
|
||||
AdminRules.EnsureActor(db, actor, required == AdminPermission.Manage ? AdminPermissions.AdminsManage : null);
|
||||
|
||||
private enum AdminPermission { None, Manage }
|
||||
|
||||
@@ -319,11 +337,13 @@ public sealed class AdminsModule : IApiModule
|
||||
audit.Write(action, result, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
|
||||
internal static AdminSummary ToSummary(AdminRecord admin) => new()
|
||||
internal static AdminSummary ToSummary(AdminRecord admin, AdminDatabase db) => new()
|
||||
{
|
||||
Id = admin.Id,
|
||||
DisplayName = admin.DisplayName,
|
||||
Role = admin.Role,
|
||||
RoleName = db.GroupName(admin.Role),
|
||||
Permissions = admin.Status == AdminStatus.Revoked ? Array.Empty<string>() : db.PermissionsOf(admin.Role),
|
||||
Status = admin.Status,
|
||||
CreatedAt = admin.CreatedAt,
|
||||
Keys = admin.Keys.Select(k => new AdminKeySummary
|
||||
@@ -339,11 +359,12 @@ public sealed class AdminsModule : IApiModule
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static InvitationSummary ToSummary(InvitationRecord invitation) => new()
|
||||
private static InvitationSummary ToSummary(InvitationRecord invitation, AdminDatabase db) => new()
|
||||
{
|
||||
Id = invitation.Id,
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
RoleName = db.GroupName(invitation.Role),
|
||||
ForAdminId = invitation.ForAdminId,
|
||||
CreatedAt = invitation.CreatedAt,
|
||||
CreatedBy = invitation.CreatedBy,
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Admins;
|
||||
|
||||
/// <summary>
|
||||
/// Groupes d'administrateurs et permissions (permission admins.manage). Toutes les modifications sont des
|
||||
/// requêtes signées, revérifiées sous le verrou d'admins.json, et inscrites au journal d'audit.
|
||||
///
|
||||
/// Garde-fous :
|
||||
/// • le groupe système « Propriétaire » a toujours toutes les permissions : ni modifiable ni supprimable
|
||||
/// (il reste donc toujours un groupe capable de tout réparer) ;
|
||||
/// • on ne modifie pas les permissions de son propre groupe (comme on ne change pas son propre groupe) ;
|
||||
/// • on n'accorde à un groupe que des permissions que l'on a soi-même ;
|
||||
/// • un groupe qui a des membres (actifs ou en attente) ou des invitations en cours ne se supprime pas ;
|
||||
/// • modification préparée sur une révision dépassée (autre administrateur) : refusée (409) ;
|
||||
/// • les permissions INTÉGRÉES (vérifiées par le code) ne se suppriment pas ; une permission personnalisée
|
||||
/// supprimée est retirée de tous les groupes.
|
||||
/// Les droits sont relus à chaque requête : une modification s'applique aussitôt aux sessions ouvertes.
|
||||
/// </summary>
|
||||
public sealed class GroupsModule : IApiModule
|
||||
{
|
||||
public string Name => "Groups";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var manage = api.MapGroup("").RequirePermission(AdminPermissions.AdminsManage);
|
||||
manage.MapGet(ApiRoutes.Groups, List);
|
||||
manage.MapPost(ApiRoutes.Groups, Create).RequireSignedRequest();
|
||||
manage.MapPut("v1/groups/{groupId}", Update).RequireSignedRequest();
|
||||
manage.MapDelete("v1/groups/{groupId}", Delete).RequireSignedRequest();
|
||||
manage.MapPost(ApiRoutes.Permissions, CreatePermission).RequireSignedRequest();
|
||||
manage.MapDelete("v1/permissions/{name}", DeletePermission).RequireSignedRequest();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private static IResult List(AdminStore store, TimeProvider time) => Results.Json(State(store.Read(), time.GetUtcNow()), ApiJson.Options);
|
||||
|
||||
internal static GroupsState State(AdminDatabase db, DateTimeOffset now)
|
||||
{
|
||||
var groups = db.Groups
|
||||
.OrderBy(g => g.Id switch { AdminRoles.Owner => 0, AdminRoles.Admin => 1, AdminRoles.Editor => 2, _ => 3 })
|
||||
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.Select(g => new GroupSummary
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
Description = g.Description,
|
||||
Permissions = db.PermissionsOf(g.Id),
|
||||
System = AdminGroups.IsSystem(g.Id),
|
||||
Revision = g.Revision,
|
||||
MemberCount = Members(db, g.Id),
|
||||
InvitationCount = Invitations(db, g.Id, now),
|
||||
UpdatedAt = g.UpdatedAt ?? g.CreatedAt,
|
||||
UpdatedBy = g.UpdatedBy ?? g.CreatedBy
|
||||
}).ToList();
|
||||
var permissions = AdminPermissions.All.Select(p => new PermissionSummary
|
||||
{
|
||||
Name = p,
|
||||
Label = AdminPermissions.BuiltInLabel(p) ?? p,
|
||||
Description = AdminPermissions.BuiltInDescription(p),
|
||||
BuiltIn = true,
|
||||
GroupCount = groups.Count(g => g.Permissions.Contains(p, StringComparer.Ordinal))
|
||||
})
|
||||
.Concat(db.CustomPermissions.OrderBy(p => p.Name, StringComparer.Ordinal).Select(p => new PermissionSummary
|
||||
{
|
||||
Name = p.Name,
|
||||
Label = p.Label,
|
||||
Description = p.Description,
|
||||
BuiltIn = false,
|
||||
GroupCount = groups.Count(g => g.Permissions.Contains(p.Name, StringComparer.Ordinal)),
|
||||
CreatedAt = p.CreatedAt,
|
||||
CreatedBy = p.CreatedBy
|
||||
})).ToList();
|
||||
return new GroupsState { Groups = groups, Permissions = permissions };
|
||||
}
|
||||
|
||||
private static int Members(AdminDatabase db, string groupId) =>
|
||||
db.Admins.Count(a => a.Status != AdminStatus.Revoked && string.Equals(a.Role, groupId, StringComparison.Ordinal));
|
||||
|
||||
private static int Invitations(AdminDatabase db, string groupId, DateTimeOffset now) =>
|
||||
db.Invitations.Count(i => i.ExpiresAt > now && i.ForAdminId is null && string.Equals(i.Role, groupId, StringComparison.Ordinal));
|
||||
|
||||
// ------------------------------------------------------------------ Groupes
|
||||
|
||||
private static IResult Create(GroupRequest request, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var (name, description) = CheckText(request);
|
||||
var created = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
if (db.Groups.Count >= AdminGroups.MaxGroups)
|
||||
throw ApiRejection.Conflict($"{AdminGroups.MaxGroups} groupes au plus : supprimez-en un avant d'en créer un autre.");
|
||||
EnsureUniqueName(db, name, exceptId: null);
|
||||
var permissions = CheckPermissions(db, request.Permissions);
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, permissions,
|
||||
missing => $"Vous ne pouvez pas accorder des permissions que vous n'avez pas ({missing}).");
|
||||
|
||||
var baseId = AdminGroups.Slug(name);
|
||||
var id = baseId;
|
||||
// Identifiant jamais réutilisé tant qu'un compte (même révoqué) ou une invitation le désigne encore.
|
||||
for (var n = 2; db.FindGroup(id) is not null || db.Admins.Any(a => a.Role == id) || db.Invitations.Any(i => i.Role == id); n++)
|
||||
id = $"{baseId}-{n}";
|
||||
var group = new GroupRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Permissions = permissions,
|
||||
Revision = 1,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor.Admin.DisplayName
|
||||
};
|
||||
db.Groups.Add(group);
|
||||
return group;
|
||||
});
|
||||
Audit(audit, context, "admins.group_create", $"{created.Name} ({created.Id})", Describe(created.Permissions));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Update(string groupId, GroupRequest request, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
if (!AdminGroups.IsValidGroupId(groupId)) throw ApiRejection.NotFound("Groupe introuvable.");
|
||||
if (AdminGroups.IsSystem(groupId))
|
||||
throw ApiRejection.Conflict("Le groupe « Propriétaire » a toujours toutes les permissions : il ne peut pas être modifié.");
|
||||
var (name, description) = CheckText(request);
|
||||
|
||||
var (group, changes) = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
var group = db.FindGroup(groupId) ?? throw ApiRejection.NotFound("Groupe introuvable : il a peut-être été supprimé.");
|
||||
if (group.Revision != request.Revision)
|
||||
throw ApiRejection.Conflict("Ce groupe a été modifié entre-temps par un autre administrateur : rechargez la page.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
EnsureUniqueName(db, name, exceptId: group.Id);
|
||||
var permissions = CheckPermissions(db, request.Permissions);
|
||||
var added = permissions.Except(group.Permissions, StringComparer.Ordinal).ToList();
|
||||
var removed = group.Permissions.Except(permissions, StringComparer.Ordinal).ToList();
|
||||
if ((added.Count > 0 || removed.Count > 0) && db.FindAdmin(actor.Admin.Id)?.Role == group.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction,
|
||||
"Vous ne pouvez pas modifier les permissions de votre propre groupe : demandez-le à un autre administrateur.");
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, added,
|
||||
missing => $"Vous ne pouvez pas accorder des permissions que vous n'avez pas ({missing}).");
|
||||
|
||||
var changes = new List<string>();
|
||||
if (group.Name != name) changes.Add($"nom : {group.Name} → {name}");
|
||||
if (group.Description != description) changes.Add("description modifiée");
|
||||
if (added.Count > 0) changes.Add("+ " + string.Join(", ", added));
|
||||
if (removed.Count > 0) changes.Add("− " + string.Join(", ", removed));
|
||||
group.Name = name;
|
||||
group.Description = description;
|
||||
group.Permissions = permissions;
|
||||
group.Revision++;
|
||||
group.UpdatedAt = now;
|
||||
group.UpdatedBy = actor.Admin.DisplayName;
|
||||
return (group, changes);
|
||||
});
|
||||
Audit(audit, context, "admins.group_update", $"{group.Name} ({group.Id})",
|
||||
changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Delete(string groupId, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
if (!AdminGroups.IsValidGroupId(groupId)) throw ApiRejection.NotFound("Groupe introuvable.");
|
||||
if (AdminGroups.IsSystem(groupId)) throw ApiRejection.Conflict("Le groupe « Propriétaire » ne peut pas être supprimé.");
|
||||
|
||||
var deleted = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
var group = db.FindGroup(groupId) ?? throw ApiRejection.NotFound("Groupe introuvable : il a peut-être déjà été supprimé.");
|
||||
if (db.FindAdmin(actor.Admin.Id)?.Role == group.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas supprimer votre propre groupe.");
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, group.Permissions,
|
||||
missing => $"Ce groupe a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas le supprimer.");
|
||||
var members = Members(db, group.Id);
|
||||
var invitations = Invitations(db, group.Id, now);
|
||||
if (members > 0 || invitations > 0)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Ce groupe a encore {members} membre(s) et {invitations} invitation(s) en cours : placez ces administrateurs dans " +
|
||||
"un autre groupe (page « Administrateurs ») et supprimez ces invitations avant de le supprimer.");
|
||||
// Invitations expirées vers ce groupe : sans effet, retirées pour ne pas désigner un groupe disparu.
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now && string.Equals(i.Role, group.Id, StringComparison.Ordinal));
|
||||
db.Groups.Remove(group);
|
||||
return group;
|
||||
});
|
||||
Audit(audit, context, "admins.group_delete", $"{deleted.Name} ({deleted.Id})", Describe(deleted.Permissions));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Permissions personnalisées
|
||||
|
||||
private static IResult CreatePermission(CreatePermissionRequest request, HttpContext context, AdminStore store, AuditLog audit,
|
||||
TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var name = request.Name?.Trim() ?? "";
|
||||
if (!AdminGroups.IsValidPermissionName(name))
|
||||
throw ApiRejection.BadRequest("Nom de permission invalide : format « module.action » en minuscules (ex. « news.edit »).");
|
||||
if (AdminPermissions.IsBuiltIn(name)) throw ApiRejection.Conflict("Cette permission existe déjà (permission intégrée).");
|
||||
var label = ApiText.CleanLine(request.Label, AdminGroups.MaxPermissionLabelLength)
|
||||
?? throw ApiRejection.BadRequest($"Libellé obligatoire ({AdminGroups.MaxPermissionLabelLength} caractères au plus, une ligne).");
|
||||
var description = AdminGroups.CleanDescription(request.Description, AdminGroups.MaxDescriptionLength, out var validDescription);
|
||||
if (!validDescription) throw ApiRejection.BadRequest($"Description invalide ({AdminGroups.MaxDescriptionLength} caractères au plus, une ligne).");
|
||||
|
||||
store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
if (db.CustomPermissions.Any(p => string.Equals(p.Name, name, StringComparison.Ordinal)))
|
||||
throw ApiRejection.Conflict("Cette permission existe déjà.");
|
||||
if (db.CustomPermissions.Count >= AdminGroups.MaxCustomPermissions)
|
||||
throw ApiRejection.Conflict($"{AdminGroups.MaxCustomPermissions} permissions personnalisées au plus.");
|
||||
db.CustomPermissions.Add(new PermissionRecord
|
||||
{
|
||||
Name = name,
|
||||
Label = label,
|
||||
Description = description,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor.Admin.DisplayName
|
||||
});
|
||||
});
|
||||
// Le groupe « Propriétaire » la reçoit automatiquement (il a toutes les permissions).
|
||||
Audit(audit, context, "admins.permission_create", name, label);
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeletePermission(string name, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
if (AdminPermissions.IsBuiltIn(name))
|
||||
throw ApiRejection.Conflict("Permission intégrée : elle est vérifiée par le serveur et le launcher, elle ne peut pas être supprimée.");
|
||||
if (!AdminGroups.IsValidPermissionName(name)) throw ApiRejection.NotFound("Permission introuvable.");
|
||||
|
||||
var groups = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
var permission = db.CustomPermissions.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal))
|
||||
?? throw ApiRejection.NotFound("Permission introuvable : elle a peut-être déjà été supprimée.");
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, new[] { name },
|
||||
missing => $"Vous ne pouvez pas supprimer une permission que vous n'avez pas ({missing}).");
|
||||
var affected = new List<string>();
|
||||
foreach (var group in db.Groups.Where(g => g.Permissions.Contains(name, StringComparer.Ordinal)))
|
||||
{
|
||||
group.Permissions.RemoveAll(p => string.Equals(p, name, StringComparison.Ordinal));
|
||||
group.Revision++;
|
||||
group.UpdatedAt = now;
|
||||
group.UpdatedBy = actor.Admin.DisplayName;
|
||||
affected.Add(group.Name);
|
||||
}
|
||||
db.CustomPermissions.Remove(permission);
|
||||
return affected;
|
||||
});
|
||||
Audit(audit, context, "admins.permission_delete", name,
|
||||
groups.Count == 0 ? "attribuée à aucun groupe" : "retirée de : " + string.Join(", ", groups.Take(20)));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static (string Name, string? Description) CheckText(GroupRequest request)
|
||||
{
|
||||
var name = AdminGroups.CleanName(request.Name)
|
||||
?? throw ApiRejection.BadRequest($"Nom du groupe obligatoire ({AdminGroups.MaxNameLength} caractères au plus, une ligne).");
|
||||
var description = AdminGroups.CleanDescription(request.Description, AdminGroups.MaxDescriptionLength, out var valid);
|
||||
if (!valid) throw ApiRejection.BadRequest($"Description invalide ({AdminGroups.MaxDescriptionLength} caractères au plus, une ligne).");
|
||||
return (name, description);
|
||||
}
|
||||
|
||||
private static void EnsureUniqueName(AdminDatabase db, string name, string? exceptId)
|
||||
{
|
||||
if (db.Groups.Any(g => g.Id != exceptId && string.Equals(g.Name, name, StringComparison.CurrentCultureIgnoreCase)))
|
||||
throw ApiRejection.Conflict($"Un groupe s'appelle déjà « {name} ».");
|
||||
}
|
||||
|
||||
/// <summary>Permissions demandées : connues du serveur, sans doublon, dans l'ordre des permissions connues.</summary>
|
||||
private static List<string> CheckPermissions(AdminDatabase db, IReadOnlyList<string>? requested)
|
||||
{
|
||||
requested ??= Array.Empty<string>();
|
||||
if (requested.Count > AdminPermissions.All.Count + AdminGroups.MaxCustomPermissions)
|
||||
throw ApiRejection.BadRequest("Trop de permissions.");
|
||||
var unknown = requested.Where(p => p is null || !db.IsKnownPermission(p)).Select(p => p ?? "(vide)").Distinct().Take(5).ToList();
|
||||
if (unknown.Count > 0) throw ApiRejection.BadRequest($"Permission inconnue : {string.Join(", ", unknown)}. Rechargez la page.");
|
||||
var set = requested.ToHashSet(StringComparer.Ordinal);
|
||||
return db.KnownPermissions().Where(set.Contains).ToList();
|
||||
}
|
||||
|
||||
private static string Describe(IReadOnlyCollection<string> permissions) =>
|
||||
permissions.Count == 0 ? "aucune permission" : "permissions : " + string.Join(", ", permissions);
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
@@ -6,18 +9,133 @@ using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Audit;
|
||||
|
||||
/// <summary>Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord.</summary>
|
||||
/// <summary>
|
||||
/// Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord. Chaque consultation est
|
||||
/// elle-même inscrite au journal (« audit.view »), ainsi que chaque export CSV annoncé par le launcher (« audit.export »).
|
||||
/// </summary>
|
||||
public sealed class AuditModule : IApiModule
|
||||
{
|
||||
public string Name => "Audit";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<AuditLog>();
|
||||
services.AddSingleton<AuditConsultations>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api) =>
|
||||
api.MapGet(ApiRoutes.Audit, (int? limit, string? cursor, AuditLog audit) =>
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Audit, Read).RequirePermission(AdminPermissions.AuditRead);
|
||||
api.MapPost(ApiRoutes.AuditExport, RecordExport).RequirePermission(AdminPermissions.AuditRead).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations,
|
||||
AdminStore store)
|
||||
{
|
||||
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
DateTimeOffset? until = null;
|
||||
if (!string.IsNullOrEmpty(before))
|
||||
{
|
||||
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
return Results.Json(audit.Read(limit ?? 50, cursor), ApiJson.Options);
|
||||
}).RequirePermission(AdminPermissions.AuditRead);
|
||||
// Format ISO 8601 uniquement (« 2026-11-07T00:00:00Z »), borné à des dates plausibles.
|
||||
if (before.Length > 40 ||
|
||||
!DateTimeOffset.TryParse(before, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) ||
|
||||
parsed.Year is < 2000 or > 9998)
|
||||
return ApiResults.BadRequest("Date « before » invalide (format ISO 8601 attendu).");
|
||||
until = parsed;
|
||||
}
|
||||
|
||||
var page = WithDeviceNames(audit.Read(limit ?? 50, cursor, until), store);
|
||||
|
||||
// Consultation inscrite APRÈS la lecture (la page renvoyée montre le journal tel qu'il était). Première page
|
||||
// (ouverture, « Actualiser », période changée) : toujours ; pages suivantes (« Charger plus ») : une fois par
|
||||
// clé tant que la consultation date de moins de 10 minutes (pas une ligne par page de 200 entrées).
|
||||
var actor = context.Admin();
|
||||
var continuation = !string.IsNullOrEmpty(cursor);
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, continuation))
|
||||
{
|
||||
var details = new List<string> { $"{page.Entries.Count} entrée(s) lue(s)" };
|
||||
if (continuation) details.Add("entrées plus anciennes");
|
||||
if (until is { } date) details.Add($"antérieures au {date.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC");
|
||||
audit.Write("audit.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
details: string.Join(" ; ", details));
|
||||
}
|
||||
return Results.Json(page, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom de l'appareil de chaque clé (défini à l'enregistrement de la clé, avec l'invitation), ajouté à la lecture :
|
||||
/// les anciennes entrées en profitent aussi, et le journal sur le disque reste inchangé (ajout seul).
|
||||
/// </summary>
|
||||
internal static AuditPage WithDeviceNames(AuditPage page, AdminStore store)
|
||||
{
|
||||
if (page.Entries.All(e => e.KeyId is null && e.KeyLabel is null)) return page;
|
||||
var database = store.Read();
|
||||
var labels = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var entries = page.Entries.Select(entry =>
|
||||
{
|
||||
if (entry.KeyId is not { } keyId) return entry with { KeyLabel = null };
|
||||
if (!labels.TryGetValue(keyId, out var label))
|
||||
labels[keyId] = label = database.FindKey(keyId) is { } found ? Auth.AuthModule.DeviceName(found.Key.Label) : null;
|
||||
return entry with { KeyLabel = label }; // seul le serveur renseigne ce champ (jamais lu depuis le fichier)
|
||||
}).ToList();
|
||||
return page with { Entries = entries };
|
||||
}
|
||||
|
||||
private static IResult RecordExport(HttpContext context, AuditLog audit)
|
||||
{
|
||||
AuditExportRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<AuditExportRequest>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Annonce d'export illisible.");
|
||||
}
|
||||
if (request is null || request.Count is < 0 or > AuditExportLimits.MaxCount ||
|
||||
request.Filters is { Length: > AuditExportLimits.MaxFiltersLength } ||
|
||||
request.From is { Year: < 2000 or > 9998 } || request.To is { Year: < 2000 or > 9998 } ||
|
||||
(request.From is { } from && request.To is { } to && from > to))
|
||||
return ApiResults.BadRequest("Annonce d'export invalide.");
|
||||
|
||||
var period = (request.From, request.To) switch
|
||||
{
|
||||
(null, null) => "toutes les dates",
|
||||
({ } f, null) => $"depuis le {Day(f)}",
|
||||
(null, { } t) => $"jusqu'au {Day(t)}",
|
||||
({ } f, { } t) => $"du {Day(f)} au {Day(t)}"
|
||||
};
|
||||
var actor = context.Admin();
|
||||
audit.Write("audit.export", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
"fichier CSV",
|
||||
$"{request.Count} entrée(s) ; {period}" + (string.IsNullOrWhiteSpace(request.Filters) ? "" : $" ; filtres : {request.Filters}"));
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static string Day(DateOnly day) => day.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Dernière consultation inscrite par clé (évite une ligne par page lors de « Charger plus »).</summary>
|
||||
public sealed class AuditConsultations
|
||||
{
|
||||
/// <summary>Pages suivantes non réinscrites pendant ce délai après la dernière consultation inscrite.</summary>
|
||||
public static readonly TimeSpan ContinuationWindow = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastRecorded = new(StringComparer.Ordinal);
|
||||
|
||||
public AuditConsultations(TimeProvider time) => _time = time;
|
||||
|
||||
public bool ShouldRecord(string keyId, bool continuation)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_lastRecorded)
|
||||
{
|
||||
if (continuation && _lastRecorded.TryGetValue(keyId, out var last) && now - last < ContinuationWindow && now >= last)
|
||||
return false;
|
||||
if (_lastRecorded.Count > 10_000) _lastRecorded.Clear(); // seules les clés enregistrées arrivent ici
|
||||
_lastRecorded[keyId] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public sealed class AuthModule : IApiModule
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
|
||||
var clientName = ClientSoftware(ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength));
|
||||
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
|
||||
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
|
||||
// admins.json à chaque connexion).
|
||||
@@ -117,8 +117,12 @@ public sealed class AuthModule : IApiModule
|
||||
}
|
||||
}
|
||||
|
||||
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
|
||||
var authenticated = new AuthenticatedAdmin(session, admin, key, AdminRoles.PermissionsOf(admin.Role));
|
||||
// Appareil : le nom choisi à l'enregistrement de la clé (invitation), jamais le nom Windows du PC.
|
||||
var device = $"appareil « {DeviceName(key.Label)} »";
|
||||
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip,
|
||||
details: clientName is null ? device : $"{device} · {clientName}");
|
||||
var database = store.Read();
|
||||
var authenticated = new AuthenticatedAdmin(session, admin, key, database.PermissionsOf(admin.Role), database.GroupName(admin.Role));
|
||||
return Results.Json(new LoginResponse
|
||||
{
|
||||
Token = token,
|
||||
@@ -129,6 +133,23 @@ public sealed class AuthModule : IApiModule
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>Nom de l'appareil tel que défini à l'enregistrement de la clé (invitation).</summary>
|
||||
internal static string DeviceName(string? keyLabel) => string.IsNullOrWhiteSpace(keyLabel) ? "sans nom" : keyLabel;
|
||||
|
||||
/// <summary>
|
||||
/// Logiciel client (« GameLauncher 2.5.0 »), pour le journal. Les launchers jusqu'à la version 2.4.1 y ajoutaient le
|
||||
/// nom Windows du PC entre parenthèses (« GameLauncher 2.4.1 (DESKTOP-ABC123) ») : il est retiré, seul le nom de
|
||||
/// l'appareil choisi à l'enregistrement de la clé identifie le poste (et le nom Windows n'est pas conservé).
|
||||
/// </summary>
|
||||
internal static string? ClientSoftware(string? clientName)
|
||||
{
|
||||
if (clientName is null) return null;
|
||||
if (clientName.StartsWith("GameLauncher ", StringComparison.Ordinal) && clientName.EndsWith(')') &&
|
||||
clientName.IndexOf(" (", StringComparison.Ordinal) is var open and > 0)
|
||||
return clientName[..open];
|
||||
return clientName;
|
||||
}
|
||||
|
||||
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
|
||||
@@ -17,19 +17,58 @@ namespace GameLauncher.Server.Modules.Catalog;
|
||||
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
|
||||
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
|
||||
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit ;
|
||||
/// • PUT images/{kind} : envoi d'une icône ou d'une image de fond (games.edit, requête signée : la signature
|
||||
/// couvre l'empreinte du contenu), contrôlée puis rangée dans catalog/images (<see cref="CatalogImageStore"/>),
|
||||
/// audit. L'image n'est visible des joueurs qu'une fois le catalogue qui la désigne publié.
|
||||
/// </summary>
|
||||
public sealed class CatalogModule : IApiModule
|
||||
{
|
||||
public string Name => "Catalog";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<CatalogStore>();
|
||||
services.AddSingleton<CatalogImageStore>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
|
||||
// Seule route du catalogue qui accepte un gros corps (image de 4 Mo au plus) ; voir nginx-api.conf.
|
||||
api.MapPut(ApiRoutes.CatalogImagePattern, UploadImage).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest()
|
||||
.WithBodyLimit(CatalogImages.MaxRequestBytes);
|
||||
}
|
||||
|
||||
private static IResult UploadImage(string kind, HttpContext context, CatalogImageStore images, AuditLog audit,
|
||||
ILogger<CatalogModule> logger)
|
||||
{
|
||||
if (!CatalogImages.IsValidKind(kind)) return ApiResults.BadRequest("Type d'image inconnu (icon ou background).");
|
||||
var actor = context.Admin();
|
||||
var content = context.CapturedBody();
|
||||
CatalogImageUploaded uploaded;
|
||||
try
|
||||
{
|
||||
uploaded = images.Save(kind, content);
|
||||
}
|
||||
catch (ApiRejection rejection) when (rejection.Status == 400)
|
||||
{
|
||||
audit.Write("catalog.image_upload", AuditResults.Denied, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId,
|
||||
context.ClientIp(), CatalogImages.KindLabel(kind), $"{content.Length / 1024} Ko : {rejection.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Envoi d'une image du catalogue");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire l'image : vérifiez le dossier catalog/images et ses droits (server/deploy/LISEZMOI.md, étape 3 bis).");
|
||||
}
|
||||
|
||||
audit.Write("catalog.image_upload", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
CatalogImages.KindLabel(kind),
|
||||
$"{uploaded.FileName} ({uploaded.Width} × {uploaded.Height} px, {uploaded.Bytes / 1024} Ko)" + (uploaded.AlreadyPresent ? " ; déjà présente" : ""));
|
||||
return Results.Json(uploaded, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Demandes des joueurs (bouton « Aide » du launcher : avis, demande d'évolution, bug, autre), rangées par jeu.
|
||||
/// • Joueurs, SANS compte (routes anonymes, seules routes d'écriture ouvertes à tous) : défi anti-spam à résoudre
|
||||
/// (<see cref="FeedbackGuard"/>), limites par adresse IP en mémoire, corps de 16 Ko au plus, contrôle complet
|
||||
/// (<see cref="FeedbackRules"/>), plafonds globaux (<see cref="FeedbackStore"/>). Ni l'adresse IP ni aucun
|
||||
/// identifiant du PC ne sont enregistrés avec la demande, ni dans l'audit de son envoi.
|
||||
/// • Administrateurs : lecture (feedback.read, consultations inscrites au journal), statut et note interne,
|
||||
/// suppression (feedback.manage, requêtes signées). Pas de réponse au joueur (module futur « comptes »).
|
||||
/// </summary>
|
||||
public sealed class FeedbackModule : IApiModule
|
||||
{
|
||||
public string Name => "Feedback";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<FeedbackStore>();
|
||||
services.AddSingleton<FeedbackGuard>();
|
||||
services.AddSingleton<FeedbackConsultations>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapPost(ApiRoutes.FeedbackChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback);
|
||||
api.MapPost(ApiRoutes.Feedback, Submit).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
|
||||
.WithBodyLimit(FeedbackRules.MaxRequestBytes);
|
||||
|
||||
api.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapPut(ApiRoutes.GameFeedbackItemPattern, Update).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
|
||||
api.MapDelete(ApiRoutes.GameFeedbackItemPattern, Delete).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult NotConfigured() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"La réception des demandes n'est pas activée sur le serveur.");
|
||||
|
||||
// ------------------------------------------------------------------ Joueurs (anonymes)
|
||||
|
||||
private static IResult Challenge(FeedbackStore store, FeedbackGuard guard) =>
|
||||
store.Enabled ? Results.Json(guard.CreateChallenge(), ApiJson.Options) : NotConfigured();
|
||||
|
||||
private static IResult Submit(HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
if (!store.Enabled) return NotConfigured();
|
||||
|
||||
FeedbackSubmission? submission;
|
||||
try
|
||||
{
|
||||
submission = JsonSerializer.Deserialize<FeedbackSubmission>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Demande illisible.");
|
||||
}
|
||||
|
||||
// 1. Défi anti-spam (une empreinte à calculer) : avant tout autre traitement.
|
||||
if (submission is null || !guard.TryConsume(submission.ChallengeId, submission.Solution))
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, "défi anti-spam absent, expiré, déjà utilisé ou mal résolu");
|
||||
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-spam échouée : réessayez.");
|
||||
}
|
||||
|
||||
// 2. Jeu accepté par ce serveur (identifiant de la configuration : seul celui-ci sert à construire un chemin).
|
||||
var gameId = store.ResolveGame(submission.GameId);
|
||||
if (gameId is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, "jeu non accepté");
|
||||
return ApiResults.BadRequest("Ce jeu n'accepte pas de demandes sur ce serveur.");
|
||||
}
|
||||
|
||||
// 3. Contenu (mêmes règles que le launcher).
|
||||
var errors = new List<string>();
|
||||
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, errors);
|
||||
if (clean is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, $"contenu refusé ({errors.Count} erreur(s))");
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
}
|
||||
|
||||
// 4. Limites par adresse IP (en mémoire uniquement), puis plafonds globaux.
|
||||
if (!guard.TryCountSubmission(context.RateLimitPartition(), out var retryAfter))
|
||||
{
|
||||
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
return ApiResults.Error(429, ApiErrorCodes.RateLimited,
|
||||
"Vous avez envoyé plusieurs demandes récemment : patientez un peu avant d'en envoyer une autre.");
|
||||
}
|
||||
var now = time.GetUtcNow();
|
||||
var record = store.Add(gameId, clean, now);
|
||||
if (record is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, $"plafond atteint ({store.FullReason(gameId, now) ?? "?"})");
|
||||
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull,
|
||||
"Le service des demandes est momentanément saturé. Réessayez plus tard.");
|
||||
}
|
||||
|
||||
// Audit SANS adresse IP : l'audit ne doit pas permettre de relier une demande « anonyme » à une adresse.
|
||||
audit.Write("feedback.submit", AuditResults.Ok, target: record.Reference,
|
||||
details: $"jeu {gameId} ; {FeedbackForms.KindLabel(record.Kind)}" + (record.Tech is null ? "" : " ; informations techniques jointes"));
|
||||
return Results.Json(new FeedbackSubmitted { Reference = record.Reference }, ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
|
||||
private static IResult Overview(FeedbackStore store, IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
var counts = store.Counts();
|
||||
return Results.Json(new FeedbackOverview
|
||||
{
|
||||
Enabled = store.Enabled,
|
||||
StorageFull = store.Enabled && counts.Any(c => store.FullReason(c.GameId, now) is not null),
|
||||
Games = counts,
|
||||
RetentionClosedMonths = options.Value.Feedback.RetentionClosedMonths,
|
||||
RetentionMaxMonths = options.Value.Feedback.RetentionMaxMonths
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult List(string gameId, string? kind, string? status, string? cursor, int? limit, HttpContext context,
|
||||
FeedbackStore store, AuditLog audit, FeedbackConsultations consultations)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null) return ApiResults.NotFound("Ce jeu ne reçoit pas de demandes sur ce serveur.");
|
||||
if (kind is not null && FeedbackForms.Find(kind) is null) return ApiResults.BadRequest("Type de demande inconnu.");
|
||||
if (!FeedbackStore.IsValidStatusFilter(status)) return ApiResults.BadRequest("Statut inconnu.");
|
||||
if (cursor is { Length: > 60 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
|
||||
var page = store.List(game, kind, status, cursor, Math.Clamp(limit ?? 100, 1, 200));
|
||||
var actor = context.Admin();
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, "list:" + game, continuation: !string.IsNullOrEmpty(cursor)))
|
||||
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"jeu {game}", $"liste : {page.Items.Count} demande(s) sur {page.Total}");
|
||||
return Results.Json(page, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Get(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit,
|
||||
FeedbackConsultations consultations)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || store.Find(game, id) is not { } record) return ApiResults.NotFound("Demande introuvable.");
|
||||
var actor = context.Admin();
|
||||
// Lecture d'une demande (texte libre : données personnelles possibles) inscrite au journal, une fois par
|
||||
// demande et par clé toutes les 10 minutes (sans le contenu).
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, "item:" + record.Id, continuation: true))
|
||||
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
record.Reference, $"jeu {game} ; {FeedbackForms.KindLabel(record.Kind)}");
|
||||
return Results.Json(record.ToDetail(), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Update(string gameId, string id, FeedbackUpdateRequest request, HttpContext context, FeedbackStore store,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
|
||||
if (!FeedbackStatuses.IsValid(request.Status)) return ApiResults.BadRequest("Statut inconnu.");
|
||||
var note = FeedbackRules.CleanAdminNote(request.AdminNote, out var noteValid);
|
||||
if (!noteValid)
|
||||
return ApiResults.BadRequest($"Note interne : {FeedbackRules.MaxAdminNoteLength} caractères au plus, sans caractère invisible.");
|
||||
|
||||
var actor = context.Admin();
|
||||
var updated = store.Update(game, id, request.Revision, request.Status, note, actor.Admin.DisplayName, time.GetUtcNow(), out var previous);
|
||||
|
||||
var changes = new List<string>();
|
||||
if (previous.Status != updated.Status)
|
||||
changes.Add($"statut : {FeedbackStatuses.Label(previous.Status)} → {FeedbackStatuses.Label(updated.Status)}");
|
||||
if (previous.AdminNote != updated.AdminNote) changes.Add(updated.AdminNote is null ? "note interne retirée" : "note interne modifiée");
|
||||
audit.Write("feedback.update", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
updated.Reference, $"jeu {game} ; " + (changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes)));
|
||||
return Results.Json(updated.ToDetail(), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Delete(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
|
||||
var actor = context.Admin();
|
||||
var deleted = store.Delete(game, id);
|
||||
audit.Write("feedback.delete", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
deleted.Reference, $"jeu {game} ; {FeedbackForms.KindLabel(deleted.Kind)} ; {FeedbackStatuses.Label(deleted.Status)}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dernière consultation inscrite par clé et par élément (liste d'un jeu, demande) : une ligne d'audit par consultation,
|
||||
/// pas une par page ni par réaffichage dans les 10 minutes.
|
||||
/// </summary>
|
||||
public sealed class FeedbackConsultations
|
||||
{
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<string, DateTimeOffset> _last = new(StringComparer.Ordinal);
|
||||
|
||||
public FeedbackConsultations(TimeProvider time) => _time = time;
|
||||
|
||||
/// <param name="continuation">Vrai : pas réinscrit si la dernière consultation date de moins de 10 minutes.</param>
|
||||
public bool ShouldRecord(string keyId, string item, bool continuation)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var key = keyId + "|" + item;
|
||||
lock (_last)
|
||||
{
|
||||
if (continuation && _last.TryGetValue(key, out var last) && now - last < Window && now >= last) return false;
|
||||
if (_last.Count > 20_000) _last.Clear(); // seules les clés enregistrées arrivent ici
|
||||
_last[key] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Fichiers du serveur » (permission files.manage : propriétaires, administrateurs) : consultation du site
|
||||
/// public, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu. Suppressions signées et
|
||||
/// inscrites au journal d'audit ; le serveur revérifie les manifests au moment de supprimer (ServerFilesStore).
|
||||
/// </summary>
|
||||
public sealed class FilesModule : IApiModule
|
||||
{
|
||||
public string Name => "Files";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<ServerFilesStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
const string permission = AdminPermissions.FilesManage;
|
||||
api.MapGet(ApiRoutes.Files, List).RequirePermission(permission);
|
||||
api.MapPost(ApiRoutes.FilesDelete, Delete).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.ObjectsPrunePattern, PreviewPrune).RequirePermission(permission);
|
||||
api.MapPost(ApiRoutes.ObjectsPrunePattern, Prune).RequirePermission(permission).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult List(string? path, ServerFilesStore store, ILogger<FilesModule> logger)
|
||||
{
|
||||
if (path is { Length: > ServerFilesLimits.MaxPathLength }) return ApiResults.BadRequest("Chemin trop long.");
|
||||
return Guard(logger, "Consultation des fichiers", () => Results.Json(store.List(path), ApiJson.Options));
|
||||
}
|
||||
|
||||
private static IResult Delete(HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
|
||||
{
|
||||
ServerFileDeleteRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<ServerFileDeleteRequest>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de suppression illisible.");
|
||||
}
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Path) || request.Path.Length > ServerFilesLimits.MaxPathLength || request.Size < 0)
|
||||
return ApiResults.BadRequest("Requête de suppression invalide.");
|
||||
|
||||
return Guard(logger, "Suppression d'un fichier", () =>
|
||||
{
|
||||
var result = store.Delete(request.Path, request.Size);
|
||||
Audit(audit, context, "files.delete", result.Path, $"{Megabytes(result.FreedBytes)} libéré(s)");
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult PreviewPrune(string gameId, ServerFilesStore store, ILogger<FilesModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
return Guard(logger, "Analyse du dossier objects", () => Results.Json(store.PreviewPrune(gameId), ApiJson.Options));
|
||||
}
|
||||
|
||||
private static IResult Prune(string gameId, HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
ObjectsPruneRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<ObjectsPruneRequest>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de nettoyage illisible.");
|
||||
}
|
||||
var manifestSha = (request?.ManifestSha256 ?? "").ToLowerInvariant();
|
||||
if (!PublishingLimits.IsValidSha256(manifestSha)) return ApiResults.BadRequest("Empreinte du manifest analysé invalide.");
|
||||
|
||||
return Guard(logger, "Nettoyage du dossier objects", () =>
|
||||
{
|
||||
var result = store.Prune(gameId, manifestSha,
|
||||
(file, ex) => logger.LogWarning(ex, "Nettoyage du dossier objects : {File} non supprimé", Path.GetFileName(file)));
|
||||
Audit(audit, context, "files.prune", $"{result.GameId} objects",
|
||||
$"{result.Deleted} fichier(s) supprimé(s), {Megabytes(result.FreedBytes)} libéré(s)" +
|
||||
(result.Failed > 0 ? $" ; {result.Failed} échec(s)" : "") + $" ; manifest {manifestSha[..12]}");
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin du serveur (le détail va dans journalctl).</summary>
|
||||
private static IResult Guard(ILogger logger, string what, Func<IResult> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
logger.LogError(ex, "{What}", what);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas le droit de modifier ce dossier : seuls les dossiers des jeux et du catalogue sont autorisés en écriture " +
|
||||
"(ReadWritePaths, server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "{What}", what);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu accéder au fichier : droits du dossier (ReadWritePaths) ou fichier en cours d'utilisation (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string details)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
|
||||
private static string Megabytes(long bytes) =>
|
||||
bytes >= 1024 * 1024 ? $"{bytes / (1024.0 * 1024):0.#} Mo" : bytes >= 1024 ? $"{bytes / 1024.0:0.#} Ko" : $"{bytes} octet(s)";
|
||||
}
|
||||
@@ -10,7 +10,10 @@ using Microsoft.Extensions.Options;
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
|
||||
/// <param name="Permissions">Permissions de son groupe, lues au moment de l'authentification de la requête.</param>
|
||||
/// <param name="RoleName">Nom affiché de son groupe.</param>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions,
|
||||
string? RoleName = null)
|
||||
{
|
||||
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
@@ -19,6 +22,7 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
|
||||
Id = Admin.Id,
|
||||
DisplayName = Admin.DisplayName,
|
||||
Role = Admin.Role,
|
||||
RoleName = RoleName ?? AdminRoles.DisplayName(Admin.Role),
|
||||
Permissions = Permissions,
|
||||
KeyId = Key.KeyId,
|
||||
KeyLabel = Key.Label
|
||||
@@ -28,8 +32,9 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
|
||||
/// <summary>
|
||||
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
|
||||
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le rôle est relu
|
||||
/// à chaque requête : un changement de rôle s'applique aussitôt.
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le groupe et ses
|
||||
/// permissions sont relus à chaque requête : un changement de groupe, ou des permissions d'un groupe,
|
||||
/// s'applique aussitôt.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
@@ -61,7 +66,8 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var match = _store.Read().FindKey(session.KeyId);
|
||||
var database = _store.Read();
|
||||
var match = database.FindKey(session.KeyId);
|
||||
if (match is not { } found || found.Admin.Id != session.AdminId ||
|
||||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
|
||||
{
|
||||
@@ -69,7 +75,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
|
||||
}
|
||||
|
||||
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
|
||||
var permissions = database.PermissionsOf(found.Admin.Role);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
|
||||
@@ -80,7 +86,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
};
|
||||
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
|
||||
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions, database.GroupName(found.Admin.Role));
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Protections de la route ANONYME d'envoi des demandes des joueurs :
|
||||
/// • défis anti-spam SANS ÉTAT (échéance, difficulté et nonce scellés par HMAC, secret en mémoire) : en demander ne
|
||||
/// coûte rien au serveur ; seuls les défis UTILISÉS sont mémorisés jusqu'à leur échéance (refus du rejeu) ;
|
||||
/// • limites par adresse IP (IPv6 : par /64) sur 10 minutes et sur 24 heures, comptées en mémoire uniquement :
|
||||
/// aucune adresse n'est écrite sur le disque ni rattachée à une demande.
|
||||
/// </summary>
|
||||
public sealed class FeedbackGuard
|
||||
{
|
||||
private const int NonceLength = 16;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 8 + 1 + NonceLength; // échéance + difficulté + nonce
|
||||
private const int MaxTracked = 100_000;
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<DateTimeOffset>> _accepted = new(StringComparer.Ordinal);
|
||||
private readonly LauncherApiOptions.FeedbackSettings _settings;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FeedbackGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_settings = options.Value.Feedback;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public FeedbackChallenge CreateChallenge()
|
||||
{
|
||||
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
|
||||
var payload = new byte[PayloadLength];
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload, expires.ToUnixTimeSeconds());
|
||||
payload[8] = (byte)_settings.ProofDifficulty;
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(9));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new FeedbackChallenge
|
||||
{
|
||||
ChallengeId = Base64Url.Encode(id),
|
||||
Nonce = Base64Url.Encode(payload.AsSpan(9)),
|
||||
Difficulty = payload[8],
|
||||
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le défi (sceau, échéance), puis la solution, puis le marque comme utilisé. Faux : défi absent, forgé,
|
||||
/// expiré, mal résolu ou déjà utilisé.
|
||||
/// </summary>
|
||||
public bool TryConsume(string? challengeId, string? solution)
|
||||
{
|
||||
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) ||
|
||||
id.Length != PayloadLength + MacLength)
|
||||
return false;
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[..8]));
|
||||
if (expires <= now) return false;
|
||||
// Difficulté scellée à la création du défi (un défi obtenu juste avant un relèvement de la difficulté reste
|
||||
// valable jusqu'à son échéance) ; jamais en dessous du minimum.
|
||||
int difficulty = payload[8];
|
||||
if (difficulty < FeedbackProof.MinDifficulty) return false;
|
||||
if (!FeedbackProof.Verify(payload[9..], difficulty, solution)) return false;
|
||||
|
||||
if (_used.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var (key, until) in _used)
|
||||
if (until <= now) _used.TryRemove(key, out _);
|
||||
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de demandes récentes.");
|
||||
}
|
||||
return _used.TryAdd(challengeId, expires);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compte une demande pour cette adresse (partition de limite de débit). Faux si la limite sur 10 minutes ou sur
|
||||
/// 24 heures est atteinte (rien n'est compté alors). <paramref name="retryAfter"/> : délai avant la prochaine place.
|
||||
/// </summary>
|
||||
public bool TryCountSubmission(string partition, out TimeSpan retryAfter)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
retryAfter = TimeSpan.Zero;
|
||||
lock (_accepted)
|
||||
{
|
||||
if (_accepted.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var key in _accepted.Where(p => p.Value.All(t => now - t >= TimeSpan.FromDays(1))).Select(p => p.Key).ToList())
|
||||
_accepted.Remove(key);
|
||||
if (_accepted.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
|
||||
}
|
||||
if (!_accepted.TryGetValue(partition, out var times)) _accepted[partition] = times = new List<DateTimeOffset>();
|
||||
times.RemoveAll(t => now - t >= TimeSpan.FromDays(1));
|
||||
|
||||
var recent = times.Where(t => now - t < TimeSpan.FromMinutes(10)).OrderBy(t => t).ToList();
|
||||
if (recent.Count >= _settings.PerIpPer10Minutes)
|
||||
{
|
||||
retryAfter = recent[recent.Count - _settings.PerIpPer10Minutes] + TimeSpan.FromMinutes(10) - now;
|
||||
return false;
|
||||
}
|
||||
if (times.Count >= _settings.PerIpPerDay)
|
||||
{
|
||||
retryAfter = times.Min() + TimeSpan.FromDays(1) - now;
|
||||
return false;
|
||||
}
|
||||
times.Add(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] Mac(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var data = new byte[Domain.Length + payload.Length];
|
||||
Domain.CopyTo(data, 0);
|
||||
payload.CopyTo(data.AsSpan(Domain.Length));
|
||||
return HMACSHA256.HashData(_secret, data)[..MacLength];
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,82 @@ namespace GameLauncher.Server.Storage;
|
||||
/// <summary>
|
||||
/// Contenu de admins.json. Ne contient AUCUN secret : clés publiques et empreintes de codes
|
||||
/// d'invitation uniquement.
|
||||
/// Format 2 : groupes d'administrateurs et permissions personnalisées (un fichier au format 1 est migré à la
|
||||
/// lecture : groupes par défaut avec les permissions des anciens rôles ; il est réécrit au format 2 à la
|
||||
/// modification suivante). Un serveur plus ancien refuse de démarrer sur un fichier au format 2.
|
||||
/// </summary>
|
||||
public sealed class AdminDatabase
|
||||
{
|
||||
public int FormatVersion { get; set; } = 1;
|
||||
public const int CurrentFormat = 2;
|
||||
|
||||
public int FormatVersion { get; set; } = CurrentFormat;
|
||||
public List<AdminRecord> Admins { get; set; } = new();
|
||||
public List<InvitationRecord> Invitations { get; set; } = new();
|
||||
|
||||
/// <summary>Groupes (le groupe système « owner » existe toujours, voir <see cref="EnsureDefaults"/>).</summary>
|
||||
public List<GroupRecord> Groups { get; set; } = new();
|
||||
|
||||
/// <summary>Permissions personnalisées (les permissions intégrées sont dans le code : <see cref="AdminPermissions.All"/>).</summary>
|
||||
public List<PermissionRecord> CustomPermissions { get; set; } = new();
|
||||
|
||||
public AdminRecord? FindAdmin(Guid id) => Admins.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
public GroupRecord? FindGroup(string? id) =>
|
||||
id is null ? null : Groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Toutes les permissions connues : intégrées, puis personnalisées.</summary>
|
||||
public IReadOnlyList<string> KnownPermissions() =>
|
||||
AdminPermissions.All.Concat(CustomPermissions.Select(p => p.Name)).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
public bool IsKnownPermission(string permission) =>
|
||||
AdminPermissions.IsBuiltIn(permission) || CustomPermissions.Any(p => string.Equals(p.Name, permission, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Permissions effectives d'un groupe : toutes pour le groupe système, sinon celles du groupe qui existent
|
||||
/// encore ; aucune pour un groupe inconnu (supprimé, ou fichier modifié à la main).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PermissionsOf(string? groupId)
|
||||
{
|
||||
if (AdminGroups.IsSystem(groupId)) return KnownPermissions();
|
||||
if (FindGroup(groupId) is not { } group) return Array.Empty<string>();
|
||||
return group.Permissions.Where(IsKnownPermission).Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
public string GroupName(string? groupId) =>
|
||||
FindGroup(groupId)?.Name ?? (groupId is null ? "Inconnu" : AdminGroups.DefaultName(groupId) ?? $"Groupe supprimé ({groupId})");
|
||||
|
||||
/// <summary>
|
||||
/// Groupes par défaut : le groupe système « Propriétaire » est (re)créé s'il manque ; au premier démarrage
|
||||
/// (ou migration du format 1), les groupes « Administrateur » et « Éditeur » reçoivent les permissions des
|
||||
/// anciens rôles. Faux si rien n'a changé.
|
||||
/// </summary>
|
||||
public bool EnsureDefaults(DateTimeOffset now)
|
||||
{
|
||||
var changed = false;
|
||||
var firstTime = Groups.Count == 0;
|
||||
foreach (var id in firstTime ? AdminRoles.All : new[] { AdminRoles.Owner })
|
||||
{
|
||||
if (FindGroup(id) is not null) continue;
|
||||
Groups.Add(new GroupRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = AdminGroups.DefaultName(id)!,
|
||||
Description = AdminGroups.DefaultDescription(id),
|
||||
Permissions = AdminGroups.IsSystem(id) ? new() : AdminRoles.PermissionsOf(id).ToList(),
|
||||
CreatedAt = now,
|
||||
CreatedBy = "Valeurs par défaut",
|
||||
Revision = 1
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
if (FindGroup(AdminRoles.Owner) is { } owner && owner.Permissions.Count > 0)
|
||||
{
|
||||
owner.Permissions.Clear(); // groupe système : toutes les permissions, jamais une liste
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>Administrateur possédant cette clé (quel que soit l'état de la clé).</summary>
|
||||
public (AdminRecord Admin, KeyRecord Key)? FindKey(string keyId)
|
||||
{
|
||||
@@ -33,7 +100,10 @@ public sealed class AdminRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
/// <summary>Identifiant du groupe (nom historique : « rôle »).</summary>
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
@@ -81,3 +151,36 @@ public sealed class InvitationRecord
|
||||
public Guid? CreatedById { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Groupe d'administrateurs : nom, description, permissions.</summary>
|
||||
public sealed class GroupRecord
|
||||
{
|
||||
/// <summary>Identifiant stable (<see cref="AdminGroups.IsValidGroupId"/>), référencé par les comptes et les invitations.</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Permissions du groupe (vide pour le groupe système, qui les a toutes).</summary>
|
||||
public List<string> Permissions { get; set; } = new();
|
||||
|
||||
/// <summary>Incrémentée à chaque modification (contrôle des modifications concurrentes).</summary>
|
||||
public long Revision { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Permission personnalisée (créée depuis le menu d'administration).</summary>
|
||||
public sealed class PermissionRecord
|
||||
{
|
||||
/// <summary>« module.action » (<see cref="AdminGroups.IsValidPermissionName"/>).</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Label { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
}
|
||||
@@ -84,15 +84,29 @@ public sealed class AdminStore
|
||||
|
||||
private AdminDatabase Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return new AdminDatabase();
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
var empty = new AdminDatabase();
|
||||
empty.EnsureDefaults(DateTimeOffset.UtcNow);
|
||||
return empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
var database = JsonSerializer.Deserialize<AdminDatabase>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException("fichier vide");
|
||||
if (database.FormatVersion != 1) throw new InvalidDataException($"format {database.FormatVersion} inconnu");
|
||||
if (database.FormatVersion is not (1 or AdminDatabase.CurrentFormat))
|
||||
throw new InvalidDataException($"format {database.FormatVersion} inconnu");
|
||||
database.Admins ??= new();
|
||||
database.Invitations ??= new();
|
||||
database.Groups ??= new();
|
||||
database.CustomPermissions ??= new();
|
||||
foreach (var admin in database.Admins) admin.Keys ??= new();
|
||||
foreach (var group in database.Groups) group.Permissions ??= new();
|
||||
CheckGroups(database);
|
||||
// Format 1 (avant les groupes) : groupes par défaut créés avec les permissions des anciens rôles ;
|
||||
// le fichier est réécrit au format 2 à la prochaine modification.
|
||||
database.FormatVersion = AdminDatabase.CurrentFormat;
|
||||
database.EnsureDefaults(DateTimeOffset.UtcNow);
|
||||
return database;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
@@ -102,6 +116,20 @@ public sealed class AdminStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Identifiants de groupes et de permissions : uniques et bien formés (sinon le fichier a été abîmé).</summary>
|
||||
private static void CheckGroups(AdminDatabase database)
|
||||
{
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var group in database.Groups)
|
||||
if (!Contracts.Security.AdminGroups.IsValidGroupId(group.Id) || !ids.Add(group.Id))
|
||||
throw new InvalidDataException($"groupe invalide ou en double : « {group.Id} »");
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var permission in database.CustomPermissions)
|
||||
if (!Contracts.Security.AdminGroups.IsValidPermissionName(permission.Name) ||
|
||||
Contracts.Security.AdminPermissions.IsBuiltIn(permission.Name) || !names.Add(permission.Name))
|
||||
throw new InvalidDataException($"permission invalide ou en double : « {permission.Name} »");
|
||||
}
|
||||
|
||||
private (DateTime, long) Stamp()
|
||||
{
|
||||
var info = new FileInfo(_path);
|
||||
|
||||
@@ -144,11 +144,41 @@ public sealed class AuditLog
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
|
||||
/// précédente (« aaaa-MM:position »). Les fichiers sont lus depuis la fin, par blocs : la mémoire
|
||||
/// utilisée ne dépend pas de leur taille.
|
||||
/// Conservation : supprime les fichiers mensuels dont TOUS les jours sont antérieurs à <paramref name="months"/> mois
|
||||
/// (le mois en cours et les précédents encore dans la fenêtre sont gardés en entier). Renvoie les mois supprimés.
|
||||
/// </summary>
|
||||
public AuditPage Read(int limit, string? cursor)
|
||||
public IReadOnlyList<string> Purge(int months)
|
||||
{
|
||||
if (months < 1) throw new ArgumentOutOfRangeException(nameof(months));
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
// Premier mois conservé : celui qui contient la date limite (ses derniers jours sont encore dans la fenêtre).
|
||||
var cutoff = now.AddMonths(-months);
|
||||
var firstKept = cutoff.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
var removed = new List<string>();
|
||||
if (!Directory.Exists(_directory)) return removed;
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
|
||||
foreach (var file in Directory.GetFiles(_directory, "audit-*.jsonl"))
|
||||
{
|
||||
var month = Path.GetFileName(file)["audit-".Length..^".jsonl".Length];
|
||||
if (!DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) continue;
|
||||
if (string.CompareOrdinal(month, firstKept) >= 0) continue;
|
||||
File.Delete(file);
|
||||
removed.Add(month);
|
||||
}
|
||||
}
|
||||
removed.Sort(StringComparer.Ordinal);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
|
||||
/// précédente (« aaaa-MM:position »). <paramref name="before"/> : seulement les entrées antérieures
|
||||
/// à cet instant (filtre « jusqu'au » du menu ; les mois plus récents ne sont pas lus). Les fichiers
|
||||
/// sont lus depuis la fin, par blocs : la mémoire utilisée ne dépend pas de leur taille.
|
||||
/// </summary>
|
||||
public AuditPage Read(int limit, string? cursor, DateTimeOffset? before = null)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, MaxPageSize);
|
||||
var months = Directory.Exists(_directory)
|
||||
@@ -169,17 +199,23 @@ public sealed class AuditLog
|
||||
startMonth = parts[0];
|
||||
}
|
||||
|
||||
// Mois postérieurs à « before » : sautés sans être lus (une entrée écrite au tout début d'un mois
|
||||
// peut porter l'heure de la fin du mois précédent : le mois de « before » est donc lu en entier).
|
||||
var lastMonth = before?.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
|
||||
var entries = new List<AuditEntry>(limit);
|
||||
for (var m = 0; m < months.Count; m++)
|
||||
{
|
||||
var month = months[m];
|
||||
if (startMonth is not null && string.CompareOrdinal(month, startMonth) > 0) continue;
|
||||
if (lastMonth is not null && string.CompareOrdinal(month, lastMonth) > 0) continue;
|
||||
var end = month == startMonth ? startOffset : long.MaxValue;
|
||||
// Lignes illisibles (écriture interrompue, modification manuelle) ignorées sans rien masquer :
|
||||
// la lecture continue jusqu'à obtenir « limit » entrées lisibles.
|
||||
foreach (var (start, line) in ReadBackward(Path.Combine(_directory, $"audit-{month}.jsonl"), end))
|
||||
{
|
||||
if (TryParse(line) is not { } entry) continue;
|
||||
if (before is { } limitDate && entry.Timestamp >= limitDate) continue;
|
||||
entries.Add(entry);
|
||||
if (entries.Count == limit)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Images du catalogue envoyées depuis le menu d'administration (icône, image de fond), sans client FTP.
|
||||
/// • Contrôle complet AVANT toute écriture (<see cref="CatalogImages.Check"/> : PNG/JPEG reconnu au contenu,
|
||||
/// taille, dimensions, fichier complet sans données ajoutées).
|
||||
/// • Rangées dans le dossier « images » du catalogue public (<c>…/catalog/images</c>, créé à l'installation :
|
||||
/// LISEZMOI.md, étape 3 bis), sous le nom <c><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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>Demande enregistrée (un fichier JSON par demande, dans les données privées).</summary>
|
||||
public sealed record FeedbackRecord
|
||||
{
|
||||
public const string FormatName = "gamelauncher-feedback";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
public string Id { get; init; } = "";
|
||||
public string Reference { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public int FormVersion { get; init; }
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public string Title { get; init; } = "";
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
public string Status { get; init; } = FeedbackStatuses.New;
|
||||
public long Revision { get; init; } = 1;
|
||||
public string? AdminNote { get; init; }
|
||||
public DateTimeOffset? StatusChangedAt { get; init; }
|
||||
public string? StatusChangedBy { get; init; }
|
||||
|
||||
public FeedbackSummary ToSummary() => new()
|
||||
{
|
||||
Id = Id,
|
||||
Reference = Reference,
|
||||
GameId = GameId,
|
||||
Kind = Kind,
|
||||
Title = Title,
|
||||
Status = Status,
|
||||
ReceivedAt = ReceivedAt,
|
||||
StatusChangedAt = StatusChangedAt,
|
||||
Badge = Kind switch
|
||||
{
|
||||
FeedbackKinds.Review when Fields.TryGetValue("rating", out var rating) => $"{rating}/5",
|
||||
FeedbackKinds.Bug when Fields.TryGetValue("severity", out var severity) =>
|
||||
FeedbackForms.Find(FeedbackKinds.Bug)?.Field("severity")?.DisplayValue(severity),
|
||||
_ => null
|
||||
},
|
||||
HasNote = !string.IsNullOrEmpty(AdminNote),
|
||||
Revision = Revision
|
||||
};
|
||||
|
||||
public FeedbackDetail ToDetail() => new()
|
||||
{
|
||||
Summary = ToSummary(),
|
||||
FormVersion = FormVersion,
|
||||
Fields = Fields,
|
||||
Tech = Tech,
|
||||
AdminNote = AdminNote,
|
||||
StatusChangedBy = StatusChangedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demandes des joueurs : <c>feedback/<jeu>/<AAAA-MM>/<id>.json</c> dans le dossier des données privées
|
||||
/// (0700, fichiers 0600, jamais servi par nginx). Un fichier par demande : écriture atomique (fichier temporaire puis
|
||||
/// renommage) d'une seule demande à la fois, suppression et purge par simple suppression de fichiers. Un index en
|
||||
/// mémoire (résumés) est construit au premier accès : les listes ne relisent pas le disque. Seul le service écrit
|
||||
/// ici (l'outil en ligne de commande n'y touche pas) : un verrou en mémoire suffit.
|
||||
///
|
||||
/// Choix (au lieu de SQLite) : volumes bornés (<see cref="LauncherApiOptions.FeedbackSettings.MaxPerGame"/>), aucune
|
||||
/// relation entre données, pas de dépendance native à maintenir sur le VPS, sauvegarde par simple copie de dossier,
|
||||
/// cohérent avec les autres stores du serveur. À réévaluer avec le futur module « comptes + échanges ».
|
||||
/// </summary>
|
||||
public sealed class FeedbackStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
private readonly string _root;
|
||||
private readonly LauncherApiOptions.FeedbackSettings _settings;
|
||||
private readonly IReadOnlySet<string> _games;
|
||||
private readonly ILogger<FeedbackStore>? _logger;
|
||||
private readonly object _lock = new();
|
||||
private Dictionary<string, Dictionary<string, FeedbackRecord>>? _index;
|
||||
private long _totalBytes;
|
||||
|
||||
public FeedbackStore(IOptions<LauncherApiOptions> options, ILogger<FeedbackStore> logger)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.Feedback, options.Value.FeedbackGameIds, logger) { }
|
||||
|
||||
public FeedbackStore(string dataDirectory, LauncherApiOptions.FeedbackSettings settings, IReadOnlySet<string> games,
|
||||
ILogger<FeedbackStore>? logger = null)
|
||||
{
|
||||
_root = Path.Combine(dataDirectory, "feedback");
|
||||
_settings = settings;
|
||||
_games = games;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool Enabled => _settings.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de jeu tel que configuré (casse du réglage), ou null si ce jeu n'accepte pas de demandes.
|
||||
/// Seul ce résultat sert à construire un chemin (jamais la valeur reçue).
|
||||
/// </summary>
|
||||
public string? ResolveGame(string? gameId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gameId) || gameId.StartsWith('.')) return null;
|
||||
return _games.FirstOrDefault(g => string.Equals(g, gameId, StringComparison.OrdinalIgnoreCase) && !g.StartsWith('.'));
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Games => _games.Where(g => !g.StartsWith('.')).Order(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
// ------------------------------------------------------------------ Envoi
|
||||
|
||||
/// <summary>Raison d'un refus d'enregistrement pour cause de plafond (null : place disponible).</summary>
|
||||
public string? FullReason(string gameId, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = Index();
|
||||
var game = index.TryGetValue(gameId, out var records) ? records : null;
|
||||
if (game is not null)
|
||||
{
|
||||
if (game.Count >= _settings.MaxPerGame) return $"{game.Count} demandes conservées pour ce jeu (plafond {_settings.MaxPerGame})";
|
||||
var lastDay = game.Values.Count(r => r.ReceivedAt > now - TimeSpan.FromDays(1));
|
||||
if (lastDay >= _settings.MaxPerGamePerDay) return $"{lastDay} demandes reçues en 24 h pour ce jeu (plafond {_settings.MaxPerGamePerDay})";
|
||||
}
|
||||
if (_totalBytes >= (long)_settings.MaxTotalMegabytes * 1024 * 1024)
|
||||
return $"taille totale des demandes {_totalBytes / (1024 * 1024)} Mo (plafond {_settings.MaxTotalMegabytes} Mo)";
|
||||
if (FreeDiskBytes() is { } free && free < (long)_settings.MinFreeDiskMegabytes * 1024 * 1024)
|
||||
return $"espace disque libre {free / (1024 * 1024)} Mo (minimum {_settings.MinFreeDiskMegabytes} Mo)";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enregistre une demande déjà validée (<see cref="FeedbackRules.Validate"/>). Null : plafond atteint.</summary>
|
||||
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (FullReason(gameId, now) is not null) return null;
|
||||
var index = Index();
|
||||
if (!index.TryGetValue(gameId, out var game)) index[gameId] = game = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
|
||||
|
||||
var existingReferences = index.Values.SelectMany(g => g.Values).Select(r => r.Reference).ToHashSet(StringComparer.Ordinal);
|
||||
string reference;
|
||||
do reference = FeedbackRules.NewReference(); while (existingReferences.Contains(reference));
|
||||
|
||||
var record = new FeedbackRecord
|
||||
{
|
||||
Id = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
Reference = reference,
|
||||
GameId = gameId,
|
||||
Kind = submission.Kind,
|
||||
FormVersion = submission.FormVersion,
|
||||
ReceivedAt = now,
|
||||
Title = submission.Title,
|
||||
Fields = new Dictionary<string, string>(submission.Fields, StringComparer.Ordinal),
|
||||
Tech = submission.Tech,
|
||||
Status = FeedbackStatuses.New,
|
||||
Revision = 1
|
||||
};
|
||||
Write(record);
|
||||
game[record.Id] = record;
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Consultation
|
||||
|
||||
public IReadOnlyList<FeedbackGameCounts> Counts()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = Index();
|
||||
return Games.Select(id =>
|
||||
{
|
||||
var records = index.TryGetValue(id, out var game) ? game.Values : (IEnumerable<FeedbackRecord>)Array.Empty<FeedbackRecord>();
|
||||
var list = records.ToList();
|
||||
return new FeedbackGameCounts
|
||||
{
|
||||
GameId = id,
|
||||
New = list.Count(r => r.Status == FeedbackStatuses.New),
|
||||
Open = list.Count(r => !FeedbackStatuses.IsClosed(r.Status)),
|
||||
Total = list.Count
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demandes d'un jeu, les plus récentes d'abord. <paramref name="cursor"/> : « ticks-id » de la dernière demande
|
||||
/// de la page précédente (stable même si des demandes arrivent entre deux pages).
|
||||
/// </summary>
|
||||
public FeedbackPage List(string gameId, string? kind, string? status, string? cursor, int limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var records = Index().TryGetValue(gameId, out var game) ? game.Values : Enumerable.Empty<FeedbackRecord>();
|
||||
var filtered = records.Where(r => (kind is null || r.Kind == kind) && (status is null || MatchesStatus(r.Status, status)))
|
||||
.OrderByDescending(r => r.ReceivedAt).ThenByDescending(r => r.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var start = 0;
|
||||
if (TryParseCursor(cursor, out var ticks, out var id))
|
||||
{
|
||||
start = filtered.FindIndex(r => r.ReceivedAt.UtcTicks < ticks ||
|
||||
(r.ReceivedAt.UtcTicks == ticks && string.CompareOrdinal(r.Id, id) < 0));
|
||||
if (start < 0) start = filtered.Count;
|
||||
}
|
||||
var page = filtered.Skip(start).Take(limit).ToList();
|
||||
var more = start + page.Count < filtered.Count;
|
||||
return new FeedbackPage
|
||||
{
|
||||
Items = page.Select(r => r.ToSummary()).ToList(),
|
||||
NextCursor = more && page.Count > 0 ? $"{page[^1].ReceivedAt.UtcTicks}-{page[^1].Id}" : null,
|
||||
Total = filtered.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Filtre de statut : un statut précis, ou « open » (non close) / « closed ».</summary>
|
||||
public const string OpenFilter = "open";
|
||||
public const string ClosedFilter = "closed";
|
||||
|
||||
public static bool IsValidStatusFilter(string? status) => status is null or OpenFilter or ClosedFilter || FeedbackStatuses.IsValid(status);
|
||||
|
||||
private static bool MatchesStatus(string status, string filter) => filter switch
|
||||
{
|
||||
OpenFilter => !FeedbackStatuses.IsClosed(status),
|
||||
ClosedFilter => FeedbackStatuses.IsClosed(status),
|
||||
_ => status == filter
|
||||
};
|
||||
|
||||
private static bool TryParseCursor(string? cursor, out long ticks, out string id)
|
||||
{
|
||||
ticks = 0;
|
||||
id = "";
|
||||
if (string.IsNullOrEmpty(cursor) || cursor.Length > 60) return false;
|
||||
var dash = cursor.IndexOf('-');
|
||||
if (dash <= 0 || !long.TryParse(cursor[..dash], NumberStyles.None, CultureInfo.InvariantCulture, out ticks)) return false;
|
||||
id = cursor[(dash + 1)..];
|
||||
return FeedbackRules.IsValidId(id);
|
||||
}
|
||||
|
||||
public FeedbackRecord? Find(string gameId, string id)
|
||||
{
|
||||
if (!FeedbackRules.IsValidId(id)) return null;
|
||||
lock (_lock)
|
||||
return Index().TryGetValue(gameId, out var game) && game.TryGetValue(id, out var record) ? record : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Traitement
|
||||
|
||||
/// <summary>Statut et note interne ; révision attendue (409 sinon), refus si la demande n'existe plus (404).</summary>
|
||||
public FeedbackRecord Update(string gameId, string id, long revision, string status, string? note, string actorName, DateTimeOffset now,
|
||||
out FeedbackRecord previous)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
previous = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (supprimée entre-temps ?).");
|
||||
if (previous.Revision != revision)
|
||||
throw ApiRejection.Conflict("La demande a été modifiée par un autre administrateur : rechargez-la.", ApiErrorCodes.RevisionConflict);
|
||||
var updated = previous with
|
||||
{
|
||||
Status = status,
|
||||
AdminNote = note,
|
||||
Revision = previous.Revision + 1,
|
||||
StatusChangedAt = now,
|
||||
StatusChangedBy = actorName
|
||||
};
|
||||
Write(updated);
|
||||
Index()[gameId][id] = updated;
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
public FeedbackRecord Delete(string gameId, string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var record = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (déjà supprimée ?).");
|
||||
DeleteFile(record);
|
||||
Index()[gameId].Remove(id);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge (conservation RGPD) : demandes closes depuis plus de <c>RetentionClosedMonths</c>, et toutes celles reçues
|
||||
/// depuis plus de <c>RetentionMaxMonths</c>. Renvoie le nombre de demandes supprimées.
|
||||
/// </summary>
|
||||
public int Purge(DateTimeOffset now)
|
||||
{
|
||||
var closedLimit = now.AddMonths(-_settings.RetentionClosedMonths);
|
||||
var maxLimit = now.AddMonths(-_settings.RetentionMaxMonths);
|
||||
var removed = 0;
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var game in Index().Values)
|
||||
{
|
||||
var expired = game.Values.Where(r => r.ReceivedAt < maxLimit ||
|
||||
(FeedbackStatuses.IsClosed(r.Status) && (r.StatusChangedAt ?? r.ReceivedAt) < closedLimit))
|
||||
.ToList();
|
||||
foreach (var record in expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteFile(record);
|
||||
game.Remove(record.Id);
|
||||
removed++;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger?.LogError(ex, "Purge de la demande {Reference} impossible", record.Reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
RemoveEmptyFolders();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Fichiers
|
||||
|
||||
private string GameFolder(string gameId) => Path.Combine(_root, gameId);
|
||||
|
||||
private string PathOf(FeedbackRecord record) =>
|
||||
Path.Combine(GameFolder(record.GameId), record.ReceivedAt.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture), record.Id + ".json");
|
||||
|
||||
private void Write(FeedbackRecord record)
|
||||
{
|
||||
if (ResolveGame(record.GameId) != record.GameId || !FeedbackRules.IsValidId(record.Id))
|
||||
throw new InvalidOperationException("Demande invalide : écriture refusée.");
|
||||
var path = PathOf(record);
|
||||
SecureFiles.EnsureDirectory(_root);
|
||||
SecureFiles.EnsureDirectory(GameFolder(record.GameId));
|
||||
SecureFiles.EnsureDirectory(Path.GetDirectoryName(path)!);
|
||||
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
SecureFiles.WriteAllTextAtomic(path, JsonSerializer.Serialize(record, Json), keepBackup: false);
|
||||
_totalBytes += new FileInfo(path).Length - oldSize;
|
||||
}
|
||||
|
||||
private void DeleteFile(FeedbackRecord record)
|
||||
{
|
||||
var path = PathOf(record);
|
||||
if (!File.Exists(path)) return;
|
||||
var size = new FileInfo(path).Length;
|
||||
File.Delete(path);
|
||||
_totalBytes -= size;
|
||||
}
|
||||
|
||||
private void RemoveEmptyFolders()
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
foreach (var game in Directory.GetDirectories(_root))
|
||||
foreach (var month in Directory.GetDirectories(game))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(month).Any()) Directory.Delete(month);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// dossier en cours d'utilisation : nettoyé à la purge suivante
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long? FreeDiskBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = Directory.Exists(_root) ? _root : Path.GetDirectoryName(_root)!;
|
||||
return new DriveInfo(Path.GetFullPath(folder)).AvailableFreeSpace; // même usage que les autres stores
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ArgumentException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index construit au premier accès : lecture de tous les fichiers des jeux configurés (dossiers et fichiers aux noms
|
||||
/// attendus uniquement, liens symboliques ignorés). Un fichier illisible est signalé et ignoré : il n'empêche ni le
|
||||
/// démarrage ni la réception.
|
||||
/// </summary>
|
||||
private Dictionary<string, Dictionary<string, FeedbackRecord>> Index()
|
||||
{
|
||||
if (_index is not null) return _index;
|
||||
var index = new Dictionary<string, Dictionary<string, FeedbackRecord>>(StringComparer.Ordinal);
|
||||
long total = 0;
|
||||
foreach (var gameId in Games)
|
||||
{
|
||||
var records = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
|
||||
index[gameId] = records;
|
||||
var folder = GameFolder(gameId);
|
||||
if (!Directory.Exists(folder) || IsLink(folder)) continue;
|
||||
foreach (var month in Directory.GetDirectories(folder))
|
||||
{
|
||||
if (IsLink(month) || !DateTime.TryParseExact(Path.GetFileName(month), "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
continue;
|
||||
foreach (var file in Directory.GetFiles(month, "*.json"))
|
||||
{
|
||||
var id = Path.GetFileNameWithoutExtension(file);
|
||||
if (!FeedbackRules.IsValidId(id) || IsLink(file)) continue;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length > 256 * 1024) throw new InvalidDataException("fichier trop volumineux");
|
||||
var record = JsonSerializer.Deserialize<FeedbackRecord>(File.ReadAllText(file), Json)
|
||||
?? throw new InvalidDataException("fichier vide");
|
||||
if (record.Format != FeedbackRecord.FormatName || record.Id != id ||
|
||||
!string.Equals(record.GameId, gameId, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("contenu inattendu");
|
||||
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>() };
|
||||
total += info.Length;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger?.LogWarning("Demande ignorée (fichier illisible) : {File} ({Message})", file, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_totalBytes = total;
|
||||
return _index = index;
|
||||
}
|
||||
|
||||
private static bool IsLink(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileInfo(path).LinkTarget is not null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System.Text;
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichier PUBLIC servi par nginx (catalogue, trousseau) : écrit atomiquement (fichier temporaire caché du
|
||||
/// Fichier PUBLIC servi par nginx (catalogue, trousseau, images du catalogue) : écrit atomiquement (fichier temporaire caché du
|
||||
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
|
||||
/// </summary>
|
||||
internal static class PublicFileWriter
|
||||
@@ -13,7 +13,12 @@ internal static class PublicFileWriter
|
||||
|
||||
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
|
||||
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
|
||||
public static void Write(string path, string text, string what)
|
||||
public static void Write(string path, string text, string what) =>
|
||||
Write(path, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text), what);
|
||||
|
||||
/// <summary>Contenu binaire (image du catalogue) : mêmes garanties que pour un texte.</summary>
|
||||
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
|
||||
public static void Write(string path, byte[] content, string what)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
@@ -23,7 +28,7 @@ internal static class PublicFileWriter
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text));
|
||||
stream.Write(content);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Fichiers du serveur » : consultation du site public (racine LauncherApi:Files:Root), suppression d'un
|
||||
/// fichier inutilisé et nettoyage du dossier « objects » d'un jeu.
|
||||
///
|
||||
/// Règles (le serveur ne fait confiance qu'à ce qu'il lit lui-même, au moment de supprimer) :
|
||||
/// • chemins relatifs à la racine uniquement : ni « .. », ni chemin absolu, ni élément caché (« .publish-… »),
|
||||
/// ni lien symbolique sur le chemin ; un dossier n'est jamais supprimé ;
|
||||
/// • fichiers .json (manifest, catalogue, trousseau, mise à jour du launcher) : JAMAIS supprimés ;
|
||||
/// • un fichier référencé par un fichier de description d'un dossier parent (archive et fichiers d'un
|
||||
/// manifest.json, exécutable de launcher-update.json, images du catalogue) n'est pas supprimable ;
|
||||
/// un fichier de description illisible rend tout son dossier non supprimable (impossible de vérifier) ;
|
||||
/// • vérification refaite sous le verrou d'écriture du manifest du jeu (celui de la publication et des notes),
|
||||
/// et refus pendant une publication de ce jeu (dossier « .publish-… » présent) ;
|
||||
/// • la suppression n'est possible que là où systemd autorise l'écriture (ReadWritePaths).
|
||||
/// </summary>
|
||||
public sealed partial class ServerFilesStore
|
||||
{
|
||||
private const string ManifestName = "manifest.json";
|
||||
private const string LauncherUpdateName = "launcher-update.json";
|
||||
private const string StagingPrefix = ".publish-";
|
||||
|
||||
/// <summary>Fichier de description lu au plus (même borne que le manifest).</summary>
|
||||
private const long MaxDescriptionBytes = PublishedFiles.MaxManifestBytes;
|
||||
|
||||
private static readonly StringComparison PathComparison =
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
private static readonly StringComparer PathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{2}\z")]
|
||||
private static partial Regex PrefixRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{64}\.bin\z")]
|
||||
private static partial Regex ObjectNameRegex();
|
||||
|
||||
/// <summary>Les dépôts de publication (« .publish-… ») sont « cachés » sous Linux : énumération explicite.</summary>
|
||||
private static readonly EnumerationOptions IncludingHidden = new() { AttributesToSkip = 0, IgnoreInaccessible = true };
|
||||
|
||||
private readonly string? _root;
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string? _catalogFile;
|
||||
private readonly string _catalogUrl;
|
||||
|
||||
public ServerFilesStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.FilesRootFullPath, options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath,
|
||||
options.Value.CatalogPublicFileFullPath, options.Value.CatalogPublicUrl) { }
|
||||
|
||||
public ServerFilesStore(string? root, IReadOnlyDictionary<string, string> manifests, string dataDirectory,
|
||||
string? catalogFile, string catalogUrl)
|
||||
{
|
||||
_root = root is null ? null : Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
_catalogFile = catalogFile is null ? null : Path.GetFullPath(catalogFile);
|
||||
_catalogUrl = catalogUrl;
|
||||
}
|
||||
|
||||
public bool Enabled => _root is not null;
|
||||
|
||||
// ================================================================== Consultation
|
||||
|
||||
public ServerFolder List(string? relativePath)
|
||||
{
|
||||
var root = RequireRoot();
|
||||
var (directory, relative) = Resolve(relativePath ?? "");
|
||||
if (!Directory.Exists(directory)) throw ApiRejection.NotFound("Dossier introuvable (supprimé ou renommé entre-temps ?).");
|
||||
|
||||
var references = ScanReferences();
|
||||
var infos = new List<FileSystemInfo>();
|
||||
var truncated = false;
|
||||
foreach (var info in new DirectoryInfo(directory).EnumerateFileSystemInfos("*", new EnumerationOptions
|
||||
{
|
||||
AttributesToSkip = 0,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = false
|
||||
}))
|
||||
{
|
||||
if (info.Name.StartsWith('.')) continue; // dépôts de publication, fichiers cachés : jamais affichés
|
||||
if (infos.Count >= ServerFilesLimits.MaxEntries)
|
||||
{
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
infos.Add(info);
|
||||
}
|
||||
|
||||
var budget = new ScanBudget(ServerFilesLimits.MaxScannedFiles);
|
||||
var entries = infos
|
||||
.OrderBy(i => i is DirectoryInfo ? 0 : 1)
|
||||
.ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(info => Describe(info, references, budget))
|
||||
.ToList();
|
||||
|
||||
long free = -1, total = -1;
|
||||
try
|
||||
{
|
||||
var drive = new DriveInfo(root);
|
||||
free = drive.AvailableFreeSpace;
|
||||
total = drive.TotalSize;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
// information facultative
|
||||
}
|
||||
|
||||
return new ServerFolder
|
||||
{
|
||||
Path = relative,
|
||||
RootName = Path.GetFileName(root) is { Length: > 0 } name ? name : root,
|
||||
Entries = entries,
|
||||
Truncated = truncated,
|
||||
FreeBytes = free,
|
||||
TotalBytes = total,
|
||||
PrunableGames = _manifests.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private ServerFileEntry Describe(FileSystemInfo info, References references, ScanBudget budget)
|
||||
{
|
||||
var modified = new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero);
|
||||
if (info.LinkTarget is not null)
|
||||
return new ServerFileEntry
|
||||
{
|
||||
Name = info.Name,
|
||||
IsDirectory = info is DirectoryInfo,
|
||||
Size = -1,
|
||||
FileCount = -1,
|
||||
Modified = modified,
|
||||
Status = ServerFileStatus.Link,
|
||||
Reason = "Lien symbolique : ni suivi ni supprimé depuis le menu."
|
||||
};
|
||||
|
||||
if (info is DirectoryInfo directory)
|
||||
{
|
||||
var (size, count) = budget.Measure(directory);
|
||||
return new ServerFileEntry
|
||||
{
|
||||
Name = info.Name,
|
||||
IsDirectory = true,
|
||||
Size = size,
|
||||
FileCount = count,
|
||||
Modified = modified,
|
||||
Status = ServerFileStatus.Directory
|
||||
};
|
||||
}
|
||||
|
||||
var (status, reason) = references.StatusOf(info.FullName);
|
||||
return new ServerFileEntry
|
||||
{
|
||||
Name = info.Name,
|
||||
Size = ((FileInfo)info).Length,
|
||||
FileCount = 1,
|
||||
Modified = modified,
|
||||
Status = status,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Taille des sous-dossiers, dans la limite d'un nombre de fichiers parcourus par requête.</summary>
|
||||
private sealed class ScanBudget
|
||||
{
|
||||
private long _remaining;
|
||||
|
||||
public ScanBudget(long files) => _remaining = files;
|
||||
|
||||
public (long Size, long Count) Measure(DirectoryInfo directory)
|
||||
{
|
||||
if (_remaining <= 0) return (-1, -1);
|
||||
long size = 0, count = 0;
|
||||
try
|
||||
{
|
||||
// Fichiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
|
||||
foreach (var file in directory.EnumerateFiles("*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
|
||||
}))
|
||||
{
|
||||
if (--_remaining < 0) return (-1, -1);
|
||||
size += file.Length;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return (-1, -1);
|
||||
}
|
||||
return (size, count);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== Suppression d'un fichier
|
||||
|
||||
public ServerFileDeleteResult Delete(string relativePath, long expectedSize)
|
||||
{
|
||||
RequireRoot();
|
||||
var (path, relative) = Resolve(relativePath);
|
||||
if (relative.Length == 0 || Directory.Exists(path))
|
||||
throw ApiRejection.BadRequest("Seul un fichier peut être supprimé (jamais un dossier).");
|
||||
var file = new FileInfo(path);
|
||||
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
|
||||
|
||||
// Même verrou que la publication et les notes de version pour chaque jeu dont le dossier contient ce fichier :
|
||||
// le manifest ne peut pas changer entre la vérification et la suppression.
|
||||
var locks = new List<FileStream>();
|
||||
try
|
||||
{
|
||||
foreach (var gameId in GamesContaining(path)) locks.Add(PublishedFiles.LockGame(_dataDirectory, gameId));
|
||||
|
||||
file.Refresh();
|
||||
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
|
||||
if (file.LinkTarget is not null) throw ApiRejection.Conflict("Lien symbolique : jamais supprimé depuis le menu.");
|
||||
if (file.Length != expectedSize)
|
||||
throw ApiRejection.Conflict("Le fichier a changé depuis l'affichage de la liste : actualisez avant de le supprimer.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
|
||||
if (PublicationFolderInProgress(path) is { } game)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Une publication est en cours dans « {RelativeDisplay(game)} » : réessayez une fois la version en ligne ou la publication abandonnée.",
|
||||
ApiErrorCodes.PublicationInProgress);
|
||||
|
||||
// Vérification refaite maintenant (manifests relus) : seul un fichier inutilisé est supprimé.
|
||||
var (status, reason) = ScanReferences().StatusOf(path);
|
||||
if (status != ServerFileStatus.Unused)
|
||||
throw ApiRejection.Conflict(status == ServerFileStatus.Protected || status == ServerFileStatus.InUse
|
||||
? $"Suppression refusée : {reason}"
|
||||
: $"Suppression refusée : {reason ?? "impossible de vérifier que ce fichier est inutilisé."}");
|
||||
|
||||
var size = file.Length;
|
||||
File.Delete(path);
|
||||
return new ServerFileDeleteResult { Path = relative, FreedBytes = size };
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var handle in locks) handle.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== Nettoyage du dossier « objects » d'un jeu
|
||||
|
||||
public ObjectsPrunePreview PreviewPrune(string gameId)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
return Analyze(manifestPath, key).Preview;
|
||||
}
|
||||
|
||||
public ObjectsPruneResult Prune(string gameId, string manifestSha256, Action<string, Exception> logFailure)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
using (PublishedFiles.LockGame(_dataDirectory, key))
|
||||
{
|
||||
var analysis = Analyze(manifestPath, key);
|
||||
if (!string.Equals(analysis.Preview.ManifestSha256, manifestSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw ApiRejection.Conflict("Le manifest en ligne a changé depuis l'analyse : relancez l'analyse avant de nettoyer.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (analysis.Preview.PublicationInProgress)
|
||||
throw ApiRejection.Conflict(
|
||||
"Une publication de ce jeu est en cours : nettoyez une fois la version en ligne (ou la publication abandonnée).",
|
||||
ApiErrorCodes.PublicationInProgress);
|
||||
|
||||
int deleted = 0, failed = 0;
|
||||
long freed = 0;
|
||||
foreach (var orphan in analysis.Orphans)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(orphan);
|
||||
if (!info.Exists || info.LinkTarget is not null) continue;
|
||||
var size = info.Length;
|
||||
File.Delete(orphan);
|
||||
deleted++;
|
||||
freed += size;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
failed++;
|
||||
logFailure(orphan, ex);
|
||||
}
|
||||
}
|
||||
return new ObjectsPruneResult { GameId = key, Deleted = deleted, FreedBytes = freed, Failed = failed };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PruneAnalysis(ObjectsPrunePreview Preview, IReadOnlyList<string> Orphans);
|
||||
|
||||
private PruneAnalysis Analyze(string manifestPath, string gameId)
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath)
|
||||
?? throw ApiRejection.NotFound($"Aucun manifest en ligne pour « {gameId} » : rien à nettoyer.");
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var manifest = ManifestReferences.Parse(bytes);
|
||||
if (!manifest.LocalObjects)
|
||||
throw ApiRejection.Conflict("Le manifest en ligne sert ses fichiers depuis une autre adresse (« objectsBaseUrl ») : " +
|
||||
"nettoyage impossible depuis le menu.");
|
||||
|
||||
var objectsDirectory = Path.Combine(gameDirectory, "objects");
|
||||
if (new DirectoryInfo(gameDirectory).LinkTarget is not null || new DirectoryInfo(objectsDirectory).LinkTarget is not null)
|
||||
throw ApiRejection.Conflict("Le dossier du jeu ou son dossier « objects » est un lien symbolique : nettoyage impossible depuis le menu.");
|
||||
|
||||
// Autres fichiers de description du site (un autre manifest peut désigner ces objets par une adresse) :
|
||||
// un objet qu'ils utilisent, ou qu'ils rendent invérifiable, est conservé.
|
||||
References? site = null;
|
||||
if (_root is not null && IsInside(gameDirectory, _root))
|
||||
{
|
||||
site = ScanReferences();
|
||||
if (site.SiteWide is { } reason) throw ApiRejection.Conflict($"Nettoyage impossible : {reason}");
|
||||
}
|
||||
|
||||
var referenced = new HashSet<string>(manifest.Objects, StringComparer.Ordinal);
|
||||
var present = new HashSet<string>(StringComparer.Ordinal);
|
||||
var orphans = new List<string>();
|
||||
long orphanBytes = 0;
|
||||
if (Directory.Exists(objectsDirectory))
|
||||
{
|
||||
foreach (var prefix in new DirectoryInfo(objectsDirectory).EnumerateDirectories())
|
||||
{
|
||||
if (!PrefixRegex().IsMatch(prefix.Name) || prefix.LinkTarget is not null) continue;
|
||||
foreach (var file in prefix.EnumerateFiles())
|
||||
{
|
||||
// Seuls les objets au nom exact (« <empreinte>.bin » dans le bon sous-dossier) sont concernés :
|
||||
// tout autre fichier est laissé tel quel.
|
||||
if (!ObjectNameRegex().IsMatch(file.Name) || !file.Name.StartsWith(prefix.Name, StringComparison.Ordinal) ||
|
||||
file.LinkTarget is not null)
|
||||
continue;
|
||||
var sha = file.Name[..64];
|
||||
if (referenced.Contains(sha))
|
||||
{
|
||||
present.Add(sha);
|
||||
continue;
|
||||
}
|
||||
if (site is not null && site.StatusOf(file.FullName).Status != ServerFileStatus.Unused) continue;
|
||||
orphans.Add(file.FullName);
|
||||
orphanBytes += file.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var inProgress = Directory.Exists(gameDirectory) &&
|
||||
new DirectoryInfo(gameDirectory).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any();
|
||||
orphans.Sort(StringComparer.Ordinal);
|
||||
return new PruneAnalysis(new ObjectsPrunePreview
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = manifest.Version ?? "",
|
||||
ManifestSha256 = PublishedFiles.Sha256(bytes),
|
||||
ReferencedCount = referenced.Count,
|
||||
MissingCount = referenced.Count - present.Count,
|
||||
OrphanCount = orphans.Count,
|
||||
OrphanBytes = orphanBytes,
|
||||
Examples = orphans.Take(10).Select(o => Path.GetRelativePath(gameDirectory, o).Replace('\\', '/')).ToList(),
|
||||
PublicationInProgress = inProgress
|
||||
}, orphans);
|
||||
}
|
||||
|
||||
// ================================================================== Références (fichiers de description)
|
||||
|
||||
/// <summary>Dossiers parcourus au plus pour trouver les fichiers de description du site.</summary>
|
||||
private const int MaxScannedFolders = 50_000;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers désignés par TOUS les fichiers de description du site (manifest.json et launcher-update.json de
|
||||
/// n'importe quel dossier, catalogue configuré), relus à chaque requête. Les adresses sont résolues comme le fait
|
||||
/// le launcher (relatives au fichier de description, « .. » compris ; adresses absolues du même serveur rattachées
|
||||
/// aux fichiers du site). Ce qui ne peut pas être vérifié n'est jamais supprimable.
|
||||
/// </summary>
|
||||
private sealed class References
|
||||
{
|
||||
public Dictionary<string, string> Files { get; } = new(PathComparer);
|
||||
|
||||
/// <summary>Dossiers dont le contenu ne peut pas être vérifié (fichier de description illisible…).</summary>
|
||||
public List<(string Folder, string Reason)> UnknownFolders { get; } = new();
|
||||
|
||||
/// <summary>Vérification impossible pour tout le site (adresse du même serveur non rattachable, site trop grand…).</summary>
|
||||
public string? SiteWide { get; set; }
|
||||
|
||||
public void Add(string path, string reason) => Files.TryAdd(path, reason);
|
||||
|
||||
public (string Status, string? Reason) StatusOf(string path)
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
if (name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
return (ServerFileStatus.Protected, name.Equals(ManifestName, StringComparison.OrdinalIgnoreCase)
|
||||
? "manifest d'un jeu : jamais supprimé."
|
||||
: "fichier de description (.json : catalogue, trousseau, mise à jour du launcher…) : jamais supprimé depuis le menu.");
|
||||
if (Files.TryGetValue(path, out var reason)) return (ServerFileStatus.InUse, reason);
|
||||
if (SiteWide is not null) return (ServerFileStatus.Unknown, SiteWide);
|
||||
foreach (var (folder, why) in UnknownFolders)
|
||||
if (IsInside(path, folder))
|
||||
return (ServerFileStatus.Unknown, why);
|
||||
return (ServerFileStatus.Unused, null);
|
||||
}
|
||||
}
|
||||
|
||||
private References ScanReferences()
|
||||
{
|
||||
var root = RequireRoot();
|
||||
var references = new References();
|
||||
var site = new SiteAddresses(root, _catalogFile, _catalogUrl);
|
||||
|
||||
var folders = new List<string> { root };
|
||||
try
|
||||
{
|
||||
// Dossiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
|
||||
foreach (var directory in new DirectoryInfo(root).EnumerateDirectories("*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
|
||||
}))
|
||||
{
|
||||
if (folders.Count >= MaxScannedFolders)
|
||||
{
|
||||
references.SiteWide = $"impossible de vérifier : le site contient plus de {MaxScannedFolders} dossiers.";
|
||||
return references;
|
||||
}
|
||||
folders.Add(directory.FullName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
references.SiteWide = "impossible de vérifier : le site n'a pas pu être parcouru entièrement.";
|
||||
return references;
|
||||
}
|
||||
|
||||
foreach (var folder in folders) ReadFolderReferences(folder, site, references);
|
||||
if (_catalogFile is not null && IsInside(_catalogFile, root) && File.Exists(_catalogFile)) ReadCatalogReferences(site, references);
|
||||
return references;
|
||||
}
|
||||
|
||||
private void ReadFolderReferences(string folder, SiteAddresses site, References references)
|
||||
{
|
||||
var display = RelativeDisplay(folder);
|
||||
var problems = new List<string>();
|
||||
|
||||
var manifest = Path.Combine(folder, ManifestName);
|
||||
if (File.Exists(manifest))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = ManifestReferences.Parse(ReadDescription(manifest));
|
||||
var version = parsed.Version is { Length: > 0 } v ? $"version {v}" : "version en ligne";
|
||||
var reason = $"manifest de {display}";
|
||||
if (parsed.Archive is { } archive &&
|
||||
Resolve(site, folder, archive, $"archive « {archive} » du {reason}", problems, references) is { } archivePath)
|
||||
references.Add(archivePath, $"archive de la {version} ({reason}).");
|
||||
var objectsBase = string.IsNullOrEmpty(parsed.ObjectsBase) ? "objects/" : parsed.ObjectsBase;
|
||||
if (!objectsBase.EndsWith('/')) objectsBase += "/";
|
||||
if (parsed.Objects.Count > 0 &&
|
||||
Resolve(site, folder, objectsBase, $"dossier des fichiers « {objectsBase} » du {reason}", problems, references) is { } objects)
|
||||
foreach (var sha in parsed.Objects)
|
||||
references.Add(Path.Combine(objects, sha[..2], sha + ".bin"), $"fichier de la {version} ({reason}).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
problems.Add($"manifest de {display} illisible ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
var update = Path.Combine(folder, LauncherUpdateName);
|
||||
if (File.Exists(update))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (version, executable) = LauncherUpdateReference(ReadDescription(update));
|
||||
if (Resolve(site, folder, executable, $"exécutable « {executable} » de launcher-update.json de {display}", problems, references) is { } target)
|
||||
references.Add(target, $"exécutable du launcher {version} (launcher-update.json de {display}).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
problems.Add($"launcher-update.json de {display} illisible ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.Count > 0)
|
||||
references.UnknownFolders.Add((folder,
|
||||
$"impossible de vérifier : {string.Join(" ; ", problems)}. Rien n'est supprimé dans {display} tant que ce n'est pas corrigé."));
|
||||
}
|
||||
|
||||
private void ReadCatalogReferences(SiteAddresses site, References references)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(_catalogFile!)!;
|
||||
try
|
||||
{
|
||||
foreach (var (gameId, kind, url) in CatalogImages(ReadDescription(_catalogFile!)))
|
||||
{
|
||||
switch (site.ResolveCatalogImage(url))
|
||||
{
|
||||
case { Path: { } path }:
|
||||
references.Add(path, $"{kind} du jeu {gameId} dans le catalogue.");
|
||||
break;
|
||||
case { Unmappable: true }:
|
||||
references.SiteWide ??= $"impossible de vérifier : {kind} du jeu {gameId} du catalogue (« {url} ») non rattachée aux fichiers du site.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
references.UnknownFolders.Add((folder, $"impossible de vérifier : catalogue illisible ({ex.Message})."));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fichier (ou dossier) du site désigné par une adresse d'un fichier de description de <paramref name="folder"/>.
|
||||
/// Null : hors du site (autre serveur, en dehors de la racine), ou non vérifiable (problème noté).
|
||||
/// </summary>
|
||||
private static string? Resolve(SiteAddresses site, string folder, string address, string what, List<string> problems, References references)
|
||||
{
|
||||
var resolved = site.Resolve(folder, address);
|
||||
if (resolved.Invalid) problems.Add($"{what} : adresse non reconnue");
|
||||
else if (resolved.Unmappable)
|
||||
references.SiteWide ??= $"impossible de vérifier : {what} désigne ce serveur par une adresse complète qui n'a pas pu être rattachée aux fichiers du site.";
|
||||
return resolved.Path;
|
||||
}
|
||||
|
||||
/// <summary>Adresse résolue : chemin du site, adresse extérieure (rien), invalide, ou du même serveur mais non rattachable.</summary>
|
||||
private readonly record struct ResolvedAddress(string? Path, bool Invalid = false, bool Unmappable = false);
|
||||
|
||||
/// <summary>
|
||||
/// Correspondance entre adresses et fichiers du site. Hôte du site : celui du catalogue ; préfixe des adresses :
|
||||
/// déduit de l'adresse publique et de l'emplacement du fichier du catalogue (« /catalog/catalog.json » ↔
|
||||
/// « www/catalog/catalog.json » : la racine du site est servie sous « / »).
|
||||
/// </summary>
|
||||
private sealed class SiteAddresses
|
||||
{
|
||||
private const string LocalHost = "site.invalid";
|
||||
private readonly string _root;
|
||||
private readonly Uri? _catalogUri;
|
||||
private readonly string? _catalogFile;
|
||||
private readonly string? _sitePrefix;
|
||||
|
||||
public SiteAddresses(string root, string? catalogFile, string catalogUrl)
|
||||
{
|
||||
_root = root;
|
||||
_catalogFile = catalogFile;
|
||||
if (Uri.TryCreate(catalogUrl, UriKind.Absolute, out var uri)) _catalogUri = uri;
|
||||
if (_catalogUri is not null && catalogFile is not null && IsInside(catalogFile, root))
|
||||
{
|
||||
var relative = "/" + System.IO.Path.GetRelativePath(root, catalogFile).Replace('\\', '/');
|
||||
var path = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
|
||||
if (path.EndsWith(relative, StringComparison.Ordinal)) _sitePrefix = path[..^(relative.Length - 1)]; // se termine par « / »
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Résolution comme le launcher : adresse relative au fichier de description (adresse fictive du dossier).</summary>
|
||||
public ResolvedAddress Resolve(string folder, string address)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address) || address.Any(char.IsControl) || address.Contains('\\')) return new(null, Invalid: true);
|
||||
var relativeFolder = System.IO.Path.GetRelativePath(_root, folder).Replace('\\', '/');
|
||||
var baseUri = new Uri($"http://{LocalHost}/" + (relativeFolder == "." ? "" : string.Join('/', relativeFolder.Split('/').Select(Uri.EscapeDataString)) + "/"));
|
||||
if (!Uri.TryCreate(baseUri, address, out var target) || target.Scheme is not ("http" or "https")) return new(null, Invalid: true);
|
||||
if (target.Host == LocalHost) return Map(Uri.UnescapeDataString(target.AbsolutePath));
|
||||
return Absolute(target);
|
||||
}
|
||||
|
||||
/// <summary>Image du catalogue : relative à l'adresse du catalogue.</summary>
|
||||
public ResolvedAddress ResolveCatalogImage(string url)
|
||||
{
|
||||
if (_catalogUri is null || _catalogFile is null) return new(null, Unmappable: true);
|
||||
if (!Uri.TryCreate(_catalogUri, url, out var image) || image.Scheme is not ("http" or "https")) return new(null, Invalid: true);
|
||||
if (!string.Equals(image.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase)) return new(null);
|
||||
if (_sitePrefix is not null) return Absolute(image);
|
||||
// Préfixe inconnu : chemin relatif au dossier du catalogue.
|
||||
var catalogPath = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
|
||||
var directory = catalogPath[..(catalogPath.LastIndexOf('/') + 1)];
|
||||
var imagePath = Uri.UnescapeDataString(image.AbsolutePath);
|
||||
if (!imagePath.StartsWith(directory, StringComparison.Ordinal)) return new(null, Unmappable: true);
|
||||
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(_catalogFile)!,
|
||||
imagePath[directory.Length..].Replace('/', System.IO.Path.DirectorySeparatorChar)));
|
||||
return IsInside(full, _root) ? new(full) : new(null);
|
||||
}
|
||||
|
||||
private ResolvedAddress Absolute(Uri target)
|
||||
{
|
||||
if (_catalogUri is not null && !string.Equals(target.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase))
|
||||
return new(null); // autre serveur : rien à protéger ici
|
||||
if (_sitePrefix is null) return new(null, Unmappable: true); // même serveur (ou inconnu) : impossible de rattacher
|
||||
var path = Uri.UnescapeDataString(target.AbsolutePath);
|
||||
return path.StartsWith(_sitePrefix, StringComparison.Ordinal) ? Map("/" + path[_sitePrefix.Length..]) : new(null);
|
||||
}
|
||||
|
||||
/// <summary>Chemin d'adresse (« /jeu/objects/ab/… ») → chemin du site (null : en dehors de la racine).</summary>
|
||||
private ResolvedAddress Map(string urlPath)
|
||||
{
|
||||
var relative = urlPath.TrimStart('/');
|
||||
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(_root, relative.Replace('/', System.IO.Path.DirectorySeparatorChar)));
|
||||
if (urlPath.EndsWith('/')) full = full.TrimEnd(System.IO.Path.DirectorySeparatorChar);
|
||||
return IsInside(full, _root) ? new(full) : new(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ReadDescription(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length > MaxDescriptionBytes) throw new InvalidDataException("fichier trop volumineux");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
private static (string Version, string Executable) LauncherUpdateReference(byte[] bytes)
|
||||
{
|
||||
var envelope = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "launcher-update.json");
|
||||
var payload = PublishedFiles.StringValue(envelope, "payload") ?? throw new InvalidDataException("contenu signé absent");
|
||||
byte[] decoded;
|
||||
try
|
||||
{
|
||||
decoded = Convert.FromBase64String(payload);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new InvalidDataException("contenu signé illisible");
|
||||
}
|
||||
var release = PublishedFiles.ParseObject(decoded, "Description du launcher");
|
||||
var file = PublishedFiles.StringValue(release, "file");
|
||||
if (string.IsNullOrWhiteSpace(file)) throw new InvalidDataException("exécutable absent");
|
||||
return (PublishedFiles.StringValue(release, "version") ?? "", file);
|
||||
}
|
||||
|
||||
private static IEnumerable<(string GameId, string Kind, string Url)> CatalogImages(byte[] bytes)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Catalogue");
|
||||
if (PublishedFiles.Property(root, "games") is not JsonObject games) yield break;
|
||||
foreach (var (gameId, node) in games)
|
||||
{
|
||||
if (node is not JsonObject game) continue;
|
||||
if (PublishedFiles.StringValue(game, "icon") is { Length: > 0 } icon) yield return (gameId, "icône", icon);
|
||||
if (PublishedFiles.StringValue(game, "background") is { Length: > 0 } background) yield return (gameId, "image de fond", background);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== Chemins
|
||||
|
||||
private string RequireRoot() => _root ?? throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Racine du site non configurée sur le serveur (LauncherApi:Files:Root, ou un jeu / le catalogue configuré).");
|
||||
|
||||
/// <summary>
|
||||
/// Chemin relatif (« / ») → chemin complet sous la racine. Refus : « .. », chemin absolu, « \ », « : »,
|
||||
/// élément caché, caractère de contrôle, lien symbolique sur le chemin.
|
||||
/// </summary>
|
||||
private (string FullPath, string Relative) Resolve(string relativePath)
|
||||
{
|
||||
var root = RequireRoot();
|
||||
var relative = relativePath.Trim().Trim('/');
|
||||
if (relative.Length == 0) return (root, "");
|
||||
if (relative.Length > ServerFilesLimits.MaxPathLength || relative.Any(char.IsControl) || relative.Contains('\\') ||
|
||||
relative.Contains(':'))
|
||||
throw ApiRejection.BadRequest("Chemin invalide.");
|
||||
var segments = relative.Split('/');
|
||||
if (segments.Any(s => s.Length == 0 || s == "." || s == ".." || s.StartsWith('.')))
|
||||
throw ApiRejection.BadRequest("Chemin invalide (élément vide, caché ou « .. »).");
|
||||
|
||||
var full = Path.GetFullPath(Path.Combine(new[] { root }.Concat(segments).ToArray()));
|
||||
if (!IsInside(full, root)) throw ApiRejection.BadRequest("Chemin en dehors du site.");
|
||||
|
||||
// Aucun lien symbolique entre la racine et l'élément (il pourrait mener en dehors du site).
|
||||
var current = root;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
|
||||
if (info.Exists && info.LinkTarget is not null)
|
||||
throw ApiRejection.BadRequest("Chemin refusé : il passe par un lien symbolique.");
|
||||
}
|
||||
return (full, string.Join('/', segments));
|
||||
}
|
||||
|
||||
/// <summary>Dossiers de la racine jusqu'à <paramref name="folder"/> inclus (fichiers de description pris en compte).</summary>
|
||||
private IEnumerable<string> FoldersFromRootTo(string folder)
|
||||
{
|
||||
var root = _root;
|
||||
var chain = new List<string>();
|
||||
for (var current = Path.GetFullPath(folder).TrimEnd(Path.DirectorySeparatorChar);
|
||||
current is not null && (root is null || IsInside(current, root));
|
||||
current = Path.GetDirectoryName(current))
|
||||
chain.Add(current);
|
||||
chain.Reverse();
|
||||
return chain;
|
||||
}
|
||||
|
||||
/// <summary>Jeux configurés dont le dossier contient ce chemin (verrous à prendre avant de supprimer).</summary>
|
||||
private IEnumerable<string> GamesContaining(string path) =>
|
||||
_manifests.Where(m => IsInside(path, Path.GetDirectoryName(m.Value)!))
|
||||
.Select(m => m.Key).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
|
||||
/// <summary>Dossier d'un jeu (contenant un manifest.json) où une publication est en cours, parmi les parents du chemin.</summary>
|
||||
private string? PublicationFolderInProgress(string path)
|
||||
{
|
||||
foreach (var folder in FoldersFromRootTo(Path.GetDirectoryName(path)!))
|
||||
if (File.Exists(Path.Combine(folder, ManifestName)) || _manifests.Values.Any(m => string.Equals(Path.GetDirectoryName(m), folder, PathComparison)))
|
||||
if (new DirectoryInfo(folder).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any())
|
||||
return folder;
|
||||
return null;
|
||||
}
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw PublishedFiles.NotConfigured(gameId, "Nettoyage des fichiers");
|
||||
}
|
||||
|
||||
private string RelativeDisplay(string folder)
|
||||
{
|
||||
if (_root is null) return Path.GetFileName(folder) + "/";
|
||||
var relative = Path.GetRelativePath(_root, folder).Replace('\\', '/');
|
||||
return relative == "." ? "la racine du site" : relative + "/";
|
||||
}
|
||||
|
||||
private static bool IsInside(string path, string parent)
|
||||
{
|
||||
var p = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar);
|
||||
var r = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar);
|
||||
return string.Equals(p, r, PathComparison) || p.StartsWith(r + Path.DirectorySeparatorChar, PathComparison);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ce qu'un manifest.json désigne sur le disque : nom de l'archive, empreintes des fichiers. Lecture tolérante (tout
|
||||
/// manifest du site, pas seulement ceux des jeux configurés) mais stricte sur ce qui sert à décider : toute empreinte
|
||||
/// invalide rend le manifest « illisible », donc rien n'est supprimé à côté.
|
||||
/// </summary>
|
||||
internal sealed record ManifestReferences(string? Version, string? Archive, string? ObjectsBase, bool LocalObjects, IReadOnlyCollection<string> Objects)
|
||||
{
|
||||
public static ManifestReferences Parse(byte[] bytes)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
var version = PublishedFiles.StringValue(root, "version");
|
||||
|
||||
string? archive = null;
|
||||
if (PublishedFiles.Property(root, "archive") is JsonObject archiveNode)
|
||||
archive = PublishedFiles.StringValue(archiveNode, "url") is { Length: > 0 } url ? url : throw new InvalidDataException("archive sans adresse");
|
||||
|
||||
var objectsBase = PublishedFiles.StringValue(root, "objectsBaseUrl");
|
||||
var local = string.IsNullOrEmpty(objectsBase) || objectsBase is "objects/" or "objects";
|
||||
|
||||
var objects = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (PublishedFiles.Property(root, "files") is JsonArray files)
|
||||
{
|
||||
foreach (var node in files)
|
||||
{
|
||||
if (node is not JsonObject file) throw new InvalidDataException("fichier invalide dans la liste");
|
||||
var sha = PublishedFiles.StringValue(file, "sha256")?.ToLowerInvariant();
|
||||
if (!PublishingLimits.IsValidSha256(sha)) throw new InvalidDataException("empreinte de fichier invalide");
|
||||
objects.Add(sha!);
|
||||
}
|
||||
}
|
||||
else if (PublishedFiles.Property(root, "files") is not null)
|
||||
{
|
||||
throw new InvalidDataException("liste des fichiers invalide");
|
||||
}
|
||||
return new ManifestReferences(version, archive, objectsBase, local, objects);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,33 @@
|
||||
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
|
||||
"Games": {},
|
||||
// Page « Fichiers du serveur » : racine du site public consultable depuis le menu. Vide = dossier parent
|
||||
// commun des jeux et du catalogue (ex. « /srv/exovival/www »). La suppression n'est possible que dans les
|
||||
// dossiers autorisés en écriture par systemd (ReadWritePaths : jeux, catalogue). Voir server/deploy/LISEZMOI.md.
|
||||
"Files": {
|
||||
"Root": ""
|
||||
},
|
||||
// Demandes des joueurs (bouton « Aide » du launcher) : désactivé par défaut. Activer dans appsettings.Production.json
|
||||
// (voir server/deploy/LISEZMOI.md, étape 3 octies). Games vide = jeux de « Games » ci-dessus.
|
||||
// Aucune adresse IP n'est enregistrée avec une demande ; les limites par adresse sont comptées en mémoire.
|
||||
"Feedback": {
|
||||
"Enabled": false,
|
||||
"Games": [],
|
||||
"ProofDifficulty": 18,
|
||||
"PerIpPer10Minutes": 3,
|
||||
"PerIpPerDay": 10,
|
||||
"RequestsPerMinute": 10,
|
||||
"MaxPerGamePerDay": 300,
|
||||
"MaxPerGame": 5000,
|
||||
"MaxTotalMegabytes": 100,
|
||||
"MinFreeDiskMegabytes": 500,
|
||||
"RetentionClosedMonths": 12,
|
||||
"RetentionMaxMonths": 24
|
||||
},
|
||||
// Journal d'audit : mois conservés (purge automatique au démarrage puis chaque jour ; CNIL : 6 mois à 1 an).
|
||||
"Audit": {
|
||||
"RetentionMonths": 12
|
||||
},
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
|
||||
|
||||
+131
-5
@@ -116,11 +116,14 @@ de ses données privées. Exemple ci-dessous avec le site de « Serveur de tél
|
||||
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
|
||||
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
|
||||
|
||||
**Images** (icône, fond) : envoie-les en SFTP (compte `deploy`) dans `www/catalog/images/`, en PNG
|
||||
ou JPEG de 4 Mo au plus, puis saisis leur adresse dans la page Jeux, par ex.
|
||||
**Images** (icône, fond) : page **Jeux** › « Parcourir… » à côté du champ (explorateur Windows). L'image
|
||||
est contrôlée sur le PC puis par le serveur (PNG ou JPEG de 4 Mo au plus, dimensions bornées, fichier
|
||||
complet), rangée dans `www/catalog/images/` sous le nom `<empreinte SHA-256>.png|.jpg`, et son adresse
|
||||
remplit le champ ; « Publier » l'applique aux joueurs. Prérequis : étape 3 septies (bloc nginx). L'envoi
|
||||
en SFTP (compte `deploy`) reste possible : saisis alors l'adresse à la main, par ex.
|
||||
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
|
||||
en cache : pour changer une image, envoie-la sous un **nouveau nom**. Les images d'un autre
|
||||
serveur sont refusées.
|
||||
en cache : pour changer une image déposée à la main, envoie-la sous un **nouveau nom** (automatique
|
||||
avec « Parcourir… »). Les images d'un autre serveur sont refusées.
|
||||
|
||||
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
|
||||
|
||||
@@ -290,6 +293,126 @@ La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans
|
||||
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
|
||||
à la fois les mises à jour du launcher et le trousseau.
|
||||
|
||||
## 3 sexies. Fichiers du serveur : libérer de la place depuis le launcher (VPS)
|
||||
|
||||
La page **« Fichiers du serveur »** du menu d'administration (propriétaires et administrateurs, permission
|
||||
`files.manage`) affiche le contenu du site public et permet de supprimer ce qui ne sert plus : anciennes archives
|
||||
`.zip`, anciens exécutables du launcher, images retirées du catalogue, et les fichiers du dossier `objects` d'un jeu
|
||||
que la version en ligne n'utilise plus (bouton **« Nettoyer le dossier objects »**, avec analyse préalable).
|
||||
|
||||
1. Racine du site : par défaut, le dossier parent commun des jeux et du catalogue configurés (ici
|
||||
`/srv/exovival/www`). Pour un autre dossier, dans `appsettings.Production.json` :
|
||||
```json
|
||||
{ "LauncherApi": { "Files": { "Root": "/srv/exovival/www" } } }
|
||||
```
|
||||
Le dossier doit être séparé des données privées (`/var/lib/launcher-api`). Les éléments cachés (`.publish-…`)
|
||||
et les liens symboliques ne sont ni affichés comme fichiers ordinaires, ni suivis, ni supprimés.
|
||||
2. Écriture : rien à ajouter pour les dossiers des jeux et du catalogue (déjà dans `games.conf` et `catalog.conf`).
|
||||
Pour supprimer aussi dans un autre dossier (ex. `/srv/exovival/www/launcher`, exécutables du launcher), ajoute
|
||||
une ligne `ReadWritePaths=` à un complément systemd, puis `sudo systemctl daemon-reload && sudo systemctl restart launcher-api`.
|
||||
Sans cela, la page affiche ces fichiers mais la suppression est refusée (« le serveur n'a pas le droit de modifier ce dossier »).
|
||||
|
||||
Règles appliquées par le serveur (le launcher ne fait qu'afficher) :
|
||||
* les fichiers `.json` (manifest, catalogue, trousseau, `launcher-update.json`) ne sont **jamais** supprimés ;
|
||||
* avant chaque suppression, le serveur relit **tous** les fichiers de description du site (chaque `manifest.json`
|
||||
et `launcher-update.json`, le catalogue) : un fichier encore désigné (archive, fichier d'une version, exécutable,
|
||||
image) est refusé, y compris par une adresse relative (`../`) ou complète vers ce serveur ;
|
||||
* un fichier de description illisible rend son dossier non supprimable ; une adresse de ce serveur impossible à
|
||||
rattacher aux fichiers du site bloque toute suppression (impossible de vérifier) ;
|
||||
* vérification refaite sous le verrou d'écriture du manifest du jeu, et refus pendant une publication de ce jeu ;
|
||||
* le nettoyage du dossier `objects` ne touche que les fichiers `<empreinte>.bin` que plus aucun manifest du site
|
||||
n'utilise, et seulement si le manifest analysé n'a pas changé entre l'analyse et le nettoyage ;
|
||||
* chaque suppression et chaque nettoyage sont inscrits au journal d'audit (`files.delete`, `files.prune`).
|
||||
|
||||
À savoir : après un nettoyage, une version précédente ne peut plus être republiée sans renvoyer ses fichiers
|
||||
(la publication les renvoie automatiquement). Un joueur en train de télécharger l'ancienne version reprend
|
||||
avec la nouvelle à sa prochaine vérification.
|
||||
|
||||
## 3 septies. Groupes d'administrateurs et envoi d'images depuis le launcher (PC et VPS)
|
||||
|
||||
**Groupes et permissions** : les rôles fixes (Propriétaire, Administrateur, Éditeur) deviennent des **groupes**
|
||||
modifiables depuis la page **« Groupes et permissions »** du menu d'administration (permission `admins.manage`).
|
||||
Au premier démarrage, le serveur crée les groupes par défaut avec les droits des anciens rôles : rien ne change
|
||||
pour les administrateurs existants. Le groupe **Propriétaire** a toujours toutes les permissions et ne peut être
|
||||
ni modifié ni supprimé.
|
||||
|
||||
**Envoi des images du catalogue** sans client FTP : page **Jeux** › « Parcourir… ».
|
||||
|
||||
1. **Avant** la mise à jour, garde une copie de la liste des administrateurs au format actuel (le serveur la
|
||||
réécrit au format 2 à la première modification ; une ancienne version du serveur refuserait de démarrer avec) :
|
||||
```
|
||||
sudo -u deploy cp /var/lib/launcher-api/admins.json /var/lib/launcher-api/admins.format1.json
|
||||
```
|
||||
2. Mets le serveur à jour (étape 8), puis vérifie : `sudo -u deploy /opt/launcher-api/GameLauncher.Server list`
|
||||
affiche maintenant aussi les groupes et leurs permissions.
|
||||
3. **nginx** : ajoute le second bloc de `server/deploy/nginx-api.conf` (`location ^~ /api/v1/catalog/images/ { … }`)
|
||||
à côté du bloc `/api/`, puis `sudo nginx -t && sudo systemctl reload nginx`. Sans lui, nginx refuse les images
|
||||
de plus de 64 Ko (« Requête trop volumineuse »).
|
||||
4. Le dossier des images doit exister (étape 3 bis, il appartient à `deploy`, groupe `www-data`) :
|
||||
```
|
||||
ls -ld /srv/exovival/www/catalog/images
|
||||
```
|
||||
Sinon : `sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog/images`. Le service ne le crée
|
||||
jamais lui-même ; il n'écrit que dans ce dossier (déjà couvert par `catalog.conf`), en `-rw-r--r--`.
|
||||
5. Test : page **Jeux** › un jeu › Icône › « Parcourir… » › une image PNG : l'adresse
|
||||
`https://launcher.lanisys.fr/catalog/images/<empreinte>.png` remplit le champ ; « Publier ».
|
||||
|
||||
Règles appliquées par le serveur : image reconnue à son **contenu** (PNG/JPEG), fichier complet sans données
|
||||
ajoutées après l'image, icône de 32 à 2048 px, fond de 640 × 360 à 7680 × 4320 px, 300 images et 300 Mo au plus
|
||||
dans le dossier (les images inutilisées se suppriment dans « Fichiers du serveur »), requête signée, permission
|
||||
`games.edit`, audit `catalog.image_upload`. Le launcher des joueurs vérifie que l'image téléchargée a bien
|
||||
l'empreinte de son nom : l'adresse figurant dans le catalogue signé, l'image est couverte par cette signature.
|
||||
|
||||
## 3 octies. Demandes des joueurs (bouton « Aide ») et purge automatique (VPS)
|
||||
|
||||
Le bouton **Aide** du launcher permet aux joueurs d'envoyer, **sans compte**, un avis, une demande d'évolution, un
|
||||
bug ou autre chose, rattaché au jeu sélectionné. Les demandes se lisent dans le menu d'administration, page
|
||||
**« Demandes des joueurs »** (permissions `feedback.read` et `feedback.manage`). C'est la **première route
|
||||
d'écriture ouverte à tous** : elle est désactivée tant que tu ne l'actives pas.
|
||||
|
||||
Protections en place : défi anti-spam à résoudre par le launcher (preuve de calcul, une fraction de seconde), 3
|
||||
demandes par 10 minutes et 10 par jour par adresse IP (comptées **en mémoire seulement**), 300 demandes par jour et
|
||||
par jeu, 5 000 demandes et 100 Mo au plus, 500 Mo d'espace disque libre minimum, corps de 16 Ko, contrôle complet du
|
||||
contenu (caractères invisibles refusés). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
|
||||
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
|
||||
|
||||
1. Active la réception dans `/opt/launcher-api/appsettings.Production.json` (section `LauncherApi`) :
|
||||
```json
|
||||
"Feedback": {
|
||||
"Enabled": true,
|
||||
"Games": [ "EZR" ]
|
||||
}
|
||||
```
|
||||
`Games` : identifiants (`gameId` de `launcher.json`) des jeux qui acceptent des demandes ; vide = les jeux de la
|
||||
section `Games`. Les autres réglages (limites, plafonds, durées de conservation) sont décrits dans
|
||||
`appsettings.json`. En cas d'afflux, relève `ProofDifficulty` (18 par défaut, 26 au plus).
|
||||
2. **nginx** : ajoute dans `/etc/nginx/conf.d/securite.conf` (à côté de `api_par_ip`) :
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m;
|
||||
```
|
||||
puis colle les **deux** blocs `location = /api/v1/feedback…` de `server/deploy/nginx-api.conf` à côté du bloc
|
||||
`/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. Redémarre le service : `sudo systemctl restart launcher-api`.
|
||||
4. **Droits des administrateurs existants** : le groupe **Propriétaire** reçoit automatiquement les nouvelles
|
||||
permissions. Pour un serveur déjà installé, les autres groupes ne changent pas tout seuls (aucune élévation de droits
|
||||
sans action d'un propriétaire) : page **« Groupes et permissions »** › groupe « Administrateur » › coche
|
||||
« Demandes des joueurs (lecture) » et « (traitement) ». Le groupe « Éditeur » n'y a pas accès par défaut (le texte
|
||||
libre peut contenir des données personnelles).
|
||||
5. Test : launcher › bouton **Aide** (point d'interrogation, en haut à droite) › Bug › envoyer ; la référence
|
||||
`FB-XXXX-XXXX` s'affiche. Menu d'administration › « Demandes des joueurs » › le jeu › la demande est là.
|
||||
|
||||
**Conservation (RGPD) — purge automatique**, au démarrage du service puis toutes les 24 heures :
|
||||
|
||||
| Données | Durée (réglage) | Journal |
|
||||
|---|---|---|
|
||||
| Journal d'audit `audit-AAAA-MM.jsonl` | 12 mois (`Audit:RetentionMonths`, 1 à 120) | `audit.purge` |
|
||||
| Demande close (traitée, rejetée, doublon) | 12 mois après la clôture (`Feedback:RetentionClosedMonths`) | `feedback.purge` |
|
||||
| Toute demande | 24 mois après réception (`Feedback:RetentionMaxMonths`) | `feedback.purge` |
|
||||
|
||||
Un fichier d'audit mensuel est supprimé quand tous ses jours dépassent la durée (le mois le plus ancien gardé peut
|
||||
donc compter quelques semaines de plus). Pense aussi à borner les journaux du système (voir la fiche RGPD) :
|
||||
`journald` (`MaxRetentionSec=1year` dans `/etc/systemd/journald.conf`) et nginx (`logrotate`).
|
||||
|
||||
## 4. Créer ta clé d'administrateur (PC)
|
||||
|
||||
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
|
||||
@@ -374,6 +497,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
|
||||
@@ -392,6 +516,7 @@ Le service n'a pas besoin d'être redémarré : une révocation ferme immédiate
|
||||
|---|---|
|
||||
| Journal du service (connexions, refus, erreurs) | `journalctl -u launcher-api -f` |
|
||||
| Journal d'audit (une ligne JSON par action) | `sudo tail -n 20 /var/lib/launcher-api/audit/audit-$(date +%Y-%m).jsonl` |
|
||||
| Demandes des joueurs reçues (nombre par jeu) | `sudo find /var/lib/launcher-api/feedback -name '*.json' \| cut -d/ -f6 \| sort \| uniq -c` |
|
||||
| Niveau de durcissement du service | `systemd-analyze security launcher-api` |
|
||||
| État | `systemctl status launcher-api` |
|
||||
|
||||
@@ -409,11 +534,12 @@ recréer les administrateurs.
|
||||
/var/lib/launcher-api/ données privées (deploy, 700)
|
||||
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
|
||||
├─ server-id identifiant aléatoire du serveur
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit (12 mois, purge automatique)
|
||||
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
|
||||
├─ catalog-history/ 20 dernières versions publiées
|
||||
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
|
||||
├─ publications/ publications en cours (version, fichiers restant à envoyer)
|
||||
├─ feedback/<jeu>/AAAA-MM/<id>.json demandes des joueurs (une par fichier, 600 ; purge automatique)
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
|
||||
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (le bloc ci-dessous)
|
||||
# (les blocs ci-dessous ; le deuxième, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies ;
|
||||
# le troisième, demandes des joueurs, voir LISEZMOI.md étape 3 octies)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
@@ -32,3 +33,69 @@ location ^~ /api/ {
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# Envoi d'une image du catalogue (icône, fond) depuis la page « Jeux » : seule route du catalogue qui accepte un
|
||||
# corps de plus de 64 Ko (image de 4 Mo au plus). Le préfixe le plus long l'emporte sur « /api/ ». Mêmes limites
|
||||
# de débit ; la route exige une session d'administrateur, la permission games.edit et une requête signée.
|
||||
location ^~ /api/v1/catalog/images/ {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 5m;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# Demandes des joueurs (bouton « Aide » du launcher) : seules routes d'ÉCRITURE ouvertes à tous (sans compte).
|
||||
# • corps de 16 Ko au plus ; limite de débit propre, plus stricte (zone feedback_par_ip, à ajouter dans
|
||||
# /etc/nginx/conf.d/securite.conf : « limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m; ») ;
|
||||
# • access_log off : l'heure d'envoi d'une demande ne peut pas être rapprochée d'une adresse IP dans les journaux
|
||||
# nginx (la demande est anonyme ; le serveur ne garde les adresses qu'en mémoire, pour ses limites).
|
||||
# Correspondance EXACTE (« = ») : prioritaire sur « ^~ /api/ », et limitée aux deux routes des joueurs (les routes
|
||||
# des administrateurs, /api/v1/feedback/overview et /api/v1/games/…/feedback, restent sous le bloc « /api/ »).
|
||||
location = /api/v1/feedback/challenge {
|
||||
limit_req zone=feedback_par_ip burst=6 nodelay;
|
||||
limit_req_status 429;
|
||||
access_log off;
|
||||
|
||||
client_max_body_size 16k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
location = /api/v1/feedback {
|
||||
limit_req zone=feedback_par_ip burst=6 nodelay;
|
||||
limit_req_status 429;
|
||||
access_log off;
|
||||
|
||||
client_max_body_size 16k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
@@ -3,8 +3,10 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
@@ -114,12 +116,59 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
|
||||
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, CancellationToken ct = default)
|
||||
{
|
||||
var route = $"{ApiRoutes.Audit}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += "&cursor=" + Uri.EscapeDataString(cursor);
|
||||
return GetAsync<AuditPage>(route, ct);
|
||||
}
|
||||
// ------------------------------------------------------------------ Groupes et permissions (admins.manage)
|
||||
|
||||
/// <summary>Groupes (permissions, membres) et permissions connues du serveur.</summary>
|
||||
public Task<GroupsState> GetGroupsAsync(CancellationToken ct = default) => GetAsync<GroupsState>(ApiRoutes.Groups, ct);
|
||||
|
||||
public Task<GroupsState> CreateGroupAsync(GroupRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Groups, request, ct);
|
||||
|
||||
/// <summary>Modifie un groupe (409 « revision_conflict » s'il a changé depuis <see cref="GroupRequest.Revision"/>).</summary>
|
||||
public Task<GroupsState> UpdateGroupAsync(string groupId, GroupRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Put, ApiRoutes.Group(CheckGroupId(groupId)), request, ct);
|
||||
|
||||
/// <summary>Supprime un groupe sans membre ni invitation en cours.</summary>
|
||||
public Task<GroupsState> DeleteGroupAsync(string groupId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Group(CheckGroupId(groupId)), (object?)null, ct);
|
||||
|
||||
public Task<GroupsState> CreatePermissionAsync(CreatePermissionRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Permissions, request, ct);
|
||||
|
||||
/// <summary>Supprime une permission personnalisée (elle est retirée de tous les groupes).</summary>
|
||||
public Task<GroupsState> DeletePermissionAsync(string name, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Permission(
|
||||
AdminGroups.IsValidPermissionName(name) ? name : throw new ArgumentException($"Permission invalide : « {name} ».", nameof(name))),
|
||||
(object?)null, ct);
|
||||
|
||||
private static string CheckGroupId(string groupId) =>
|
||||
AdminGroups.IsValidGroupId(groupId) ? groupId : throw new ArgumentException($"Identifiant de groupe invalide : « {groupId} ».", nameof(groupId));
|
||||
|
||||
/// <param name="before">Facultatif : seulement les entrées antérieures à cet instant (filtre « jusqu'au »).</param>
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, DateTimeOffset? before = null, CancellationToken ct = default) =>
|
||||
GetAsync<AuditPage>(ApiRoutes.AuditPage(limit, cursor, before), ct);
|
||||
|
||||
/// <summary>Inscrit un export CSV du journal au journal (à faire AVANT d'écrire le fichier).</summary>
|
||||
public Task RecordAuditExportAsync(AuditExportRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuditExport, request, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Fichiers du site public (www)
|
||||
|
||||
/// <summary>Contenu d'un dossier du site (chemin relatif à sa racine, « / » comme séparateur ; « » : racine).</summary>
|
||||
public Task<ServerFolder> GetServerFolderAsync(string path, CancellationToken ct = default) =>
|
||||
GetAsync<ServerFolder>(ApiRoutes.FilesFolder(path), ct);
|
||||
|
||||
/// <summary>Supprime un fichier que le serveur juge inutilisé (refus s'il est référencé par un manifest).</summary>
|
||||
public Task<ServerFileDeleteResult> DeleteServerFileAsync(string path, long size, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ServerFileDeleteResult>(HttpMethod.Post, ApiRoutes.FilesDelete, new ServerFileDeleteRequest { Path = path, Size = size }, ct);
|
||||
|
||||
public Task<ObjectsPrunePreview> GetObjectsPruneAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ObjectsPrunePreview>(ApiRoutes.ObjectsPrune(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>Supprime les fichiers d'« objects » que le manifest analysé n'utilise plus (refus s'il a changé).</summary>
|
||||
public Task<ObjectsPruneResult> PruneObjectsAsync(string gameId, string manifestSha256, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ObjectsPruneResult>(HttpMethod.Post, ApiRoutes.ObjectsPrune(CheckGameId(gameId)),
|
||||
new ObjectsPruneRequest { ManifestSha256 = manifestSha256 }, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
|
||||
@@ -131,6 +180,15 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
|
||||
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Envoie une image du catalogue (icône ou fond, contrôlée avant l'envoi avec <see cref="CatalogImages.Check"/>) :
|
||||
/// requête signée (la signature couvre l'empreinte du contenu). Renvoie l'adresse à mettre dans le catalogue.
|
||||
/// </summary>
|
||||
public Task<CatalogImageUploaded> UploadCatalogImageAsync(string kind, byte[] content, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<CatalogImageUploaded>(HttpMethod.Put,
|
||||
ApiRoutes.CatalogImage(CatalogImages.IsValidKind(kind) ? kind : throw new ArgumentException($"Type d'image inconnu : « {kind} ».", nameof(kind))),
|
||||
content, CatalogImages.ContentType, UploadTimeout, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
|
||||
|
||||
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
|
||||
@@ -215,6 +273,31 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs
|
||||
|
||||
public Task<FeedbackOverview> GetFeedbackOverviewAsync(CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackOverview>(ApiRoutes.FeedbackOverview, ct);
|
||||
|
||||
/// <param name="status">Statut précis, « open » (non closes) ou « closed » ; null : toutes.</param>
|
||||
public Task<FeedbackPage> GetFeedbackPageAsync(string gameId, string? kind = null, string? status = null, string? cursor = null,
|
||||
int limit = 100, CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackPage>(ApiRoutes.GameFeedback(CheckGameId(gameId), kind, status, cursor, limit), ct);
|
||||
|
||||
public Task<FeedbackDetail> GetFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackDetail>(ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), ct);
|
||||
|
||||
/// <summary>Statut et note interne (409 « revision_conflict » si la demande a changé depuis <paramref name="revision"/>).</summary>
|
||||
public Task<FeedbackDetail> UpdateFeedbackAsync(string gameId, string id, long revision, string status, string? adminNote,
|
||||
CancellationToken ct = default) =>
|
||||
SendSignedAsync<FeedbackDetail>(HttpMethod.Put, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)),
|
||||
new FeedbackUpdateRequest { Revision = revision, Status = status, AdminNote = adminNote }, ct);
|
||||
|
||||
public Task DeleteFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), null, ct);
|
||||
|
||||
private static string CheckFeedbackId(string id) =>
|
||||
FeedbackRules.IsValidId(id) ? id : throw new ArgumentException("Identifiant de demande invalide.", nameof(id));
|
||||
|
||||
private static string CheckPublicationId(string id) =>
|
||||
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
@@ -106,6 +107,23 @@ public sealed class LauncherApiClient : IDisposable
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs (routes anonymes)
|
||||
|
||||
/// <summary>
|
||||
/// Envoie une demande de joueur (bouton « Aide ») : défi anti-spam demandé au serveur, résolu sur ce PC (une fraction de
|
||||
/// seconde, hors du fil de l'interface), puis envoi. Aucune session, aucune clé, aucun identifiant du PC.
|
||||
/// </summary>
|
||||
public async Task<FeedbackSubmitted> SendFeedbackAsync(FeedbackSubmission submission, CancellationToken ct = default)
|
||||
{
|
||||
var challenge = await SendAsync<FeedbackChallenge>(HttpMethod.Post, ApiRoutes.FeedbackChallenge, null, null, ct).ConfigureAwait(false);
|
||||
if (challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty || !Base64Url.TryDecode(challenge.Nonce, out var nonce) ||
|
||||
nonce.Length is 0 or > 64)
|
||||
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
|
||||
var solution = await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
|
||||
return await SendAsync<FeedbackSubmitted>(HttpMethod.Post, ApiRoutes.Feedback,
|
||||
Serialize(submission with { ChallengeId = challenge.ChallengeId, Solution = solution }), null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Transport
|
||||
|
||||
internal static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options);
|
||||
|
||||
@@ -37,12 +37,57 @@ public static class ApiRoutes
|
||||
public static string KeyApprove(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/approve";
|
||||
public static string KeyRevoke(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/revoke";
|
||||
|
||||
// ------------------------------------------------------------------ Groupes et permissions (admins.manage)
|
||||
public const string Groups = "v1/groups";
|
||||
public const string Permissions = "v1/permissions";
|
||||
|
||||
/// <summary>Un groupe (identifiant : minuscules, chiffres, tirets — rien à encoder).</summary>
|
||||
public static string Group(string groupId) => $"v1/groups/{groupId}";
|
||||
|
||||
/// <summary>Une permission personnalisée (« module.action » : rien à encoder).</summary>
|
||||
public static string Permission(string name) => $"v1/permissions/{name}";
|
||||
|
||||
// ------------------------------------------------------------------ Audit
|
||||
public const string Audit = "v1/audit";
|
||||
|
||||
/// <summary>Annonce d'un export CSV du journal (requête signée, inscrite au journal avant l'écriture du fichier).</summary>
|
||||
public const string AuditExport = "v1/audit/export";
|
||||
|
||||
/// <summary>
|
||||
/// Page du journal : <paramref name="before"/> (facultatif) = entrées antérieures à cet instant (filtre « jusqu'au »),
|
||||
/// <paramref name="cursor"/> = suite de la page précédente.
|
||||
/// </summary>
|
||||
public static string AuditPage(int limit, string? cursor, DateTimeOffset? before)
|
||||
{
|
||||
var route = $"{Audit}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
|
||||
if (before is { } date)
|
||||
route += $"&before={Uri.EscapeDataString(date.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture))}";
|
||||
return route;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Fichiers du site public (www)
|
||||
public const string Files = "v1/files";
|
||||
public const string FilesDelete = "v1/files/delete";
|
||||
public const string ObjectsPrunePattern = "v1/games/{gameId}/objects/prune";
|
||||
|
||||
/// <summary>Contenu d'un dossier (chemin relatif à la racine du site, « » : racine).</summary>
|
||||
public static string FilesFolder(string path) =>
|
||||
string.IsNullOrEmpty(path) ? Files : $"{Files}?path={Uri.EscapeDataString(path)}";
|
||||
|
||||
public static string ObjectsPrune(string gameId) => $"v1/games/{gameId}/objects/prune";
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
public const string Catalog = "v1/catalog";
|
||||
|
||||
/// <summary>Envoi d'une image du catalogue (icône ou fond) : corps binaire, requête signée, games.edit.</summary>
|
||||
public const string CatalogImagePattern = "v1/catalog/images/{kind}";
|
||||
|
||||
/// <summary>Préfixe des routes d'envoi d'image (limite de taille propre, voir nginx-api.conf).</summary>
|
||||
public const string CatalogImagesPrefix = "v1/catalog/images/";
|
||||
|
||||
public static string CatalogImage(string kind) => $"v1/catalog/images/{kind}";
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
|
||||
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
|
||||
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
|
||||
@@ -63,6 +108,32 @@ public static class ApiRoutes
|
||||
|
||||
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs (aide, avis, bugs…)
|
||||
/// <summary>Défi anti-spam (anonyme) à résoudre avant l'envoi d'une demande.</summary>
|
||||
public const string FeedbackChallenge = "v1/feedback/challenge";
|
||||
|
||||
/// <summary>Envoi d'une demande par un joueur (anonyme, corps de 16 Ko au plus).</summary>
|
||||
public const string Feedback = "v1/feedback";
|
||||
|
||||
/// <summary>Compteurs par jeu (feedback.read).</summary>
|
||||
public const string FeedbackOverview = "v1/feedback/overview";
|
||||
|
||||
public const string GameFeedbackPattern = "v1/games/{gameId}/feedback";
|
||||
public const string GameFeedbackItemPattern = "v1/games/{gameId}/feedback/{id}";
|
||||
|
||||
/// <summary>Demandes d'un jeu (identifiant : caractères sûrs ; filtres et curseur encodés).</summary>
|
||||
public static string GameFeedback(string gameId, string? kind = null, string? status = null, string? cursor = null, int limit = 100)
|
||||
{
|
||||
var route = $"v1/games/{gameId}/feedback?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(kind)) route += $"&kind={Uri.EscapeDataString(kind)}";
|
||||
if (!string.IsNullOrEmpty(status)) route += $"&status={Uri.EscapeDataString(status)}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
|
||||
return route;
|
||||
}
|
||||
|
||||
/// <summary>Une demande (identifiant : 32 caractères hexadécimaux, rien à encoder).</summary>
|
||||
public static string GameFeedbackItem(string gameId, string id) => $"v1/games/{gameId}/feedback/{id}";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Images du catalogue (icône, image de fond) envoyées depuis le menu d'administration : contrôles communs
|
||||
/// au launcher (avant l'envoi) et au serveur (refus de tout fichier non conforme).
|
||||
/// • PNG ou JPEG uniquement, reconnus à leur contenu (pas à leur extension), <see cref="MaxBytes"/> au plus
|
||||
/// (la limite de téléchargement des launchers) ;
|
||||
/// • dimensions lues dans l'en-tête et bornées (pas d'image démesurée à décoder chez les joueurs) ;
|
||||
/// • fichier complet, sans données ajoutées après la fin de l'image (PNG : bloc IEND ; JPEG : marqueur EOI) ;
|
||||
/// • nom du fichier publié = empreinte SHA-256 du contenu (<c><64 hex>.png|.jpg</c>) : une image publiée ne
|
||||
/// change jamais de contenu (les launchers la gardent en cache sous son adresse), et le launcher des joueurs
|
||||
/// vérifie que l'image téléchargée a bien cette empreinte. Comme l'adresse figure dans le catalogue signé,
|
||||
/// l'image est ainsi protégée par la même signature.
|
||||
/// </summary>
|
||||
public static partial class CatalogImages
|
||||
{
|
||||
/// <summary>Taille maximale d'une image (celle acceptée par les launchers des joueurs).</summary>
|
||||
public const long MaxBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Taille maximale d'une requête d'envoi (image + marge) ; voir nginx-api.conf.</summary>
|
||||
public const long MaxRequestBytes = MaxBytes + 64 * 1024;
|
||||
|
||||
public const string Icon = "icon";
|
||||
public const string Background = "background";
|
||||
|
||||
public const string ContentType = "application/octet-stream";
|
||||
|
||||
/// <summary>Dossier des images, à côté du fichier public du catalogue.</summary>
|
||||
public const string FolderName = "images";
|
||||
|
||||
/// <summary>Nombre et taille totale des images du dossier (les images inutilisées se suppriment dans « Fichiers du serveur »).</summary>
|
||||
public const int MaxStoredFiles = 300;
|
||||
public const long MaxStoredBytes = 300L * 1024 * 1024;
|
||||
|
||||
/// <summary>Nombre maximal de pixels (≈ 8K) : borne la mémoire nécessaire au décodage chez les joueurs.</summary>
|
||||
public const long MaxPixels = 40_000_000;
|
||||
|
||||
public static bool IsValidKind(string? kind) => kind is Icon or Background;
|
||||
|
||||
public static string KindLabel(string kind) => kind == Icon ? "icône" : "image de fond";
|
||||
|
||||
/// <summary>Dimensions acceptées (largeur et hauteur, en pixels) selon le type d'image.</summary>
|
||||
public static (int MinWidth, int MinHeight, int MaxWidth, int MaxHeight) Bounds(string kind) =>
|
||||
kind == Icon ? (32, 32, 2048, 2048) : (640, 360, 7680, 4320);
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{64}\.(png|jpg)\z")]
|
||||
private static partial Regex StoredNameRegex();
|
||||
|
||||
/// <summary>Nom d'une image publiée par le serveur (empreinte SHA-256 du contenu + extension).</summary>
|
||||
public static bool IsStoredName(string? name) => name is not null && StoredNameRegex().IsMatch(name);
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte attendue d'une image dont l'adresse se termine par un nom publié par le serveur
|
||||
/// (« …/catalog/images/<sha256>.png ») ; null pour une autre adresse (image déposée à la main).
|
||||
/// </summary>
|
||||
public static string? ExpectedSha256(Uri url)
|
||||
{
|
||||
var name = url.Segments.Length == 0 ? "" : Uri.UnescapeDataString(url.Segments[^1]);
|
||||
return IsStoredName(name) ? name[..64] : null;
|
||||
}
|
||||
|
||||
/// <summary>Format et dimensions lus dans le contenu.</summary>
|
||||
/// <param name="Format">« png » ou « jpg » (extension du fichier publié).</param>
|
||||
public sealed record ImageInfo(string Format, int Width, int Height);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'une image pour un type donné : null si elle est acceptable, sinon la raison du refus
|
||||
/// (en français, affichable). <paramref name="info"/> est renseigné dès que le format est reconnu.
|
||||
/// </summary>
|
||||
public static string? Check(string kind, ReadOnlySpan<byte> data, out ImageInfo? info)
|
||||
{
|
||||
info = null;
|
||||
if (!IsValidKind(kind)) return "Type d'image inconnu.";
|
||||
if (data.Length == 0) return "Fichier vide.";
|
||||
if (data.Length > MaxBytes) return $"Image trop volumineuse ({data.Length / 1024 / 1024.0:0.0} Mo) : {MaxBytes / 1024 / 1024} Mo au plus.";
|
||||
|
||||
string? error;
|
||||
if (data.StartsWith(PngSignature)) info = ReadPng(data, out error);
|
||||
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) info = ReadJpeg(data, out error);
|
||||
else return "Ce fichier n'est pas une image PNG ou JPEG.";
|
||||
if (info is null) return error;
|
||||
|
||||
var (minW, minH, maxW, maxH) = Bounds(kind);
|
||||
if (info.Width < minW || info.Height < minH || info.Width > maxW || info.Height > maxH ||
|
||||
(long)info.Width * info.Height > MaxPixels)
|
||||
return $"Dimensions refusées pour une {KindLabel(kind)} : {info.Width} × {info.Height} px " +
|
||||
$"(de {minW} × {minH} à {maxW} × {maxH} px).";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
/// <summary>Dernier bloc d'un PNG : longueur 0, « IEND », CRC fixe.</summary>
|
||||
private static ReadOnlySpan<byte> PngEnd => new byte[] { 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 };
|
||||
|
||||
private static ImageInfo? ReadPng(ReadOnlySpan<byte> data, out string? error)
|
||||
{
|
||||
error = "Image PNG invalide ou incomplète.";
|
||||
// Signature (8) + bloc IHDR : longueur (4) = 13, type (4), largeur (4), hauteur (4)…
|
||||
if (data.Length < 8 + 8 + 13 + 4 + PngEnd.Length) return null;
|
||||
if (BinaryPrimitives.ReadUInt32BigEndian(data[8..]) != 13 || !data.Slice(12, 4).SequenceEqual("IHDR"u8)) return null;
|
||||
var width = BinaryPrimitives.ReadUInt32BigEndian(data[16..]);
|
||||
var height = BinaryPrimitives.ReadUInt32BigEndian(data[20..]);
|
||||
if (width is 0 or > int.MaxValue || height is 0 or > int.MaxValue) return null;
|
||||
if (!data[^PngEnd.Length..].SequenceEqual(PngEnd))
|
||||
{
|
||||
error = "Image PNG incomplète, ou suivie de données étrangères à l'image.";
|
||||
return null;
|
||||
}
|
||||
error = null;
|
||||
return new ImageInfo("png", (int)width, (int)height);
|
||||
}
|
||||
|
||||
private static ImageInfo? ReadJpeg(ReadOnlySpan<byte> data, out string? error)
|
||||
{
|
||||
error = "Image JPEG invalide ou incomplète.";
|
||||
if (data.Length < 4 || data[^2] != 0xFF || data[^1] != 0xD9)
|
||||
{
|
||||
error = "Image JPEG incomplète, ou suivie de données étrangères à l'image.";
|
||||
return null;
|
||||
}
|
||||
var i = 2;
|
||||
while (i + 4 <= data.Length)
|
||||
{
|
||||
if (data[i] != 0xFF) return null;
|
||||
var marker = data[i + 1];
|
||||
if (marker == 0xFF)
|
||||
{
|
||||
i++; // octet de remplissage
|
||||
continue;
|
||||
}
|
||||
if (marker is 0x01 or (>= 0xD0 and <= 0xD7))
|
||||
{
|
||||
i += 2; // marqueur sans longueur
|
||||
continue;
|
||||
}
|
||||
if (marker is 0xD8 or 0xD9 or 0xDA) return null; // début d'image, fin ou données avant les dimensions
|
||||
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 2)..]);
|
||||
if (length < 2 || i + 2 + length > data.Length) return null;
|
||||
// SOF0 à SOF15, sauf DHT (C4), JPG (C8) et DAC (CC) : précision (1), hauteur (2), largeur (2).
|
||||
if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC)
|
||||
{
|
||||
if (length < 7) return null;
|
||||
var height = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 5)..]);
|
||||
var width = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 7)..]);
|
||||
if (width == 0 || height == 0) return null;
|
||||
error = null;
|
||||
return new ImageInfo("jpg", width, height);
|
||||
}
|
||||
i += 2 + length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Image envoyée (PUT v1/catalog/images/{kind}) : adresse publique à mettre dans le catalogue.</summary>
|
||||
public sealed record CatalogImageUploaded
|
||||
{
|
||||
/// <summary>Adresse https de l'image, sur le serveur du catalogue.</summary>
|
||||
public string Url { get; init; } = "";
|
||||
|
||||
/// <summary>Nom du fichier publié (empreinte SHA-256 du contenu + extension).</summary>
|
||||
public string FileName { get; init; } = "";
|
||||
|
||||
public string Sha256 { get; init; } = "";
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public long Bytes { get; init; }
|
||||
|
||||
/// <summary>Le serveur avait déjà cette image (même contenu) : rien n'a été écrit.</summary>
|
||||
public bool AlreadyPresent { get; init; }
|
||||
}
|
||||
@@ -26,7 +26,16 @@ public sealed record AdminSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
|
||||
/// <summary>Identifiant du groupe de l'administrateur (historiquement « rôle »).</summary>
|
||||
public string Role { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché du groupe (null : ancien serveur, ou groupe supprimé d'un compte révoqué).</summary>
|
||||
public string? RoleName { get; init; }
|
||||
|
||||
/// <summary>Permissions effectives (celles du groupe, relues à chaque requête par le serveur).</summary>
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
public string Status { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
|
||||
@@ -44,7 +53,7 @@ public sealed record CreateInvitationRequest
|
||||
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>Rôle du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
/// <summary>Groupe du nouvel administrateur (identifiant ; ignoré pour un nouvel appareil).</summary>
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>Renseigné : invitation « nouvel appareil » pour cet administrateur existant.</summary>
|
||||
@@ -56,6 +65,9 @@ public sealed record InvitationSummary
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché du groupe (null : ancien serveur).</summary>
|
||||
public string? RoleName { get; init; }
|
||||
public Guid? ForAdminId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
@@ -82,5 +94,6 @@ public sealed record ApproveKeyRequest
|
||||
|
||||
public sealed record ChangeRoleRequest
|
||||
{
|
||||
/// <summary>Identifiant du nouveau groupe.</summary>
|
||||
public string Role { get; init; } = "";
|
||||
}
|
||||
@@ -13,6 +13,14 @@ public sealed record AuditEntry
|
||||
public Guid? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Nom de l'appareil de la clé <see cref="KeyId"/>, tel que défini à l'enregistrement de la clé (invitation).
|
||||
/// Jamais écrit dans les fichiers du journal : ajouté par le serveur à la lecture (null : clé inconnue, ou serveur
|
||||
/// plus ancien).
|
||||
/// </summary>
|
||||
public string? KeyLabel { get; init; }
|
||||
|
||||
public string? Ip { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? Details { get; init; }
|
||||
@@ -26,6 +34,30 @@ public sealed record AuditPage
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export CSV du journal par le menu d'administration (requête signée) : le launcher l'annonce au serveur AVANT
|
||||
/// d'écrire le fichier, et n'écrit rien si le serveur ne l'a pas inscrit au journal (action « audit.export »).
|
||||
/// </summary>
|
||||
public sealed record AuditExportRequest
|
||||
{
|
||||
/// <summary>Nombre d'entrées exportées.</summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>Période exportée (jours inclus, heure du PC), null si non bornée.</summary>
|
||||
public DateOnly? From { get; init; }
|
||||
|
||||
public DateOnly? To { get; init; }
|
||||
|
||||
/// <summary>Autres filtres appliqués (catégorie, résultat, recherche), en clair ; null si aucun.</summary>
|
||||
public string? Filters { get; init; }
|
||||
}
|
||||
|
||||
public static class AuditExportLimits
|
||||
{
|
||||
public const int MaxCount = 1_000_000;
|
||||
public const int MaxFiltersLength = 300;
|
||||
}
|
||||
|
||||
public static class AuditResults
|
||||
{
|
||||
public const string Ok = "ok";
|
||||
|
||||
@@ -34,7 +34,13 @@ public sealed record AdminProfile
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
|
||||
/// <summary>Identifiant du groupe (historiquement « rôle »).</summary>
|
||||
public string Role { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché du groupe (null : ancien serveur).</summary>
|
||||
public string? RoleName { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? KeyLabel { get; init; }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
/// <summary>Groupe d'administrateurs (page « Groupes et permissions »).</summary>
|
||||
public sealed record GroupSummary
|
||||
{
|
||||
/// <summary>Identifiant stable (« owner », « admin », « editor », « moderateurs »…), utilisé par les comptes et les invitations.</summary>
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Permissions effectives (groupe système : toutes, y compris les permissions personnalisées).</summary>
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Groupe système (« Propriétaire ») : toutes les permissions, ni modifiable ni supprimable.</summary>
|
||||
public bool System { get; init; }
|
||||
|
||||
/// <summary>Révision : une modification préparée sur une révision dépassée est refusée (409 revision_conflict).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Comptes actifs ou en attente du groupe (les comptes révoqués ne comptent pas).</summary>
|
||||
public int MemberCount { get; init; }
|
||||
|
||||
/// <summary>Invitations en cours vers ce groupe.</summary>
|
||||
public int InvitationCount { get; init; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Permission connue du serveur : intégrée (vérifiée par le code) ou personnalisée.</summary>
|
||||
public sealed record PermissionSummary
|
||||
{
|
||||
/// <summary>« module.action ».</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Label { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Intégrée : vérifiée par le serveur et le launcher, jamais supprimable.</summary>
|
||||
public bool BuiltIn { get; init; }
|
||||
|
||||
/// <summary>Groupes qui ont cette permission (le groupe système compte).</summary>
|
||||
public int GroupCount { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>GET v1/groups : groupes et permissions connues.</summary>
|
||||
public sealed record GroupsState
|
||||
{
|
||||
public IReadOnlyList<GroupSummary> Groups { get; init; } = Array.Empty<GroupSummary>();
|
||||
public IReadOnlyList<PermissionSummary> Permissions { get; init; } = Array.Empty<PermissionSummary>();
|
||||
}
|
||||
|
||||
/// <summary>Création (POST v1/groups) ou modification (PUT v1/groups/{id}) d'un groupe.</summary>
|
||||
public sealed record GroupRequest
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Modification : révision sur laquelle elle a été préparée (ignorée à la création).</summary>
|
||||
public long Revision { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Création d'une permission personnalisée (POST v1/permissions).</summary>
|
||||
public sealed record CreatePermissionRequest
|
||||
{
|
||||
/// <summary>« module.action », ex. « news.edit ».</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Label { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -68,4 +68,10 @@ public static class ApiErrorCodes
|
||||
|
||||
/// <summary>Espace disque insuffisant sur le serveur.</summary>
|
||||
public const string InsufficientStorage = "insufficient_storage";
|
||||
|
||||
/// <summary>Demandes des joueurs : plafond atteint (nombre, taille, espace disque), envoi refusé pour l'instant.</summary>
|
||||
public const string FeedbackFull = "feedback_full";
|
||||
|
||||
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
|
||||
public const string ProofInvalid = "proof_invalid";
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
// ------------------------------------------------------------------ Joueur (routes anonymes)
|
||||
|
||||
/// <summary>Défi anti-spam (POST v1/feedback/challenge) : à résoudre avant l'envoi, voir <see cref="FeedbackProof"/>.</summary>
|
||||
public sealed record FeedbackChallenge
|
||||
{
|
||||
/// <summary>Identifiant scellé par le serveur (sans état), à renvoyer avec la demande.</summary>
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
/// <summary>Valeur aléatoire (base64url) à combiner avec la solution.</summary>
|
||||
public string Nonce { get; init; } = "";
|
||||
|
||||
/// <summary>Nombre de bits à zéro exigés en tête de l'empreinte.</summary>
|
||||
public int Difficulty { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Informations techniques jointes à une demande (facultatives, choisies par le joueur).</summary>
|
||||
public sealed record FeedbackTechInfo
|
||||
{
|
||||
public string? LauncherVersion { get; init; }
|
||||
|
||||
/// <summary>Version du jeu installée (absente si le jeu n'est pas installé).</summary>
|
||||
public string? GameVersion { get; init; }
|
||||
|
||||
/// <summary>« Windows 11 64 bits (10.0.26100) ».</summary>
|
||||
public string? Os { get; init; }
|
||||
|
||||
/// <summary>Langue de Windows (« fr-FR »).</summary>
|
||||
public string? Language { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demande envoyée par un joueur (POST v1/feedback), SANS compte : aucun identifiant du joueur ni de son PC.
|
||||
/// Le serveur ne fait confiance à rien : il revalide tout avec <see cref="FeedbackRules"/> et pose lui-même la date,
|
||||
/// l'identifiant, la référence et le statut.
|
||||
/// </summary>
|
||||
public sealed record FeedbackSubmission
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public int FormVersion { get; init; } = FeedbackForms.CurrentVersion;
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
/// <summary>Valeurs des champs du formulaire (identifiant du champ → valeur).</summary>
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
|
||||
/// <summary>Défi résolu (<see cref="FeedbackChallenge.ChallengeId"/>) et sa solution.</summary>
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
public string Solution { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Demande enregistrée : référence à citer (Discord, e-mail…).</summary>
|
||||
public sealed record FeedbackSubmitted
|
||||
{
|
||||
public string Reference { get; init; } = "";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Menu d'administration
|
||||
|
||||
/// <summary>Statuts d'une demande (traitement par les administrateurs).</summary>
|
||||
public static class FeedbackStatuses
|
||||
{
|
||||
public const string New = "new";
|
||||
public const string InProgress = "in_progress";
|
||||
public const string Done = "done";
|
||||
public const string Rejected = "rejected";
|
||||
public const string Duplicate = "duplicate";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { New, InProgress, Done, Rejected, Duplicate };
|
||||
|
||||
public static bool IsValid(string? status) => status is not null && All.Contains(status, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Demande close (traitée, rejetée, doublon) : comptée pour la durée de conservation après clôture.</summary>
|
||||
public static bool IsClosed(string? status) => status is Done or Rejected or Duplicate;
|
||||
|
||||
public static string Label(string? status) => status switch
|
||||
{
|
||||
New => "Nouvelle",
|
||||
InProgress => "En cours",
|
||||
Done => "Traitée",
|
||||
Rejected => "Rejetée",
|
||||
Duplicate => "Doublon",
|
||||
_ => status ?? "?"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Résumé d'une demande (listes).</summary>
|
||||
public sealed record FeedbackSummary
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Reference { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public string Title { get; init; } = "";
|
||||
public string Status { get; init; } = FeedbackStatuses.New;
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public DateTimeOffset? StatusChangedAt { get; init; }
|
||||
|
||||
/// <summary>Note (avis) ou gravité (bug), pour l'aperçu de la liste.</summary>
|
||||
public string? Badge { get; init; }
|
||||
|
||||
public bool HasNote { get; init; }
|
||||
public long Revision { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Page de demandes d'un jeu (GET v1/games/{gameId}/feedback), les plus récentes d'abord.</summary>
|
||||
public sealed record FeedbackPage
|
||||
{
|
||||
public IReadOnlyList<FeedbackSummary> Items { get; init; } = Array.Empty<FeedbackSummary>();
|
||||
|
||||
/// <summary>Suite de la liste (null : fin).</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
/// <summary>Nombre de demandes correspondant aux filtres.</summary>
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Demande complète (GET v1/games/{gameId}/feedback/{id}).</summary>
|
||||
public sealed record FeedbackDetail
|
||||
{
|
||||
public FeedbackSummary Summary { get; init; } = new();
|
||||
public int FormVersion { get; init; }
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
|
||||
/// <summary>Note interne des administrateurs (jamais visible du joueur).</summary>
|
||||
public string? AdminNote { get; init; }
|
||||
|
||||
/// <summary>Nom de l'administrateur qui a changé le statut ou la note en dernier.</summary>
|
||||
public string? StatusChangedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Changement de statut et/ou de note interne (PUT, requête signée).</summary>
|
||||
public sealed record FeedbackUpdateRequest
|
||||
{
|
||||
/// <summary>Révision lue : 409 « revision_conflict » si la demande a changé entre-temps.</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
public string Status { get; init; } = FeedbackStatuses.New;
|
||||
public string? AdminNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Compteurs d'un jeu (tableau de bord, tuiles du choix du jeu).</summary>
|
||||
public sealed record FeedbackGameCounts
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public int New { get; init; }
|
||||
public int Open { get; init; }
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Vue d'ensemble (GET v1/feedback/overview).</summary>
|
||||
public sealed record FeedbackOverview
|
||||
{
|
||||
/// <summary>Réception des demandes activée sur le serveur (LauncherApi:Feedback:Enabled).</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Plafond atteint (nombre, taille ou espace disque) : les nouvelles demandes sont refusées.</summary>
|
||||
public bool StorageFull { get; init; }
|
||||
|
||||
public IReadOnlyList<FeedbackGameCounts> Games { get; init; } = Array.Empty<FeedbackGameCounts>();
|
||||
|
||||
/// <summary>Durées de conservation appliquées (mois).</summary>
|
||||
public int RetentionClosedMonths { get; init; }
|
||||
|
||||
public int RetentionMaxMonths { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>Type d'un champ de formulaire.</summary>
|
||||
public enum FeedbackFieldKind
|
||||
{
|
||||
/// <summary>Texte libre sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
Multiline,
|
||||
|
||||
/// <summary>Un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
|
||||
Choice,
|
||||
|
||||
/// <summary>Note de 1 à 5 (valeur : « 1 » à « 5 »).</summary>
|
||||
Rating
|
||||
}
|
||||
|
||||
/// <summary>Option d'un champ à choix : identifiant stable (envoyé au serveur) et libellé affiché.</summary>
|
||||
public sealed record FeedbackOption(string Id, string Label);
|
||||
|
||||
/// <summary>Champ prédéfini d'un formulaire.</summary>
|
||||
/// <param name="Id">Identifiant stable (clé dans <see cref="FeedbackSubmission.Fields"/>).</param>
|
||||
/// <param name="Label">Libellé affiché (joueur et menu d'administration).</param>
|
||||
/// <param name="Hint">Aide sous le champ, ou texte indicatif dans la zone de saisie.</param>
|
||||
public sealed record FeedbackField(
|
||||
string Id,
|
||||
string Label,
|
||||
FeedbackFieldKind Kind,
|
||||
bool Required,
|
||||
string? Hint = null,
|
||||
int MaxLength = FeedbackRules.MaxMultilineLength,
|
||||
IReadOnlyList<FeedbackOption>? Options = null)
|
||||
{
|
||||
public IReadOnlyList<FeedbackOption> Choices => Options ?? Array.Empty<FeedbackOption>();
|
||||
|
||||
/// <summary>Libellé d'une valeur (option d'un choix, « 4/5 » pour une note, texte sinon).</summary>
|
||||
public string DisplayValue(string? value) => Kind switch
|
||||
{
|
||||
_ when string.IsNullOrEmpty(value) => "",
|
||||
FeedbackFieldKind.Choice => Choices.FirstOrDefault(o => o.Id == value)?.Label ?? value!,
|
||||
FeedbackFieldKind.Rating => $"{value}/5",
|
||||
_ => value!
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Formulaire d'un type de demande.</summary>
|
||||
/// <param name="Kind">Identifiant stable (<see cref="FeedbackKinds"/>).</param>
|
||||
/// <param name="IconKey">Icône du launcher (ressource « Icon.<clé> »).</param>
|
||||
/// <param name="TitleHint">Texte indicatif du titre de la demande.</param>
|
||||
/// <param name="TechByDefault">« Joindre les informations techniques » coché à l'ouverture du formulaire.</param>
|
||||
public sealed record FeedbackForm(
|
||||
string Kind,
|
||||
string Label,
|
||||
string Description,
|
||||
string IconKey,
|
||||
string TitleHint,
|
||||
bool TechByDefault,
|
||||
IReadOnlyList<FeedbackField> Fields)
|
||||
{
|
||||
public FeedbackField? Field(string id) => Fields.FirstOrDefault(f => f.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>Types de demande (identifiants stables, jamais traduits).</summary>
|
||||
public static class FeedbackKinds
|
||||
{
|
||||
public const string Review = "review";
|
||||
public const string Feature = "feature";
|
||||
public const string Bug = "bug";
|
||||
public const string Other = "other";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes des joueurs, décrits UNE fois ici : le launcher les génère, le serveur valide chaque
|
||||
/// demande avec (<see cref="FeedbackRules"/>), le menu d'administration les utilise pour afficher les libellés.
|
||||
/// Ajouter un champ = le déclarer ici (champ facultatif de préférence : les anciens launchers continuent de
|
||||
/// fonctionner) ; changer le sens d'un champ existant = nouvel identifiant et <see cref="CurrentVersion"/> + 1.
|
||||
/// </summary>
|
||||
public static class FeedbackForms
|
||||
{
|
||||
/// <summary>Version des formulaires (enregistrée avec chaque demande).</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private static FeedbackOption O(string id, string label) => new(id, label);
|
||||
|
||||
public static readonly IReadOnlyList<FeedbackForm> All = new[]
|
||||
{
|
||||
new FeedbackForm(FeedbackKinds.Review, "Avis", "Dites-nous ce que vous pensez du jeu.", "Star",
|
||||
"Votre avis en une phrase", TechByDefault: false, new[]
|
||||
{
|
||||
new FeedbackField("rating", "Note", FeedbackFieldKind.Rating, Required: true),
|
||||
new FeedbackField("topic", "Sujet", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("overall", "Le jeu dans son ensemble"), O("gameplay", "Gameplay"), O("graphics", "Graphismes"),
|
||||
O("sound", "Son et musique"), O("performance", "Performances"), O("story", "Histoire et ambiance"),
|
||||
O("launcher", "Launcher"), O("other", "Autre")
|
||||
}),
|
||||
new FeedbackField("liked", "Ce que vous aimez", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Ce qui vous plaît le plus.", MaxLength: 1500),
|
||||
new FeedbackField("improve", "Ce qui pourrait être amélioré", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Ce qui vous gêne ou vous manque.", MaxLength: 1500),
|
||||
new FeedbackField("recommend", "Recommanderiez-vous le jeu ?", FeedbackFieldKind.Choice, Required: false, Options: new[]
|
||||
{
|
||||
O("yes", "Oui"), O("maybe", "Peut-être"), O("no", "Non")
|
||||
})
|
||||
}),
|
||||
|
||||
new FeedbackForm(FeedbackKinds.Feature, "Demande d'évolution", "Proposez une idée ou une amélioration.", "Lightbulb",
|
||||
"Votre idée en une phrase", TechByDefault: false, new[]
|
||||
{
|
||||
new FeedbackField("area", "Domaine", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("gameplay", "Gameplay"), O("content", "Contenu (niveaux, objets, ennemis…)"), O("interface", "Interface et menus"),
|
||||
O("accessibility", "Accessibilité"), O("launcher", "Launcher"), O("other", "Autre")
|
||||
}),
|
||||
new FeedbackField("idea", "Description de l'idée", FeedbackFieldKind.Multiline, Required: true,
|
||||
"Décrivez ce que vous aimeriez voir dans le jeu."),
|
||||
new FeedbackField("problem", "Quel problème cela résoudrait-il ?", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Facultatif : pourquoi cette idée vous semble utile.", MaxLength: 1500),
|
||||
new FeedbackField("importance", "Importance pour vous", FeedbackFieldKind.Choice, Required: false, Options: new[]
|
||||
{
|
||||
O("bonus", "Un petit plus"), O("useful", "Utile"), O("important", "Importante")
|
||||
})
|
||||
}),
|
||||
|
||||
new FeedbackForm(FeedbackKinds.Bug, "Bug", "Signalez un problème rencontré dans le jeu ou le launcher.", "Bug",
|
||||
"Le problème en une phrase", TechByDefault: true, new[]
|
||||
{
|
||||
new FeedbackField("category", "Catégorie", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("crash", "Plantage ou fermeture du jeu"), O("graphics", "Graphismes"), O("sound", "Son"), O("gameplay", "Gameplay"),
|
||||
O("performance", "Performances (lenteurs, saccades)"), O("install", "Installation ou mise à jour"),
|
||||
O("launcher", "Launcher"), O("other", "Autre")
|
||||
}),
|
||||
new FeedbackField("severity", "Gravité", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("minor", "Mineur"), O("annoying", "Gênant"), O("blocking", "Bloquant"), O("crash", "Plantage")
|
||||
}),
|
||||
new FeedbackField("frequency", "Fréquence", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("always", "À chaque fois"), O("often", "Souvent"), O("sometimes", "Parfois"), O("once", "Une seule fois")
|
||||
}),
|
||||
new FeedbackField("steps", "Étapes pour reproduire", FeedbackFieldKind.Multiline, Required: true,
|
||||
"1. Je lance une partie…\n2. J'ouvre l'inventaire…"),
|
||||
new FeedbackField("expected", "Résultat attendu", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Ce qui aurait dû se passer.", MaxLength: 1000),
|
||||
new FeedbackField("actual", "Résultat obtenu", FeedbackFieldKind.Multiline, Required: true,
|
||||
"Ce qui s'est passé à la place (message d'erreur…).", MaxLength: 1500)
|
||||
}),
|
||||
|
||||
new FeedbackForm(FeedbackKinds.Other, "Autre", "Une question, une remarque, autre chose.", "Forum",
|
||||
"Le sujet de votre message", TechByDefault: false, new[]
|
||||
{
|
||||
new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true, "Votre message.")
|
||||
})
|
||||
};
|
||||
|
||||
public static FeedbackForm? Find(string? kind) => All.FirstOrDefault(f => f.Kind == kind);
|
||||
|
||||
public static string KindLabel(string? kind) => Find(kind)?.Label ?? kind ?? "?";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Preuve de travail anti-spam des demandes anonymes : trouver une solution (nombre) telle que
|
||||
/// SHA-256(domaine ‖ nonce ‖ solution) commence par <c>difficulty</c> bits à zéro. Environ 2^difficulty essais
|
||||
/// (une fraction de seconde pour un joueur, coûteux pour un robot qui veut en envoyer des milliers) ; la
|
||||
/// vérification par le serveur ne coûte qu'une empreinte. Aucun service tiers, aucun captcha.
|
||||
/// </summary>
|
||||
public static class FeedbackProof
|
||||
{
|
||||
public const int MinDifficulty = 8;
|
||||
public const int MaxDifficulty = 26;
|
||||
|
||||
/// <summary>Solution : nombre décimal (20 chiffres au plus).</summary>
|
||||
public const int MaxSolutionLength = 20;
|
||||
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Proof/v1\n");
|
||||
|
||||
/// <summary>Bits à zéro en tête de l'empreinte de cette solution.</summary>
|
||||
public static int LeadingZeroBits(ReadOnlySpan<byte> nonce, ulong solution)
|
||||
{
|
||||
Span<byte> data = stackalloc byte[Domain.Length + nonce.Length + 8];
|
||||
Domain.CopyTo(data);
|
||||
nonce.CopyTo(data[Domain.Length..]);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(data[(Domain.Length + nonce.Length)..], solution);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
var bits = 0;
|
||||
foreach (var b in hash)
|
||||
{
|
||||
if (b == 0)
|
||||
{
|
||||
bits += 8;
|
||||
continue;
|
||||
}
|
||||
bits += System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
|
||||
break;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
public static bool Verify(ReadOnlySpan<byte> nonce, int difficulty, string? solution) =>
|
||||
solution is { Length: > 0 and <= MaxSolutionLength } &&
|
||||
solution.All(char.IsAsciiDigit) &&
|
||||
ulong.TryParse(solution, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value) &&
|
||||
LeadingZeroBits(nonce, value) >= difficulty;
|
||||
|
||||
/// <summary>Cherche une solution (launcher). Annulable ; part d'un point aléatoire.</summary>
|
||||
public static string Solve(ReadOnlySpan<byte> nonce, int difficulty, CancellationToken ct = default)
|
||||
{
|
||||
if (difficulty is < 0 or > MaxDifficulty) throw new ArgumentOutOfRangeException(nameof(difficulty));
|
||||
var start = BinaryPrimitives.ReadUInt64BigEndian(RandomNumberGenerator.GetBytes(8)) >> 1;
|
||||
for (var i = 0UL; ; i++)
|
||||
{
|
||||
if ((i & 0xFFFF) == 0) ct.ThrowIfCancellationRequested();
|
||||
var candidate = start + i;
|
||||
if (LeadingZeroBits(nonce, candidate) >= difficulty)
|
||||
return candidate.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Règles communes au launcher (contrôle avant l'envoi) et au serveur (refus de toute valeur invalide) pour les
|
||||
/// demandes des joueurs. La route d'envoi est anonyme : n'importe qui peut l'appeler avec un client modifié, d'où
|
||||
/// des bornes strictes et le refus des caractères invisibles (de contrôle, de mise en forme, d'inversion du sens
|
||||
/// d'écriture) qui pourraient tromper l'administrateur qui lit la demande.
|
||||
/// </summary>
|
||||
public static partial class FeedbackRules
|
||||
{
|
||||
public const int MinTitleLength = 5;
|
||||
public const int MaxTitleLength = 120;
|
||||
public const int MaxMultilineLength = 2000;
|
||||
public const int MaxLines = 40;
|
||||
|
||||
/// <summary>Total des textes d'une demande (titre et champs).</summary>
|
||||
public const int MaxTotalLength = 8000;
|
||||
|
||||
/// <summary>Corps d'une requête d'envoi (Kestrel et nginx).</summary>
|
||||
public const int MaxRequestBytes = 16 * 1024;
|
||||
|
||||
public const int MaxAdminNoteLength = 500;
|
||||
|
||||
/// <summary>Identifiant interne d'une demande : 128 bits en hexadécimal minuscule.</summary>
|
||||
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
|
||||
private static partial Regex IdRegex();
|
||||
|
||||
/// <summary>Référence montrée au joueur : « FB-7K3Q-9P2M » (base32 sans caractères ambigus).</summary>
|
||||
[GeneratedRegex(@"\AFB-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}\z")]
|
||||
private static partial Regex ReferenceRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9A-Za-z][0-9A-Za-z.+\-]{0,31}\z")]
|
||||
private static partial Regex VersionRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9 .,()+\-]{0,79}\z")]
|
||||
private static partial Regex OsRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,2}\z")]
|
||||
private static partial Regex LanguageRegex();
|
||||
|
||||
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidReference(string? reference) => reference is not null && ReferenceRegex().IsMatch(reference);
|
||||
|
||||
/// <summary>Alphabet des références (Crockford : ni I, L, O, U).</summary>
|
||||
public const string ReferenceAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// <summary>Référence aléatoire (40 bits).</summary>
|
||||
public static string NewReference()
|
||||
{
|
||||
var bytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(5);
|
||||
ulong value = 0;
|
||||
foreach (var b in bytes) value = (value << 8) | b;
|
||||
var chars = new char[8];
|
||||
for (var i = 7; i >= 0; i--)
|
||||
{
|
||||
chars[i] = ReferenceAlphabet[(int)(value & 31)];
|
||||
value >>= 5;
|
||||
}
|
||||
return $"FB-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'une demande. Renvoie la demande nettoyée (textes normalisés, champs vides retirés,
|
||||
/// défi conservé) ou null avec les erreurs (françaises, lisibles par le joueur).
|
||||
/// </summary>
|
||||
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors)
|
||||
{
|
||||
if (submission is null)
|
||||
{
|
||||
errors.Add("Demande vide.");
|
||||
return null;
|
||||
}
|
||||
if (!Catalog.CatalogRules.IsValidGameId(submission.GameId)) errors.Add("Jeu inconnu.");
|
||||
if (submission.FormVersion != FeedbackForms.CurrentVersion)
|
||||
errors.Add("Formulaire d'une autre version du launcher : mettez le launcher à jour.");
|
||||
var form = FeedbackForms.Find(submission.Kind);
|
||||
if (form is null)
|
||||
{
|
||||
errors.Add("Type de demande inconnu.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var title = ApiText.CleanLine(submission.Title, MaxTitleLength);
|
||||
if (title is null || title.Length < MinTitleLength)
|
||||
errors.Add($"Titre : {MinTitleLength} à {MaxTitleLength} caractères sur une ligne, sans caractère spécial invisible.");
|
||||
|
||||
var fields = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var given = submission.Fields ?? new Dictionary<string, string>();
|
||||
foreach (var key in given.Keys)
|
||||
if (form.Field(key) is null)
|
||||
{
|
||||
errors.Add("Champ inconnu dans la demande.");
|
||||
break;
|
||||
}
|
||||
|
||||
var total = title?.Length ?? 0;
|
||||
foreach (var field in form.Fields)
|
||||
{
|
||||
given.TryGetValue(field.Id, out var raw);
|
||||
var value = CheckField(field, raw, errors);
|
||||
if (value is null) continue;
|
||||
fields[field.Id] = value;
|
||||
total += value.Length;
|
||||
}
|
||||
if (total > MaxTotalLength) errors.Add($"Demande trop longue ({total} caractères, {MaxTotalLength} au plus).");
|
||||
|
||||
var tech = CheckTech(submission.Tech, errors);
|
||||
return errors.Count > 0 ? null : submission with
|
||||
{
|
||||
Title = title!,
|
||||
Fields = fields,
|
||||
Tech = tech
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Valeur d'un champ contrôlée (null : vide ou invalide ; l'erreur est ajoutée si besoin).</summary>
|
||||
public static string? CheckField(FeedbackField field, string? raw, ICollection<string> errors)
|
||||
{
|
||||
switch (field.Kind)
|
||||
{
|
||||
case FeedbackFieldKind.Choice:
|
||||
case FeedbackFieldKind.Rating:
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : choix obligatoire.");
|
||||
return null;
|
||||
}
|
||||
var valid = field.Kind == FeedbackFieldKind.Rating
|
||||
? raw is "1" or "2" or "3" or "4" or "5"
|
||||
: field.Choices.Any(o => string.Equals(o.Id, raw, StringComparison.Ordinal));
|
||||
if (!valid)
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
|
||||
default:
|
||||
var text = CleanMultiline(raw, field.MaxLength, out var problem);
|
||||
if (problem is not null)
|
||||
{
|
||||
errors.Add($"{field.Label} : {problem}");
|
||||
return null;
|
||||
}
|
||||
if (text is null && field.Required) errors.Add($"{field.Label} : champ obligatoire.");
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Texte sur plusieurs lignes : fins de ligne « \n », tabulations remplacées par une espace, espaces de fin de
|
||||
/// ligne retirés, trois lignes vides consécutives au plus. Null si vide. <paramref name="problem"/> : raison du
|
||||
/// refus (caractère interdit, trop long, trop de lignes).
|
||||
/// </summary>
|
||||
public static string? CleanMultiline(string? value, int maxLength, out string? problem)
|
||||
{
|
||||
problem = null;
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
if (value.Length > maxLength * 2)
|
||||
{
|
||||
problem = $"{maxLength} caractères au plus.";
|
||||
return null;
|
||||
}
|
||||
var sb = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var c = value[i];
|
||||
if (c == '\r')
|
||||
{
|
||||
sb.Append('\n');
|
||||
if (i + 1 < value.Length && value[i + 1] == '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '\n')
|
||||
{
|
||||
sb.Append('\n');
|
||||
continue;
|
||||
}
|
||||
if (c == '\t')
|
||||
{
|
||||
sb.Append(' ');
|
||||
continue;
|
||||
}
|
||||
if (char.IsHighSurrogate(c))
|
||||
{
|
||||
if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]) &&
|
||||
CharUnicodeInfo.GetUnicodeCategory(value, i) is not (UnicodeCategory.Format or UnicodeCategory.PrivateUse))
|
||||
{
|
||||
sb.Append(c).Append(value[i + 1]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
problem = "caractère non autorisé.";
|
||||
return null;
|
||||
}
|
||||
if (char.IsLowSurrogate(c) || char.IsControl(c) || c is '' or '' ||
|
||||
CharUnicodeInfo.GetUnicodeCategory(c) is UnicodeCategory.Format or UnicodeCategory.LineSeparator
|
||||
or UnicodeCategory.ParagraphSeparator or UnicodeCategory.PrivateUse)
|
||||
{
|
||||
problem = "caractère invisible ou spécial non autorisé.";
|
||||
return null;
|
||||
}
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
var lines = sb.ToString().Split('\n').Select(l => l.TrimEnd()).ToList();
|
||||
var kept = new List<string>(lines.Count);
|
||||
var blanks = 0;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
blanks = line.Length == 0 ? blanks + 1 : 0;
|
||||
if (blanks <= 2) kept.Add(line);
|
||||
}
|
||||
var text = string.Join('\n', kept).Trim('\n', ' ');
|
||||
if (text.Length == 0) return null;
|
||||
if (text.Length > maxLength)
|
||||
{
|
||||
problem = $"{maxLength} caractères au plus ({text.Length} saisis).";
|
||||
return null;
|
||||
}
|
||||
if (text.Count(ch => ch == '\n') + 1 > MaxLines)
|
||||
{
|
||||
problem = $"{MaxLines} lignes au plus.";
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>Informations techniques : formats stricts (rien d'autre que ce que le launcher envoie).</summary>
|
||||
public static FeedbackTechInfo? CheckTech(FeedbackTechInfo? tech, ICollection<string> errors)
|
||||
{
|
||||
if (tech is null) return null;
|
||||
bool Ok(string? value, Regex regex) => value is null || regex.IsMatch(value);
|
||||
if (!Ok(tech.LauncherVersion, VersionRegex()) || !Ok(tech.GameVersion, VersionRegex()) ||
|
||||
!Ok(tech.Os, OsRegex()) || !Ok(tech.Language, LanguageRegex()))
|
||||
{
|
||||
errors.Add("Informations techniques invalides.");
|
||||
return null;
|
||||
}
|
||||
return tech.LauncherVersion is null && tech.GameVersion is null && tech.Os is null && tech.Language is null ? null : tech;
|
||||
}
|
||||
|
||||
/// <summary>Note interne d'un administrateur (vide : null) ; <paramref name="valid"/> faux si elle est refusée.</summary>
|
||||
public static string? CleanAdminNote(string? note, out bool valid)
|
||||
{
|
||||
var text = CleanMultiline(note, MaxAdminNoteLength, out var problem);
|
||||
valid = problem is null;
|
||||
return valid ? text : null;
|
||||
}
|
||||
|
||||
/// <summary>Version au format des informations techniques (« 2.5.0 », « 0.9.3-beta »), sinon null.</summary>
|
||||
public static string? SafeVersion(string? value) => value is not null && VersionRegex().IsMatch(value) ? value : null;
|
||||
|
||||
/// <summary>Description de Windows au format des informations techniques, sinon null.</summary>
|
||||
public static string? SafeOs(string? value) => value is not null && OsRegex().IsMatch(value) ? value : null;
|
||||
|
||||
public static string? SafeLanguage(string? value) => value is not null && LanguageRegex().IsMatch(value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
namespace GameLauncher.Contracts.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Fichiers du serveur » : contenu d'un dossier du site public (www), état de chaque fichier
|
||||
/// vis-à-vis des manifests, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu.
|
||||
/// Le serveur décide seul de ce qui peut être supprimé ; le launcher ne fait qu'afficher.
|
||||
/// </summary>
|
||||
public static class ServerFileStatus
|
||||
{
|
||||
/// <summary>Dossier (ouvrable, jamais supprimé depuis le menu).</summary>
|
||||
public const string Directory = "directory";
|
||||
|
||||
/// <summary>Fichier de description (.json : manifest, catalogue, trousseau, mise à jour du launcher) : jamais supprimé.</summary>
|
||||
public const string Protected = "protected";
|
||||
|
||||
/// <summary>Référencé par un manifest (archive, fichier de la version en ligne) ou par le catalogue (image).</summary>
|
||||
public const string InUse = "in_use";
|
||||
|
||||
/// <summary>Référencé par aucun fichier de description : peut être supprimé.</summary>
|
||||
public const string Unused = "unused";
|
||||
|
||||
/// <summary>Un fichier de description voisin est illisible : impossible de vérifier, donc jamais supprimé.</summary>
|
||||
public const string Unknown = "unknown";
|
||||
|
||||
/// <summary>Lien symbolique : ni suivi, ni supprimé.</summary>
|
||||
public const string Link = "link";
|
||||
}
|
||||
|
||||
/// <summary>Un élément d'un dossier.</summary>
|
||||
public sealed record ServerFileEntry
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public bool IsDirectory { get; init; }
|
||||
|
||||
/// <summary>Taille en octets (dossier : total de son contenu ; -1 si trop de fichiers pour le calculer).</summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>Nombre de fichiers d'un dossier (-1 si non calculé).</summary>
|
||||
public long FileCount { get; init; }
|
||||
|
||||
public DateTimeOffset Modified { get; init; }
|
||||
|
||||
/// <summary>Voir <see cref="ServerFileStatus"/>.</summary>
|
||||
public string Status { get; init; } = ServerFileStatus.Unknown;
|
||||
|
||||
/// <summary>Explication de l'état (« Archive de la version 1.2.0 (manifest de exovivalzombie/) »…).</summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'un dossier du site public.</summary>
|
||||
public sealed record ServerFolder
|
||||
{
|
||||
/// <summary>Chemin relatif à la racine du site (« » : racine ; séparateur « / »).</summary>
|
||||
public string Path { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché de la racine (dernier élément du chemin configuré, ex. « www »).</summary>
|
||||
public string RootName { get; init; } = "";
|
||||
|
||||
public List<ServerFileEntry> Entries { get; init; } = new();
|
||||
|
||||
/// <summary>Le dossier contient plus d'éléments que la limite affichée.</summary>
|
||||
public bool Truncated { get; init; }
|
||||
|
||||
/// <summary>Espace libre et taille du disque du site (-1 si inconnus).</summary>
|
||||
public long FreeBytes { get; init; } = -1;
|
||||
|
||||
public long TotalBytes { get; init; } = -1;
|
||||
|
||||
/// <summary>Jeux dont le dossier « objects » peut être nettoyé (identifiants configurés sur le serveur).</summary>
|
||||
public List<string> PrunableGames { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Suppression d'un fichier inutilisé (requête signée).</summary>
|
||||
public sealed record ServerFileDeleteRequest
|
||||
{
|
||||
/// <summary>Chemin relatif à la racine du site (séparateur « / »).</summary>
|
||||
public string Path { get; init; } = "";
|
||||
|
||||
/// <summary>Taille vue dans la liste : refus si le fichier a changé depuis (remplacé entre-temps).</summary>
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ServerFileDeleteResult
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public long FreedBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Analyse du dossier « objects » d'un jeu : fichiers que le manifest en ligne n'utilise plus.</summary>
|
||||
public sealed record ObjectsPrunePreview
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version du manifest en ligne.</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest analysé : à renvoyer pour le nettoyage (refus s'il a changé entre-temps).</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>Fichiers de la version en ligne (objets distincts).</summary>
|
||||
public int ReferencedCount { get; init; }
|
||||
|
||||
/// <summary>Fichiers de la version en ligne absents du serveur (information : à republier).</summary>
|
||||
public int MissingCount { get; init; }
|
||||
|
||||
public int OrphanCount { get; init; }
|
||||
|
||||
public long OrphanBytes { get; init; }
|
||||
|
||||
/// <summary>Quelques fichiers inutilisés (aperçu).</summary>
|
||||
public List<string> Examples { get; init; } = new();
|
||||
|
||||
/// <summary>Une publication de ce jeu est en cours : nettoyage impossible pour l'instant.</summary>
|
||||
public bool PublicationInProgress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Nettoyage du dossier « objects » d'un jeu (requête signée).</summary>
|
||||
public sealed record ObjectsPruneRequest
|
||||
{
|
||||
/// <summary><see cref="ObjectsPrunePreview.ManifestSha256"/> de l'analyse affichée.</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ObjectsPruneResult
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public int Deleted { get; init; }
|
||||
public long FreedBytes { get; init; }
|
||||
|
||||
/// <summary>Fichiers qui n'ont pas pu être supprimés (droits) : le serveur l'explique dans son journal.</summary>
|
||||
public int Failed { get; init; }
|
||||
}
|
||||
|
||||
public static class ServerFilesLimits
|
||||
{
|
||||
/// <summary>Éléments renvoyés au plus pour un dossier.</summary>
|
||||
public const int MaxEntries = 2000;
|
||||
|
||||
/// <summary>Fichiers parcourus au plus pour calculer la taille des sous-dossiers d'un dossier.</summary>
|
||||
public const int MaxScannedFiles = 200_000;
|
||||
|
||||
public const int MaxPathLength = 1024;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Groupes d'administrateurs (anciennement « rôles ») et permissions personnalisées : règles communes au
|
||||
/// serveur (refus de toute valeur invalide) et au menu d'administration (contrôle avant l'envoi).
|
||||
/// • Chaque administrateur appartient à UN groupe ; ses droits sont les permissions de ce groupe, relues
|
||||
/// à chaque requête (une modification s'applique immédiatement, sessions ouvertes comprises).
|
||||
/// • Le groupe « Propriétaire » (<see cref="AdminRoles.Owner"/>) est un groupe SYSTÈME : il a toujours
|
||||
/// toutes les permissions (y compris celles créées plus tard) et ne peut être ni modifié ni supprimé.
|
||||
/// • Les groupes « Administrateur » et « Éditeur » sont créés par défaut ; comme tout autre groupe, ils
|
||||
/// se modifient, et se suppriment s'ils n'ont plus de membre ni d'invitation en cours.
|
||||
/// </summary>
|
||||
public static partial class AdminGroups
|
||||
{
|
||||
public const int MaxGroups = 50;
|
||||
public const int MaxNameLength = 40;
|
||||
public const int MaxDescriptionLength = 200;
|
||||
public const int MaxCustomPermissions = 40;
|
||||
public const int MaxPermissionLabelLength = 40;
|
||||
public const int MaxPermissionNameLength = 57;
|
||||
|
||||
/// <summary>Identifiant d'un groupe : minuscules, chiffres et tirets (jamais modifié après création).</summary>
|
||||
[GeneratedRegex(@"\A[a-z][a-z0-9-]{1,31}\z")]
|
||||
private static partial Regex GroupIdRegex();
|
||||
|
||||
/// <summary>Permission personnalisée : « module.action » (une seule séparation, minuscules).</summary>
|
||||
[GeneratedRegex(@"\A[a-z][a-z0-9]{1,23}\.[a-z][a-z0-9_-]{1,31}\z")]
|
||||
private static partial Regex PermissionNameRegex();
|
||||
|
||||
public static bool IsValidGroupId(string? id) => id is not null && GroupIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidPermissionName(string? name) => name is not null && PermissionNameRegex().IsMatch(name);
|
||||
|
||||
/// <summary>Groupe système (« Propriétaire ») : toutes les permissions, non modifiable.</summary>
|
||||
public static bool IsSystem(string? groupId) => groupId == AdminRoles.Owner;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant proposé pour un nouveau groupe, d'après son nom : « Modérateurs forum » → « moderateurs-forum ».
|
||||
/// Le serveur ajoute un suffixe s'il existe déjà.
|
||||
/// </summary>
|
||||
public static string Slug(string name)
|
||||
{
|
||||
var decomposed = name.Normalize(NormalizationForm.FormD);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in decomposed)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
|
||||
var lower = char.ToLowerInvariant(c);
|
||||
if (lower is >= 'a' and <= 'z' or >= '0' and <= '9') sb.Append(lower);
|
||||
else if (sb.Length > 0 && sb[^1] != '-') sb.Append('-');
|
||||
}
|
||||
var slug = sb.ToString().Trim('-');
|
||||
if (slug.Length > 24) slug = slug[..24].TrimEnd('-');
|
||||
if (slug.Length < 2 || !char.IsAsciiLetterLower(slug[0])) slug = "groupe-" + slug;
|
||||
return slug.TrimEnd('-');
|
||||
}
|
||||
|
||||
/// <summary>Nom d'un groupe : une ligne, sans caractère invisible, <see cref="MaxNameLength"/> caractères au plus.</summary>
|
||||
public static string? CleanName(string? name) => ApiText.CleanLine(name, MaxNameLength);
|
||||
|
||||
/// <summary>Description facultative (vide : null) ; <paramref name="valid"/> faux si elle est invalide.</summary>
|
||||
public static string? CleanDescription(string? description, int maxLength, out bool valid)
|
||||
{
|
||||
valid = true;
|
||||
if (string.IsNullOrWhiteSpace(description)) return null;
|
||||
var clean = ApiText.CleanLine(description, maxLength);
|
||||
valid = clean is not null;
|
||||
return clean;
|
||||
}
|
||||
|
||||
/// <summary>Nom affiché d'un groupe connu par défaut (null : groupe créé depuis le menu d'administration).</summary>
|
||||
public static string? DefaultName(string groupId) => groupId switch
|
||||
{
|
||||
AdminRoles.Owner => "Propriétaire",
|
||||
AdminRoles.Admin => "Administrateur",
|
||||
AdminRoles.Editor => "Éditeur",
|
||||
_ => null
|
||||
};
|
||||
|
||||
public static string? DefaultDescription(string groupId) => groupId switch
|
||||
{
|
||||
AdminRoles.Owner => "Tous les droits, y compris ceux des permissions créées plus tard. Groupe système : non modifiable.",
|
||||
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes des joueurs et journal d'audit.",
|
||||
AdminRoles.Editor => "Informations des jeux et notes de version.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Permissions d'administration, au format « module.action ». Un module futur ajoute les siennes
|
||||
/// ici (ou dans son propre fichier de contrats) et les attribue aux rôles dans <see cref="AdminRoles"/>.
|
||||
/// Permissions d'administration INTÉGRÉES, au format « module.action » : celles que le code vérifie (routes
|
||||
/// du serveur, pages du launcher). Un module futur ajoute les siennes ici. Elles ne peuvent être ni créées ni
|
||||
/// supprimées depuis le menu d'administration ; les groupes (<see cref="AdminGroups"/>) les reçoivent.
|
||||
/// Des permissions PERSONNALISÉES peuvent être ajoutées depuis la page « Groupes et permissions » (même
|
||||
/// format) : elles sont attribuables aux groupes, en prévision de modules futurs.
|
||||
/// Le serveur est seul juge : côté launcher, elles servent uniquement à masquer les pages inutiles.
|
||||
/// </summary>
|
||||
public static class AdminPermissions
|
||||
@@ -22,8 +25,51 @@ public static class AdminPermissions
|
||||
/// <summary>Publication d'une nouvelle version du jeu — phase 5.</summary>
|
||||
public const string ReleasesPublish = "releases.publish";
|
||||
|
||||
/// <summary>Fichiers du site public (www) : consultation, suppression des fichiers inutilisés, nettoyage d'« objects ».</summary>
|
||||
public const string FilesManage = "files.manage";
|
||||
|
||||
/// <summary>Lecture des demandes des joueurs (avis, évolutions, bugs…), rangées par jeu.</summary>
|
||||
public const string FeedbackRead = "feedback.read";
|
||||
|
||||
/// <summary>Traitement des demandes des joueurs : statut, note interne, suppression.</summary>
|
||||
public const string FeedbackManage = "feedback.manage";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage
|
||||
};
|
||||
|
||||
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
|
||||
public static bool IsBuiltIn(string? permission) => permission is not null && All.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Nom court d'une permission intégrée (null : permission personnalisée).</summary>
|
||||
public static string? BuiltInLabel(string permission) => permission switch
|
||||
{
|
||||
AdminsManage => "Gestion des administrateurs",
|
||||
AuditRead => "Journal d'audit",
|
||||
GamesEdit => "Informations des jeux",
|
||||
ChangelogEdit => "Notes de version",
|
||||
ReleasesPublish => "Publication des versions",
|
||||
FilesManage => "Fichiers du serveur",
|
||||
FeedbackRead => "Demandes des joueurs (lecture)",
|
||||
FeedbackManage => "Demandes des joueurs (traitement)",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>Ce que permet une permission intégrée (null : permission personnalisée).</summary>
|
||||
public static string? BuiltInDescription(string permission) => permission switch
|
||||
{
|
||||
AdminsManage => "Inviter, approuver, changer de groupe et révoquer les administrateurs ; gérer les groupes et les permissions ; " +
|
||||
"publier le trousseau de signature. Équivaut presque aux droits d'un propriétaire.",
|
||||
AuditRead => "Consulter et exporter le journal d'audit.",
|
||||
GamesEdit => "Modifier et publier les informations des jeux (nom affiché, présentation, liens, icône, image de fond).",
|
||||
ChangelogEdit => "Corriger les notes des versions déjà publiées.",
|
||||
ReleasesPublish => "Publier une nouvelle version d'un jeu et signer une version déjà en ligne.",
|
||||
FilesManage => "Parcourir le site, supprimer les fichiers inutilisés et nettoyer le dossier « objects ».",
|
||||
FeedbackRead => "Lire les demandes envoyées par les joueurs (avis, évolutions, bugs) : leur texte libre peut contenir " +
|
||||
"des données personnelles. Chaque consultation est inscrite au journal d'audit.",
|
||||
FeedbackManage => "Changer le statut des demandes des joueurs, écrire une note interne, supprimer une demande " +
|
||||
"(à donner avec la lecture).",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>Rôles des administrateurs et permissions associées.</summary>
|
||||
/// <summary>
|
||||
/// Groupes créés par défaut (identifiants historiques des « rôles ») et leurs permissions INITIALES.
|
||||
/// Depuis la gestion des groupes, les permissions effectives d'un groupe sont celles enregistrées sur le
|
||||
/// serveur (admins.json, voir <see cref="AdminGroups"/>) : <see cref="PermissionsOf"/> ne sert plus qu'à
|
||||
/// créer les groupes par défaut (premier démarrage, migration) et de valeur de repli pour un ancien serveur.
|
||||
/// </summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
/// <summary>Tous les droits, dont la gestion des administrateurs.</summary>
|
||||
public const string Owner = "owner";
|
||||
|
||||
/// <summary>Contenu et publication, sans gestion des administrateurs.</summary>
|
||||
/// <summary>Contenu, publication et fichiers du site, sans gestion des administrateurs.</summary>
|
||||
public const string Admin = "admin";
|
||||
|
||||
/// <summary>Contenu uniquement (informations des jeux, notes de version).</summary>
|
||||
@@ -21,7 +26,8 @@ public static class AdminRoles
|
||||
[Admin] = new[]
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
|
||||
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
@@ -39,7 +45,8 @@ public static class AdminRoles
|
||||
Owner => "Propriétaire",
|
||||
Admin => "Administrateur",
|
||||
Editor => "Éditeur",
|
||||
_ => "Inconnu"
|
||||
null or "" => "Inconnu",
|
||||
_ => role // groupe créé depuis le menu d'administration : son identifiant (le nom vient du serveur)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -48,7 +48,7 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
|
||||
|
||||
protected AccountsPanelBase(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
@@ -62,7 +62,9 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
|
||||
get => _statusMessage;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
if (!SetProperty(ref _statusMessage, value)) return;
|
||||
OnPropertyChanged(nameof(HasStatus));
|
||||
if (value is not null) RequestScrollToTop(); // résultat d'une action d'une liste : bandeau en haut de la page
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +250,7 @@ public sealed class InvitationItem
|
||||
IsDevice = invitation.ForAdminId is not null;
|
||||
Title = IsDevice
|
||||
? $"Nouvel appareil de {invitation.DisplayName}"
|
||||
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role)}";
|
||||
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role, invitation.RoleName)}";
|
||||
DetailText = $"Créée par {invitation.CreatedBy ?? "le serveur"} le {AccountTexts.Date(invitation.CreatedAt)} · " +
|
||||
$"expire le {AccountTexts.Date(invitation.ExpiresAt)}";
|
||||
CanDelete = canDelete;
|
||||
|
||||
@@ -3,8 +3,8 @@ using GameLauncher.Contracts.Security;
|
||||
namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Comptes » : page « Mes appareils » (tout administrateur) et page « Administrateurs »
|
||||
/// (gestion des administrateurs : propriétaires).
|
||||
/// Module « Comptes » : page « Mes appareils » (tout administrateur), page « Administrateurs » et page
|
||||
/// « Groupes et permissions » (gestion des administrateurs : admins.manage).
|
||||
/// </summary>
|
||||
public sealed class AccountsAdminModule : ILauncherModule
|
||||
{
|
||||
@@ -26,10 +26,19 @@ public sealed class AccountsAdminModule : ILauncherModule
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
AdminsPanelViewModel.PanelId,
|
||||
"Administrateurs",
|
||||
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son rôle ou révoquez-le.",
|
||||
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son groupe ou révoquez-le.",
|
||||
"AccountGroup",
|
||||
AdminPermissions.AdminsManage,
|
||||
Order: 50,
|
||||
context => new AdminsPanelViewModel(context)));
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
GroupsPanelViewModel.PanelId,
|
||||
"Groupes et permissions",
|
||||
"Créez, modifiez ou supprimez des groupes d'administrateurs et choisissez leurs permissions ; créez des permissions " +
|
||||
"personnalisées. Les changements s'appliquent immédiatement, sessions ouvertes comprises.",
|
||||
"ShieldAccount",
|
||||
AdminPermissions.AdminsManage,
|
||||
Order: 52,
|
||||
context => new GroupsPanelViewModel(context)));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
@@ -8,9 +9,10 @@ namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Administrateurs » (admins.manage) : comptes et appareils, invitations, approbation par
|
||||
/// empreinte, rôles, révocation. Le serveur reste seul juge : cette page ne propose que ce qu'il
|
||||
/// accepte (pas de changement de son propre rôle, pas d'appareil approuvé au nom d'un autre
|
||||
/// administrateur actif…) et affiche ses refus tels quels.
|
||||
/// empreinte, groupe de chaque compte, révocation. Le serveur reste seul juge : cette page ne propose que
|
||||
/// ce qu'il accepte (pas de changement de son propre groupe, pas d'appareil approuvé au nom d'un autre
|
||||
/// administrateur actif, seulement les groupes dont on a soi-même toutes les permissions…) et affiche ses
|
||||
/// refus tels quels. Les groupes viennent du serveur (page « Groupes et permissions »).
|
||||
/// </summary>
|
||||
public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
{
|
||||
@@ -22,15 +24,27 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
private bool _showRevoked;
|
||||
private string _newName = "";
|
||||
private Choice _newRole;
|
||||
private IReadOnlyList<GroupSummary> _groupSummaries = Array.Empty<GroupSummary>();
|
||||
private IReadOnlyList<Choice> _roleChoices;
|
||||
|
||||
public AdminsPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RoleChoices = AdminRoles.All.Select(r => new Choice(r, AdminTexts.Role(r))).ToList();
|
||||
_newRole = RoleChoices.First(c => c.Value == AdminRoles.Editor);
|
||||
InviteCommand = new AsyncRelayCommand(InviteAsync, () => !IsBusy && !HasCreatedInvitation && !string.IsNullOrWhiteSpace(_newName));
|
||||
// Groupes par défaut, remplacés au chargement par ceux du serveur.
|
||||
_roleChoices = AdminRoles.All.Select(r => new Choice(r, AdminTexts.Role(r))).ToList();
|
||||
_newRole = _roleChoices.First(c => c.Value == AdminRoles.Editor);
|
||||
InviteCommand = new AsyncRelayCommand(InviteAsync,
|
||||
() => !IsBusy && !HasCreatedInvitation && !string.IsNullOrWhiteSpace(_newName) && _newRole.Value is not null);
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> RoleChoices { get; }
|
||||
/// <summary>Groupes que l'administrateur connecté peut attribuer (ceux dont il a toutes les permissions).</summary>
|
||||
public IReadOnlyList<Choice> RoleChoices
|
||||
{
|
||||
get => _roleChoices;
|
||||
private set => SetProperty(ref _roleChoices, value);
|
||||
}
|
||||
|
||||
internal string GroupName(string? groupId) =>
|
||||
_groupSummaries.FirstOrDefault(g => g.Id == groupId)?.Name ?? AdminTexts.Role(groupId);
|
||||
|
||||
public IReadOnlyList<AdminAccountItem> Admins
|
||||
{
|
||||
@@ -94,11 +108,26 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
get => _newRole;
|
||||
set
|
||||
{
|
||||
if (value is not null && SetProperty(ref _newRole, value)) OnPropertyChanged(nameof(NewRoleHint));
|
||||
if (value is null || !SetProperty(ref _newRole, value)) return;
|
||||
OnPropertyChanged(nameof(NewRoleHint));
|
||||
InviteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string NewRoleHint => $"{AdminTexts.Role(_newRole.Value)} : {AccountTexts.RoleHint(_newRole.Value ?? "")}.";
|
||||
public string NewRoleHint => _newRole.Value is null ? "Aucun groupe disponible." : $"{GroupName(_newRole.Value)} : {GroupHint(_newRole.Value)}";
|
||||
|
||||
/// <summary>Ce que permet un groupe : sa description, sinon la liste de ses permissions.</summary>
|
||||
internal string GroupHint(string groupId)
|
||||
{
|
||||
var group = _groupSummaries.FirstOrDefault(g => g.Id == groupId);
|
||||
if (group is null) return AccountTexts.RoleHint(groupId) is { Length: > 0 } hint ? hint + "." : "";
|
||||
if (!string.IsNullOrWhiteSpace(group.Description)) return group.Description!;
|
||||
return group.Permissions.Count == 0 ? "aucune permission." : string.Join(", ", group.Permissions.Select(AdminTexts.Permission)) + ".";
|
||||
}
|
||||
|
||||
internal bool GroupHas(string groupId, string permission) =>
|
||||
_groupSummaries.FirstOrDefault(g => g.Id == groupId) is { } group ? group.Permissions.Contains(permission)
|
||||
: AdminRoles.Has(groupId, permission);
|
||||
|
||||
public AsyncRelayCommand InviteCommand { get; }
|
||||
|
||||
@@ -113,6 +142,11 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
$"{name} aura tous les droits, dont celui de gérer les administrateurs (y compris de révoquer votre compte).",
|
||||
"Inviter", danger: true))
|
||||
return;
|
||||
if (role != AdminRoles.Owner && GroupHas(role, AdminPermissions.AdminsManage) &&
|
||||
!Context.Dialogs.Confirm("Inviter un gestionnaire d'administrateurs ?",
|
||||
$"Le groupe « {GroupName(role)} » permet de gérer les administrateurs, les groupes et les permissions (sans dépasser ses propres droits).",
|
||||
"Inviter", danger: true))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
InvitationCreated? created = null;
|
||||
if (!await RunAsync(async () =>
|
||||
@@ -120,7 +154,7 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
return;
|
||||
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
|
||||
NewName = "";
|
||||
ShowInvitation(created!, $"Invitation de {created!.Invitation.DisplayName} ({AdminTexts.Role(created.Invitation.Role)})",
|
||||
ShowInvitation(created!, $"Invitation de {created!.Invitation.DisplayName} ({AdminTexts.Role(created.Invitation.Role, created.Invitation.RoleName)})",
|
||||
$"Transmettez ce code à {created.Invitation.DisplayName}. Dans le launcher : menu d'administration › « J'ai une invitation ». " +
|
||||
"Le launcher lui affichera ensuite une empreinte : faites-la-vous dicter (téléphone, message), puis approuvez son appareil " +
|
||||
"ci-dessous en la saisissant.");
|
||||
@@ -133,16 +167,18 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
{
|
||||
var role = admin.SelectedRole.Value!;
|
||||
if (role == admin.Role) return;
|
||||
var message = $"{admin.DisplayName} : {AdminTexts.Role(admin.Role)} → {AdminTexts.Role(role)}. Le changement s'applique " +
|
||||
var message = $"{admin.DisplayName} : {admin.RoleText} → {GroupName(role)}. Le changement s'applique " +
|
||||
"immédiatement, y compris à ses sessions ouvertes.";
|
||||
var sensitive = role == AdminRoles.Owner || GroupHas(role, AdminPermissions.AdminsManage);
|
||||
if (role == AdminRoles.Owner) message += "\n\nUn propriétaire peut gérer tous les administrateurs, y compris révoquer votre compte.";
|
||||
if (!Context.Dialogs.Confirm("Changer le rôle ?", message, "Changer le rôle", danger: role == AdminRoles.Owner))
|
||||
else if (sensitive) message += "\n\nCe groupe permet de gérer les administrateurs, les groupes et les permissions.";
|
||||
if (!Context.Dialogs.Confirm("Changer de groupe ?", message, "Changer de groupe", danger: sensitive))
|
||||
{
|
||||
admin.ResetRole();
|
||||
return;
|
||||
}
|
||||
if (!await ApplyAsync(() => Context.Session.ChangeRoleAsync(admin.Id, role),
|
||||
$"{admin.DisplayName} est maintenant {AdminTexts.Role(role).ToLowerInvariant()}."))
|
||||
$"{admin.DisplayName} est maintenant dans le groupe « {GroupName(role)} »."))
|
||||
admin.ResetRole();
|
||||
}
|
||||
|
||||
@@ -160,6 +196,7 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
|
||||
protected override async Task ReloadAsync()
|
||||
{
|
||||
await LoadGroupsAsync();
|
||||
var admins = await Context.Session.ListAdminsAsync();
|
||||
var invitations = await Context.Session.ListInvitationsAsync();
|
||||
_summaries = admins;
|
||||
@@ -169,6 +206,32 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
OnPropertyChanged(nameof(SummaryText));
|
||||
}
|
||||
|
||||
/// <summary>Groupes du serveur : choix proposés limités à ceux que l'administrateur connecté peut attribuer.</summary>
|
||||
private async Task LoadGroupsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_groupSummaries = (await Context.Session.GetGroupsAsync()).Groups;
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
return; // serveur antérieur à la gestion des groupes : rôles par défaut
|
||||
}
|
||||
var me = Context.Session.Profile;
|
||||
var mine = me.Permissions.ToHashSet(StringComparer.Ordinal);
|
||||
RoleChoices = _groupSummaries
|
||||
.Where(g => g.System ? me.Role == AdminRoles.Owner : g.Permissions.All(mine.Contains))
|
||||
.Select(g => new Choice(g.Id, g.Name)).ToList();
|
||||
_newRole = RoleChoices.FirstOrDefault(c => c.Value == _newRole.Value)
|
||||
?? RoleChoices.FirstOrDefault(c => c.Value == AdminRoles.Editor)
|
||||
?? RoleChoices.FirstOrDefault(c => c.Value != AdminRoles.Owner)
|
||||
?? RoleChoices.FirstOrDefault()
|
||||
?? new Choice(null, "Aucun groupe");
|
||||
OnPropertyChanged(nameof(NewRole));
|
||||
OnPropertyChanged(nameof(NewRoleHint));
|
||||
InviteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void BuildAdmins()
|
||||
{
|
||||
var me = Context.Session.Profile;
|
||||
@@ -178,7 +241,8 @@ public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
.OrderBy(a => a.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
|
||||
.ThenBy(a => a.Id == me.Id ? 0 : 1)
|
||||
.ThenBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.Select(a => new AdminAccountItem(this, a, me.Id, me.KeyId, RoleChoices))
|
||||
.Select(a => new AdminAccountItem(this, a, me.Id, me.KeyId,
|
||||
RoleChoices.Any(c => c.Value == a.Role) ? RoleChoices : RoleChoices.Prepend(new Choice(a.Role, AdminTexts.Role(a.Role, a.RoleName))).ToList()))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -196,6 +260,7 @@ public sealed class AdminAccountItem : ObservableObject
|
||||
DisplayName = admin.DisplayName;
|
||||
Initials = AdminTexts.Initials(admin.DisplayName);
|
||||
Role = admin.Role;
|
||||
RoleText = AdminTexts.Role(admin.Role, admin.RoleName);
|
||||
Status = admin.Status;
|
||||
IsYou = admin.Id == myId;
|
||||
CreatedText = $"Invité le {AccountTexts.Date(admin.CreatedAt)}";
|
||||
@@ -226,7 +291,7 @@ public sealed class AdminAccountItem : ObservableObject
|
||||
public string DisplayName { get; }
|
||||
public string Initials { get; }
|
||||
public string Role { get; }
|
||||
public string RoleText => AdminTexts.Role(Role);
|
||||
public string RoleText { get; }
|
||||
public string Status { get; }
|
||||
public string StatusText => AccountTexts.Status(Status);
|
||||
public bool IsPending => Status == AdminStatus.Pending;
|
||||
@@ -235,7 +300,7 @@ public sealed class AdminAccountItem : ObservableObject
|
||||
public string CreatedText { get; }
|
||||
public IReadOnlyList<AdminKeyItem> Keys { get; }
|
||||
|
||||
/// <summary>On ne change ni son propre rôle ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
|
||||
/// <summary>On ne change ni son propre groupe ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
|
||||
public bool CanChangeRole => !IsYou && !IsRevoked;
|
||||
|
||||
public bool CanRevoke => !IsYou && !IsRevoked;
|
||||
@@ -253,7 +318,7 @@ public sealed class AdminAccountItem : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rôle choisi différent du rôle actuel : bouton « Appliquer » affiché.</summary>
|
||||
/// <summary>Groupe choisi différent du groupe actuel : bouton « Appliquer » affiché.</summary>
|
||||
public bool RoleChanged => _selectedRole.Value != Role;
|
||||
|
||||
public AsyncRelayCommand ChangeRoleCommand { get; }
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Groupes et permissions » (admins.manage) :
|
||||
/// • à gauche, les groupes (nombre de membres, pastille « modifié ») et « Nouveau groupe » ;
|
||||
/// • à droite, le groupe choisi : nom, description, permissions à cocher ; « Enregistrer » dans la barre du bas ;
|
||||
/// • en dessous, les permissions : intégrées (vérifiées par le code, non supprimables) et personnalisées
|
||||
/// (création, suppression après confirmation : elle est retirée de tous les groupes).
|
||||
/// La page ne propose que ce que le serveur accepte (groupe « Propriétaire » en lecture seule, pas de
|
||||
/// modification des permissions de son propre groupe, pas de permission accordée que l'on n'a pas soi-même,
|
||||
/// pas de suppression d'un groupe qui a des membres ou des invitations) ; le serveur reste seul juge et ses
|
||||
/// refus sont affichés tels quels.
|
||||
/// </summary>
|
||||
public sealed class GroupsPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "admins.groups";
|
||||
|
||||
private GroupsState _state = new();
|
||||
private IReadOnlyList<GroupItem> _groups = Array.Empty<GroupItem>();
|
||||
private IReadOnlyList<PermissionItem> _permissions = Array.Empty<PermissionItem>();
|
||||
private GroupItem? _selected;
|
||||
private string? _statusMessage;
|
||||
private string _newPermissionName = "", _newPermissionLabel = "", _newPermissionDescription = "";
|
||||
|
||||
public GroupsPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(ReloadWithConfirmationAsync), () => !IsBusy);
|
||||
SaveCommand = new AsyncRelayCommand(ScrollingToTop(SaveAsync), () => !IsBusy && _selected?.Editor.CanSave == true);
|
||||
NewGroupCommand = new RelayCommand(NewGroup, () => !IsBusy && !_groups.Any(g => g.IsNew) && _groups.Count < AdminGroups.MaxGroups);
|
||||
CreatePermissionCommand = new AsyncRelayCommand(CreatePermissionAsync,
|
||||
() => !IsBusy && !string.IsNullOrWhiteSpace(_newPermissionName) && !string.IsNullOrWhiteSpace(_newPermissionLabel));
|
||||
}
|
||||
|
||||
/// <summary>Liste des groupes et fiche (puis permissions) défilent séparément.</summary>
|
||||
public override bool HasSplitLayout => true;
|
||||
|
||||
public IReadOnlyList<GroupItem> Groups
|
||||
{
|
||||
get => _groups;
|
||||
private set => SetProperty(ref _groups, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<PermissionItem> Permissions
|
||||
{
|
||||
get => _permissions;
|
||||
private set => SetProperty(ref _permissions, value);
|
||||
}
|
||||
|
||||
/// <summary>Groupe affiché à droite (ses modifications non enregistrées restent en attente si l'on en choisit un autre).</summary>
|
||||
public GroupItem? Selected
|
||||
{
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _selected, value)) return; // null : liste en cours de remplacement
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
/// <summary>Faux pendant un échange avec le serveur (saisie suspendue).</summary>
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
public string SummaryText =>
|
||||
$"{_state.Groups.Count} groupe(s) · {_state.Permissions.Count(p => p.BuiltIn)} permission(s) intégrée(s), " +
|
||||
$"{_state.Permissions.Count(p => !p.BuiltIn)} personnalisée(s)";
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand SaveCommand { get; }
|
||||
public RelayCommand NewGroupCommand { get; }
|
||||
public AsyncRelayCommand CreatePermissionCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Nouvelle permission
|
||||
|
||||
public string NewPermissionName
|
||||
{
|
||||
get => _newPermissionName;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _newPermissionName, value ?? "")) CreatePermissionCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string NewPermissionLabel
|
||||
{
|
||||
get => _newPermissionLabel;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _newPermissionLabel, value ?? "")) CreatePermissionCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string NewPermissionDescription
|
||||
{
|
||||
get => _newPermissionDescription;
|
||||
set => SetProperty(ref _newPermissionDescription, value ?? "");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Droits de l'administrateur connecté
|
||||
|
||||
internal string MyGroupId => Context.Session.Profile.Role;
|
||||
internal bool IAmOwner => AdminGroups.IsSystem(MyGroupId);
|
||||
internal bool IHave(string permission) => Context.Session.Has(permission);
|
||||
|
||||
internal string LabelOf(string permission) =>
|
||||
_state.Permissions.FirstOrDefault(p => p.Name == permission)?.Label ?? AdminTexts.Permission(permission);
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
RaiseCommands();
|
||||
}
|
||||
|
||||
internal void RaiseCommands()
|
||||
{
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
NewGroupCommand.RaiseCanExecuteChanged();
|
||||
CreatePermissionCommand.RaiseCanExecuteChanged();
|
||||
foreach (var group in _groups) group.Editor.RaiseCommands();
|
||||
foreach (var permission in _permissions) permission.DeleteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(LoadAsync) : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
var state = await Context.Session.GetGroupsAsync();
|
||||
await RefreshOwnProfileAsync(); // droits à jour : cases que l'on peut cocher, permissions supprimables
|
||||
Apply(state);
|
||||
}
|
||||
|
||||
private async Task ReloadWithConfirmationAsync()
|
||||
{
|
||||
if (_groups.Any(g => g.Editor.IsDirty) &&
|
||||
!Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les modifications non enregistrées des groupes seront perdues : la page affichera les groupes enregistrés sur le serveur.",
|
||||
"Abandonner et recharger"))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
await RunAsync(LoadAsync);
|
||||
}
|
||||
|
||||
/// <summary>Affiche l'état renvoyé par le serveur ; resélectionne <paramref name="selectId"/> (sinon le groupe affiché).</summary>
|
||||
private void Apply(GroupsState state, string? selectId = null)
|
||||
{
|
||||
_state = state;
|
||||
selectId ??= _selected is { IsNew: false } current ? current.Id : null;
|
||||
var items = state.Groups.Select(g => new GroupItem(this, g, state.Permissions)).ToList();
|
||||
Groups = items;
|
||||
Permissions = state.Permissions.Select(p => new PermissionItem(this, p)).ToList();
|
||||
_selected = items.FirstOrDefault(g => g.Id == selectId) ?? items.FirstOrDefault(g => !g.IsSystem) ?? items.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(Selected));
|
||||
OnPropertyChanged(nameof(SummaryText));
|
||||
RaiseCommands();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Groupes
|
||||
|
||||
private void NewGroup()
|
||||
{
|
||||
var draft = GroupItem.NewDraft(this, _state.Permissions);
|
||||
Groups = _groups.Append(draft).ToList();
|
||||
Selected = draft;
|
||||
StatusMessage = null;
|
||||
RaiseCommands();
|
||||
}
|
||||
|
||||
/// <summary>Retire le nouveau groupe pas encore enregistré.</summary>
|
||||
internal void DiscardDraft(GroupItem draft)
|
||||
{
|
||||
Groups = _groups.Where(g => g != draft).ToList();
|
||||
_selected = _groups.FirstOrDefault(g => !g.IsSystem) ?? _groups.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(Selected));
|
||||
RaiseCommands();
|
||||
}
|
||||
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_selected is not { } group) return;
|
||||
var editor = group.Editor;
|
||||
var errors = editor.Validate();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
var added = editor.AddedPermissions();
|
||||
if (added.Contains(AdminPermissions.AdminsManage) &&
|
||||
!Context.Dialogs.Confirm("Donner la gestion des administrateurs ?",
|
||||
$"Les membres de « {editor.Name.Trim()} » pourront inviter et révoquer des administrateurs, et gérer les groupes et les " +
|
||||
"permissions (sans jamais dépasser leurs propres droits). C'est presque un droit de propriétaire.",
|
||||
"Donner ce droit", danger: true))
|
||||
return;
|
||||
if (!group.IsNew && editor.RemovedPermissions().Count > 0 && group.Summary.MemberCount > 0 &&
|
||||
!Context.Dialogs.Confirm("Retirer des permissions ?",
|
||||
$"Les {group.Summary.MemberCount} membre(s) de « {group.Name} » perdent immédiatement ces droits, y compris dans leurs sessions ouvertes :\n• " +
|
||||
string.Join("\n• ", editor.RemovedPermissions().Select(LabelOf)),
|
||||
"Retirer"))
|
||||
return;
|
||||
|
||||
var request = editor.ToRequest();
|
||||
StatusMessage = null;
|
||||
try
|
||||
{
|
||||
if (group.IsNew)
|
||||
{
|
||||
var state = await Context.Session.CreateGroupAsync(request);
|
||||
await RefreshOwnProfileAsync();
|
||||
var created = state.Groups.FirstOrDefault(g => string.Equals(g.Name, request.Name.Trim(), StringComparison.CurrentCultureIgnoreCase));
|
||||
Apply(state, created?.Id);
|
||||
StatusMessage = $"Groupe « {request.Name.Trim()} » créé. Placez-y des administrateurs depuis la page « Administrateurs ».";
|
||||
Log.Info($"Administration : groupe « {created?.Id} » créé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var state = await Context.Session.UpdateGroupAsync(group.Id, request);
|
||||
await RefreshOwnProfileAsync();
|
||||
Apply(state, group.Id);
|
||||
StatusMessage = $"Groupe « {request.Name.Trim()} » enregistré. Les droits de ses membres changent immédiatement.";
|
||||
Log.Info($"Administration : groupe « {group.Id} » modifié.");
|
||||
}
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Modifié entre-temps",
|
||||
"Ce groupe a été modifié par un autre administrateur pendant vos modifications. Rechargez la page pour partir de sa " +
|
||||
"version (vos modifications non enregistrées seront perdues : notez-les si besoin).", ModuleMessageKind.Warning);
|
||||
}
|
||||
});
|
||||
|
||||
internal async Task DeleteGroupAsync(GroupItem group)
|
||||
{
|
||||
if (group.IsNew)
|
||||
{
|
||||
DiscardDraft(group);
|
||||
return;
|
||||
}
|
||||
if (!Context.Dialogs.Confirm($"Supprimer le groupe « {group.Name} » ?",
|
||||
"Il n'a plus de membre ni d'invitation en cours. La suppression est définitive (inscrite au journal d'audit).",
|
||||
"Supprimer le groupe", danger: true))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
if (await RunAsync(async () => Apply(await Context.Session.DeleteGroupAsync(group.Id))))
|
||||
StatusMessage = $"Groupe « {group.Name} » supprimé.";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Permissions personnalisées
|
||||
|
||||
private Task CreatePermissionAsync() => RunAsync(async () =>
|
||||
{
|
||||
var name = _newPermissionName.Trim().ToLowerInvariant();
|
||||
var errors = new List<string>();
|
||||
if (!AdminGroups.IsValidPermissionName(name))
|
||||
errors.Add("Nom : format « module.action » en minuscules, sans espace ni accent (ex. « news.edit »).");
|
||||
else if (_state.Permissions.Any(p => p.Name == name)) errors.Add("Cette permission existe déjà.");
|
||||
if (ApiText.CleanLine(_newPermissionLabel, AdminGroups.MaxPermissionLabelLength) is null)
|
||||
errors.Add($"Libellé : une ligne, {AdminGroups.MaxPermissionLabelLength} caractères au plus.");
|
||||
AdminGroups.CleanDescription(_newPermissionDescription, AdminGroups.MaxDescriptionLength, out var validDescription);
|
||||
if (!validDescription) errors.Add($"Description : une ligne, {AdminGroups.MaxDescriptionLength} caractères au plus.");
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
var label = _newPermissionLabel.Trim();
|
||||
var state = await Context.Session.CreatePermissionAsync(new CreatePermissionRequest
|
||||
{
|
||||
Name = name,
|
||||
Label = label,
|
||||
Description = string.IsNullOrWhiteSpace(_newPermissionDescription) ? null : _newPermissionDescription.Trim()
|
||||
});
|
||||
await RefreshOwnProfileAsync(); // un propriétaire reçoit d'office la nouvelle permission
|
||||
KeepDraftsAndApply(state);
|
||||
NewPermissionName = NewPermissionLabel = NewPermissionDescription = "";
|
||||
StatusMessage = $"Permission « {name} » créée. Cochez-la dans les groupes qui doivent l'avoir. Elle n'a d'effet que si un module " +
|
||||
"du launcher ou du serveur la vérifie.";
|
||||
});
|
||||
|
||||
internal async Task DeletePermissionAsync(PermissionItem permission)
|
||||
{
|
||||
var groups = _state.Groups.Where(g => !g.System && g.Permissions.Contains(permission.Name)).Select(g => g.Name).ToList();
|
||||
if (!Context.Dialogs.Confirm($"Supprimer la permission « {permission.Name} » ?",
|
||||
(groups.Count == 0 ? "Aucun groupe ne l'a (hors « Propriétaire »)." : $"Elle sera retirée de : {string.Join(", ", groups)}.") +
|
||||
"\n\nUn module qui la vérifierait refuserait alors l'accès à tous, sauf aux propriétaires. Suppression définitive.",
|
||||
"Supprimer la permission", danger: true))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
if (await RunAsync(async () =>
|
||||
{
|
||||
var state = await Context.Session.DeletePermissionAsync(permission.Name);
|
||||
await RefreshOwnProfileAsync();
|
||||
KeepDraftsAndApply(state);
|
||||
}))
|
||||
StatusMessage = $"Permission « {permission.Name} » supprimée.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nouvel état après une modification des permissions : les groupes en cours de modification gardent leur saisie
|
||||
/// (une permission supprimée en disparaît), le nouveau groupe non enregistré aussi.
|
||||
/// </summary>
|
||||
private void KeepDraftsAndApply(GroupsState state)
|
||||
{
|
||||
var drafts = _groups.Where(g => g.Editor.IsDirty).ToDictionary(g => g.IsNew ? "" : g.Id, g => g.Editor.Snapshot());
|
||||
var selected = _selected;
|
||||
Apply(state, selected is { IsNew: false } ? selected.Id : null);
|
||||
foreach (var group in _groups)
|
||||
if (drafts.TryGetValue(group.Id, out var draft)) group.Editor.Restore(draft);
|
||||
if (drafts.TryGetValue("", out var newDraft))
|
||||
{
|
||||
var draft = GroupItem.NewDraft(this, state.Permissions);
|
||||
draft.Editor.Restore(newDraft);
|
||||
Groups = _groups.Append(draft).ToList();
|
||||
if (selected is { IsNew: true }) Selected = draft;
|
||||
}
|
||||
RaiseCommands();
|
||||
}
|
||||
|
||||
/// <summary>Droits de l'administrateur connecté relus (si son propre groupe a changé, par exemple).</summary>
|
||||
private async Task RefreshOwnProfileAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Context.Session.RefreshAsync();
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
// sans conséquence : relu au prochain prolongement de la session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Groupe de la liste, et sa saisie (<see cref="GroupEditor"/>).</summary>
|
||||
public sealed class GroupItem : ObservableObject
|
||||
{
|
||||
internal GroupItem(GroupsPanelViewModel page, GroupSummary summary, IReadOnlyList<PermissionSummary> permissions, bool isNew = false)
|
||||
{
|
||||
Summary = summary;
|
||||
IsNew = isNew;
|
||||
Editor = new GroupEditor(page, this, permissions);
|
||||
Editor.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(GroupEditor.IsDirty)) OnPropertyChanged(nameof(IsDirty));
|
||||
if (e.PropertyName == nameof(GroupEditor.Name)) OnPropertyChanged(nameof(Title));
|
||||
};
|
||||
}
|
||||
|
||||
internal static GroupItem NewDraft(GroupsPanelViewModel page, IReadOnlyList<PermissionSummary> permissions) =>
|
||||
new(page, new GroupSummary { Id = "", Name = "" }, permissions, isNew: true);
|
||||
|
||||
public GroupSummary Summary { get; }
|
||||
public GroupEditor Editor { get; }
|
||||
public bool IsNew { get; }
|
||||
public string Id => Summary.Id;
|
||||
public string Name => Summary.Name;
|
||||
public bool IsSystem => Summary.System;
|
||||
|
||||
public string Title => IsNew ? (string.IsNullOrWhiteSpace(Editor.Name) ? "Nouveau groupe" : Editor.Name.Trim()) : Summary.Name;
|
||||
|
||||
public string Subtitle => IsNew
|
||||
? "Pas encore enregistré"
|
||||
: IsSystem
|
||||
? $"{Members(Summary.MemberCount)} · toutes les permissions"
|
||||
: $"{Members(Summary.MemberCount)} · {Summary.Permissions.Count} permission(s)";
|
||||
|
||||
public string IconKey => IsSystem ? "ShieldAccount" : IsNew ? "Plus" : "AccountGroup";
|
||||
|
||||
/// <summary>Modifié, non enregistré (pastille dans la liste).</summary>
|
||||
public bool IsDirty => Editor.IsDirty;
|
||||
|
||||
private static string Members(int count) => count switch { 0 => "aucun membre", 1 => "1 membre", _ => $"{count} membres" };
|
||||
}
|
||||
|
||||
/// <summary>Saisie d'un groupe : nom, description, permissions cochées.</summary>
|
||||
public sealed class GroupEditor : ObservableObject
|
||||
{
|
||||
private readonly GroupsPanelViewModel _page;
|
||||
private readonly GroupItem _group;
|
||||
private string _name, _description;
|
||||
private bool _isDirty;
|
||||
|
||||
internal GroupEditor(GroupsPanelViewModel page, GroupItem group, IReadOnlyList<PermissionSummary> permissions)
|
||||
{
|
||||
_page = page;
|
||||
_group = group;
|
||||
var summary = group.Summary;
|
||||
_name = summary.Name;
|
||||
_description = summary.Description ?? "";
|
||||
IsYourGroup = !group.IsNew && summary.Id == page.MyGroupId;
|
||||
Toggles = permissions.Select(p => new PermissionToggle(this, p, summary.Permissions.Contains(p.Name), CanToggle(p.Name, summary))).ToList();
|
||||
DeleteCommand = new AsyncRelayCommand(() => page.DeleteGroupAsync(group), () => !page.IsBusy && CanDelete);
|
||||
}
|
||||
|
||||
public bool IsNew => _group.IsNew;
|
||||
public bool IsSystem => _group.IsSystem;
|
||||
public bool IsYourGroup { get; }
|
||||
|
||||
/// <summary>Nom et description modifiables (pas pour le groupe système).</summary>
|
||||
public bool CanEditText => !IsSystem;
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _name, value ?? "")) Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Description
|
||||
{
|
||||
get => _description;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _description, value ?? "")) Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PermissionToggle> Toggles { get; }
|
||||
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isDirty, value)) return;
|
||||
OnPropertyChanged(nameof(CanSave));
|
||||
_page.SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanSave => !IsSystem && (IsDirty || IsNew);
|
||||
|
||||
public string Header => IsNew ? "Nouveau groupe" : _group.Summary.Name;
|
||||
|
||||
public string IdText => IsNew ? "Identifiant attribué à l'enregistrement, d'après le nom." : $"Identifiant : {_group.Id}";
|
||||
|
||||
public string MembersText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsNew) return "";
|
||||
var s = _group.Summary;
|
||||
var text = s.MemberCount switch { 0 => "Aucun membre", 1 => "1 membre", _ => $"{s.MemberCount} membres" };
|
||||
if (s.InvitationCount > 0) text += $" · {s.InvitationCount} invitation(s) en cours";
|
||||
if (s.UpdatedAt is { } at) text += $" · modifié le {AccountTexts.Date(at)}" + (s.UpdatedBy is { } by ? $" par {by}" : "");
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Explication en haut de la carte (groupe système, votre groupe…), ou rien.</summary>
|
||||
public string? Notice =>
|
||||
IsSystem ? "Groupe système : il a toujours TOUTES les permissions, y compris celles créées plus tard. Il ne peut être ni modifié ni " +
|
||||
"supprimé, pour qu'il reste toujours quelqu'un capable de tout réparer."
|
||||
: IsYourGroup ? "C'est votre groupe : vous pouvez changer son nom et sa description, mais pas ses permissions (demandez-le à un autre " +
|
||||
"administrateur, comme pour changer de groupe)."
|
||||
: null;
|
||||
|
||||
public bool HasNotice => Notice is not null;
|
||||
|
||||
public bool CanDelete => !IsSystem && !IsYourGroup && (IsNew || (_group.Summary.MemberCount == 0 && _group.Summary.InvitationCount == 0));
|
||||
|
||||
public string DeleteText => IsNew ? "Annuler ce nouveau groupe" : "Supprimer le groupe";
|
||||
|
||||
public string? DeleteHint =>
|
||||
IsSystem || IsNew ? null
|
||||
: IsYourGroup ? "Votre propre groupe ne peut pas être supprimé."
|
||||
: _group.Summary.MemberCount > 0 || _group.Summary.InvitationCount > 0
|
||||
? "Pour le supprimer, placez d'abord ses membres dans un autre groupe et supprimez ses invitations (page « Administrateurs »)."
|
||||
: null;
|
||||
|
||||
public AsyncRelayCommand DeleteCommand { get; }
|
||||
|
||||
internal void RaiseCommands() => DeleteCommand.RaiseCanExecuteChanged();
|
||||
|
||||
/// <summary>Case modifiable : pas pour le groupe système ni le sien ; une permission que l'on n'a pas peut être retirée, pas donnée.</summary>
|
||||
private bool CanToggle(string permission, GroupSummary summary) =>
|
||||
!summary.System && !IsYourGroup && (_page.IHave(permission) || summary.Permissions.Contains(permission));
|
||||
|
||||
internal void Changed()
|
||||
{
|
||||
var s = _group.Summary;
|
||||
IsDirty = IsNew
|
||||
? !string.IsNullOrWhiteSpace(_name) || !string.IsNullOrWhiteSpace(_description) || Toggles.Any(t => t.IsChecked)
|
||||
: _name.Trim() != s.Name || (_description.Trim().Length == 0 ? null : _description.Trim()) != s.Description ||
|
||||
AddedPermissions().Count > 0 || RemovedPermissions().Count > 0;
|
||||
if (IsNew) OnPropertyChanged(nameof(Header));
|
||||
}
|
||||
|
||||
internal List<string> Checked() => Toggles.Where(t => t.IsChecked).Select(t => t.Name).ToList();
|
||||
|
||||
internal List<string> AddedPermissions() => Checked().Except(IsNew ? Array.Empty<string>() : _group.Summary.Permissions).ToList();
|
||||
|
||||
internal List<string> RemovedPermissions() =>
|
||||
IsNew ? new List<string>() : _group.Summary.Permissions.Except(Checked()).ToList();
|
||||
|
||||
internal List<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (AdminGroups.CleanName(_name) is null)
|
||||
errors.Add($"Nom du groupe : obligatoire, une ligne, {AdminGroups.MaxNameLength} caractères au plus.");
|
||||
AdminGroups.CleanDescription(_description, AdminGroups.MaxDescriptionLength, out var valid);
|
||||
if (!valid) errors.Add($"Description : une ligne, {AdminGroups.MaxDescriptionLength} caractères au plus.");
|
||||
var missing = AddedPermissions().Where(p => !_page.IHave(p)).ToList();
|
||||
if (missing.Count > 0) errors.Add($"Vous ne pouvez pas accorder des permissions que vous n'avez pas : {string.Join(", ", missing)}.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
internal GroupRequest ToRequest() => new()
|
||||
{
|
||||
Name = _name.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(_description) ? null : _description.Trim(),
|
||||
Permissions = Checked(),
|
||||
Revision = _group.Summary.Revision
|
||||
};
|
||||
|
||||
internal (string Name, string Description, List<string> Checked) Snapshot() => (_name, _description, Checked());
|
||||
|
||||
internal void Restore((string Name, string Description, List<string> Checked) draft)
|
||||
{
|
||||
_name = draft.Name;
|
||||
_description = draft.Description;
|
||||
foreach (var toggle in Toggles.Where(t => t.IsEnabled)) toggle.SetChecked(draft.Checked.Contains(toggle.Name));
|
||||
OnPropertyChanged(nameof(Name));
|
||||
OnPropertyChanged(nameof(Description));
|
||||
Changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Case d'une permission dans la saisie d'un groupe.</summary>
|
||||
public sealed class PermissionToggle : ObservableObject
|
||||
{
|
||||
private readonly GroupEditor _editor;
|
||||
private bool _isChecked;
|
||||
|
||||
internal PermissionToggle(GroupEditor editor, PermissionSummary permission, bool isChecked, bool isEnabled)
|
||||
{
|
||||
_editor = editor;
|
||||
Name = permission.Name;
|
||||
Label = permission.Label;
|
||||
Description = permission.Description;
|
||||
IsBuiltIn = permission.BuiltIn;
|
||||
_isChecked = isChecked;
|
||||
IsEnabled = isEnabled;
|
||||
IsSensitive = permission.Name == AdminPermissions.AdminsManage;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Label { get; }
|
||||
public string? Description { get; }
|
||||
public bool HasDescription => !string.IsNullOrEmpty(Description);
|
||||
public bool IsBuiltIn { get; }
|
||||
public string KindText => IsBuiltIn ? "Intégrée" : "Personnalisée";
|
||||
|
||||
/// <summary>Permission sensible (gestion des administrateurs) : mise en évidence.</summary>
|
||||
public bool IsSensitive { get; }
|
||||
|
||||
public bool IsEnabled { get; }
|
||||
|
||||
public string? DisabledReason => IsEnabled ? null
|
||||
: _editor.IsSystem ? "Le groupe « Propriétaire » a toutes les permissions."
|
||||
: _editor.IsYourGroup ? "Vous ne pouvez pas modifier les permissions de votre propre groupe."
|
||||
: "Vous n'avez pas cette permission : vous ne pouvez pas l'accorder.";
|
||||
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (!IsEnabled || !SetProperty(ref _isChecked, value)) return;
|
||||
_editor.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
internal void SetChecked(bool value)
|
||||
{
|
||||
if (SetProperty(ref _isChecked, value, nameof(IsChecked))) _editor.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Permission de la liste (intégrée ou personnalisée).</summary>
|
||||
public sealed class PermissionItem : ObservableObject
|
||||
{
|
||||
internal PermissionItem(GroupsPanelViewModel page, PermissionSummary permission)
|
||||
{
|
||||
Summary = permission;
|
||||
DeleteCommand = new AsyncRelayCommand(() => page.DeletePermissionAsync(this), () => !page.IsBusy && CanDelete);
|
||||
CanDelete = !permission.BuiltIn && page.IHave(permission.Name);
|
||||
}
|
||||
|
||||
public PermissionSummary Summary { get; }
|
||||
public string Name => Summary.Name;
|
||||
public string Label => Summary.Label;
|
||||
public string? Description => Summary.Description;
|
||||
public bool HasDescription => !string.IsNullOrEmpty(Description);
|
||||
public bool IsBuiltIn => Summary.BuiltIn;
|
||||
public string KindText => IsBuiltIn ? "Intégrée" : "Personnalisée";
|
||||
|
||||
/// <summary>« 2 groupes (Propriétaire compris) · créée le … par … ».</summary>
|
||||
public string UsageText
|
||||
{
|
||||
get
|
||||
{
|
||||
var text = Summary.GroupCount <= 1 ? "Groupe « Propriétaire » uniquement" : $"{Summary.GroupCount} groupes (Propriétaire compris)";
|
||||
if (Summary.CreatedAt is { } at) text += $" · créée le {AccountTexts.Date(at)}" + (Summary.CreatedBy is { } by ? $" par {by}" : "");
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanDelete { get; }
|
||||
public AsyncRelayCommand DeleteCommand { get; }
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public sealed class MyDevicesPanelViewModel : AccountsPanelBase
|
||||
|
||||
public string DisplayName => Context.Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
|
||||
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role, Context.Session.Profile.RoleName);
|
||||
|
||||
/// <summary>Révoquer un de ses appareils exige la gestion des administrateurs (sinon : un propriétaire s'en charge).</summary>
|
||||
public bool CanRevokeDevices => Context.Session.Has(AdminPermissions.AdminsManage);
|
||||
|
||||
@@ -8,8 +8,10 @@ namespace GameLauncher.Modules.Admin.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Journal d'audit » (audit.read) : actions enregistrées par le serveur, les plus récentes
|
||||
/// d'abord, chargées par pages de 200 ; filtres (texte, catégorie, résultat) appliqués aux entrées
|
||||
/// chargées ; export CSV de ce qui est affiché.
|
||||
/// d'abord, chargées par pages de 200 ; filtres (période, texte, catégorie, résultat) ; export CSV
|
||||
/// des entrées filtrées. Période : le serveur ne renvoie que les entrées antérieures à la fin de la
|
||||
/// période, et les pages sont chargées jusqu'à son début (dans la limite de <see cref="MaxLoaded"/>).
|
||||
/// Chaque consultation est inscrite au journal par le serveur ; chaque export y est inscrit avant l'écriture du fichier.
|
||||
/// </summary>
|
||||
public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
@@ -33,6 +35,14 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
private Choice _result;
|
||||
private string? _lastRefresh;
|
||||
private bool _hasLoaded;
|
||||
private Choice _period;
|
||||
private string _fromText = "";
|
||||
private string _toText = "";
|
||||
private string? _dateError;
|
||||
private DateTime? _from; // début de la période (jour inclus, heure locale)
|
||||
private DateTime? _to; // fin de la période (jour inclus, heure locale)
|
||||
private bool _settingPeriod;
|
||||
private bool _periodReloadPending;
|
||||
|
||||
public AuditPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
@@ -45,6 +55,8 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
new("catalog.", "Informations des jeux"),
|
||||
new("changelog.", "Notes de version"),
|
||||
new("release.", "Publications"),
|
||||
new("files.", "Fichiers du serveur"),
|
||||
new("audit.", "Consultations et exports du journal"),
|
||||
new("security", "Refus de sécurité")
|
||||
};
|
||||
Results = new List<Choice>
|
||||
@@ -53,9 +65,20 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
new(AuditResults.Ok, "Réussis"),
|
||||
new(AuditResults.Denied, "Refusés")
|
||||
};
|
||||
Periods = new List<Choice>
|
||||
{
|
||||
new(null, "Toutes les dates"),
|
||||
new(PeriodToday, "Aujourd'hui"),
|
||||
new(PeriodWeek, "7 derniers jours"),
|
||||
new(PeriodMonth, "30 derniers jours"),
|
||||
new(PeriodThisMonth, "Ce mois-ci"),
|
||||
new(PeriodLastMonth, "Mois précédent"),
|
||||
new(PeriodCustom, "Dates choisies")
|
||||
};
|
||||
_category = Categories[0];
|
||||
_result = Results[0];
|
||||
RefreshCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: true)), () => !IsBusy);
|
||||
_period = Periods[0];
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(reset: true))), () => !IsBusy);
|
||||
LoadMoreCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: false)), () => !IsBusy && CanLoadMore);
|
||||
ClearFiltersCommand = new RelayCommand(ClearFilters, () => HasFilters);
|
||||
ExportCommand = new AsyncRelayCommand(ExportAsync, () => !IsBusy && _entries.Count > 0);
|
||||
@@ -63,6 +86,130 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
|
||||
public IReadOnlyList<Choice> Categories { get; }
|
||||
public IReadOnlyList<Choice> Results { get; }
|
||||
public IReadOnlyList<Choice> Periods { get; }
|
||||
|
||||
private const string PeriodToday = "today", PeriodWeek = "7d", PeriodMonth = "30d", PeriodThisMonth = "this-month",
|
||||
PeriodLastMonth = "last-month", PeriodCustom = "custom";
|
||||
private const string DateFormat = "dd/MM/yyyy";
|
||||
|
||||
/// <summary>Aujourd'hui (heure locale) ; remplaçable par les tests.</summary>
|
||||
internal Func<DateTime> Today { get; set; } = () => DateTime.Today;
|
||||
|
||||
/// <summary>Rechargement lancé par un changement de période (tests).</summary>
|
||||
internal Task PeriodReload { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <summary>Période rapide ; « Dates choisies » : champs « Du » / « Au ».</summary>
|
||||
public Choice Period
|
||||
{
|
||||
get => _period;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _period, value)) return;
|
||||
if (value.Value == PeriodCustom) return; // les champs restent tels quels
|
||||
var today = Today().Date;
|
||||
var (from, to) = value.Value switch
|
||||
{
|
||||
PeriodToday => (today, today),
|
||||
PeriodWeek => (today.AddDays(-6), today),
|
||||
PeriodMonth => (today.AddDays(-29), today),
|
||||
PeriodThisMonth => (new DateTime(today.Year, today.Month, 1), today),
|
||||
PeriodLastMonth => (new DateTime(today.Year, today.Month, 1).AddMonths(-1), new DateTime(today.Year, today.Month, 1).AddDays(-1)),
|
||||
_ => ((DateTime?)null, (DateTime?)null)
|
||||
};
|
||||
_settingPeriod = true;
|
||||
FromText = from?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
|
||||
ToText = to?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
|
||||
_settingPeriod = false;
|
||||
ApplyPeriod();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Début de la période (jj/mm/aaaa, inclus ; vide : depuis le début du journal).</summary>
|
||||
public string FromText
|
||||
{
|
||||
get => _fromText;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _fromText, value ?? "")) return;
|
||||
if (!_settingPeriod) OnDateEdited();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fin de la période (jj/mm/aaaa, inclus ; vide : jusqu'à maintenant).</summary>
|
||||
public string ToText
|
||||
{
|
||||
get => _toText;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _toText, value ?? "")) return;
|
||||
if (!_settingPeriod) OnDateEdited();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Date saisie invalide (affichée sous les champs) ; la période précédente reste appliquée.</summary>
|
||||
public string? DateError
|
||||
{
|
||||
get => _dateError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _dateError, value)) OnPropertyChanged(nameof(HasDateError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasDateError => !string.IsNullOrEmpty(DateError);
|
||||
|
||||
public bool HasPeriod => _from is not null || _to is not null;
|
||||
|
||||
private void OnDateEdited()
|
||||
{
|
||||
if (_period.Value != PeriodCustom)
|
||||
{
|
||||
_period = Periods.First(p => p.Value == PeriodCustom);
|
||||
OnPropertyChanged(nameof(Period));
|
||||
}
|
||||
ApplyPeriod();
|
||||
}
|
||||
|
||||
/// <summary>Contrôle les champs ; période changée → rechargement depuis le serveur.</summary>
|
||||
private void ApplyPeriod()
|
||||
{
|
||||
if (!TryParseDate(_fromText, out var from) || !TryParseDate(_toText, out var to))
|
||||
{
|
||||
DateError = "Date attendue au format jj/mm/aaaa (ex. 07/11/2026).";
|
||||
return;
|
||||
}
|
||||
if (from is { } f && to is { } t && f > t)
|
||||
{
|
||||
DateError = "La date de début est postérieure à la date de fin.";
|
||||
return;
|
||||
}
|
||||
DateError = null;
|
||||
if (from == _from && to == _to) return;
|
||||
_from = from;
|
||||
_to = to;
|
||||
OnPropertyChanged(nameof(HasPeriod));
|
||||
OnPropertyChanged(nameof(HasFilters));
|
||||
ClearFiltersCommand.RaiseCanExecuteChanged();
|
||||
ApplyFilters();
|
||||
if (IsBusy) _periodReloadPending = true; // rechargé dès la fin du chargement en cours
|
||||
else if (_hasLoaded) PeriodReload = RunAsync(() => LoadAsync(reset: true));
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string text, out DateTime? date)
|
||||
{
|
||||
date = null;
|
||||
if (string.IsNullOrWhiteSpace(text)) return true;
|
||||
if (!DateTime.TryParseExact(text.Trim(), new[] { DateFormat, "d/M/yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ||
|
||||
parsed.Year < 2000)
|
||||
return false;
|
||||
date = parsed.Date;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Fin de la période en heure universelle (le lendemain du dernier jour, 0 h locale), pour le serveur.</summary>
|
||||
private DateTimeOffset? Before => _to is { } to ? new DateTimeOffset(to.AddDays(1), TimeZoneInfo.Local.GetUtcOffset(to.AddDays(1))) : null;
|
||||
|
||||
private DateTimeOffset? After => _from is { } from ? new DateTimeOffset(from, TimeZoneInfo.Local.GetUtcOffset(from)) : null;
|
||||
|
||||
public IReadOnlyList<AuditItem> Entries
|
||||
{
|
||||
@@ -118,7 +265,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null;
|
||||
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null || HasPeriod;
|
||||
|
||||
/// <summary>« 37 entrées affichées sur 200 chargées (depuis le 12/10/2026 à 08:14) ».</summary>
|
||||
public string SummaryText
|
||||
@@ -128,7 +275,8 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
if (_loaded.Count == 0) return "";
|
||||
var oldest = _loaded[^1].Timestamp.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
|
||||
var shown = HasFilters ? $"{_filtered.Count} entrée(s) sur {_loaded.Count} chargée(s)" : $"{_loaded.Count} entrée(s) chargée(s)";
|
||||
return $"{shown}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
|
||||
var period = _to is { } to ? $" jusqu'au {to.ToString(DateFormat, CultureInfo.InvariantCulture)}" : "";
|
||||
return $"{shown}{period}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
|
||||
: _loaded.Count >= MaxLoaded ? $". Limite de {MaxLoaded} entrées atteinte : les plus anciennes restent dans les fichiers du journal sur le VPS."
|
||||
: ".");
|
||||
}
|
||||
@@ -153,6 +301,18 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
LoadMoreCommand.RaiseCanExecuteChanged();
|
||||
ExportCommand.RaiseCanExecuteChanged();
|
||||
if (!IsBusy && _periodReloadPending)
|
||||
{
|
||||
_periodReloadPending = false;
|
||||
PeriodReload = ReloadAfterCurrentAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Période changée pendant un chargement : nouveau chargement juste après celui-ci.</summary>
|
||||
private async Task ReloadAfterCurrentAsync()
|
||||
{
|
||||
await Task.Yield(); // après la fin du chargement en cours (fil de l'interface)
|
||||
await RunAsync(() => LoadAsync(reset: true));
|
||||
}
|
||||
|
||||
/// <summary>Chargé au premier affichage ; ensuite « Actualiser » (garde les pages déjà chargées et les filtres d'ici là).</summary>
|
||||
@@ -160,10 +320,18 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
|
||||
private async Task LoadAsync(bool reset)
|
||||
{
|
||||
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor);
|
||||
var before = Before;
|
||||
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor, before);
|
||||
if (reset) _loaded.Clear();
|
||||
_loaded.AddRange(page.Entries);
|
||||
_nextCursor = page.NextCursor;
|
||||
// Période : pages suivantes jusqu'à son début (l'export doit contenir toute la période chargée).
|
||||
while (reset && After is { } after && _nextCursor is not null && _loaded.Count < MaxLoaded && _loaded[^1].Timestamp >= after)
|
||||
{
|
||||
page = await Context.Session.GetAuditAsync(PageSize, _nextCursor, before);
|
||||
_loaded.AddRange(page.Entries);
|
||||
_nextCursor = page.NextCursor;
|
||||
}
|
||||
_hasLoaded = true;
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
OnPropertyChanged(nameof(CanLoadMore));
|
||||
@@ -179,13 +347,16 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
OnPropertyChanged(nameof(Category));
|
||||
OnPropertyChanged(nameof(Result));
|
||||
if (HasPeriod || _period.Value is not null || HasDateError) Period = Periods[0]; // recharge toutes les dates
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
var text = _searchText.Trim();
|
||||
var (after, before) = (After, Before);
|
||||
_filtered = _loaded
|
||||
.Where(e => (after is null || e.Timestamp >= after) && (before is null || e.Timestamp < before))
|
||||
.Where(e => AuditTexts.InCategory(e.Action, _category.Value))
|
||||
.Where(e => _result.Value is null || e.Result == _result.Value)
|
||||
.Select(e => new AuditItem(e))
|
||||
@@ -202,14 +373,45 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
var period = (_from, _to) switch
|
||||
{
|
||||
(null, null) => "",
|
||||
({ } f, null) => $"-depuis-{f:yyyy-MM-dd}",
|
||||
(null, { } t) => $"-jusqu-au-{t:yyyy-MM-dd}",
|
||||
({ } f, { } t) => $"-{f:yyyy-MM-dd}-au-{t:yyyy-MM-dd}"
|
||||
};
|
||||
var path = Context.Dialogs.PickSaveFile("Exporter le journal d'audit", "Fichier CSV (*.csv)|*.csv",
|
||||
$"journal-audit-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
|
||||
$"journal-audit{period}-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
|
||||
if (path is null) return;
|
||||
var entries = _filtered;
|
||||
if (await RunAsync(() => File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true))))
|
||||
var announce = new AuditExportRequest
|
||||
{
|
||||
Count = entries.Count,
|
||||
From = _from is { } from ? DateOnly.FromDateTime(from) : null,
|
||||
To = _to is { } to ? DateOnly.FromDateTime(to) : null,
|
||||
Filters = ExportFiltersText()
|
||||
};
|
||||
// L'export est inscrit au journal du serveur AVANT l'écriture du fichier : s'il ne peut pas l'être
|
||||
// (serveur injoignable, session expirée, signature refusée), le fichier n'est pas écrit.
|
||||
if (await RunAsync(async () =>
|
||||
{
|
||||
await Context.Session.RecordAuditExportAsync(announce);
|
||||
await File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
|
||||
}))
|
||||
Context.Dialogs.ShowMessage("Journal exporté", $"{entries.Count} entrée(s) enregistrée(s) dans {Path.GetFileName(path)}.",
|
||||
ModuleMessageKind.Success);
|
||||
}
|
||||
|
||||
/// <summary>Filtres de l'export, en clair pour le journal (la période est transmise à part).</summary>
|
||||
private string? ExportFiltersText()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (_category.Value is not null) parts.Add($"catégorie « {_category.Label} »");
|
||||
if (_result.Value is not null) parts.Add($"résultat « {_result.Label} »");
|
||||
var text = _searchText.Trim();
|
||||
if (text.Length > 0) parts.Add($"recherche « {(text.Length > 80 ? text[..80] + "…" : text)} »");
|
||||
return parts.Count == 0 ? null : string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Entrée du journal telle qu'affichée.</summary>
|
||||
@@ -226,7 +428,10 @@ public sealed class AuditItem
|
||||
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
|
||||
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
|
||||
DetailText = string.Join(" — ", parts);
|
||||
// Appareil : nom choisi à l'enregistrement de la clé (invitation), fourni par le serveur — jamais le nom Windows.
|
||||
DeviceText = string.IsNullOrEmpty(entry.KeyLabel) ? "" : $"Appareil « {entry.KeyLabel} »";
|
||||
var origin = new List<string>();
|
||||
if (DeviceText.Length > 0) origin.Add(DeviceText);
|
||||
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
|
||||
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
|
||||
OriginText = string.Join(" · ", origin);
|
||||
@@ -245,6 +450,10 @@ public sealed class AuditItem
|
||||
public bool IsDenied => Entry.Result == AuditResults.Denied;
|
||||
public bool IsFailed => Entry.Result == AuditResults.Failed;
|
||||
public string ActorText { get; }
|
||||
|
||||
/// <summary>« Appareil « PC bureau » » (vide : entrée sans clé, ou clé inconnue du serveur).</summary>
|
||||
public string DeviceText { get; }
|
||||
|
||||
public string DetailText { get; }
|
||||
public bool HasDetail => DetailText.Length > 0;
|
||||
public string OriginText { get; }
|
||||
@@ -267,18 +476,28 @@ public static class AuditTexts
|
||||
"admins.invite_deleted" => "Invitation supprimée",
|
||||
"admins.key_approve" => "Appareil approuvé",
|
||||
"admins.key_revoke" => "Appareil révoqué",
|
||||
"admins.role" => "Rôle modifié",
|
||||
"admins.role" => "Groupe d'un administrateur modifié",
|
||||
"admins.group_create" => "Groupe créé",
|
||||
"admins.group_update" => "Groupe modifié",
|
||||
"admins.group_delete" => "Groupe supprimé",
|
||||
"admins.permission_create" => "Permission créée",
|
||||
"admins.permission_delete" => "Permission supprimée",
|
||||
"admins.revoke" => "Compte révoqué",
|
||||
"cli.add_owner" => "Propriétaire ajouté sur le VPS",
|
||||
"cli.add_key" => "Clé ajoutée sur le VPS",
|
||||
"cli.revoke_admin" => "Compte révoqué sur le VPS",
|
||||
"cli.revoke_key" => "Clé révoquée sur le VPS",
|
||||
"catalog.publish" => "Informations des jeux publiées",
|
||||
"catalog.image_upload" => "Image du catalogue envoyée",
|
||||
"changelog.edit" => "Notes de version modifiées",
|
||||
"release.start" => "Publication d'une version commencée",
|
||||
"release.publish" => "Version mise en ligne",
|
||||
"release.cancel" => "Publication abandonnée",
|
||||
"files.delete" => "Fichier du serveur supprimé",
|
||||
"files.prune" => "Dossier « objects » nettoyé",
|
||||
"request.signature" => "Requête signée refusée",
|
||||
"audit.view" => "Journal d'audit consulté",
|
||||
"audit.export" => "Journal d'audit exporté (CSV)",
|
||||
"audit.suppressed" => "Tentatives refusées regroupées",
|
||||
_ => action
|
||||
};
|
||||
@@ -291,11 +510,15 @@ public static class AuditTexts
|
||||
_ => result
|
||||
};
|
||||
|
||||
/// <summary>Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées.</summary>
|
||||
/// <summary>
|
||||
/// Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées ;
|
||||
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité).
|
||||
/// </summary>
|
||||
public static bool InCategory(string action, string? category) => category switch
|
||||
{
|
||||
null => true,
|
||||
"security" => action is "request.signature" or "audit.suppressed",
|
||||
"audit." => action is "audit.view" or "audit.export",
|
||||
_ => action.StartsWith(category, StringComparison.Ordinal)
|
||||
};
|
||||
|
||||
@@ -308,14 +531,14 @@ public static class AuditTexts
|
||||
public static string ToCsv(IEnumerable<AuditItem> items)
|
||||
{
|
||||
var csv = new StringBuilder();
|
||||
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
|
||||
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;Appareil;IP;Clé;Cible;Détails");
|
||||
foreach (var item in items)
|
||||
{
|
||||
var e = item.Entry;
|
||||
csv.AppendLine(string.Join(';', new[]
|
||||
{
|
||||
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
item.ActionText, e.Action, item.ResultText, e.ActorName, e.Ip, e.KeyId, e.Target, e.Details
|
||||
item.ActionText, e.Action, item.ResultText, e.ActorName, e.KeyLabel, e.Ip, e.KeyId, e.Target, e.Details
|
||||
}.Select(Cell)));
|
||||
}
|
||||
return csv.ToString();
|
||||
|
||||
@@ -107,8 +107,12 @@ public sealed class GameEditor : CatalogEditor
|
||||
private string _gameName = "", _tagline = "", _description = "", _websiteUrl = "", _icon = "", _background = "", _interval = "";
|
||||
private Choice _theme;
|
||||
private bool _hidden;
|
||||
private UploadedImagePreview? _iconPreview, _backgroundPreview;
|
||||
|
||||
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed) : base(changed)
|
||||
/// <param name="browseImage">« Parcourir… » d'une image (type <see cref="CatalogImages.Icon"/> ou <see cref="CatalogImages.Background"/>) :
|
||||
/// choix du fichier, contrôle, envoi au serveur, puis adresse renseignée ici. Null : boutons absents.</param>
|
||||
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed,
|
||||
Func<GameEditor, string, Task>? browseImage = null) : base(changed)
|
||||
{
|
||||
Declared = declared;
|
||||
_builtInThemeChoices = new[] { new Choice(null, $"Par défaut ({ThemeOf(declared)})") }
|
||||
@@ -117,9 +121,16 @@ public sealed class GameEditor : CatalogEditor
|
||||
_theme = ThemeChoices[0];
|
||||
LinkKinds = CatalogLinkKinds.All.Select(k => new Choice(k, CatalogLinkKinds.DefaultLabel(k))).ToList();
|
||||
AddLinkCommand = new RelayCommand(AddLink, () => Links.Count < CatalogRules.MaxLinks);
|
||||
CanBrowseImages = browseImage is not null;
|
||||
BrowseIconCommand = new AsyncRelayCommand(() => browseImage?.Invoke(this, CatalogImages.Icon) ?? Task.CompletedTask, () => CanBrowseImages);
|
||||
BrowseBackgroundCommand = new AsyncRelayCommand(() => browseImage?.Invoke(this, CatalogImages.Background) ?? Task.CompletedTask,
|
||||
() => CanBrowseImages);
|
||||
ClearIconCommand = new RelayCommand(() => Icon = "", () => !string.IsNullOrEmpty(Icon));
|
||||
ClearBackgroundCommand = new RelayCommand(() => Background = "", () => !string.IsNullOrEmpty(Background));
|
||||
Links.CollectionChanged += (_, _) =>
|
||||
{
|
||||
AddLinkCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(HasLinks));
|
||||
Changed();
|
||||
};
|
||||
}
|
||||
@@ -145,8 +156,65 @@ public sealed class GameEditor : CatalogEditor
|
||||
public string Tagline { get => _tagline; set => Set(ref _tagline, value ?? ""); }
|
||||
public string Description { get => _description; set => Set(ref _description, value ?? ""); }
|
||||
public string WebsiteUrl { get => _websiteUrl; set => Set(ref _websiteUrl, value ?? ""); }
|
||||
public string Icon { get => _icon; set => Set(ref _icon, value ?? ""); }
|
||||
public string Background { get => _background; set => Set(ref _background, value ?? ""); }
|
||||
public string Icon
|
||||
{
|
||||
get => _icon;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _icon, value ?? "")) return;
|
||||
if (_iconPreview is not null && _iconPreview.Url != _icon) IconPreview = null; // adresse changée à la main
|
||||
ClearIconCommand?.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string Background
|
||||
{
|
||||
get => _background;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _background, value ?? "")) return;
|
||||
if (_backgroundPreview is not null && _backgroundPreview.Url != _background) BackgroundPreview = null;
|
||||
ClearBackgroundCommand?.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images envoyées depuis le menu (sans FTP)
|
||||
|
||||
/// <summary>Boutons « Parcourir… » disponibles (page reliée au serveur).</summary>
|
||||
public bool CanBrowseImages { get; }
|
||||
|
||||
public AsyncRelayCommand BrowseIconCommand { get; }
|
||||
public AsyncRelayCommand BrowseBackgroundCommand { get; }
|
||||
public RelayCommand ClearIconCommand { get; }
|
||||
public RelayCommand ClearBackgroundCommand { get; }
|
||||
|
||||
/// <summary>Aperçu de la dernière icône envoyée (tant que son adresse est celle du champ).</summary>
|
||||
public UploadedImagePreview? IconPreview
|
||||
{
|
||||
get => _iconPreview;
|
||||
private set => SetProperty(ref _iconPreview, value);
|
||||
}
|
||||
|
||||
public UploadedImagePreview? BackgroundPreview
|
||||
{
|
||||
get => _backgroundPreview;
|
||||
private set => SetProperty(ref _backgroundPreview, value);
|
||||
}
|
||||
|
||||
/// <summary>Image envoyée au serveur : son adresse remplit le champ (à publier ensuite avec « Publier »).</summary>
|
||||
internal void SetUploadedImage(string kind, UploadedImagePreview preview)
|
||||
{
|
||||
if (kind == CatalogImages.Icon)
|
||||
{
|
||||
Icon = preview.Url;
|
||||
IconPreview = preview;
|
||||
}
|
||||
else
|
||||
{
|
||||
Background = preview.Url;
|
||||
BackgroundPreview = preview;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minutes entre deux recherches de mise à jour (vide : launcher.json ; 0 : désactivé).</summary>
|
||||
public string UpdateInterval { get => _interval; set => Set(ref _interval, value ?? ""); }
|
||||
@@ -178,6 +246,9 @@ public sealed class GameEditor : CatalogEditor
|
||||
}
|
||||
|
||||
public ObservableCollection<LinkEditor> Links { get; } = new();
|
||||
|
||||
/// <summary>Au moins un lien : les en-têtes des colonnes (type, texte, adresse) sont affichés.</summary>
|
||||
public bool HasLinks => Links.Count > 0;
|
||||
public IReadOnlyList<Choice> LinkKinds { get; }
|
||||
public RelayCommand AddLinkCommand { get; }
|
||||
|
||||
@@ -188,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;
|
||||
|
||||
@@ -209,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;
|
||||
@@ -226,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>
|
||||
@@ -310,3 +399,11 @@ public sealed class LinkEditor : ObservableObject
|
||||
|
||||
internal bool IsEmpty => string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Label);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image tout juste envoyée au serveur : contenu (pour l'aperçu, décodé par le launcher WPF), adresse et dimensions.
|
||||
/// </summary>
|
||||
public sealed record UploadedImagePreview(string Url, byte[] Content, int Width, int Height, long Bytes, string FileName)
|
||||
{
|
||||
public string Text => $"{FileName} · {Width} × {Height} px · {Bytes / 1024.0:0} Ko · envoyée, à publier";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
@@ -16,7 +17,10 @@ namespace GameLauncher.Modules.Admin.Catalog;
|
||||
/// • une liste : « Launcher » (nom, jeu par défaut) puis les jeux, dans l'ordre d'affichage (↑ ↓) ;
|
||||
/// • champ vide = valeur de launcher.json (rappelée sous le champ) ;
|
||||
/// • « Publier » : contrôle complet (mêmes règles que le serveur), requête signée, révision attendue
|
||||
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé).
|
||||
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé) ;
|
||||
/// • icône et image de fond : « Parcourir… » ouvre l'explorateur Windows ; l'image choisie est contrôlée sur le
|
||||
/// PC (<see cref="CatalogImages.Check"/>), envoyée au serveur (requête signée), puis son adresse renseignée dans
|
||||
/// le champ ; elle n'est appliquée aux joueurs qu'à la publication du catalogue (signé).
|
||||
/// </summary>
|
||||
public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
@@ -33,17 +37,20 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
public GamesPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
Launcher = new LauncherEditor(context.LauncherName, context.DefaultGameId, context.Games, OnEdited);
|
||||
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
|
||||
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited, BrowseImageAsync));
|
||||
RebuildItems(Launcher);
|
||||
|
||||
PublishCommand = new AsyncRelayCommand(PublishAsync, () => !IsBusy && HasChanges && PublishingEnabled);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy);
|
||||
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => !IsBusy && HasChanges && PublishingEnabled);
|
||||
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
|
||||
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
|
||||
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
|
||||
}
|
||||
|
||||
public LauncherEditor Launcher { get; }
|
||||
|
||||
/// <summary>Liste « Launcher + jeux » et fiche défilent séparément.</summary>
|
||||
public override bool HasSplitLayout => true;
|
||||
|
||||
/// <summary>Jeux dans l'ordre d'affichage du launcher.</summary>
|
||||
public ObservableCollection<GameEditor> Games { get; } = new();
|
||||
|
||||
@@ -184,6 +191,8 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
// Clé de ce PC absente du trousseau publié : les launchers des joueurs ignoreraient ce catalogue (avertissement seulement).
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Catalog)) return;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -204,6 +213,75 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Images (icône, fond) envoyées sans FTP
|
||||
|
||||
private const string ImageFilter = "Images PNG ou JPEG (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg";
|
||||
|
||||
private async Task BrowseImageAsync(GameEditor editor, string kind)
|
||||
{
|
||||
var label = CatalogImages.KindLabel(kind);
|
||||
if (IsBusy) return;
|
||||
if (_state is not { PublishingEnabled: true } state)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Envoi impossible",
|
||||
"La publication du catalogue n'est pas configurée sur le serveur : les images ne peuvent pas y être envoyées.",
|
||||
ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
var path = Context.Dialogs.PickOpenFile($"Choisir l'{label} de « {editor.Title} »", ImageFilter, null);
|
||||
if (path is null) return;
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
StatusMessage = null;
|
||||
// Lecture bornée : la taille est contrôlée avant de lire, puis le contenu lui-même (format, dimensions, fin du fichier).
|
||||
byte[] content;
|
||||
try
|
||||
{
|
||||
var file = new FileInfo(path);
|
||||
if (!file.Exists) throw new FileNotFoundException("Fichier introuvable.", path);
|
||||
if (file.Length > CatalogImages.MaxBytes)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Image trop volumineuse",
|
||||
$"« {file.Name} » fait {file.Length / 1024.0 / 1024.0:0.0} Mo : {CatalogImages.MaxBytes / 1024 / 1024} Mo au plus.",
|
||||
ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
if (stream.Length > CatalogImages.MaxBytes) throw new IOException("Le fichier a changé pendant sa lecture.");
|
||||
content = new byte[stream.Length];
|
||||
await stream.ReadExactlyAsync(content);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Lecture impossible", $"Impossible de lire « {Path.GetFileName(path)} » : {ex.Message}", ModuleMessageKind.Error);
|
||||
return;
|
||||
}
|
||||
if (CatalogImages.Check(kind, content, out _) is { } refused)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Image refusée", refused, ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var uploaded = await Context.Session.UploadCatalogImageAsync(kind, content);
|
||||
|
||||
// L'adresse renvoyée sera SIGNÉE avec le catalogue : elle doit désigner exactement ce fichier, sur le serveur du catalogue.
|
||||
var sha256 = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
|
||||
var catalogHost = Uri.TryCreate(state.PublicUrl, UriKind.Absolute, out var catalogUrl) ? catalogUrl.Host : null;
|
||||
if (!Uri.TryCreate(uploaded.Url, UriKind.Absolute, out var url) || url.Scheme != Uri.UriSchemeHttps ||
|
||||
!string.Equals(url.Host, catalogHost, StringComparison.OrdinalIgnoreCase) || CatalogImages.ExpectedSha256(url) != sha256)
|
||||
throw new InvalidOperationException("Le serveur a renvoyé une adresse d'image inattendue : elle n'a pas été utilisée.");
|
||||
|
||||
editor.SetUploadedImage(kind, new UploadedImagePreview(url.AbsoluteUri, content, uploaded.Width, uploaded.Height, content.Length,
|
||||
Path.GetFileName(path)));
|
||||
Selected = editor;
|
||||
StatusMessage = $"{char.ToUpperInvariant(label[0])}{label[1..]} de « {editor.Title} » envoyée sur le serveur" +
|
||||
(uploaded.AlreadyPresent ? " (elle y était déjà)" : "") +
|
||||
". Cliquez sur « Publier » pour l'appliquer aux launchers des joueurs.";
|
||||
Log.Info($"Administration : {label} envoyée pour {editor.GameId} ({uploaded.FileName}).");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Catalogue saisi (ordre affiché, champs non vides).</summary>
|
||||
private CatalogDocument BuildCatalog(List<string> errors)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
@@ -13,6 +14,7 @@ namespace GameLauncher.Modules.Admin.Changelog;
|
||||
/// <summary>
|
||||
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
|
||||
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
|
||||
/// • plusieurs jeux déclarés : rien n'est chargé ni affiché avant le choix du jeu (aucun jeu par défaut) ;
|
||||
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
|
||||
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
|
||||
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
|
||||
@@ -32,11 +34,11 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
|
||||
public ChangelogPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
|
||||
GameChoices = GameTile.Choices(context);
|
||||
_selectedGame = GameTile.Initial(GameChoices);
|
||||
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
|
||||
SaveCommand = new AsyncRelayCommand(ScrollingToTop(SaveAsync), () => !IsBusy && HasChanges && !HasConflict && _state is not null);
|
||||
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy && _selectedGame is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu
|
||||
@@ -44,12 +46,36 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
|
||||
public IReadOnlyList<GameTile> GameTiles { get; }
|
||||
|
||||
public bool HasSelectedGame => _selectedGame is not null;
|
||||
public bool ShowGamePicker => _selectedGame is null;
|
||||
|
||||
/// <summary>Chargement lancé par le dernier choix de jeu (tests).</summary>
|
||||
internal Task GameLoad { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
|
||||
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
|
||||
|
||||
/// <summary>Pas de changement de jeu pendant un chargement ou un enregistrement.</summary>
|
||||
public bool IsGameSwitchEnabled => !IsBusy;
|
||||
|
||||
public string GamePickerText => GameChoices.Count == 0
|
||||
? "Aucun jeu n'est déclaré dans launcher.json."
|
||||
: "Choisissez le jeu dont vous voulez modifier les notes de version.";
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame)) return;
|
||||
if (IsBusy)
|
||||
{
|
||||
OnPropertyChanged(); // pas pendant un chargement ou un enregistrement
|
||||
return;
|
||||
}
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
|
||||
{
|
||||
@@ -58,8 +84,12 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
}
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher) })
|
||||
OnPropertyChanged(name);
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
ErrorMessage = null;
|
||||
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
|
||||
_ = LoadAsync();
|
||||
GameLoad = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +108,9 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
}
|
||||
|
||||
public bool HasVersions => Versions.Count > 0;
|
||||
|
||||
/// <summary>Liste des versions et notes défilent séparément (seulement quand il y a des versions à afficher).</summary>
|
||||
public override bool HasSplitLayout => HasVersions;
|
||||
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
|
||||
|
||||
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
|
||||
@@ -126,6 +159,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
OnPropertyChanged(nameof(IsGameSwitchEnabled));
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
@@ -138,6 +172,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
if (!string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase)) return; // jeu changé entre-temps
|
||||
Apply(state);
|
||||
StatusMessage = null;
|
||||
});
|
||||
@@ -162,7 +197,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
_skippedVersions = state.Entries.Count - editable.Count;
|
||||
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
|
||||
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(HasSplitLayout), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
}
|
||||
@@ -231,7 +266,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
Versions.Clear();
|
||||
_selectedVersion = null;
|
||||
_skippedVersions = 0;
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(HasSplitLayout), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Demandes des joueurs » : page de lecture et de traitement des demandes envoyées depuis le bouton « Aide »
|
||||
/// (permission feedback.read ; statut, note et suppression : feedback.manage). Le formulaire des joueurs est dans
|
||||
/// GameLauncher.Modules.Feedback.
|
||||
/// </summary>
|
||||
public sealed class FeedbackAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "feedback";
|
||||
|
||||
public string DisplayName => "Demandes des joueurs";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
FeedbackPanelViewModel.PanelId,
|
||||
"Demandes des joueurs",
|
||||
"Avis, demandes d'évolution, bugs et autres demandes envoyés depuis le bouton « Aide » du launcher, rangés par jeu.",
|
||||
"MessageQuestion",
|
||||
AdminPermissions.FeedbackRead,
|
||||
Order: 15,
|
||||
context => new FeedbackPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>Tuile du choix du jeu, avec les compteurs de demandes (même modèle d'affichage que <see cref="ViewModels.GameTile"/>).</summary>
|
||||
public sealed class FeedbackGameTile : ObservableObject
|
||||
{
|
||||
private string _detail;
|
||||
private int _newCount;
|
||||
|
||||
public FeedbackGameTile(Choice choice, string name, Action<Choice> select)
|
||||
{
|
||||
Choice = choice;
|
||||
Name = name;
|
||||
_detail = $"Identifiant {choice.Value}";
|
||||
SelectCommand = new RelayCommand(() => select(choice));
|
||||
}
|
||||
|
||||
public Choice Choice { get; }
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>« 3 nouvelles · 5 à traiter · 12 au total ».</summary>
|
||||
public string Detail
|
||||
{
|
||||
get => _detail;
|
||||
private set => SetProperty(ref _detail, value);
|
||||
}
|
||||
|
||||
public int NewCount
|
||||
{
|
||||
get => _newCount;
|
||||
private set => SetProperty(ref _newCount, value);
|
||||
}
|
||||
|
||||
public RelayCommand SelectCommand { get; }
|
||||
|
||||
public void Apply(FeedbackGameCounts? counts)
|
||||
{
|
||||
NewCount = counts?.New ?? 0;
|
||||
Detail = counts is null
|
||||
? $"Identifiant {Choice.Value} · ne reçoit pas de demandes"
|
||||
: counts.Total == 0
|
||||
? "Aucune demande"
|
||||
: $"{Plural(counts.New, "nouvelle", "nouvelles")} · {counts.Open} à traiter · {counts.Total} au total";
|
||||
}
|
||||
|
||||
internal static string Plural(int count, string one, string many) => $"{count} {(count > 1 ? many : one)}";
|
||||
}
|
||||
|
||||
/// <summary>Demande dans la liste (résumé).</summary>
|
||||
public sealed class FeedbackItem : ObservableObject
|
||||
{
|
||||
private FeedbackSummary _summary;
|
||||
|
||||
public FeedbackItem(FeedbackSummary summary) => _summary = summary;
|
||||
|
||||
public FeedbackSummary Summary
|
||||
{
|
||||
get => _summary;
|
||||
set
|
||||
{
|
||||
_summary = value;
|
||||
OnPropertyChanged(string.Empty); // toutes les propriétés
|
||||
}
|
||||
}
|
||||
|
||||
public string Id => _summary.Id;
|
||||
public string Reference => _summary.Reference;
|
||||
public string Title => _summary.Title;
|
||||
public string Kind => _summary.Kind;
|
||||
public string KindLabel => FeedbackForms.KindLabel(_summary.Kind);
|
||||
public string KindIconKey => FeedbackForms.Find(_summary.Kind)?.IconKey ?? "Forum";
|
||||
public string Status => _summary.Status;
|
||||
public string StatusLabel => FeedbackStatuses.Label(_summary.Status);
|
||||
public bool IsNew => _summary.Status == FeedbackStatuses.New;
|
||||
public bool IsClosed => FeedbackStatuses.IsClosed(_summary.Status);
|
||||
public string? Badge => _summary.Badge;
|
||||
public bool HasBadge => !string.IsNullOrEmpty(_summary.Badge);
|
||||
public bool HasNote => _summary.HasNote;
|
||||
|
||||
/// <summary>« 08/11/2026 21:14 » (heure du PC).</summary>
|
||||
public string DateText => _summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>« Bug · FB-7K3Q-9P2M · 08/11/2026 21:14 ».</summary>
|
||||
public string DetailText => $"{KindLabel} · {Reference} · {DateText}";
|
||||
|
||||
public bool Matches(string search) =>
|
||||
string.IsNullOrWhiteSpace(search) ||
|
||||
Title.Contains(search.Trim(), StringComparison.CurrentCultureIgnoreCase) ||
|
||||
Reference.Contains(search.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Champ d'une demande, tel qu'affiché dans la fiche (libellé du formulaire, valeur en texte brut).</summary>
|
||||
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong);
|
||||
|
||||
/// <summary>Statut proposé dans la fiche.</summary>
|
||||
public sealed record FeedbackStatusChoice(string Value, string Label);
|
||||
|
||||
/// <summary>
|
||||
/// Fiche d'une demande : tous les champs (texte brut, jamais interprété : il vient d'un inconnu), informations
|
||||
/// techniques, statut et note interne modifiables (feedback.manage), suppression.
|
||||
/// </summary>
|
||||
public sealed class FeedbackDetailView : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private FeedbackDetail _detail;
|
||||
private FeedbackStatusChoice _status;
|
||||
private string _note;
|
||||
|
||||
public FeedbackDetailView(FeedbackDetail detail, bool canManage, Action changed, Action<string>? copy = null)
|
||||
{
|
||||
_detail = detail;
|
||||
_changed = changed;
|
||||
CanManage = canManage;
|
||||
CopyReferenceCommand = new RelayCommand(() => copy?.Invoke(detail.Summary.Reference));
|
||||
_status = AllStatuses.FirstOrDefault(s => s.Value == detail.Summary.Status) ?? AllStatuses[0];
|
||||
_note = detail.AdminNote ?? "";
|
||||
Lines = BuildLines(detail);
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyList<FeedbackStatusChoice> AllStatuses =
|
||||
FeedbackStatuses.All.Select(s => new FeedbackStatusChoice(s, FeedbackStatuses.Label(s))).ToList();
|
||||
|
||||
/// <summary>Statuts proposés (propriété d'instance : liaison WPF).</summary>
|
||||
public IReadOnlyList<FeedbackStatusChoice> Statuses => AllStatuses;
|
||||
|
||||
/// <summary>Copie la référence dans le presse-papiers.</summary>
|
||||
public RelayCommand CopyReferenceCommand { get; }
|
||||
|
||||
public FeedbackDetail Detail => _detail;
|
||||
public string Id => _detail.Summary.Id;
|
||||
public long Revision => _detail.Summary.Revision;
|
||||
public bool CanManage { get; }
|
||||
|
||||
public string Title => _detail.Summary.Title;
|
||||
public string Reference => _detail.Summary.Reference;
|
||||
public string KindLabel => FeedbackForms.KindLabel(_detail.Summary.Kind);
|
||||
public string KindIconKey => FeedbackForms.Find(_detail.Summary.Kind)?.IconKey ?? "Forum";
|
||||
|
||||
public string ReceivedText =>
|
||||
$"Reçue le {_detail.Summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}";
|
||||
|
||||
public string? StatusChangedText => _detail.Summary.StatusChangedAt is { } at
|
||||
? $"Dernière modification le {at.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}" +
|
||||
(string.IsNullOrEmpty(_detail.StatusChangedBy) ? "" : $" par {_detail.StatusChangedBy}")
|
||||
: null;
|
||||
|
||||
public IReadOnlyList<FeedbackFieldLine> Lines { get; }
|
||||
|
||||
public bool HasTech => _detail.Tech is not null;
|
||||
|
||||
public string TechText => _detail.Tech is not { } tech ? "Non jointes par le joueur." : string.Join("\n", new[]
|
||||
{
|
||||
tech.LauncherVersion is { } l ? $"Launcher : {l}" : null,
|
||||
$"Jeu : {tech.GameVersion ?? "non installé"}",
|
||||
tech.Os is { } os ? $"Système : {os}" : null,
|
||||
tech.Language is { } lang ? $"Langue : {lang}" : null
|
||||
}.Where(l => l is not null));
|
||||
|
||||
public FeedbackStatusChoice Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _status, value)) return;
|
||||
OnPropertyChanged(nameof(HasChanges));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Note
|
||||
{
|
||||
get => _note;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _note, value ?? "")) return;
|
||||
OnPropertyChanged(nameof(HasChanges));
|
||||
OnPropertyChanged(nameof(NoteLengthText));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public int NoteMaxLength => FeedbackRules.MaxAdminNoteLength;
|
||||
public string NoteLengthText => $"{_note.Trim().Length} / {FeedbackRules.MaxAdminNoteLength}";
|
||||
|
||||
/// <summary>Statut ou note différents de ceux enregistrés (note comparée telle que le serveur la nettoiera).</summary>
|
||||
public bool HasChanges =>
|
||||
_status.Value != _detail.Summary.Status ||
|
||||
(FeedbackRules.CleanMultiline(_note, int.MaxValue / 4, out _) ?? "") != (_detail.AdminNote ?? "");
|
||||
|
||||
/// <summary>Note telle qu'envoyée (vide : null).</summary>
|
||||
public string? NoteToSend => string.IsNullOrWhiteSpace(_note) ? null : _note;
|
||||
|
||||
private static IReadOnlyList<FeedbackFieldLine> BuildLines(FeedbackDetail detail)
|
||||
{
|
||||
var form = FeedbackForms.Find(detail.Summary.Kind);
|
||||
var lines = new List<FeedbackFieldLine>();
|
||||
foreach (var field in form?.Fields ?? Array.Empty<FeedbackField>())
|
||||
if (detail.Fields.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value))
|
||||
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline));
|
||||
// Champ d'une version plus récente du formulaire, inconnu de ce launcher : affiché tel quel.
|
||||
foreach (var (id, value) in detail.Fields.Where(f => form?.Field(f.Key) is null))
|
||||
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n')));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Demandes des joueurs » : avis, demandes d'évolution, bugs et autres demandes envoyés SANS compte depuis le
|
||||
/// bouton « Aide » du launcher, rangés par jeu.
|
||||
/// • plusieurs jeux : choix du jeu d'abord (tuiles avec le nombre de nouvelles demandes), rien n'est chargé avant ;
|
||||
/// • liste filtrable (type, statut, recherche dans le titre ou la référence), pages de 100, les plus récentes d'abord ;
|
||||
/// • fiche : champs en texte brut, informations techniques, statut et note interne (feedback.manage, requête signée,
|
||||
/// révision attendue : si un autre administrateur l'a modifiée entre-temps, rien n'est écrasé), suppression ;
|
||||
/// • chaque consultation est inscrite au journal d'audit par le serveur (le texte libre peut contenir des données
|
||||
/// personnelles). Pas de réponse au joueur : les demandes sont anonymes (module futur « comptes »).
|
||||
/// </summary>
|
||||
public sealed class FeedbackPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "feedback.requests";
|
||||
private const int PageSize = 100;
|
||||
|
||||
private readonly List<FeedbackItem> _loaded = new();
|
||||
private readonly Dictionary<string, FeedbackGameTile> _tiles;
|
||||
private FeedbackOverview? _overview;
|
||||
private Choice? _selectedGame;
|
||||
private Choice _kind;
|
||||
private Choice _status;
|
||||
private string _searchText = "";
|
||||
private string? _nextCursor;
|
||||
private int _total;
|
||||
private FeedbackItem? _selectedItem;
|
||||
private FeedbackDetailView? _detail;
|
||||
private string? _statusMessage;
|
||||
private bool _hasConflict;
|
||||
private int _loadVersion;
|
||||
|
||||
public FeedbackPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.Count == 1 ? GameChoices[0] : null;
|
||||
_tiles = GameChoices.ToDictionary(c => c.Value!, c => new FeedbackGameTile(c,
|
||||
context.Games.First(g => g.GameId == c.Value).GameName, choice => SelectedGame = choice), StringComparer.OrdinalIgnoreCase);
|
||||
GameTiles = _tiles.Values.ToList();
|
||||
|
||||
Kinds = new[] { new Choice(null, "Tous les types") }
|
||||
.Concat(FeedbackForms.All.Select(f => new Choice(f.Kind, f.Label))).ToList();
|
||||
Statuses = new[]
|
||||
{
|
||||
new Choice("open", "À traiter (nouvelles et en cours)"),
|
||||
new Choice(FeedbackStatuses.New, "Nouvelles"),
|
||||
new Choice(FeedbackStatuses.InProgress, "En cours"),
|
||||
new Choice("closed", "Closes (traitées, rejetées, doublons)"),
|
||||
new Choice(FeedbackStatuses.Done, "Traitées"),
|
||||
new Choice(FeedbackStatuses.Rejected, "Rejetées"),
|
||||
new Choice(FeedbackStatuses.Duplicate, "Doublons"),
|
||||
new Choice(null, "Toutes")
|
||||
};
|
||||
_kind = Kinds[0];
|
||||
_status = Statuses[0];
|
||||
|
||||
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
|
||||
LoadMoreCommand = new AsyncRelayCommand(LoadMoreAsync, () => !IsBusy && _nextCursor is not null);
|
||||
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && _detail is { CanManage: true, HasChanges: true } && !_hasConflict);
|
||||
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => !IsBusy && _detail is { CanManage: true });
|
||||
}
|
||||
|
||||
public bool CanManage => Context.Session.Has(AdminPermissions.FeedbackManage);
|
||||
|
||||
// ------------------------------------------------------------------ Jeu (modèles AdminGamePickerTemplate / AdminGameSwitcherTemplate)
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public IReadOnlyList<FeedbackGameTile> GameTiles { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
public bool ShowGamePicker => _selectedGame is null;
|
||||
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
|
||||
public bool HasSelectedGame => _selectedGame is not null;
|
||||
public bool IsGameSwitchEnabled => !IsBusy;
|
||||
|
||||
public string GamePickerText => GameChoices.Count == 0
|
||||
? "Aucun jeu n'est déclaré dans launcher.json."
|
||||
: "Choisissez le jeu dont vous voulez lire les demandes : les demandes d'un jeu ne se mélangent jamais à celles d'un autre.";
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame)) return;
|
||||
if (IsBusy || !ConfirmDiscard())
|
||||
{
|
||||
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
|
||||
return;
|
||||
}
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
foreach (var name in new[] { nameof(ShowGamePicker), nameof(ShowGameSwitcher), nameof(HasSelectedGame) }) OnPropertyChanged(name);
|
||||
ClearList();
|
||||
ErrorMessage = null;
|
||||
StatusMessage = null;
|
||||
GameLoad = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chargement lancé par le dernier choix de jeu (tests).</summary>
|
||||
internal Task GameLoad { get; private set; } = Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Serveur
|
||||
|
||||
/// <summary>Réception désactivée sur le serveur (LauncherApi:Feedback:Enabled).</summary>
|
||||
public bool ShowDisabled => _overview is { Enabled: false };
|
||||
|
||||
/// <summary>Plafond atteint : les nouvelles demandes sont refusées.</summary>
|
||||
public bool ShowStorageFull => _overview is { StorageFull: true };
|
||||
|
||||
public string RetentionText => _overview is null ? "" :
|
||||
$"Conservation : {_overview.RetentionClosedMonths} mois après la clôture d'une demande, {_overview.RetentionMaxMonths} mois au plus (purge automatique).";
|
||||
|
||||
// ------------------------------------------------------------------ Filtres et liste
|
||||
|
||||
public IReadOnlyList<Choice> Kinds { get; }
|
||||
public IReadOnlyList<Choice> Statuses { get; }
|
||||
|
||||
public Choice Kind
|
||||
{
|
||||
get => _kind;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _kind)) return;
|
||||
if (IsBusy || !ConfirmDiscard())
|
||||
{
|
||||
OnPropertyChanged();
|
||||
return;
|
||||
}
|
||||
_kind = value;
|
||||
OnPropertyChanged();
|
||||
GameLoad = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public Choice Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _status)) return;
|
||||
if (IsBusy || !ConfirmDiscard())
|
||||
{
|
||||
OnPropertyChanged();
|
||||
return;
|
||||
}
|
||||
_status = value;
|
||||
OnPropertyChanged();
|
||||
GameLoad = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Recherche locale (titre, référence) parmi les demandes chargées.</summary>
|
||||
public string SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _searchText, value ?? "")) ApplySearch();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<FeedbackItem> Items { get; } = new();
|
||||
|
||||
public FeedbackItem? SelectedItem
|
||||
{
|
||||
get => _selectedItem;
|
||||
set
|
||||
{
|
||||
if (value is null || ReferenceEquals(value, _selectedItem)) return;
|
||||
if (IsBusy || !ConfirmDiscard())
|
||||
{
|
||||
OnPropertyChanged();
|
||||
return;
|
||||
}
|
||||
_selectedItem = value;
|
||||
OnPropertyChanged();
|
||||
DetailLoad = LoadDetailAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chargement de la fiche lancé par la dernière sélection (tests).</summary>
|
||||
internal Task DetailLoad { get; private set; } = Task.CompletedTask;
|
||||
|
||||
public FeedbackDetailView? Detail
|
||||
{
|
||||
get => _detail;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _detail, value)) return;
|
||||
OnPropertyChanged(nameof(HasDetail));
|
||||
OnPropertyChanged(nameof(ShowNoSelection));
|
||||
HasConflict = false;
|
||||
RaiseCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasDetail => _detail is not null;
|
||||
public bool ShowNoSelection => _detail is null && Items.Count > 0;
|
||||
public bool HasItems => Items.Count > 0;
|
||||
public override bool HasSplitLayout => HasItems;
|
||||
|
||||
public string? EmptyText => _selectedGame is null || IsBusy || Items.Count > 0 ? null
|
||||
: _loaded.Count > 0 ? "Aucune demande ne correspond à la recherche."
|
||||
: "Aucune demande pour ces filtres.";
|
||||
|
||||
/// <summary>« 12 demandes · 100 affichées ».</summary>
|
||||
public string CountText => _selectedGame is null ? "" :
|
||||
$"{FeedbackGameTile.Plural(_total, "demande", "demandes")}" + (_loaded.Count < _total ? $" · {_loaded.Count} chargées" : "");
|
||||
|
||||
public bool CanLoadMore => _nextCursor is not null;
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
|
||||
|
||||
/// <summary>La demande a été modifiée par un autre administrateur : à recharger avant d'enregistrer.</summary>
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
public AsyncRelayCommand LoadMoreCommand { get; }
|
||||
public AsyncRelayCommand SaveCommand { get; }
|
||||
public AsyncRelayCommand DeleteCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
OnPropertyChanged(nameof(IsGameSwitchEnabled));
|
||||
OnPropertyChanged(nameof(EmptyText));
|
||||
RaiseCommands();
|
||||
}
|
||||
|
||||
private void RaiseCommands()
|
||||
{
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
LoadMoreCommand.RaiseCanExecuteChanged();
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
DeleteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
/// <summary>Compteurs (tuiles, bandeaux) puis, si un jeu est choisi, la première page de ses demandes.</summary>
|
||||
public Task LoadAsync() => RunAsync(async () =>
|
||||
{
|
||||
var version = ++_loadVersion;
|
||||
var overview = await Context.Session.GetFeedbackOverviewAsync();
|
||||
ApplyOverview(overview);
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var page = await Context.Session.GetFeedbackPageAsync(gameId, _kind.Value, _status.Value, null, PageSize);
|
||||
if (version != _loadVersion) return; // jeu ou filtre changé entre-temps
|
||||
ClearList();
|
||||
AddPage(page);
|
||||
});
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (!ConfirmDiscard()) return;
|
||||
var selectedId = _selectedItem?.Id;
|
||||
StatusMessage = null;
|
||||
await LoadAsync();
|
||||
if (selectedId is not null && Items.FirstOrDefault(i => i.Id == selectedId) is { } again)
|
||||
{
|
||||
_selectedItem = again;
|
||||
OnPropertyChanged(nameof(SelectedItem));
|
||||
await LoadDetailAsync(again);
|
||||
}
|
||||
}
|
||||
|
||||
private Task LoadMoreAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId || _nextCursor is null) return;
|
||||
var version = _loadVersion;
|
||||
var page = await Context.Session.GetFeedbackPageAsync(gameId, _kind.Value, _status.Value, _nextCursor, PageSize);
|
||||
if (version != _loadVersion) return;
|
||||
AddPage(page);
|
||||
});
|
||||
|
||||
private Task LoadDetailAsync(FeedbackItem item) => RunAsync(async () =>
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var detail = await Context.Session.GetFeedbackAsync(gameId, item.Id);
|
||||
if (!ReferenceEquals(item, _selectedItem)) return; // autre demande choisie entre-temps
|
||||
item.Summary = detail.Summary;
|
||||
Detail = NewDetail(detail);
|
||||
});
|
||||
|
||||
private FeedbackDetailView NewDetail(FeedbackDetail detail) =>
|
||||
new(detail, CanManage, () => SaveCommand.RaiseCanExecuteChanged(), reference => Context.Dialogs.CopyToClipboard(reference));
|
||||
|
||||
private void ApplyOverview(FeedbackOverview overview)
|
||||
{
|
||||
_overview = overview;
|
||||
foreach (var (gameId, tile) in _tiles)
|
||||
tile.Apply(overview.Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)));
|
||||
foreach (var name in new[] { nameof(ShowDisabled), nameof(ShowStorageFull), nameof(RetentionText) }) OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
private void ClearList()
|
||||
{
|
||||
_loaded.Clear();
|
||||
_nextCursor = null;
|
||||
_total = 0;
|
||||
_selectedItem = null;
|
||||
OnPropertyChanged(nameof(SelectedItem));
|
||||
Detail = null;
|
||||
ApplySearch();
|
||||
}
|
||||
|
||||
private void AddPage(FeedbackPage page)
|
||||
{
|
||||
_loaded.AddRange(page.Items.Select(s => new FeedbackItem(s)));
|
||||
_nextCursor = page.NextCursor;
|
||||
_total = page.Total;
|
||||
ApplySearch();
|
||||
}
|
||||
|
||||
private void ApplySearch()
|
||||
{
|
||||
Items.Clear();
|
||||
foreach (var item in _loaded.Where(i => i.Matches(_searchText))) Items.Add(item);
|
||||
foreach (var name in new[] { nameof(HasItems), nameof(HasSplitLayout), nameof(EmptyText), nameof(CountText), nameof(CanLoadMore), nameof(ShowNoSelection) })
|
||||
OnPropertyChanged(name);
|
||||
LoadMoreCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Traitement
|
||||
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId || _detail is not { } detail) return;
|
||||
StatusMessage = null;
|
||||
if (FeedbackRules.CleanMultiline(detail.Note, FeedbackRules.MaxAdminNoteLength, out var problem) is null && problem is not null)
|
||||
{
|
||||
ErrorMessage = $"Note interne : {problem}";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var saved = await Context.Session.UpdateFeedbackAsync(gameId, detail.Id, detail.Revision, detail.Status.Value, detail.NoteToSend);
|
||||
if (_loaded.FirstOrDefault(i => i.Id == saved.Summary.Id) is { } item) item.Summary = saved.Summary;
|
||||
Detail = NewDetail(saved);
|
||||
StatusMessage = $"Demande {saved.Summary.Reference} enregistrée : {FeedbackStatuses.Label(saved.Summary.Status)}.";
|
||||
await RefreshCountsAsync();
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (_detail is not { } detail || _selectedGame?.Value is not { } gameId) return;
|
||||
if (!Context.Dialogs.Confirm("Supprimer la demande ?",
|
||||
$"La demande {detail.Reference} (« {detail.Title} ») sera supprimée définitivement du serveur. Cette action est inscrite au journal d'audit.",
|
||||
"Supprimer", danger: true))
|
||||
return;
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await Context.Session.DeleteFeedbackAsync(gameId, detail.Id);
|
||||
_loaded.RemoveAll(i => i.Id == detail.Id);
|
||||
_total = Math.Max(0, _total - 1);
|
||||
_selectedItem = null;
|
||||
OnPropertyChanged(nameof(SelectedItem));
|
||||
Detail = null;
|
||||
ApplySearch();
|
||||
StatusMessage = $"Demande {detail.Reference} supprimée.";
|
||||
await RefreshCountsAsync();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Compteurs des tuiles après un changement (sans effet sur la page en cas d'échec).</summary>
|
||||
private async Task RefreshCountsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ApplyOverview(await Context.Session.GetFeedbackOverviewAsync());
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
// compteurs indicatifs : le prochain rechargement les mettra à jour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Statut ou note modifiés et non enregistrés : confirmation avant de les abandonner.</summary>
|
||||
private bool ConfirmDiscard() =>
|
||||
_detail is not { HasChanges: true } ||
|
||||
Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Le statut ou la note interne de la demande affichée n'ont pas été enregistrés : ils seront perdus.", "Abandonner");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Fichiers du serveur » : parcours du site public (www), suppression des fichiers inutilisés
|
||||
/// (anciennes archives…) et nettoyage du dossier « objects » des jeux. Permission files.manage
|
||||
/// (propriétaires, administrateurs).
|
||||
/// </summary>
|
||||
public sealed class FilesAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "files";
|
||||
|
||||
public string DisplayName => "Fichiers du serveur";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
FilesPanelViewModel.PanelId,
|
||||
"Fichiers du serveur",
|
||||
"Contenu du site public. Libérez de la place en supprimant les fichiers qu'aucun manifest n'utilise plus " +
|
||||
"(anciennes archives, anciens exécutables…) : le serveur vérifie les manifests avant chaque suppression.",
|
||||
"Harddisk",
|
||||
AdminPermissions.FilesManage,
|
||||
Order: 35,
|
||||
context => new FilesPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Fichiers du serveur » (files.manage) : parcours du site public (www), suppression d'un fichier que le
|
||||
/// serveur juge inutilisé (après confirmation), nettoyage du dossier « objects » d'un jeu (analyse, puis
|
||||
/// confirmation). Le serveur revérifie tout au moment de supprimer : la page ne fait qu'afficher ses réponses.
|
||||
/// </summary>
|
||||
public sealed class FilesPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "files.manage";
|
||||
|
||||
private ServerFolder? _folder;
|
||||
private IReadOnlyList<ServerFileItem> _entries = Array.Empty<ServerFileItem>();
|
||||
private IReadOnlyList<FolderCrumb> _crumbs = Array.Empty<FolderCrumb>();
|
||||
private string _path = "";
|
||||
private bool _showOnlyUnused;
|
||||
private string? _statusMessage;
|
||||
private IReadOnlyList<Choice> _pruneGames = Array.Empty<Choice>();
|
||||
private Choice? _pruneGame;
|
||||
private ObjectsPrunePreview? _prune;
|
||||
|
||||
public FilesPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(_path))), () => !IsBusy);
|
||||
UpCommand = new AsyncRelayCommand(() => OpenAsync(Parent(_path)), () => !IsBusy && _path.Length > 0);
|
||||
AnalyzeCommand = new AsyncRelayCommand(AnalyzeAsync, () => !IsBusy && _pruneGame is not null);
|
||||
PruneCommand = new AsyncRelayCommand(ScrollingToTop(PruneAsync), () => !IsBusy && CanPrune);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Dossier affiché
|
||||
|
||||
public string RootName => _folder?.RootName is { Length: > 0 } name ? name : "www";
|
||||
|
||||
/// <summary>Chemin du dossier affiché (« » : racine du site).</summary>
|
||||
public string CurrentPath => _path;
|
||||
|
||||
/// <summary>Fil d'Ariane : racine, puis chaque dossier jusqu'au dossier affiché.</summary>
|
||||
public IReadOnlyList<FolderCrumb> Crumbs
|
||||
{
|
||||
get => _crumbs;
|
||||
private set => SetProperty(ref _crumbs, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ServerFileItem> Entries
|
||||
{
|
||||
get => _entries;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _entries, value)) return;
|
||||
OnPropertyChanged(nameof(EmptyText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>N'afficher que les fichiers supprimables (et les dossiers, pour continuer à parcourir).</summary>
|
||||
public bool ShowOnlyUnused
|
||||
{
|
||||
get => _showOnlyUnused;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _showOnlyUnused, value)) ApplyFolder();
|
||||
}
|
||||
}
|
||||
|
||||
public string EmptyText => _folder is null || Entries.Count > 0 ? ""
|
||||
: _showOnlyUnused ? "Aucun fichier inutilisé dans ce dossier." : "Dossier vide.";
|
||||
|
||||
/// <summary>« 12 éléments · 3 fichier(s) inutilisé(s) : 1,2 Go ».</summary>
|
||||
public string SummaryText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_folder is null) return "";
|
||||
var unused = _folder.Entries.Where(e => e.Status == ServerFileStatus.Unused).ToList();
|
||||
var text = $"{_folder.Entries.Count} élément(s)";
|
||||
if (unused.Count > 0) text += $" · {unused.Count} fichier(s) inutilisé(s) : {Bytes(unused.Sum(e => e.Size))}";
|
||||
return text + (_folder.Truncated ? $" · seuls les {ServerFilesLimits.MaxEntries} premiers sont affichés" : "");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>« Espace libre sur le serveur : 12,4 Go sur 40 Go ».</summary>
|
||||
public string DiskText => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f
|
||||
? $"Espace libre sur le serveur : {Bytes(f.FreeBytes)} sur {Bytes(f.TotalBytes)}"
|
||||
: "";
|
||||
|
||||
/// <summary>Part utilisée du disque (0 à 1), pour la jauge.</summary>
|
||||
public double DiskUsedRatio => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f ? 1 - (double)f.FreeBytes / f.TotalBytes : 0;
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand UpCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Nettoyage du dossier « objects »
|
||||
|
||||
/// <summary>Jeux configurés sur le serveur (seuls leurs dossiers « objects » peuvent être nettoyés).</summary>
|
||||
public IReadOnlyList<Choice> PruneGames
|
||||
{
|
||||
get => _pruneGames;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _pruneGames, value)) return;
|
||||
OnPropertyChanged(nameof(HasPruneGames));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPruneGames => _pruneGames.Count > 0;
|
||||
|
||||
public Choice? PruneGame
|
||||
{
|
||||
get => _pruneGame;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _pruneGame, value)) return;
|
||||
Prune = null;
|
||||
AnalyzeCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dernière analyse (null : pas encore analysé, ou jeu changé).</summary>
|
||||
public ObjectsPrunePreview? Prune
|
||||
{
|
||||
get => _prune;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _prune, value)) return;
|
||||
OnPropertyChanged(nameof(HasPrune));
|
||||
OnPropertyChanged(nameof(PruneText));
|
||||
OnPropertyChanged(nameof(PruneDetail));
|
||||
OnPropertyChanged(nameof(CanPrune));
|
||||
OnPropertyChanged(nameof(PruneButtonText));
|
||||
PruneCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPrune => _prune is not null;
|
||||
|
||||
public bool CanPrune => _prune is { OrphanCount: > 0, PublicationInProgress: false };
|
||||
|
||||
public string PruneButtonText => _prune is { OrphanCount: > 0 } p
|
||||
? $"Supprimer {p.OrphanCount} fichier(s) ({Bytes(p.OrphanBytes)})"
|
||||
: "Supprimer les fichiers inutilisés";
|
||||
|
||||
public string PruneText => _prune switch
|
||||
{
|
||||
null => "",
|
||||
{ PublicationInProgress: true } p =>
|
||||
$"Une publication de {p.GameId} est en cours : nettoyage possible une fois la version en ligne (ou la publication abandonnée).",
|
||||
{ OrphanCount: 0 } p => $"Rien à nettoyer : les {p.ReferencedCount} fichier(s) du dossier « objects » sont tous utilisés par la version {p.Version}.",
|
||||
var p => $"{p.OrphanCount} fichier(s) ne sont plus utilisés par la version en ligne ({p.Version}) : {Bytes(p.OrphanBytes)} à libérer."
|
||||
};
|
||||
|
||||
public string PruneDetail => _prune switch
|
||||
{
|
||||
null => "",
|
||||
{ MissingCount: > 0 } p => $"Attention : {p.MissingCount} fichier(s) de la version {p.Version} sont absents du serveur. Republiez la version.",
|
||||
{ OrphanCount: > 0 } p => "Exemples : " + string.Join(", ", p.Examples.Take(3).Select(e => Shorten(e.Split('/').Last()))),
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public AsyncRelayCommand AnalyzeCommand { get; }
|
||||
public AsyncRelayCommand PruneCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
UpCommand.RaiseCanExecuteChanged();
|
||||
AnalyzeCommand.RaiseCanExecuteChanged();
|
||||
PruneCommand.RaiseCanExecuteChanged();
|
||||
foreach (var entry in _entries) entry.RaiseCanExecuteChanged();
|
||||
foreach (var crumb in _crumbs) crumb.OpenCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync("")) : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre un dossier (fil d'Ariane, double-clic, « Remonter ») sans déplacer l'ascenseur : la page reste où elle est
|
||||
/// (le fil d'Ariane et le contenu restent visibles d'un dossier à l'autre). Seule une erreur remonte en haut.
|
||||
/// </summary>
|
||||
internal async Task OpenAsync(string path)
|
||||
{
|
||||
StatusMessage = null;
|
||||
await RunAsync(() => LoadAsync(path));
|
||||
}
|
||||
|
||||
private async Task LoadAsync(string path)
|
||||
{
|
||||
var folder = await Context.Session.GetServerFolderAsync(path);
|
||||
_folder = folder;
|
||||
_path = folder.Path;
|
||||
Crumbs = BuildCrumbs(folder.RootName, folder.Path);
|
||||
var games = folder.PrunableGames.Select(id => new Choice(id, GameLabel(id))).ToList();
|
||||
if (!games.Select(g => g.Value).SequenceEqual(_pruneGames.Select(g => g.Value)))
|
||||
{
|
||||
PruneGames = games;
|
||||
PruneGame = games.Count == 1 ? games[0] : null; // plusieurs jeux : choisi par l'administrateur, jamais par défaut
|
||||
}
|
||||
ApplyFolder();
|
||||
OnPropertyChanged(nameof(CurrentPath));
|
||||
OnPropertyChanged(nameof(RootName));
|
||||
OnPropertyChanged(nameof(DiskText));
|
||||
OnPropertyChanged(nameof(DiskUsedRatio));
|
||||
UpCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void ApplyFolder()
|
||||
{
|
||||
if (_folder is null) return;
|
||||
Entries = _folder.Entries
|
||||
.Where(e => !_showOnlyUnused || e.IsDirectory || e.Status == ServerFileStatus.Unused)
|
||||
.Select(e => new ServerFileItem(this, e))
|
||||
.ToList();
|
||||
OnPropertyChanged(nameof(SummaryText));
|
||||
OnPropertyChanged(nameof(EmptyText));
|
||||
}
|
||||
|
||||
internal async Task DeleteAsync(ServerFileItem item)
|
||||
{
|
||||
if (!item.CanDelete) return;
|
||||
var path = Combine(_path, item.Name);
|
||||
if (!Context.Dialogs.Confirm("Supprimer ce fichier",
|
||||
$"« {item.Name} » ({item.SizeText}) va être supprimé définitivement du serveur.\n\n" +
|
||||
"Le serveur vérifie d'abord qu'aucun manifest ne l'utilise : un fichier encore utilisé n'est jamais supprimé.",
|
||||
"Supprimer", danger: true))
|
||||
return;
|
||||
|
||||
StatusMessage = null;
|
||||
ServerFileDeleteResult? result = null;
|
||||
if (await RunAsync(async () => result = await Context.Session.DeleteServerFileAsync(path, item.Entry.Size)))
|
||||
StatusMessage = $"« {item.Name} » supprimé : {Bytes(result!.FreedBytes)} libéré(s).";
|
||||
RequestScrollToTop();
|
||||
await ReloadKeepingErrorAsync(); // la liste reflète l'état réel du serveur
|
||||
}
|
||||
|
||||
/// <summary>Recharge le dossier sans effacer l'erreur de l'action qui précède (refus du serveur).</summary>
|
||||
private async Task ReloadKeepingErrorAsync()
|
||||
{
|
||||
var error = ErrorMessage;
|
||||
await RunAsync(() => LoadAsync(_path));
|
||||
if (error is not null && ErrorMessage is null) ErrorMessage = error;
|
||||
}
|
||||
|
||||
private async Task AnalyzeAsync()
|
||||
{
|
||||
if (_pruneGame?.Value is not { } gameId) return;
|
||||
Prune = null;
|
||||
ObjectsPrunePreview? preview = null;
|
||||
if (await RunAsync(async () => preview = await Context.Session.GetObjectsPruneAsync(gameId))) Prune = preview;
|
||||
}
|
||||
|
||||
private async Task PruneAsync()
|
||||
{
|
||||
if (_prune is not { OrphanCount: > 0 } preview) return;
|
||||
if (!Context.Dialogs.Confirm("Nettoyer le dossier « objects »",
|
||||
$"{preview.OrphanCount} fichier(s) que la version en ligne de {preview.GameId} ({preview.Version}) n'utilise plus vont être " +
|
||||
$"supprimés définitivement ({Bytes(preview.OrphanBytes)}).\n\n" +
|
||||
"Les joueurs qui ont déjà la version en ligne ne sont pas concernés. Une version plus ancienne ne pourra plus être " +
|
||||
"republiée sans renvoyer ces fichiers.",
|
||||
"Supprimer", danger: true))
|
||||
return;
|
||||
|
||||
StatusMessage = null;
|
||||
ObjectsPruneResult? result = null;
|
||||
if (await RunAsync(async () => result = await Context.Session.PruneObjectsAsync(preview.GameId, preview.ManifestSha256)))
|
||||
StatusMessage = $"Dossier « objects » de {result!.GameId} nettoyé : {result.Deleted} fichier(s) supprimé(s), {Bytes(result.FreedBytes)} libéré(s)." +
|
||||
(result.Failed > 0 ? $" {result.Failed} fichier(s) n'ont pas pu être supprimés (journal du serveur)." : "");
|
||||
Prune = null;
|
||||
await ReloadKeepingErrorAsync();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private string GameLabel(string gameId) =>
|
||||
Context.Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)) is { } game
|
||||
? $"{game.GameName} ({gameId})"
|
||||
: gameId;
|
||||
|
||||
private IReadOnlyList<FolderCrumb> BuildCrumbs(string rootName, string path)
|
||||
{
|
||||
var crumbs = new List<FolderCrumb> { new(this, string.IsNullOrEmpty(rootName) ? "www" : rootName, "") };
|
||||
var current = "";
|
||||
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
current = Combine(current, segment);
|
||||
crumbs.Add(new FolderCrumb(this, segment, current));
|
||||
}
|
||||
crumbs[^1].IsCurrent = true;
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
internal static string Combine(string folder, string name) => folder.Length == 0 ? name : folder + "/" + name;
|
||||
|
||||
private static string Parent(string path) => path.LastIndexOf('/') is var i and > 0 ? path[..i] : "";
|
||||
|
||||
/// <summary>Taille lisible : « 812 o », « 4,2 Ko », « 1,3 Go ».</summary>
|
||||
public static string Bytes(long bytes)
|
||||
{
|
||||
if (bytes < 0) return "?";
|
||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
||||
double value = bytes;
|
||||
var unit = 0;
|
||||
while (value >= 1024 && unit < units.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return unit == 0 ? $"{bytes} o" : value.ToString(value >= 100 ? "0" : "0.#", CultureInfo.InvariantCulture).Replace('.', ',') + " " + units[unit];
|
||||
}
|
||||
|
||||
private static string Shorten(string name) => name.Length > 16 ? name[..12] + "…" : name;
|
||||
}
|
||||
|
||||
/// <summary>Élément du fil d'Ariane.</summary>
|
||||
public sealed class FolderCrumb
|
||||
{
|
||||
internal FolderCrumb(FilesPanelViewModel page, string label, string path)
|
||||
{
|
||||
Label = label;
|
||||
Path = path;
|
||||
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(path), () => !page.IsBusy && !IsCurrent);
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
public string Path { get; }
|
||||
public bool IsCurrent { get; internal set; }
|
||||
public AsyncRelayCommand OpenCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Fichier ou dossier tel qu'affiché.</summary>
|
||||
public sealed class ServerFileItem
|
||||
{
|
||||
internal ServerFileItem(FilesPanelViewModel page, ServerFileEntry entry)
|
||||
{
|
||||
Entry = entry;
|
||||
Name = entry.Name;
|
||||
IsDirectory = entry.IsDirectory && entry.Status == ServerFileStatus.Directory;
|
||||
SizeText = entry.IsDirectory
|
||||
? entry.Size >= 0 ? $"{FilesPanelViewModel.Bytes(entry.Size)} · {entry.FileCount} fichier(s)" : "taille non calculée (trop de fichiers)"
|
||||
: FilesPanelViewModel.Bytes(entry.Size);
|
||||
DateText = entry.Modified.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
|
||||
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(FilesPanelViewModel.Combine(page.CurrentPath, entry.Name)),
|
||||
() => IsDirectory && !page.IsBusy);
|
||||
DeleteCommand = new AsyncRelayCommand(() => page.DeleteAsync(this), () => CanDelete && !page.IsBusy);
|
||||
}
|
||||
|
||||
public ServerFileEntry Entry { get; }
|
||||
public string Name { get; }
|
||||
public bool IsDirectory { get; }
|
||||
public string SizeText { get; }
|
||||
public string DateText { get; }
|
||||
public string Status => Entry.Status;
|
||||
|
||||
/// <summary>Supprimable : le serveur l'a jugé inutilisé (il le revérifie au moment de supprimer).</summary>
|
||||
public bool CanDelete => Entry.Status == ServerFileStatus.Unused;
|
||||
|
||||
public string StatusText => Entry.Status switch
|
||||
{
|
||||
ServerFileStatus.Directory => "",
|
||||
ServerFileStatus.Protected => "Protégé",
|
||||
ServerFileStatus.InUse => "Utilisé",
|
||||
ServerFileStatus.Unused => "Inutilisé",
|
||||
ServerFileStatus.Link => "Lien",
|
||||
_ => "Non vérifiable"
|
||||
};
|
||||
|
||||
/// <summary>Explication (infobulle) : pourquoi le fichier est protégé ou utilisé.</summary>
|
||||
public string ToolTipText => Entry.Status switch
|
||||
{
|
||||
ServerFileStatus.Directory => "Ouvrir le dossier",
|
||||
ServerFileStatus.Unused => "Aucun manifest ni fichier de description n'utilise ce fichier : il peut être supprimé.",
|
||||
_ => Capitalize(Entry.Reason ?? "Ce fichier ne peut pas être supprimé depuis le menu.")
|
||||
};
|
||||
|
||||
public string IconKey => Entry.IsDirectory ? "Folder"
|
||||
: Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "PackageVariant"
|
||||
: Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? "FileDocument"
|
||||
: Name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "ImageOutline"
|
||||
: "TextBoxOutline";
|
||||
|
||||
public AsyncRelayCommand OpenCommand { get; }
|
||||
public AsyncRelayCommand DeleteCommand { get; }
|
||||
|
||||
internal void RaiseCanExecuteChanged()
|
||||
{
|
||||
OpenCommand.RaiseCanExecuteChanged();
|
||||
DeleteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static string Capitalize(string text) => text.Length > 0 ? char.ToUpper(text[0], CultureInfo.InvariantCulture) + text[1..] : text;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Publishing;
|
||||
@@ -14,7 +15,9 @@ namespace GameLauncher.Modules.Admin.Publishing;
|
||||
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
|
||||
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
|
||||
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
|
||||
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète.
|
||||
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète ;
|
||||
/// • plusieurs jeux déclarés : rien n'est affiché avant le choix du jeu (aucun jeu par défaut) ; un envoi
|
||||
/// interrompu reste attaché à son jeu (bandeau « Reprendre / Abandonner » quand on revient à ce jeu).
|
||||
/// </summary>
|
||||
public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
@@ -29,19 +32,20 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
private bool _isPublishing, _committing;
|
||||
private double _progressValue;
|
||||
private PublicationState? _interrupted;
|
||||
private readonly Dictionary<string, PublicationState> _interruptedByGame = new(StringComparer.OrdinalIgnoreCase);
|
||||
private CancellationTokenSource? _cancellation;
|
||||
|
||||
public PublishPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit);
|
||||
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _manifestPath is not null);
|
||||
PublishCommand = new AsyncRelayCommand(PublishAsync, () => CanEdit && _release is not null);
|
||||
GameChoices = GameTile.Choices(context);
|
||||
_selectedGame = GameTile.Initial(GameChoices);
|
||||
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
|
||||
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit && _selectedGame is not null);
|
||||
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _selectedGame is not null && _manifestPath is not null);
|
||||
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => CanEdit && _release is not null);
|
||||
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
|
||||
SignOnlineCommand = new AsyncRelayCommand(SignOnlineAsync, () => CanEdit && CanSignOnline);
|
||||
AbandonCommand = new AsyncRelayCommand(AbandonAsync, () => CanEdit && _interrupted is not null);
|
||||
SignOnlineCommand = new AsyncRelayCommand(ScrollingToTop(SignOnlineAsync), () => CanEdit && CanSignOnline);
|
||||
AbandonCommand = new AsyncRelayCommand(ScrollingToTop(AbandonAsync), () => CanEdit && _interrupted is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu, dossier
|
||||
@@ -49,16 +53,56 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
|
||||
public IReadOnlyList<GameTile> GameTiles { get; }
|
||||
|
||||
public bool HasSelectedGame => _selectedGame is not null;
|
||||
public bool ShowGamePicker => _selectedGame is null;
|
||||
|
||||
/// <summary>Lecture de la version en ligne lancée par le dernier choix de jeu (tests).</summary>
|
||||
internal Task GameLoad { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
|
||||
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
|
||||
|
||||
/// <summary>Pas de changement de jeu pendant un contrôle ou un envoi.</summary>
|
||||
public bool IsGameSwitchEnabled => CanEdit;
|
||||
|
||||
public string GamePickerText => GameChoices.Count == 0
|
||||
? "Aucun jeu n'est déclaré dans launcher.json."
|
||||
: "Choisissez le jeu dont vous voulez publier une version.";
|
||||
|
||||
/// <summary>Nom du jeu choisi (titres, confirmations) : « Exovival Zombie Reborn (EZR) ».</summary>
|
||||
public string SelectedGameLabel => _selectedGame?.Label ?? "";
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame) || _isPublishing) return;
|
||||
if (value is null || Equals(value, _selectedGame) || _isPublishing || IsBusy)
|
||||
{
|
||||
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
|
||||
return;
|
||||
}
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher), nameof(SelectedGameLabel) })
|
||||
OnPropertyChanged(name);
|
||||
// Rien de l'ancien jeu ne reste affiché ni utilisable : dossier, version en ligne, signature, avancement.
|
||||
ErrorMessage = null;
|
||||
StatusMessage = null;
|
||||
ManifestPath = null;
|
||||
SetRelease(null);
|
||||
_ = RefreshPublishedAsync();
|
||||
_publishedVersion = null;
|
||||
_online = null;
|
||||
PublishedText = "";
|
||||
ProgressText = null;
|
||||
ProgressValue = 0;
|
||||
SpeedText = null;
|
||||
SetInterrupted(_interruptedByGame.GetValueOrDefault(value.Value ?? ""), remember: false);
|
||||
RefreshCommands();
|
||||
GameLoad = RefreshPublishedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +158,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
if (!SetProperty(ref _isPublishing, value)) return;
|
||||
OnPropertyChanged(nameof(HasInterrupted));
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
OnPropertyChanged(nameof(IsGameSwitchEnabled));
|
||||
OnPropertyChanged(nameof(ShowBusyBar));
|
||||
RefreshCommands();
|
||||
}
|
||||
@@ -201,7 +246,9 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
private async Task SignOnlineAsync()
|
||||
{
|
||||
if (_release is not { } release || _selectedGame?.Value is not { } gameId || _online is not { } online) return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Manifest, "Signer quand même")) return;
|
||||
if (!Context.Dialogs.Confirm($"Signer la version en ligne {online.Version} ?",
|
||||
$"Jeu : {SelectedGameLabel}.\n\n" +
|
||||
"Vous attestez que la version en ligne est bien celle de ce dossier de publication (le serveur vérifie que les fichiers, " +
|
||||
"l'archive et l'exécutable sont identiques). Les launchers des joueurs l'accepteront ensuite.", "Signer"))
|
||||
return;
|
||||
@@ -218,14 +265,17 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
|
||||
private async Task RefreshSignatureAsync(string gameId)
|
||||
{
|
||||
ManifestSignatureState? online;
|
||||
try
|
||||
{
|
||||
_online = await Context.Session.GetManifestSignatureAsync(gameId);
|
||||
online = await Context.Session.GetManifestSignatureAsync(gameId);
|
||||
}
|
||||
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
_online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
|
||||
online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
|
||||
}
|
||||
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
|
||||
_online = online;
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
@@ -240,6 +290,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
OnPropertyChanged(nameof(IsGameSwitchEnabled));
|
||||
OnPropertyChanged(nameof(ShowBusyBar));
|
||||
RefreshCommands();
|
||||
}
|
||||
@@ -276,8 +327,10 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
"" => "aucune version en ligne",
|
||||
_ => $"version en ligne : {_publishedVersion}"
|
||||
};
|
||||
// Clé de ce PC absente du trousseau publié : les launchers des joueurs refuseraient cette version (avertissement seulement).
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Manifest)) return;
|
||||
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
|
||||
$"Les joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
|
||||
$"Jeu : {SelectedGameLabel}.\n\nLes joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
|
||||
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
|
||||
return;
|
||||
|
||||
@@ -299,7 +352,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
SetInterrupted(null);
|
||||
_publishedVersion = result.Version;
|
||||
PublishedText = $"Version en ligne : {result.Version}";
|
||||
StatusMessage = $"Version {result.Version} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
|
||||
StatusMessage = $"Version {result.Version} de {SelectedGameLabel} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
|
||||
(result.ArchiveAdded ? " et l'archive" : "") +
|
||||
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
|
||||
UpdateWarnings();
|
||||
@@ -353,22 +406,26 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
|
||||
_publishedVersion = state.ManifestVersion;
|
||||
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
|
||||
await RefreshSignatureAsync(gameId);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
if (!IsSelected(gameId)) return;
|
||||
_publishedVersion = "";
|
||||
PublishedText = "Aucune version en ligne pour l'instant.";
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
|
||||
{
|
||||
if (!IsSelected(gameId)) return;
|
||||
_publishedVersion = null;
|
||||
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
|
||||
}
|
||||
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
if (!IsSelected(gameId)) return;
|
||||
_publishedVersion = null;
|
||||
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
|
||||
}
|
||||
@@ -407,8 +464,14 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
|
||||
}
|
||||
|
||||
private void SetInterrupted(PublicationState? state)
|
||||
/// <param name="remember">Faux au changement de jeu : l'état affiché est celui déjà mémorisé pour ce jeu.</param>
|
||||
private void SetInterrupted(PublicationState? state, bool remember = true)
|
||||
{
|
||||
if (remember && _selectedGame?.Value is { } gameId)
|
||||
{
|
||||
if (state is null) _interruptedByGame.Remove(gameId);
|
||||
else _interruptedByGame[gameId] = state;
|
||||
}
|
||||
_interrupted = state;
|
||||
OnPropertyChanged(nameof(HasInterrupted));
|
||||
OnPropertyChanged(nameof(InterruptedText));
|
||||
@@ -439,5 +502,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
SignOnlineCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private bool IsSelected(string gameId) => string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public sealed class AdminLoginService
|
||||
private readonly IAdminTpm? _tpm;
|
||||
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
|
||||
|
||||
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
|
||||
/// <param name="clientName">Logiciel pour le journal d'audit, ex. « GameLauncher 2.5.0 » (l'appareil est identifié par sa clé).</param>
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
/// <param name="rootKeys">
|
||||
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
|
||||
|
||||
@@ -5,17 +5,12 @@ namespace GameLauncher.Modules.Admin.Services;
|
||||
/// <summary>Libellés affichés (français).</summary>
|
||||
public static class AdminTexts
|
||||
{
|
||||
public static string Permission(string permission) => permission switch
|
||||
{
|
||||
AdminPermissions.AdminsManage => "Gestion des administrateurs",
|
||||
AdminPermissions.AuditRead => "Journal d'audit",
|
||||
AdminPermissions.GamesEdit => "Informations des jeux",
|
||||
AdminPermissions.ChangelogEdit => "Notes de version",
|
||||
AdminPermissions.ReleasesPublish => "Publication des versions",
|
||||
_ => permission
|
||||
};
|
||||
/// <summary>Nom court d'une permission intégrée ; « module.action » pour une permission personnalisée.</summary>
|
||||
public static string Permission(string permission) => AdminPermissions.BuiltInLabel(permission) ?? permission;
|
||||
|
||||
public static string Role(string? role) => AdminRoles.DisplayName(role);
|
||||
/// <summary>Nom affiché d'un groupe : celui donné par le serveur, sinon celui des groupes par défaut.</summary>
|
||||
public static string Role(string? role, string? roleName = null) =>
|
||||
string.IsNullOrWhiteSpace(roleName) ? AdminRoles.DisplayName(role) : roleName;
|
||||
|
||||
/// <summary>Initiales pour l'avatar : « Nicolas Deville » → « ND ».</summary>
|
||||
public static string Initials(string? name)
|
||||
|
||||
@@ -25,9 +25,9 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
|
||||
public SigningPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
|
||||
ExportRequestCommand = new AsyncRelayCommand(ExportRequestAsync, () => !IsBusy && _state is not null && NeedsNewKeyring);
|
||||
PublishSignedCommand = new AsyncRelayCommand(PublishSignedAsync, () => !IsBusy && _state is { KeyringPublishingEnabled: true });
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
|
||||
ExportRequestCommand = new AsyncRelayCommand(ScrollingToTop(ExportRequestAsync), () => !IsBusy && _state is not null && NeedsNewKeyring);
|
||||
PublishSignedCommand = new AsyncRelayCommand(ScrollingToTop(PublishSignedAsync), () => !IsBusy && _state is { KeyringPublishingEnabled: true });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Trousseau publié
|
||||
@@ -141,7 +141,7 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
PublishSignedCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Appareils actifs des comptes actifs dont le rôle permet de publier une version ou le catalogue.</summary>
|
||||
/// <summary>Appareils actifs des comptes actifs dont le groupe permet de publier une version ou le catalogue.</summary>
|
||||
internal static List<KeyringKey> Propose(IEnumerable<AdminSummary> admins) =>
|
||||
admins.Where(a => a.Status == AdminStatus.Active)
|
||||
.SelectMany(a => a.Keys
|
||||
@@ -151,7 +151,8 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
KeyId = k.KeyId,
|
||||
PublicKey = k.PublicKey!,
|
||||
Name = Truncate($"{a.DisplayName} — {AccountTexts.KeyLabel(k.Label)}"),
|
||||
Scopes = SigningScopes.FromPermissions(AdminRoles.PermissionsOf(a.Role)).ToList()
|
||||
// Permissions du groupe, données par le serveur (ancien serveur, sans nom de groupe : celles du rôle par défaut).
|
||||
Scopes = SigningScopes.FromPermissions(a.RoleName is null ? AdminRoles.PermissionsOf(a.Role) : a.Permissions).ToList()
|
||||
}))
|
||||
.Where(k => k.Scopes.Count > 0)
|
||||
.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
|
||||
@@ -41,6 +41,12 @@ public sealed class AdminEnvironment
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; init; } = Array.Empty<(string, byte[])>();
|
||||
|
||||
/// <summary>
|
||||
/// Avertissement avant de publier un contenu signé par une clé que les launchers des joueurs n'accepteront pas
|
||||
/// (absente du trousseau publié, ou sans l'autorisation voulue). Null : aucun avertissement.
|
||||
/// </summary>
|
||||
public PlayerSigningCheck? PlayerSigning { get; init; }
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
@@ -53,6 +59,10 @@ public sealed class AdminEnvironment
|
||||
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
|
||||
public bool StartTimers { get; init; } = true;
|
||||
|
||||
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
|
||||
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
|
||||
/// <summary>
|
||||
/// Logiciel, pour le journal d'audit du serveur (« GameLauncher 2.5.0 »). Le poste y est identifié par le nom de
|
||||
/// l'appareil choisi à l'enregistrement de sa clé (invitation), que le serveur connaît : jamais par le nom Windows du
|
||||
/// PC, qui n'est pas envoyé.
|
||||
/// </summary>
|
||||
public string ClientName => $"GameLauncher {LauncherVersion}";
|
||||
}
|
||||
@@ -80,7 +80,7 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
|
||||
|
||||
public string DisplayName => Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(Session.Profile.DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Session.Profile.Role);
|
||||
public string RoleText => AdminTexts.Role(Session.Profile.Role, Session.Profile.RoleName);
|
||||
public string KeyLabel => (Session.Profile.KeyLabel ?? Path.GetFileNameWithoutExtension(_connection.KeyPath)) +
|
||||
(_connection.IsTpm ? " · puce TPM" : "");
|
||||
public string ServerHost => _shell.ServerHost;
|
||||
@@ -126,6 +126,7 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
|
||||
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
|
||||
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -28,9 +30,58 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
|
||||
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
|
||||
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
|
||||
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(RefreshAsync), () => !IsBusy);
|
||||
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
|
||||
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
|
||||
OpenFeedbackCommand = new RelayCommand(() => Context.Navigate(FeedbackPanelViewModel.PanelId), () => CanReadFeedback);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs
|
||||
|
||||
/// <summary>Carte « Demandes des joueurs » : permission feedback.read et page présente.</summary>
|
||||
public bool CanReadFeedback => Context.Session.Has(AdminPermissions.FeedbackRead) &&
|
||||
Context.Panels.Any(p => p.Id == FeedbackPanelViewModel.PanelId);
|
||||
|
||||
public RelayCommand OpenFeedbackCommand { get; }
|
||||
|
||||
private string _feedbackText = "…";
|
||||
private bool _hasNewFeedback;
|
||||
|
||||
/// <summary>« Exovival : 3 nouvelles · Autre jeu : aucune » (ou la raison d'une indisponibilité).</summary>
|
||||
public string FeedbackText
|
||||
{
|
||||
get => _feedbackText;
|
||||
private set => SetProperty(ref _feedbackText, value);
|
||||
}
|
||||
|
||||
public bool HasNewFeedback
|
||||
{
|
||||
get => _hasNewFeedback;
|
||||
private set => SetProperty(ref _hasNewFeedback, value);
|
||||
}
|
||||
|
||||
private async Task RefreshFeedbackAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var overview = await Context.Session.GetFeedbackOverviewAsync();
|
||||
HasNewFeedback = overview.Games.Any(g => g.New > 0);
|
||||
if (!overview.Enabled)
|
||||
{
|
||||
FeedbackText = "Réception désactivée sur le serveur (LauncherApi:Feedback:Enabled).";
|
||||
return;
|
||||
}
|
||||
var names = Context.Games.ToDictionary(g => g.GameId, g => g.GameName, StringComparer.OrdinalIgnoreCase);
|
||||
var parts = overview.Games.Select(g =>
|
||||
$"{(names.TryGetValue(g.GameId, out var name) ? name : g.GameId)} : " +
|
||||
(g.New == 0 ? "aucune nouvelle" : g.New == 1 ? "1 nouvelle" : $"{g.New} nouvelles") + $" ({g.Open} à traiter)").ToList();
|
||||
FeedbackText = (parts.Count == 0 ? "Aucun jeu ne reçoit de demandes." : string.Join(" · ", parts)) +
|
||||
(overview.StorageFull ? " · plafond atteint : nouvelles demandes refusées" : "");
|
||||
}
|
||||
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
FeedbackText = ex.Status == 404 ? "Serveur à mettre à jour pour les demandes des joueurs." : ApiErrorText.Describe(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pages « Administrateurs » et « Mes appareils » présentes (modules déclarés, droits).</summary>
|
||||
@@ -43,7 +94,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}";
|
||||
@@ -130,6 +181,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
|
||||
ActiveInvitations = invitations.Count;
|
||||
}
|
||||
if (CanReadFeedback) await RefreshFeedbackAsync();
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Choix du jeu d'une page qui ne concerne qu'un jeu à la fois (« Notes de version », « Publier une version ») :
|
||||
/// une tuile par jeu déclaré dans launcher.json. Avec plusieurs jeux, la page n'affiche rien d'autre tant qu'aucune
|
||||
/// tuile n'a été choisie (pas de jeu par défaut : pas de modification ni de publication sur le mauvais jeu par mégarde).
|
||||
/// </summary>
|
||||
public sealed class GameTile
|
||||
{
|
||||
private GameTile(Choice choice, string name, string detail, Action<Choice> select)
|
||||
{
|
||||
Choice = choice;
|
||||
Name = name;
|
||||
Detail = detail;
|
||||
SelectCommand = new RelayCommand(() => select(choice));
|
||||
}
|
||||
|
||||
public Choice Choice { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>« Identifiant EZR · serveur cdn.exemple.fr ».</summary>
|
||||
public string Detail { get; }
|
||||
|
||||
public RelayCommand SelectCommand { get; }
|
||||
|
||||
/// <summary>Choix « Nom (identifiant) » des jeux déclarés.</summary>
|
||||
public static IReadOnlyList<Choice> Choices(IAdminContext context) =>
|
||||
context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
|
||||
/// <summary>Jeu choisi d'office : le seul déclaré, sinon aucun (l'administrateur choisit).</summary>
|
||||
public static Choice? Initial(IReadOnlyList<Choice> choices) => choices.Count == 1 ? choices[0] : null;
|
||||
|
||||
public static IReadOnlyList<GameTile> For(IAdminContext context, IReadOnlyList<Choice> choices, Action<Choice> select) =>
|
||||
choices.Select(choice =>
|
||||
{
|
||||
var game = context.Games.FirstOrDefault(g => string.Equals(g.GameId, choice.Value, StringComparison.OrdinalIgnoreCase));
|
||||
var host = game is not null && Uri.TryCreate(game.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : null;
|
||||
return new GameTile(choice, game?.GameName ?? choice.Label,
|
||||
$"Identifiant {choice.Value}" + (string.IsNullOrEmpty(host) ? "" : $" · serveur {host}"), select);
|
||||
}).ToList();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>Étoile d'un champ « note » (1 à 5).</summary>
|
||||
public sealed class RatingStar : ObservableObject
|
||||
{
|
||||
private bool _isOn;
|
||||
|
||||
public RatingStar(int value, Action<int> select)
|
||||
{
|
||||
Value = value;
|
||||
SelectCommand = new RelayCommand(() => select(value));
|
||||
}
|
||||
|
||||
public int Value { get; }
|
||||
|
||||
public string ToolTip => Value switch
|
||||
{
|
||||
1 => "1 étoile : décevant",
|
||||
2 => "2 étoiles : moyen",
|
||||
3 => "3 étoiles : bien",
|
||||
4 => "4 étoiles : très bien",
|
||||
_ => "5 étoiles : excellent"
|
||||
};
|
||||
|
||||
public bool IsOn
|
||||
{
|
||||
get => _isOn;
|
||||
set => SetProperty(ref _isOn, value);
|
||||
}
|
||||
|
||||
public RelayCommand SelectCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Champ du formulaire, généré à partir de sa description partagée (<see cref="FeedbackField"/>).</summary>
|
||||
public sealed class FeedbackFieldViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>Choix « non précisé » des listes facultatives.</summary>
|
||||
public static readonly FeedbackOption NoChoice = new("", "Non précisé");
|
||||
|
||||
private string _text = "";
|
||||
private FeedbackOption? _selectedOption;
|
||||
private int _rating;
|
||||
private string? _error;
|
||||
|
||||
public FeedbackFieldViewModel(FeedbackField field)
|
||||
{
|
||||
Field = field;
|
||||
Options = field.Required ? field.Choices : new[] { NoChoice }.Concat(field.Choices).ToList();
|
||||
if (!field.Required) _selectedOption = NoChoice;
|
||||
Stars = Enumerable.Range(1, 5).Select(i => new RatingStar(i, value => Rating = value)).ToList();
|
||||
}
|
||||
|
||||
public FeedbackField Field { get; }
|
||||
public string Id => Field.Id;
|
||||
public string Label => Field.Required ? Field.Label + " *" : Field.Label;
|
||||
public string? Hint => Field.Hint;
|
||||
|
||||
public bool IsMultiline => Field.Kind == FeedbackFieldKind.Multiline;
|
||||
public bool IsChoice => Field.Kind == FeedbackFieldKind.Choice;
|
||||
public bool IsRating => Field.Kind == FeedbackFieldKind.Rating;
|
||||
public int MaxLength => Field.MaxLength;
|
||||
|
||||
public string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _text, value ?? "")) return;
|
||||
OnPropertyChanged(nameof(LengthText));
|
||||
OnPropertyChanged(nameof(IsTooLong));
|
||||
Error = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>« 120 / 2000 » (texte), affiché sous la zone de saisie.</summary>
|
||||
public string LengthText => $"{_text.Trim().Length} / {MaxLength}";
|
||||
|
||||
public bool IsTooLong => _text.Trim().Length > MaxLength;
|
||||
|
||||
public IReadOnlyList<FeedbackOption> Options { get; }
|
||||
|
||||
public FeedbackOption? SelectedOption
|
||||
{
|
||||
get => _selectedOption;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedOption, value)) Error = null;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RatingStar> Stars { get; }
|
||||
|
||||
public int Rating
|
||||
{
|
||||
get => _rating;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _rating, Math.Clamp(value, 0, 5))) return;
|
||||
foreach (var star in Stars) star.IsOn = star.Value <= _rating;
|
||||
OnPropertyChanged(nameof(RatingText));
|
||||
Error = null;
|
||||
}
|
||||
}
|
||||
|
||||
public string RatingText => _rating == 0 ? "Choisissez une note" : Stars[_rating - 1].ToolTip;
|
||||
|
||||
/// <summary>Erreur propre à ce champ (affichée sous le champ).</summary>
|
||||
public string? Error
|
||||
{
|
||||
get => _error;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _error, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(_error);
|
||||
|
||||
/// <summary>Valeur envoyée (null : vide).</summary>
|
||||
public string? Value => Field.Kind switch
|
||||
{
|
||||
FeedbackFieldKind.Choice => string.IsNullOrEmpty(_selectedOption?.Id) ? null : _selectedOption!.Id,
|
||||
FeedbackFieldKind.Rating => _rating == 0 ? null : _rating.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => string.IsNullOrWhiteSpace(_text) ? null : _text
|
||||
};
|
||||
|
||||
/// <summary>Contrôle local (mêmes règles que le serveur). Vrai si le champ est valide.</summary>
|
||||
public bool Check()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
FeedbackRules.CheckField(Field, Value, errors);
|
||||
Error = errors.Count == 0 ? null : errors[0].StartsWith(Field.Label + " : ", StringComparison.Ordinal)
|
||||
? char.ToUpperInvariant(errors[0][(Field.Label.Length + 3)]) + errors[0][(Field.Label.Length + 4)..]
|
||||
: errors[0];
|
||||
return errors.Count == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>Jeu concerné par la demande : celui sélectionné dans le launcher à l'ouverture (figé ensuite).</summary>
|
||||
/// <param name="InstalledVersion">Version installée (null : jeu non installé).</param>
|
||||
public sealed record FeedbackTarget(string GameId, string GameName, string? InstalledVersion, string LauncherVersion);
|
||||
|
||||
/// <summary>Envoi d'une demande (Launcher API en production, simulé dans les tests). Renvoie la référence.</summary>
|
||||
public interface IFeedbackSender
|
||||
{
|
||||
Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Envoi par la Launcher API (route anonyme : défi anti-spam résolu sur ce PC, aucune session).</summary>
|
||||
public sealed class ApiFeedbackSender : IFeedbackSender
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
|
||||
public ApiFeedbackSender(LauncherApiClient client) => _client = client;
|
||||
|
||||
public async Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct) =>
|
||||
(await _client.SendFeedbackAsync(submission, ct).ConfigureAwait(false)).Reference;
|
||||
}
|
||||
|
||||
/// <summary>Type de demande proposé à la première étape (une tuile par formulaire).</summary>
|
||||
public sealed class FeedbackKindChoice
|
||||
{
|
||||
public FeedbackKindChoice(FeedbackForm form, Action<FeedbackForm> select)
|
||||
{
|
||||
Form = form;
|
||||
SelectCommand = new RelayCommand(() => select(form));
|
||||
}
|
||||
|
||||
public FeedbackForm Form { get; }
|
||||
public string Label => Form.Label;
|
||||
public string Description => Form.Description;
|
||||
public string IconKey => Form.IconKey;
|
||||
public RelayCommand SelectCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Étapes de la fenêtre « Aide ».</summary>
|
||||
public enum FeedbackStep
|
||||
{
|
||||
ChooseKind,
|
||||
Fill,
|
||||
Done
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre « Aide » : choix du type de demande, formulaire (généré à partir des formulaires partagés), envoi, référence.
|
||||
/// • le jeu est celui sélectionné à l'ouverture, affiché et non modifiable ;
|
||||
/// • contrôle avant l'envoi avec les règles du serveur (<see cref="FeedbackRules"/>) ;
|
||||
/// • en cas d'échec (réseau, serveur occupé…), la saisie est conservée et l'envoi peut être relancé ;
|
||||
/// • demande ANONYME : aucun identifiant du joueur ni du PC ; informations techniques facultatives, montrées avant l'envoi.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IFeedbackSender _sender;
|
||||
private readonly FeedbackTechInfo _tech;
|
||||
private CancellationTokenSource? _sending;
|
||||
private FeedbackStep _step = FeedbackStep.ChooseKind;
|
||||
private FeedbackForm? _form;
|
||||
private string _title = "";
|
||||
private string? _titleError;
|
||||
private bool _includeTech;
|
||||
private bool _isSending;
|
||||
private string? _errorMessage;
|
||||
private string? _reference;
|
||||
|
||||
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null)
|
||||
{
|
||||
Target = target;
|
||||
_sender = sender;
|
||||
_tech = tech ?? FeedbackTech.Collect(target.LauncherVersion, target.InstalledVersion);
|
||||
Kinds = FeedbackForms.All.Select(f => new FeedbackKindChoice(f, Choose)).ToList();
|
||||
SendCommand = new AsyncRelayCommand(SendAsync, () => _form is not null && !_isSending && _step == FeedbackStep.Fill);
|
||||
BackCommand = new RelayCommand(Back, () => !_isSending && _step == FeedbackStep.Fill);
|
||||
CloseCommand = new RelayCommand(() => CloseRequested?.Invoke());
|
||||
}
|
||||
|
||||
public FeedbackTarget Target { get; }
|
||||
|
||||
/// <summary>« Demande pour : Exovival Zombie Reborn ».</summary>
|
||||
public string GameText => $"Demande pour : {Target.GameName}";
|
||||
|
||||
public IReadOnlyList<FeedbackKindChoice> Kinds { get; }
|
||||
|
||||
/// <summary>La fenêtre doit se fermer (« Fermer », « Annuler »).</summary>
|
||||
public event Action? CloseRequested;
|
||||
|
||||
// ------------------------------------------------------------------ Étapes
|
||||
|
||||
public FeedbackStep Step
|
||||
{
|
||||
get => _step;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _step, value)) return;
|
||||
OnPropertyChanged(nameof(IsChoosingKind));
|
||||
OnPropertyChanged(nameof(IsFilling));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
RaiseCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsChoosingKind => _step == FeedbackStep.ChooseKind;
|
||||
public bool IsFilling => _step == FeedbackStep.Fill;
|
||||
public bool IsDone => _step == FeedbackStep.Done;
|
||||
|
||||
public FeedbackForm? Form
|
||||
{
|
||||
get => _form;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _form, value)) return;
|
||||
OnPropertyChanged(nameof(FormTitle));
|
||||
OnPropertyChanged(nameof(FormIconKey));
|
||||
OnPropertyChanged(nameof(TitleHint));
|
||||
}
|
||||
}
|
||||
|
||||
public string FormTitle => _form?.Label ?? "Aide";
|
||||
public string FormIconKey => _form?.IconKey ?? "MessageQuestion";
|
||||
public string TitleHint => _form?.TitleHint ?? "";
|
||||
|
||||
private void Choose(FeedbackForm form)
|
||||
{
|
||||
if (_isSending) return;
|
||||
if (!ReferenceEquals(form, _form))
|
||||
{
|
||||
Form = form;
|
||||
Fields.Clear();
|
||||
foreach (var field in form.Fields) Fields.Add(new FeedbackFieldViewModel(field));
|
||||
IncludeTech = form.TechByDefault;
|
||||
TitleError = null;
|
||||
ErrorMessage = null;
|
||||
}
|
||||
Step = FeedbackStep.Fill;
|
||||
}
|
||||
|
||||
/// <summary>Retour au choix du type (la saisie de ce type est gardée tant qu'on y revient).</summary>
|
||||
private void Back()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
Step = FeedbackStep.ChooseKind;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Saisie
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _title, value ?? "")) return;
|
||||
TitleError = null;
|
||||
OnPropertyChanged(nameof(TitleLengthText));
|
||||
}
|
||||
}
|
||||
|
||||
public int TitleMaxLength => FeedbackRules.MaxTitleLength;
|
||||
public string TitleLengthText => $"{_title.Trim().Length} / {FeedbackRules.MaxTitleLength}";
|
||||
|
||||
public string? TitleError
|
||||
{
|
||||
get => _titleError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _titleError, value)) OnPropertyChanged(nameof(HasTitleError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasTitleError => !string.IsNullOrEmpty(_titleError);
|
||||
|
||||
public ObservableCollection<FeedbackFieldViewModel> Fields { get; } = new();
|
||||
|
||||
/// <summary>Joindre les informations techniques (cochée par défaut pour un bug).</summary>
|
||||
public bool IncludeTech
|
||||
{
|
||||
get => _includeTech;
|
||||
set => SetProperty(ref _includeTech, value);
|
||||
}
|
||||
|
||||
/// <summary>Exactement ce qui sera joint si la case est cochée.</summary>
|
||||
public string TechPreview => string.Join("\n", new[]
|
||||
{
|
||||
_tech.LauncherVersion is { } launcher ? $"Version du launcher : {launcher}" : null,
|
||||
$"Version du jeu : {_tech.GameVersion ?? "non installé"}",
|
||||
_tech.Os is { } os ? $"Système : {os}" : null,
|
||||
_tech.Language is { } language ? $"Langue : {language}" : null
|
||||
}.Where(l => l is not null));
|
||||
|
||||
public string PrivacyText =>
|
||||
"Demande anonyme : aucun compte, aucune adresse IP enregistrée avec votre demande. N'écrivez pas d'informations " +
|
||||
"personnelles (e-mail, nom, téléphone…). Nous lisons toutes les demandes, mais ne pouvons pas y répondre.";
|
||||
|
||||
// ------------------------------------------------------------------ Envoi
|
||||
|
||||
public AsyncRelayCommand SendCommand { get; }
|
||||
public RelayCommand BackCommand { get; }
|
||||
public RelayCommand CloseCommand { get; }
|
||||
|
||||
public bool IsSending
|
||||
{
|
||||
get => _isSending;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isSending, value)) return;
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
RaiseCommands();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Saisie suspendue pendant l'envoi.</summary>
|
||||
public bool IsEditable => !_isSending;
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(_errorMessage);
|
||||
|
||||
/// <summary>Référence de la demande envoyée (« FB-7K3Q-9P2M »).</summary>
|
||||
public string? Reference
|
||||
{
|
||||
get => _reference;
|
||||
private set => SetProperty(ref _reference, value);
|
||||
}
|
||||
|
||||
/// <summary>Contrôle local de toute la saisie. Vrai si tout est valide.</summary>
|
||||
public bool Validate()
|
||||
{
|
||||
var ok = true;
|
||||
var title = Contracts.ApiText.CleanLine(_title, FeedbackRules.MaxTitleLength);
|
||||
if (title is null || title.Length < FeedbackRules.MinTitleLength)
|
||||
{
|
||||
TitleError = string.IsNullOrWhiteSpace(_title)
|
||||
? "Titre obligatoire."
|
||||
: $"{FeedbackRules.MinTitleLength} à {FeedbackRules.MaxTitleLength} caractères sur une ligne.";
|
||||
ok = false;
|
||||
}
|
||||
foreach (var field in Fields) ok &= field.Check();
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>Demande telle qu'elle sera envoyée (null si la saisie est invalide).</summary>
|
||||
public FeedbackSubmission? BuildSubmission()
|
||||
{
|
||||
if (_form is null) return null;
|
||||
var submission = new FeedbackSubmission
|
||||
{
|
||||
GameId = Target.GameId,
|
||||
Kind = _form.Kind,
|
||||
FormVersion = FeedbackForms.CurrentVersion,
|
||||
Title = _title,
|
||||
Fields = Fields.Where(f => f.Value is not null).ToDictionary(f => f.Id, f => f.Value!),
|
||||
Tech = _includeTech ? _tech : null
|
||||
};
|
||||
var errors = new List<string>();
|
||||
var clean = FeedbackRules.Validate(submission, errors);
|
||||
if (clean is null) ErrorMessage = errors.FirstOrDefault();
|
||||
return clean;
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
if (_form is null || _isSending) return;
|
||||
ErrorMessage = null;
|
||||
if (!Validate())
|
||||
{
|
||||
ErrorMessage = "Vérifiez les champs signalés.";
|
||||
return;
|
||||
}
|
||||
if (BuildSubmission() is not { } submission) return;
|
||||
|
||||
IsSending = true;
|
||||
_sending = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
Reference = await _sender.SendAsync(submission, _sending.Token);
|
||||
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}");
|
||||
Step = FeedbackStep.Done;
|
||||
}
|
||||
catch (OperationCanceledException) when (_sending.IsCancellationRequested)
|
||||
{
|
||||
// fenêtre fermée pendant l'envoi
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"[{Target.GameId}] Envoi d'une demande impossible : {ex.Message}");
|
||||
ErrorMessage = Describe(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sending.Dispose();
|
||||
_sending = null;
|
||||
IsSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Message clair pour le joueur (jamais de détail technique du serveur d'administration).</summary>
|
||||
public static string Describe(Exception ex) => ex switch
|
||||
{
|
||||
ApiException { Status: 0 } =>
|
||||
"Impossible de joindre le serveur. Vérifiez votre connexion Internet, puis réessayez : votre saisie est conservée.",
|
||||
ApiException { Status: 429 } api => api.Message is { Length: > 0 } && api.Code == ApiErrorCodes.RateLimited
|
||||
? api.Message
|
||||
: "Vous avez envoyé plusieurs demandes récemment : patientez un peu avant d'en envoyer une autre.",
|
||||
// 404 : serveur pas encore mis à jour (route absente) ; pour le joueur, c'est la même situation.
|
||||
ApiException { Code: ApiErrorCodes.NotConfigured } or ApiException { Status: 404 } =>
|
||||
"L'envoi des demandes n'est pas disponible pour le moment. Réessayez plus tard.",
|
||||
ApiException { Code: ApiErrorCodes.FeedbackFull } or ApiException { Status: >= 500 } =>
|
||||
"Le service des demandes est momentanément indisponible. Réessayez plus tard : votre saisie est conservée.",
|
||||
ApiException { Code: ApiErrorCodes.ProofInvalid } => "Vérification anti-spam échouée : cliquez de nouveau sur « Envoyer ».",
|
||||
ApiException { Status: 400 } api => api.Message,
|
||||
_ => "L'envoi a échoué. Réessayez dans un instant : votre saisie est conservée."
|
||||
};
|
||||
|
||||
private void RaiseCommands()
|
||||
{
|
||||
SendCommand.RaiseCanExecuteChanged();
|
||||
BackCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Fermeture de la fenêtre : un envoi en cours est abandonné.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sending?.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// envoi déjà terminé
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Informations techniques jointes à une demande (si le joueur laisse la case cochée) : versions du launcher et du jeu,
|
||||
/// version de Windows, langue. RIEN d'identifiant : ni nom du PC ni de l'utilisateur, ni chemin, ni matériel.
|
||||
/// Chaque valeur est contrôlée avec les formats du serveur (une valeur inattendue est simplement omise).
|
||||
/// </summary>
|
||||
public static class FeedbackTech
|
||||
{
|
||||
public static FeedbackTechInfo Collect(string? launcherVersion, string? gameVersion) => new()
|
||||
{
|
||||
LauncherVersion = FeedbackRules.SafeVersion(launcherVersion),
|
||||
GameVersion = FeedbackRules.SafeVersion(gameVersion),
|
||||
Os = FeedbackRules.SafeOs(DescribeOs()),
|
||||
Language = FeedbackRules.SafeLanguage(CultureInfo.CurrentUICulture.Name)
|
||||
};
|
||||
|
||||
/// <summary>« Windows 11 64 bits (10.0.26100) ».</summary>
|
||||
public static string DescribeOs()
|
||||
{
|
||||
var version = Environment.OSVersion.Version;
|
||||
var bits = Environment.Is64BitOperatingSystem ? "64 bits" : "32 bits";
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return $"{RuntimeInformation.OSDescription.Split(' ', 2)[0]} {bits}";
|
||||
var name = version.Major == 10 && version.Build >= 22000 ? "Windows 11" : version.Major == 10 ? "Windows 10" : "Windows";
|
||||
return $"{name} {bits} ({version.Major}.{version.Minor}.{version.Build})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) généré à partir
|
||||
des formulaires partagés (GameLauncher.Contracts.Feedback), contrôlé avec les mêmes règles que le serveur, puis envoyé
|
||||
SANS compte à la Launcher API. La fenêtre correspondante est dans le launcher (Views\FeedbackWindow.xaml).
|
||||
La page d'administration « Demandes des joueurs » est dans GameLauncher.Modules.Admin (Feedback).
|
||||
Voir docs\ADMIN-ARCHITECTURE.md, § 11 quinquies.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules.Feedback</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules.Feedback</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,3 +1,4 @@
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
@@ -38,6 +39,42 @@ public abstract class AdminPanelViewModel : ObservableObject
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Page en deux colonnes (liste à gauche, fiche à droite) qui défilent séparément : la fenêtre ne fait alors plus
|
||||
/// défiler la page entière, elle lui donne la hauteur disponible (la liste reste visible quand on descend dans la
|
||||
/// fiche). Indication d'affichage seulement ; une page qui en change signale <see cref="SplitLayoutChanged"/>.
|
||||
/// </summary>
|
||||
public virtual bool HasSplitLayout => false;
|
||||
|
||||
protected void SplitLayoutChanged() => OnPropertyChanged(nameof(HasSplitLayout));
|
||||
|
||||
/// <summary>
|
||||
/// Change à chaque demande de retour en haut de la page (la fenêtre fait défiler la page jusqu'en haut) :
|
||||
/// après « Recharger », « Publier »… le bandeau du résultat, en haut, est visible sans remonter à la main.
|
||||
/// </summary>
|
||||
public int ScrollToTopToken
|
||||
{
|
||||
get => _scrollToTopToken;
|
||||
private set => SetProperty(ref _scrollToTopToken, value);
|
||||
}
|
||||
private int _scrollToTopToken;
|
||||
|
||||
protected void RequestScrollToTop() => ScrollToTopToken = unchecked(_scrollToTopToken + 1);
|
||||
|
||||
/// <summary>Action d'un bouton « Recharger », « Publier »… : retour en haut de la page au clic et une fois terminée.</summary>
|
||||
protected Func<Task> ScrollingToTop(Func<Task> action) => async () =>
|
||||
{
|
||||
RequestScrollToTop();
|
||||
try
|
||||
{
|
||||
await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
RequestScrollToTop();
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
|
||||
public Task ActivateAsync()
|
||||
{
|
||||
@@ -48,6 +85,38 @@ public abstract class AdminPanelViewModel : ObservableObject
|
||||
|
||||
protected virtual Task OnActivatedAsync(bool firstTime) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Avant une publication signée (catalogue, version d'un jeu) : si les launchers des joueurs n'accepteront pas la
|
||||
/// clé de ce PC (absente du trousseau publié, ou sans l'autorisation voulue), avertissement avec la possibilité de
|
||||
/// continuer. Vrai : publier ; faux : annulé. Simple avertissement : le serveur, lui, accepte la publication.
|
||||
/// </summary>
|
||||
/// <param name="kind">ContentKinds.Catalog ou ContentKinds.Manifest.</param>
|
||||
/// <param name="confirmText">Bouton pour continuer malgré l'avertissement.</param>
|
||||
protected async Task<bool> ConfirmPlayerSigningAsync(string kind, string confirmText = "Publier quand même")
|
||||
{
|
||||
if (Context.PlayerSigning is not { } check) return true;
|
||||
string? warning;
|
||||
var wasBusy = IsBusy;
|
||||
IsBusy = true; // contrôle réseau (quelques secondes au plus) : boutons désactivés
|
||||
try
|
||||
{
|
||||
warning = await check.WarningAsync(kind, Context.Session.PublicKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Contrôle indicatif : une erreur imprévue ne bloque pas la publication.
|
||||
Log.Warn($"Administration : contrôle du trousseau des joueurs impossible ({ex.Message}).");
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = wasBusy;
|
||||
}
|
||||
if (warning is null) return true;
|
||||
Log.Warn($"Administration : avertissement avant publication ({kind}) : {warning.Split('\n')[0]}");
|
||||
return Context.Dialogs.Confirm("Clé non reconnue par les launchers des joueurs", warning, confirmText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exécute une action réseau : « occupé » pendant l'action, erreur affichée en cas d'échec,
|
||||
/// retour à la connexion si la session a expiré. Vrai si l'action a réussi.
|
||||
@@ -67,7 +136,10 @@ public abstract class AdminPanelViewModel : ObservableObject
|
||||
if (ApiErrorText.IsSessionLost(ex))
|
||||
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
|
||||
else
|
||||
{
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
RequestScrollToTop(); // bandeau d'erreur en haut de la page
|
||||
}
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -30,6 +30,12 @@ public interface IAdminContext
|
||||
/// <summary>Clés racine intégrées à l'exe (vérification du trousseau avant sa publication).</summary>
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle avant publication : la clé de ce PC est-elle acceptée par les launchers des joueurs (trousseau publié) ?
|
||||
/// Null : pas de contrôle (signatures non vérifiées par ce launcher, ou tests).
|
||||
/// </summary>
|
||||
PlayerSigningCheck? PlayerSigning => null;
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Trust;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Avertissement avant une publication signée (catalogue, version d'un jeu) : la clé de ce PC figure-t-elle dans le
|
||||
/// trousseau publié (celui que téléchargent les launchers des joueurs), avec l'autorisation voulue ?
|
||||
/// Contrôle côté administration uniquement, sans effet sur le serveur, qui accepte la publication dans tous les cas :
|
||||
/// c'est le launcher des joueurs qui ignore un contenu signé par une clé absente du trousseau (contentSigning « require »).
|
||||
/// Le trousseau est relu à chaque contrôle (pas de cache) : une clé ajoutée par un propriétaire est vue aussitôt.
|
||||
/// </summary>
|
||||
public sealed class PlayerSigningCheck
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri? _keyringUrl;
|
||||
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
|
||||
|
||||
/// <param name="download">Téléchargement d'un petit fichier (https, taille limitée, sans cache).</param>
|
||||
/// <param name="keyringUrl">« keyringUrl » du launcher.json intégré (le même que celui des joueurs).</param>
|
||||
/// <param name="mode">« contentSigning » du launcher.json intégré.</param>
|
||||
/// <param name="minKeyringSequence">« keyringMinSequence » : un trousseau plus ancien est refusé par les launchers.</param>
|
||||
/// <param name="rootKeys">Clés racine intégrées à l'exe (vérification du trousseau, comme chez les joueurs).</param>
|
||||
public PlayerSigningCheck(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? keyringUrl, ContentSigningMode mode,
|
||||
long minKeyringSequence, IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys)
|
||||
{
|
||||
_download = download;
|
||||
_keyringUrl = keyringUrl;
|
||||
Mode = mode;
|
||||
MinKeyringSequence = Math.Max(0, minKeyringSequence);
|
||||
_rootKeys = rootKeys;
|
||||
}
|
||||
|
||||
public ContentSigningMode Mode { get; }
|
||||
public long MinKeyringSequence { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
|
||||
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
|
||||
/// </summary>
|
||||
/// <param name="kind"><see cref="ContentKinds.Catalog"/> ou <see cref="ContentKinds.Manifest"/>.</param>
|
||||
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
|
||||
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return null;
|
||||
var keyId = AdminKeys.ComputeKeyId(publicKey);
|
||||
var scope = ContentKinds.ScopeOf(kind);
|
||||
|
||||
string problem;
|
||||
if (_keyringUrl is null)
|
||||
{
|
||||
problem = "Aucun trousseau n'est configuré dans ce launcher (« keyringUrl ») : les launchers des joueurs ne peuvent vérifier aucune signature.";
|
||||
}
|
||||
else
|
||||
{
|
||||
KeyringDocument keyring;
|
||||
try
|
||||
{
|
||||
keyring = await DownloadAsync(_keyringUrl, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
|
||||
{
|
||||
problem = $"Le trousseau publié ({_keyringUrl}) est refusé par ce launcher : {ex.Message}.";
|
||||
return Compose(kind, problem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
problem = $"Le trousseau publié ({_keyringUrl}) est injoignable ({ex.Message}) : impossible de vérifier que la clé de ce PC y figure.";
|
||||
return Compose(kind, problem);
|
||||
}
|
||||
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
|
||||
if (key is null)
|
||||
problem = $"La clé de ce PC ({keyId}) n'est pas dans le trousseau publié (n° {keyring.Sequence}).";
|
||||
else if (!key.Scopes.Contains(scope, StringComparer.Ordinal))
|
||||
problem = $"La clé de ce PC ({keyId}, « {key.Name} ») est dans le trousseau publié (n° {keyring.Sequence}), " +
|
||||
$"mais sans l'autorisation « {SigningScopes.DisplayName(scope)} ».";
|
||||
else
|
||||
return null;
|
||||
}
|
||||
return Compose(kind, problem);
|
||||
}
|
||||
|
||||
/// <summary>Trousseau publié, vérifié comme le font les launchers des joueurs (format, clé racine, numéro minimal).</summary>
|
||||
private async Task<KeyringDocument> DownloadAsync(Uri url, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
var bytes = await _download(url, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0].TrimEnd('.'));
|
||||
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error.TrimEnd('.'));
|
||||
if (keyring.Sequence < MinKeyringSequence)
|
||||
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
|
||||
return keyring;
|
||||
}
|
||||
|
||||
private string Compose(string kind, string problem)
|
||||
{
|
||||
var catalog = kind == ContentKinds.Catalog;
|
||||
var consequence = Mode == ContentSigningMode.Require
|
||||
? catalog
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
|
||||
"(celui intégré au launcher pour une installation neuve)."
|
||||
: "Les launchers des joueurs (signatures obligatoires) refuseront cette version : ni installation, ni mise à jour " +
|
||||
"(le jeu déjà installé reste jouable)."
|
||||
: "Les launchers des joueurs l'accepteront, mais inscriront une alerte de signature dans leur journal (vérification en mode « warn »).";
|
||||
return problem + "\n\n" + consequence + "\n\n" +
|
||||
"Le serveur accepte la publication quand même. Pour régler ce point, un propriétaire ajoute la clé de ce PC au trousseau " +
|
||||
"(page « Signature »), le fait signer avec la clé racine puis le publie : les launchers accepteront alors ce contenu " +
|
||||
"sans nouvelle publication.";
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Modules.Admin.Changelog;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Audit;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Files;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Platform;
|
||||
@@ -57,6 +59,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private AdminWindowService? _admin;
|
||||
private CatalogService? _catalogService;
|
||||
private ContentTrustService? _trust;
|
||||
private PlayerSigningCheck? _playerSigning;
|
||||
private Task<CatalogDocument?>? _catalogFetch;
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
@@ -253,6 +256,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
var version = VersionUtil.Display(_version);
|
||||
Log.Info($"Démarrage du launcher {version} ({catalog.Games.Count} jeu(x))");
|
||||
|
||||
// Mode d'affichage (plein écran ou fenêtré) mémorisé pour chaque fenêtre qui le permet. Pas en mode --capture.
|
||||
Controls.WindowModeAssist.Store = readOnly ? null : new WindowModeStore(Path.Combine(catalog.DataDirectory, WindowModeStore.FileName));
|
||||
|
||||
var window = new Views.MainWindow();
|
||||
MainWindow = window;
|
||||
_mainWindow = window;
|
||||
@@ -355,8 +361,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
|
||||
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
|
||||
ThemeManager.BuiltInThemes.ToList());
|
||||
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
|
||||
window.AdminRequested += _admin.Open;
|
||||
|
||||
// Bouton « Aide » (demandes des joueurs) : même adresse d'API, lue au même endroit (launcher.json intégré en Release).
|
||||
var feedback = new FeedbackWindowService(window, apiUrl, version);
|
||||
launcher.IsHelpAvailable = feedback.IsAvailable;
|
||||
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
|
||||
}
|
||||
|
||||
window.DataContext = launcher;
|
||||
@@ -475,8 +486,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
Log.Error("Signatures du contenu : aucune clé racine intégrée (Security\\update-signing.pub) : aucun trousseau ne peut être vérifié.");
|
||||
Log.Info($"Signatures du contenu : mode « {mode.ToString().ToLowerInvariant()} », trousseau {keyringUrl ?? "non configuré"}" +
|
||||
(minSequence > 0 ? $" (n° {minSequence} au minimum)." : "."));
|
||||
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUrl is null ? null : new Uri(keyringUrl), roots, mode,
|
||||
minSequence);
|
||||
var keyringUri = keyringUrl is null ? null : new Uri(keyringUrl);
|
||||
// Menu d'administration : avertissement avant de publier avec une clé que ces launchers n'accepteraient pas.
|
||||
_playerSigning = new PlayerSigningCheck(_http!.GetBytesAsync, keyringUri, mode, minSequence, roots);
|
||||
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUri, roots, mode, minSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -566,8 +579,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
new AdminModule(),
|
||||
new CatalogAdminModule(),
|
||||
new FeedbackAdminModule(),
|
||||
new ChangelogAdminModule(),
|
||||
new PublishAdminModule(),
|
||||
new FilesAdminModule(),
|
||||
new AccountsAdminModule(),
|
||||
new SigningAdminModule(),
|
||||
new AuditAdminModule()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace GameLauncher.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Texte indicatif d'un champ vide (« https://… », « jj/mm/aaaa »…), affiché par les styles
|
||||
/// ModernTextBoxStyle / ModernMultilineTextBoxStyle (Themes/Controls.xaml) tant que le champ est vide.
|
||||
/// </summary>
|
||||
public static class TextAssist
|
||||
{
|
||||
public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached(
|
||||
"Placeholder", typeof(string), typeof(TextAssist), new FrameworkPropertyMetadata(null));
|
||||
|
||||
public static string? GetPlaceholder(DependencyObject element) => (string?)element.GetValue(PlaceholderProperty);
|
||||
public static void SetPlaceholder(DependencyObject element, string? value) => element.SetValue(PlaceholderProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retour en haut d'un <see cref="ScrollViewer"/> : chaque changement de <c>ScrollToTopToken</c> (demandé par la page
|
||||
/// après « Recharger », « Publier »…) ou de <c>ResetOn</c> (page affichée) remonte la page, une fois la mise en page faite.
|
||||
/// </summary>
|
||||
public static class ScrollAssist
|
||||
{
|
||||
public static readonly DependencyProperty ScrollToTopTokenProperty = DependencyProperty.RegisterAttached(
|
||||
"ScrollToTopToken", typeof(int), typeof(ScrollAssist), new PropertyMetadata(0, OnScrollRequested));
|
||||
|
||||
public static int GetScrollToTopToken(DependencyObject element) => (int)element.GetValue(ScrollToTopTokenProperty);
|
||||
public static void SetScrollToTopToken(DependencyObject element, int value) => element.SetValue(ScrollToTopTokenProperty, value);
|
||||
|
||||
public static readonly DependencyProperty ResetOnProperty = DependencyProperty.RegisterAttached(
|
||||
"ResetOn", typeof(object), typeof(ScrollAssist), new PropertyMetadata(null, OnScrollRequested));
|
||||
|
||||
public static object? GetResetOn(DependencyObject element) => element.GetValue(ResetOnProperty);
|
||||
public static void SetResetOn(DependencyObject element, object? value) => element.SetValue(ResetOnProperty, value);
|
||||
|
||||
private static void OnScrollRequested(DependencyObject element, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (element is not ScrollViewer viewer) return;
|
||||
viewer.ScrollToTop();
|
||||
// Contenu remplacé au même moment (rechargement) : encore une fois après la mise en page.
|
||||
viewer.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(viewer.ScrollToTop));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Molette de la souris dans le menu d'administration : elle fait défiler ce qui est SOUS le curseur, jamais une
|
||||
/// liste déroulante fermée (pas de paramètre changé par mégarde en faisant défiler la page), ni un élément qui
|
||||
/// garderait la molette sans pouvoir défiler (liste, champ texte) : la page défile alors à sa place.
|
||||
/// Une liste déroulante OUVERTE garde son comportement normal (défilement de ses choix).
|
||||
/// </summary>
|
||||
public static class WheelAssist
|
||||
{
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled", typeof(bool), typeof(WheelAssist), new PropertyMetadata(false, OnIsEnabledChanged));
|
||||
|
||||
public static bool GetIsEnabled(DependencyObject element) => (bool)element.GetValue(IsEnabledProperty);
|
||||
public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value);
|
||||
|
||||
/// <summary>Défilement d'un cran de molette (comme WPF : 3 lignes de 16 pixels par défaut).</summary>
|
||||
private static double PixelsPerNotch => Math.Max(1, SystemParameters.WheelScrollLines) * 16.0;
|
||||
|
||||
private static void OnIsEnabledChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (element is not UIElement root) return;
|
||||
root.PreviewMouseWheel -= OnPreviewMouseWheel;
|
||||
if (e.NewValue is true) root.PreviewMouseWheel += OnPreviewMouseWheel;
|
||||
}
|
||||
|
||||
private static void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
if (e.Handled || sender is not UIElement root || e.Delta == 0) return;
|
||||
var source = e.OriginalSource as DependencyObject;
|
||||
|
||||
// Liste déroulante ouverte (son contenu est dans une fenêtre surgissante) : comportement normal.
|
||||
if (source is Visual sourceVisual && PresentationSource.FromVisual(sourceVisual) is { } sourcePresentation &&
|
||||
!ReferenceEquals(sourcePresentation, PresentationSource.FromVisual(root)))
|
||||
return;
|
||||
if (Ancestor<ComboBox>(source) is { IsDropDownOpen: true }) return;
|
||||
|
||||
// Élément sous le curseur (pas forcément celui qui a le focus clavier).
|
||||
var hit = root.InputHitTest(e.GetPosition(root)) as DependencyObject;
|
||||
if (hit is null) return;
|
||||
if (Ancestor<ComboBox>(hit) is { IsDropDownOpen: true }) return;
|
||||
|
||||
var target = ScrollableAncestor(hit, e.Delta);
|
||||
var overClosedComboBox = Ancestor<ComboBox>(source) is { IsDropDownOpen: false } || Ancestor<ComboBox>(hit) is { IsDropDownOpen: false };
|
||||
if (!overClosedComboBox && ReferenceEquals(Ancestor<ScrollViewer>(source), target)) return; // comportement normal
|
||||
|
||||
e.Handled = true; // la liste déroulante fermée ou l'élément qui ne peut pas défiler ne reçoit pas la molette
|
||||
if (target is null) return;
|
||||
if (target.CanContentScroll)
|
||||
{
|
||||
for (var i = 0; i < Math.Max(1, SystemParameters.WheelScrollLines); i++)
|
||||
if (e.Delta > 0) target.LineUp();
|
||||
else target.LineDown();
|
||||
}
|
||||
else
|
||||
{
|
||||
target.ScrollToVerticalOffset(target.VerticalOffset - e.Delta * PixelsPerNotch / Mouse.MouseWheelDeltaForOneLine);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Premier ScrollViewer parent qui peut encore défiler dans le sens de la molette.</summary>
|
||||
private static ScrollViewer? ScrollableAncestor(DependencyObject? element, int delta)
|
||||
{
|
||||
for (var current = element; current is not null; current = Parent(current))
|
||||
if (current is ScrollViewer viewer && viewer.ScrollableHeight > 0 &&
|
||||
(delta > 0 ? viewer.VerticalOffset > 0 : viewer.VerticalOffset < viewer.ScrollableHeight))
|
||||
return viewer;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static T? Ancestor<T>(DependencyObject? element) where T : DependencyObject
|
||||
{
|
||||
for (var current = element; current is not null; current = Parent(current))
|
||||
if (current is T match)
|
||||
return match;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DependencyObject? Parent(DependencyObject element) =>
|
||||
element is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(element) ?? LogicalTreeHelper.GetParent(element)
|
||||
: LogicalTreeHelper.GetParent(element); // Run, Hyperlink… (éléments de texte)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Barre d'actions d'une page du menu d'administration, affichée SOUS la zone qui défile (toujours visible) :
|
||||
/// modèle « AdminFooter.<type de la page> » de Themes/Admin.xaml, sinon rien.
|
||||
/// </summary>
|
||||
public sealed class AdminFooterTemplateSelector : DataTemplateSelector
|
||||
{
|
||||
public override DataTemplate? SelectTemplate(object? item, DependencyObject container)
|
||||
{
|
||||
if (item is null || container is not FrameworkElement element) return null;
|
||||
return element.TryFindResource("AdminFooter." + item.GetType().Name) as DataTemplate
|
||||
?? element.TryFindResource("AdminFooter.None") as DataTemplate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace GameLauncher.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Mise en page responsive d'une fenêtre à deux modes (fenêtré / plein écran, voir <see cref="WindowModeAssist"/>) :
|
||||
/// <list type="bullet">
|
||||
/// <item>place disponible plus GRANDE que la taille minimale de mise en page (<see cref="DesignWidth"/> ×
|
||||
/// <see cref="DesignHeight"/>, un peu en dessous de la taille fenêtrée : le contenu défile déjà verticalement) : le contenu
|
||||
/// occupe toute la place (colonnes, listes et ascenseurs s'adaptent) ;</item>
|
||||
/// <item>place plus PETITE (petit écran, mise à l'échelle Windows 125-150 %) : le contenu est mis en page à la taille de
|
||||
/// conception puis réduit d'un bloc, sans rien rogner ni masquer : tous les boutons restent accessibles, tous les textes
|
||||
/// visibles.</item>
|
||||
/// </list>
|
||||
/// Le contenu n'est jamais agrandi : en plein écran sur un grand écran, il gagne de la place, pas des pixels flous.
|
||||
/// </summary>
|
||||
public sealed class ScaleToFitDecorator : Decorator
|
||||
{
|
||||
public static readonly DependencyProperty DesignWidthProperty = DependencyProperty.Register(
|
||||
nameof(DesignWidth), typeof(double), typeof(ScaleToFitDecorator),
|
||||
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
|
||||
|
||||
public static readonly DependencyProperty DesignHeightProperty = DependencyProperty.Register(
|
||||
nameof(DesignHeight), typeof(double), typeof(ScaleToFitDecorator),
|
||||
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
|
||||
|
||||
/// <summary>Largeur minimale de mise en page du contenu (0 : jamais réduit en largeur).</summary>
|
||||
public double DesignWidth
|
||||
{
|
||||
get => (double)GetValue(DesignWidthProperty);
|
||||
set => SetValue(DesignWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Hauteur minimale de mise en page du contenu (0 : jamais réduit en hauteur).</summary>
|
||||
public double DesignHeight
|
||||
{
|
||||
get => (double)GetValue(DesignHeightProperty);
|
||||
set => SetValue(DesignHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Échelle appliquée (1 : taille réelle ; en dessous, contenu réduit).</summary>
|
||||
public double Scale { get; private set; } = 1;
|
||||
|
||||
/// <summary>Plus petite échelle acceptée : en dessous, les ascenseurs du contenu prennent le relais.</summary>
|
||||
private const double MinScale = 0.5;
|
||||
|
||||
/// <summary>Écart négligeable (arrondis) : pas de réduction, qui rendrait le texte un peu flou pour rien.</summary>
|
||||
private const double Tolerance = 0.995;
|
||||
|
||||
private static bool IsValidSize(object value) => value is double d && !double.IsNaN(d) && !double.IsInfinity(d) && d >= 0;
|
||||
|
||||
private double ScaleFor(Size available)
|
||||
{
|
||||
var scale = 1.0;
|
||||
if (DesignWidth > 0 && !double.IsInfinity(available.Width) && available.Width < DesignWidth)
|
||||
scale = Math.Min(scale, available.Width / DesignWidth);
|
||||
if (DesignHeight > 0 && !double.IsInfinity(available.Height) && available.Height < DesignHeight)
|
||||
scale = Math.Min(scale, available.Height / DesignHeight);
|
||||
return scale >= Tolerance ? 1 : Math.Max(MinScale, scale);
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size constraint)
|
||||
{
|
||||
if (Child is not { } child) return default;
|
||||
var scale = ScaleFor(constraint);
|
||||
child.Measure(new Size(constraint.Width / scale, constraint.Height / scale));
|
||||
var desired = child.DesiredSize;
|
||||
return new Size(Math.Min(desired.Width * scale, constraint.Width), Math.Min(desired.Height * scale, constraint.Height));
|
||||
}
|
||||
|
||||
protected override Size ArrangeOverride(Size arrangeSize)
|
||||
{
|
||||
if (Child is not { } child) return arrangeSize;
|
||||
var scale = ScaleFor(arrangeSize);
|
||||
Scale = scale;
|
||||
// Mis en page à la taille « non réduite », puis réduit à l'affichage (le clic et les infobulles suivent la réduction).
|
||||
child.Arrange(new Rect(0, 0, arrangeSize.Width / scale, arrangeSize.Height / scale));
|
||||
if (scale >= 1)
|
||||
{
|
||||
if (child.RenderTransform is not MatrixTransform { Matrix.IsIdentity: true }) child.RenderTransform = Transform.Identity;
|
||||
}
|
||||
else if (child.RenderTransform is not ScaleTransform current || Math.Abs(current.ScaleX - scale) > 0.0001)
|
||||
{
|
||||
child.RenderTransform = new ScaleTransform(scale, scale);
|
||||
}
|
||||
return arrangeSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using GameLauncher.Platform;
|
||||
|
||||
namespace GameLauncher.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Deux modes d'affichage seulement pour une fenêtre sans bordure : FENÊTRÉ (taille d'origine, jamais redimensionnable
|
||||
/// par les bords ou les coins) ou PLEIN ÉCRAN, au sens d'une fenêtre Windows agrandie : toute la zone utile de l'écran où
|
||||
/// se trouve la fenêtre, la barre des tâches restant visible (même en masquage automatique). Ce n'est pas le plein écran
|
||||
/// exclusif d'un jeu.
|
||||
/// <para>
|
||||
/// Une fenêtre s'y inscrit en XAML, sans code : <c>ui:WindowModeAssist.AllowFullScreen="True"</c> (et, pour retrouver
|
||||
/// le mode au prochain affichage, <c>ui:WindowModeAssist.PersistenceKey="clé"</c>). Elle reçoit alors :
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>la touche F11 et la commande <see cref="ToggleFullScreenCommand"/> (bouton prêt à l'emploi : style
|
||||
/// « WindowModeButtonStyle » de Themes/Controls.xaml, icône et infobulle selon le mode) ;</item>
|
||||
/// <item>les ressources « WindowChrome.* » de la fenêtre (CornerRadius, Thickness : arrondis, marge de l'ombre) mises à 0
|
||||
/// en plein écran, puis restaurées : les éléments qui les utilisent en DynamicResource suivent le mode ;</item>
|
||||
/// <item>en mode fenêtré, une taille limitée à la zone utile de SON écran (petits écrans, mise à l'échelle 125-150 %).</item>
|
||||
/// </list>
|
||||
/// Les fenêtres qui ne s'y inscrivent pas (boîtes de dialogue, paramètres…) gardent leur taille fixe.
|
||||
/// </summary>
|
||||
public static class WindowModeAssist
|
||||
{
|
||||
/// <summary>Bascule fenêtré / plein écran de la fenêtre qui contient l'élément (F11).</summary>
|
||||
public static readonly RoutedUICommand ToggleFullScreenCommand =
|
||||
new("Plein écran", nameof(ToggleFullScreenCommand), typeof(WindowModeAssist));
|
||||
|
||||
/// <summary>Préfixe des ressources de la fenêtre (CornerRadius, Thickness) remises à zéro en plein écran.</summary>
|
||||
public const string ChromeResourcePrefix = "WindowChrome.";
|
||||
|
||||
/// <summary>
|
||||
/// Modes mémorisés d'une fenêtre à l'autre (<see cref="PersistenceKeyProperty"/>). Null : rien n'est mémorisé
|
||||
/// (mode --capture, tests). Fixé par l'application avant l'affichage des fenêtres.
|
||||
/// </summary>
|
||||
public static WindowModeStore? Store { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------ Propriétés attachées
|
||||
|
||||
public static readonly DependencyProperty AllowFullScreenProperty = DependencyProperty.RegisterAttached(
|
||||
"AllowFullScreen", typeof(bool), typeof(WindowModeAssist), new PropertyMetadata(false, OnAllowFullScreenChanged));
|
||||
|
||||
public static bool GetAllowFullScreen(DependencyObject element) => (bool)element.GetValue(AllowFullScreenProperty);
|
||||
public static void SetAllowFullScreen(DependencyObject element, bool value) => element.SetValue(AllowFullScreenProperty, value);
|
||||
|
||||
/// <summary>Mode courant de la fenêtre (liable ; les déclencheurs des styles s'en servent).</summary>
|
||||
public static readonly DependencyProperty IsFullScreenProperty = DependencyProperty.RegisterAttached(
|
||||
"IsFullScreen", typeof(bool), typeof(WindowModeAssist),
|
||||
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsFullScreenChanged));
|
||||
|
||||
public static bool GetIsFullScreen(DependencyObject element) => (bool)element.GetValue(IsFullScreenProperty);
|
||||
public static void SetIsFullScreen(DependencyObject element, bool value) => element.SetValue(IsFullScreenProperty, value);
|
||||
|
||||
/// <summary>Clé sous laquelle le mode de la fenêtre est mémorisé (« main », « admin »…) ; vide : non mémorisé.</summary>
|
||||
public static readonly DependencyProperty PersistenceKeyProperty = DependencyProperty.RegisterAttached(
|
||||
"PersistenceKey", typeof(string), typeof(WindowModeAssist), new PropertyMetadata(null));
|
||||
|
||||
public static string? GetPersistenceKey(DependencyObject element) => (string?)element.GetValue(PersistenceKeyProperty);
|
||||
public static void SetPersistenceKey(DependencyObject element, string? value) => element.SetValue(PersistenceKeyProperty, value);
|
||||
|
||||
// État interne d'une fenêtre inscrite.
|
||||
private static readonly DependencyProperty StateProperty = DependencyProperty.RegisterAttached(
|
||||
"State", typeof(WindowModeState), typeof(WindowModeAssist), new PropertyMetadata(null));
|
||||
|
||||
private sealed class WindowModeState
|
||||
{
|
||||
public readonly Dictionary<object, object> WindowedChrome = new();
|
||||
public bool Syncing;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ API pour le code
|
||||
|
||||
/// <summary>Bascule le mode d'une fenêtre inscrite (sans effet sinon).</summary>
|
||||
public static void Toggle(Window window)
|
||||
{
|
||||
if (GetAllowFullScreen(window)) SetIsFullScreen(window, !GetIsFullScreen(window));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Réaffiche une fenêtre réduite dans SON mode (plein écran ou fenêtré) : à utiliser à la place de
|
||||
/// <c>WindowState = Normal</c>, qui ferait quitter le plein écran.
|
||||
/// </summary>
|
||||
public static void RestoreFromMinimized(Window window)
|
||||
{
|
||||
if (window.WindowState != WindowState.Minimized) return;
|
||||
window.WindowState = GetAllowFullScreen(window) && GetIsFullScreen(window) ? WindowState.Maximized : WindowState.Normal;
|
||||
}
|
||||
|
||||
/// <summary>Déplacement par la barre de titre : seulement en mode fenêtré (en plein écran, la fenêtre reste en place).</summary>
|
||||
public static void DragMove(Window window)
|
||||
{
|
||||
if (window.WindowState != WindowState.Normal) return;
|
||||
try
|
||||
{
|
||||
window.DragMove();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// bouton de la souris déjà relâché
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zone utile (hors barre des tâches) de l'écran où se trouve l'élément, en pixels indépendants (DIP).</summary>
|
||||
public static Rect WorkArea(Visual? reference)
|
||||
{
|
||||
if (reference is not null && PresentationSource.FromVisual(reference) is HwndSource { Handle: var handle } source &&
|
||||
handle != IntPtr.Zero && source.CompositionTarget is { } target)
|
||||
{
|
||||
var monitor = MonitorFromWindow(handle, MonitorDefaultToNearest);
|
||||
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
|
||||
if (monitor != IntPtr.Zero && GetMonitorInfo(monitor, ref info))
|
||||
{
|
||||
var fromDevice = target.TransformFromDevice;
|
||||
var topLeft = fromDevice.Transform(new Point(info.Work.Left, info.Work.Top));
|
||||
var bottomRight = fromDevice.Transform(new Point(info.Work.Right, info.Work.Bottom));
|
||||
return new Rect(topLeft, bottomRight);
|
||||
}
|
||||
}
|
||||
return SystemParameters.WorkArea;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mise en place
|
||||
|
||||
private static void OnAllowFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (element is not Window window) return;
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
if (window.GetValue(StateProperty) is not null) return;
|
||||
window.SetValue(StateProperty, new WindowModeState());
|
||||
window.CommandBindings.Add(new CommandBinding(ToggleFullScreenCommand, (_, args) =>
|
||||
{
|
||||
Toggle(window);
|
||||
args.Handled = true;
|
||||
}, (_, args) =>
|
||||
{
|
||||
args.CanExecute = true;
|
||||
args.Handled = true;
|
||||
}));
|
||||
window.InputBindings.Add(new KeyBinding(ToggleFullScreenCommand, Key.F11, ModifierKeys.None));
|
||||
window.SourceInitialized += OnSourceInitialized;
|
||||
window.StateChanged += OnStateChanged;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetIsFullScreen(window, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Juste avant le premier affichage : taille adaptée à l'écran, puis mode mémorisé.</summary>
|
||||
private static void OnSourceInitialized(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is not Window window) return;
|
||||
// Une fenêtre sans bordure agrandie couvrirait par défaut tout l'écran, barre des tâches comprise :
|
||||
// sa taille agrandie est ramenée à la zone utile de son écran (WM_GETMINMAXINFO).
|
||||
if (PresentationSource.FromVisual(window) is HwndSource source) source.AddHook(LimitMaximizedToWorkArea);
|
||||
FitToWorkArea(window);
|
||||
if (GetPersistenceKey(window) is { Length: > 0 } key && Store?.Get(key) == true) SetIsFullScreen(window, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mode fenêtré : jamais plus grande que la zone utile de son écran, et entièrement visible. Une fenêtre placée
|
||||
/// volontairement hors des écrans (captures de tools\UiSnapshot) n'est pas déplacée.
|
||||
/// </summary>
|
||||
private static void FitToWorkArea(Window window)
|
||||
{
|
||||
var work = WorkArea(window);
|
||||
if (work.Width < 1 || work.Height < 1) return;
|
||||
if (window.Width > work.Width) window.Width = work.Width;
|
||||
if (window.Height > work.Height) window.Height = work.Height;
|
||||
if (double.IsNaN(window.Left) || double.IsNaN(window.Top)) return;
|
||||
if (!work.IntersectsWith(new Rect(window.Left, window.Top, window.Width, window.Height))) return;
|
||||
if (window.Left + window.Width > work.Right) window.Left = work.Right - window.Width;
|
||||
if (window.Top + window.Height > work.Bottom) window.Top = work.Bottom - window.Height;
|
||||
if (window.Left < work.Left) window.Left = work.Left;
|
||||
if (window.Top < work.Top) window.Top = work.Top;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Changement de mode
|
||||
|
||||
private static void OnIsFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (element is not Window window || window.GetValue(StateProperty) is not WindowModeState state) return;
|
||||
var fullScreen = e.NewValue is true;
|
||||
if (fullScreen && !GetAllowFullScreen(window))
|
||||
{
|
||||
SetIsFullScreen(window, false);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyChrome(window, state, fullScreen);
|
||||
if (!state.Syncing)
|
||||
{
|
||||
state.Syncing = true;
|
||||
try
|
||||
{
|
||||
// Fenêtre réduite : le mode s'appliquera à sa réapparition (RestoreFromMinimized).
|
||||
if (window.WindowState != WindowState.Minimized)
|
||||
window.WindowState = fullScreen ? WindowState.Maximized : WindowState.Normal;
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.Syncing = false;
|
||||
}
|
||||
}
|
||||
if (GetPersistenceKey(window) is { Length: > 0 } key) Store?.Set(key, fullScreen);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// État changé par Windows (Win+Flèche bas, réduction depuis la barre des tâches…) : le mode suit. Une fenêtre réduite
|
||||
/// garde son mode ; une fenêtre agrandie n'existe qu'en plein écran (pas d'agrandissement partiel).
|
||||
/// </summary>
|
||||
private static void OnStateChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is not Window window || window.GetValue(StateProperty) is not WindowModeState state || state.Syncing) return;
|
||||
if (window.WindowState == WindowState.Minimized) return;
|
||||
var fullScreen = window.WindowState == WindowState.Maximized;
|
||||
if (fullScreen == GetIsFullScreen(window)) return;
|
||||
state.Syncing = true;
|
||||
try
|
||||
{
|
||||
SetIsFullScreen(window, fullScreen);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.Syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ressources « WindowChrome.* » de la fenêtre : 0 en plein écran, valeurs d'origine en mode fenêtré.</summary>
|
||||
private static void ApplyChrome(Window window, WindowModeState state, bool fullScreen)
|
||||
{
|
||||
if (fullScreen)
|
||||
{
|
||||
foreach (var key in window.Resources.Keys.OfType<string>().Where(k => k.StartsWith(ChromeResourcePrefix, StringComparison.Ordinal)).ToList())
|
||||
{
|
||||
var value = window.Resources[key];
|
||||
object? flat = value switch
|
||||
{
|
||||
CornerRadius => new CornerRadius(0),
|
||||
Thickness => new Thickness(0),
|
||||
_ => null
|
||||
};
|
||||
if (flat is null) continue;
|
||||
state.WindowedChrome.TryAdd(key, value);
|
||||
window.Resources[key] = flat;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (key, value) in state.WindowedChrome) window.Resources[key] = value;
|
||||
state.WindowedChrome.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Agrandie = zone utile de l'écran
|
||||
|
||||
private const int WmGetMinMaxInfo = 0x0024;
|
||||
|
||||
/// <summary>Marge laissée sur le bord d'une barre des tâches masquée automatiquement, pour qu'elle puisse réapparaître.</summary>
|
||||
private const int AutoHideMargin = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Taille et position de la fenêtre agrandie : la zone utile de l'écran qui la contient (hors barre des tâches et autres
|
||||
/// barres d'application). Barre des tâches en masquage automatique : un liseré de 2 pixels lui est laissé sur son bord,
|
||||
/// sinon la souris ne pourrait plus la faire apparaître.
|
||||
/// </summary>
|
||||
private static IntPtr LimitMaximizedToWorkArea(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg != WmGetMinMaxInfo || lParam == IntPtr.Zero) return IntPtr.Zero;
|
||||
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
|
||||
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
|
||||
if (monitor == IntPtr.Zero || !GetMonitorInfo(monitor, ref info)) return IntPtr.Zero;
|
||||
|
||||
var work = info.Work;
|
||||
if (work.Left == info.Monitor.Left && work.Top == info.Monitor.Top &&
|
||||
work.Right == info.Monitor.Right && work.Bottom == info.Monitor.Bottom)
|
||||
{
|
||||
// Aucune place réservée : barre des tâches absente de cet écran, ou masquée automatiquement.
|
||||
if (HasAutoHideBar(AppBarEdgeLeft, info.Monitor)) work.Left += AutoHideMargin;
|
||||
else if (HasAutoHideBar(AppBarEdgeTop, info.Monitor)) work.Top += AutoHideMargin;
|
||||
else if (HasAutoHideBar(AppBarEdgeRight, info.Monitor)) work.Right -= AutoHideMargin;
|
||||
else if (HasAutoHideBar(AppBarEdgeBottom, info.Monitor)) work.Bottom -= AutoHideMargin;
|
||||
}
|
||||
|
||||
var minMax = Marshal.PtrToStructure<MinMaxInfo>(lParam);
|
||||
// Position relative au coin de l'écran (Windows l'applique à l'écran où la fenêtre est agrandie).
|
||||
minMax.MaxPosition = new NativePoint { X = work.Left - info.Monitor.Left, Y = work.Top - info.Monitor.Top };
|
||||
minMax.MaxSize = new NativePoint { X = work.Right - work.Left, Y = work.Bottom - work.Top };
|
||||
Marshal.StructureToPtr(minMax, lParam, false);
|
||||
handled = true;
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static bool HasAutoHideBar(int edge, NativeRect monitor)
|
||||
{
|
||||
var data = new AppBarData { Size = Marshal.SizeOf<AppBarData>(), Edge = edge, Rect = monitor };
|
||||
return SHAppBarMessage(AppBarGetAutoHideBarEx, ref data) != IntPtr.Zero;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Win32
|
||||
|
||||
private const int MonitorDefaultToNearest = 2;
|
||||
private const int AppBarGetAutoHideBarEx = 0x0000000B;
|
||||
private const int AppBarEdgeLeft = 0, AppBarEdgeTop = 1, AppBarEdgeRight = 2, AppBarEdgeBottom = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X, Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MinMaxInfo
|
||||
{
|
||||
public NativePoint Reserved;
|
||||
public NativePoint MaxSize;
|
||||
public NativePoint MaxPosition;
|
||||
public NativePoint MinTrackSize;
|
||||
public NativePoint MaxTrackSize;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct AppBarData
|
||||
{
|
||||
public int Size;
|
||||
public IntPtr Window;
|
||||
public uint CallbackMessage;
|
||||
public int Edge;
|
||||
public NativeRect Rect;
|
||||
public IntPtr Param;
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll")]
|
||||
private static extern IntPtr SHAppBarMessage(int message, ref AppBarData data);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeRect
|
||||
{
|
||||
public int Left, Top, Right, Bottom;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MonitorInfo
|
||||
{
|
||||
public int Size;
|
||||
public NativeRect Monitor;
|
||||
public NativeRect Work;
|
||||
public uint Flags;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int flags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info);
|
||||
}
|
||||
@@ -73,3 +73,64 @@ public sealed class KindToResourceConverter : IValueConverter
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contenu d'une image (octets PNG/JPEG déjà contrôlés) → image affichable, décodée en mémoire (aucun fichier
|
||||
/// verrouillé) et réduite : paramètre = largeur de décodage en pixels (480 par défaut). Null si illisible.
|
||||
/// </summary>
|
||||
public sealed class BytesToImageConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not byte[] { Length: > 0 } bytes) return null;
|
||||
try
|
||||
{
|
||||
var image = new System.Windows.Media.Imaging.BitmapImage();
|
||||
image.BeginInit();
|
||||
image.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
||||
image.CreateOptions = System.Windows.Media.Imaging.BitmapCreateOptions.IgnoreColorProfile;
|
||||
image.DecodePixelWidth = parameter is string text && int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var width)
|
||||
? width
|
||||
: 480;
|
||||
image.StreamSource = new System.IO.MemoryStream(bytes, writable: false);
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
catch (Exception ex) when (ex is NotSupportedException or System.IO.IOException or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>null → Collapsed, sinon Visible.</summary>
|
||||
public sealed class NullToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
value is null ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hauteur d'une page « en deux colonnes » du menu d'administration : toute la zone visible (arrondie à l'inférieur, pour
|
||||
/// ne jamais faire apparaître d'ascenseur inutile), mais jamais moins que le paramètre (pixels) : sur un très petit écran,
|
||||
/// la page entière défile alors, et rien n'est rogné.
|
||||
/// </summary>
|
||||
public sealed class ViewportHeightConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var viewport = value is double d && !double.IsNaN(d) && !double.IsInfinity(d) ? Math.Floor(d) : 0;
|
||||
var minimum = double.TryParse(parameter as string, NumberStyles.Float, CultureInfo.InvariantCulture, out var m) ? m : 0;
|
||||
return Math.Max(viewport, minimum);
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
|
||||
une version STRICTEMENT supérieure à la leur.
|
||||
-->
|
||||
<Version>2.1.0</Version>
|
||||
<Version>3.0.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
@@ -52,6 +52,8 @@
|
||||
<!-- Modules (SDK commun + menu d'administration) : logique et ViewModels, sans WPF ; les vues sont ici (Views\Admin, Themes\Admin.xaml). -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
|
||||
<!-- Module « Aide » (demandes des joueurs) : formulaire sans WPF ; la fenêtre est ici (Views\FeedbackWindow.xaml). -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Mode d'affichage mémorisé de chaque fenêtre qui le permet (plein écran ou fenêtré) :
|
||||
/// %AppData%\<Publisher>\GameLauncher\window-modes.json, ex. { "main": true, "admin": false }.
|
||||
/// Simple préférence d'affichage, sans donnée personnelle ; fichier illisible ou modifié à la main : ignoré.
|
||||
/// </summary>
|
||||
public sealed partial class WindowModeStore
|
||||
{
|
||||
public const string FileName = "window-modes.json";
|
||||
private const int MaxFileSize = 4 * 1024;
|
||||
private const int MaxEntries = 32;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly Dictionary<string, bool> _modes;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public WindowModeStore(string path)
|
||||
{
|
||||
_path = path;
|
||||
_modes = Load(path);
|
||||
}
|
||||
|
||||
/// <summary>Plein écran mémorisé pour cette fenêtre (null : jamais choisi).</summary>
|
||||
public bool? Get(string key)
|
||||
{
|
||||
lock (_lock) return _modes.TryGetValue(key, out var fullScreen) ? fullScreen : null;
|
||||
}
|
||||
|
||||
public void Set(string key, bool fullScreen)
|
||||
{
|
||||
if (!IsValidKey(key)) return;
|
||||
Dictionary<string, bool> snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_modes.TryGetValue(key, out var current) && current == fullScreen) return;
|
||||
if (!_modes.ContainsKey(key) && _modes.Count >= MaxEntries) return;
|
||||
_modes[key] = fullScreen;
|
||||
snapshot = new Dictionary<string, bool>(_modes);
|
||||
}
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path))!);
|
||||
FileUtil.WriteAllTextAtomic(_path, JsonSerializer.Serialize(snapshot, JsonDefaults.Options));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Mode d'affichage non mémorisé ({_path}) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, bool> Load(string path)
|
||||
{
|
||||
var modes = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path) || new FileInfo(path).Length > MaxFileSize) return modes;
|
||||
var loaded = JsonSerializer.Deserialize<Dictionary<string, bool>>(File.ReadAllText(path), JsonDefaults.Options);
|
||||
foreach (var (key, value) in loaded ?? new Dictionary<string, bool>())
|
||||
if (IsValidKey(key) && modes.Count < MaxEntries) modes[key] = value;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"Modes d'affichage illisibles ({path}) : {ex.Message}");
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
private static bool IsValidKey(string key) => KeyPattern().IsMatch(key);
|
||||
|
||||
[GeneratedRegex("^[a-z0-9][a-z0-9.-]{0,31}$")]
|
||||
private static partial Regex KeyPattern();
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public sealed class WindowService : IWindowService
|
||||
public void Restore()
|
||||
{
|
||||
if (!_window.IsVisible) _window.Show(); // l'icône de la zone de notification disparaît (voir App)
|
||||
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
|
||||
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
|
||||
_window.Activate();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Platform;
|
||||
using GameLauncher.Views;
|
||||
@@ -26,6 +27,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
private readonly string _launcherVersion;
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string? _apiUrlError;
|
||||
private readonly PlayerSigningCheck? _playerSigning;
|
||||
private LauncherApiClient? _client;
|
||||
private AdminWindow? _window;
|
||||
|
||||
@@ -34,8 +36,12 @@ public sealed class AdminWindowService : IDisposable
|
||||
/// <param name="catalog">Configuration active (dossier des données du launcher).</param>
|
||||
/// <param name="declared">Jeux déclarés dans launcher.json, avant le catalogue (valeurs par défaut de la page « Jeux »).</param>
|
||||
/// <param name="themes">Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</param>
|
||||
/// <param name="playerSigning">
|
||||
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
|
||||
/// </param>
|
||||
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
|
||||
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes)
|
||||
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
|
||||
PlayerSigningCheck? playerSigning = null)
|
||||
{
|
||||
_owner = owner;
|
||||
_modules = modules;
|
||||
@@ -45,6 +51,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
_launcherVersion = launcherVersion;
|
||||
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
|
||||
_apiUrlError = apiUrlError;
|
||||
_playerSigning = playerSigning;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
@@ -54,7 +61,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
{
|
||||
if (_window is not null)
|
||||
{
|
||||
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
|
||||
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
|
||||
_window.Activate();
|
||||
return;
|
||||
}
|
||||
@@ -85,7 +92,8 @@ public sealed class AdminWindowService : IDisposable
|
||||
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion,
|
||||
RootKeys = LauncherUpdateService.LoadRootKeys()
|
||||
RootKeys = LauncherUpdateService.LoadRootKeys(),
|
||||
PlayerSigning = _playerSigning
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Windows;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Feedback;
|
||||
using GameLauncher.Views;
|
||||
|
||||
namespace GameLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bouton « Aide » de la fenêtre principale : ouvre le formulaire de demande pour le jeu sélectionné (fenêtre modale,
|
||||
/// le jeu ne peut donc pas changer pendant la saisie). L'adresse de l'API est celle du menu d'administration
|
||||
/// (« apiUrl » du launcher.json INTÉGRÉ en Release) : sans elle, le bouton n'est pas affiché.
|
||||
/// Le launcher ne contacte l'API qu'au moment de l'envoi, jamais en arrière-plan.
|
||||
/// </summary>
|
||||
public sealed class FeedbackWindowService
|
||||
{
|
||||
private readonly MainWindow _owner;
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string _launcherVersion;
|
||||
private LauncherApiClient? _client;
|
||||
private FeedbackWindow? _window;
|
||||
|
||||
public FeedbackWindowService(MainWindow owner, string? apiUrl, string launcherVersion)
|
||||
{
|
||||
_owner = owner;
|
||||
_apiUrl = apiUrl is not null && Uri.TryCreate(apiUrl, UriKind.Absolute, out var uri) ? uri : null;
|
||||
_launcherVersion = launcherVersion;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
|
||||
/// <param name="installedVersion">Version installée du jeu (null : non installé), pour les informations techniques.</param>
|
||||
public void Open(string gameId, string gameName, string? installedVersion)
|
||||
{
|
||||
if (_apiUrl is null) return;
|
||||
if (_window is not null)
|
||||
{
|
||||
_window.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Agent utilisateur générique (version du launcher seulement) : rien qui identifie le joueur ou son PC.
|
||||
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
|
||||
var viewModel = new FeedbackFormViewModel(new FeedbackTarget(gameId, gameName, installedVersion, _launcherVersion),
|
||||
new ApiFeedbackSender(_client));
|
||||
var window = new FeedbackWindow(viewModel);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
_window = window;
|
||||
Log.Info($"[{gameId}] Ouverture de l'aide (demande).");
|
||||
_owner.SetDimmed(true);
|
||||
try
|
||||
{
|
||||
window.ShowDialog();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_owner.SetDimmed(false);
|
||||
_window = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_window = null;
|
||||
Log.Error("Ouverture de l'aide", ex);
|
||||
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Aide indisponible", ex.Message, DialogKind.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1294
-231
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@
|
||||
<conv:StringToVisibilityConverter x:Key="StringToVisibility" />
|
||||
<conv:IconKeyConverter x:Key="IconKey" />
|
||||
<conv:KindToResourceConverter x:Key="KindToResource" />
|
||||
<conv:ViewportHeightConverter x:Key="ViewportHeight" />
|
||||
|
||||
<!-- ============================================================ Focus clavier discret -->
|
||||
<Style x:Key="FocusVisualStyle">
|
||||
@@ -69,6 +70,26 @@
|
||||
<Setter Property="ui:IconAssist.HoverBrush" Value="{DynamicResource DangerBrush}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Plein écran / mode fenêtré (F11), pour toute fenêtre inscrite avec ui:WindowModeAssist.AllowFullScreen="True" :
|
||||
icône et infobulle suivent le mode ; bouton masqué dans une fenêtre qui ne le permet pas.
|
||||
Usage : <Button Style="{StaticResource WindowModeButtonStyle}" />, entre « Réduire » et « Fermer ».
|
||||
-->
|
||||
<Style x:Key="WindowModeButtonStyle" TargetType="Button" BasedOn="{StaticResource IconButtonStyle}">
|
||||
<Setter Property="Command" Value="{x:Static ui:WindowModeAssist.ToggleFullScreenCommand}" />
|
||||
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.Fullscreen}" />
|
||||
<Setter Property="ToolTip" Value="Plein écran (F11)" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
|
||||
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.FullscreenExit}" />
|
||||
<Setter Property="ToolTip" Value="Quitter le plein écran (F11)" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.AllowFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="False">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ Bouton principal (JOUER / INSTALLER…) -->
|
||||
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
|
||||
<Setter Property="Height" Value="60" />
|
||||
@@ -442,11 +463,21 @@
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
|
||||
VerticalAlignment="Center" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
|
||||
<Grid>
|
||||
<!-- Texte indicatif (ui:TextAssist.Placeholder), visible tant que le champ est vide -->
|
||||
<TextBlock x:Name="Placeholder" Margin="{TemplateBinding Padding}" Padding="2,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
|
||||
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
|
||||
TextTrimming="CharacterEllipsis" IsHitTestVisible="False" Visibility="Collapsed" />
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
|
||||
VerticalAlignment="Center" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Text" Value="">
|
||||
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
|
||||
</Trigger>
|
||||
@@ -473,10 +504,19 @@
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
|
||||
<Grid>
|
||||
<TextBlock x:Name="Placeholder" Margin="{TemplateBinding Padding}" Padding="2,0,0,0" VerticalAlignment="Top"
|
||||
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
|
||||
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
|
||||
TextWrapping="Wrap" IsHitTestVisible="False" Visibility="Collapsed" />
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Text" Value="">
|
||||
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
|
||||
</Trigger>
|
||||
@@ -556,6 +596,11 @@
|
||||
<Grid>
|
||||
<ToggleButton x:Name="Toggle" Style="{StaticResource ModernComboBoxToggleStyle}"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
<!-- Texte indicatif (ui:TextAssist.Placeholder), visible tant que rien n'est choisi -->
|
||||
<TextBlock x:Name="Placeholder" Margin="14,0,34,0" VerticalAlignment="Center" IsHitTestVisible="False"
|
||||
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
|
||||
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
|
||||
TextTrimming="CharacterEllipsis" Visibility="Collapsed" />
|
||||
<ContentPresenter Margin="12,0,34,0" VerticalAlignment="Center" HorizontalAlignment="Left" IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
@@ -578,6 +623,9 @@
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="SelectedItem" Value="{x:Null}">
|
||||
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<Geometry x:Key="Icon.Cog">M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z</Geometry>
|
||||
<Geometry x:Key="Icon.Close">M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z</Geometry>
|
||||
<Geometry x:Key="Icon.Minimize">M20,14H4V10H20</Geometry>
|
||||
<!-- Plein écran (F11) / quitter le plein écran : bouton des fenêtres qui le permettent (WindowModeAssist) -->
|
||||
<Geometry x:Key="Icon.Fullscreen">M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z</Geometry>
|
||||
<Geometry x:Key="Icon.FullscreenExit">M14,14H19V16H16V19H14V14M5,14H10V19H8V16H5V14M8,5H10V10H5V8H8V5M19,8V10H14V5H16V8H19Z</Geometry>
|
||||
<Geometry x:Key="Icon.WindowRestore">M4,8H8V4H20V16H16V20H4V8M16,8V14H18V6H10V8H16M6,12V18H14V12H6Z</Geometry>
|
||||
<Geometry x:Key="Icon.ExitToApp">M19,3H5C3.89,3 3,3.89 3,5V9H5V5H19V19H5V15H3V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10.08,15.58L11.5,17L16.5,12L11.5,7L10.08,8.41L12.67,11H3V13H12.67L10.08,15.58Z</Geometry>
|
||||
<Geometry x:Key="Icon.Folder">M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z</Geometry>
|
||||
@@ -92,5 +95,12 @@
|
||||
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
|
||||
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
|
||||
<Geometry x:Key="Icon.AccountRemove">M15,14C17.67,14 23,15.33 23,18V20H7V18C7,15.33 12.33,14 15,14M15,12A4,4 0 0,1 11,8A4,4 0 0,1 15,4A4,4 0 0,1 19,8A4,4 0 0,1 15,12M5,9.59L7.12,7.46L8.54,8.88L6.41,11L8.54,13.12L7.12,14.54L5,12.41L2.88,14.54L1.46,13.12L3.59,11L1.46,8.88L2.88,7.46L5,9.59Z</Geometry>
|
||||
<Geometry x:Key="Icon.Broom">M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z</Geometry>
|
||||
<Geometry x:Key="Icon.CalendarRange">M9,10H7V12H9V10M13,10H11V12H13V10M17,10H15V12H17V10M19,3H18V1H16V3H8V1H6V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M19,19H5V8H19V19Z</Geometry>
|
||||
<!-- Demandes des joueurs (bouton « Aide ») : types Avis, Demande d'évolution, Bug -->
|
||||
<Geometry x:Key="Icon.Star">M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z</Geometry>
|
||||
<Geometry x:Key="Icon.Lightbulb">M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z</Geometry>
|
||||
<Geometry x:Key="Icon.Bug">M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z</Geometry>
|
||||
<Geometry x:Key="Icon.MessageQuestion">M4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H16L12,22L8,18H4A2,2 0 0,1 2,16V4A2,2 0 0,1 4,2M12.19,5.5C11.3,5.5 10.59,5.68 10.05,6.04C9.5,6.4 9.22,7 9.27,7.69H11.24C11.24,7.41 11.34,7.2 11.5,7.06C11.7,6.92 11.92,6.85 12.19,6.85C12.5,6.85 12.77,6.93 12.95,7.11C13.13,7.28 13.22,7.5 13.22,7.8C13.22,8.08 13.14,8.33 13,8.54C12.83,8.76 12.62,8.94 12.36,9.08C11.84,9.4 11.5,9.68 11.29,9.92C11.1,10.16 11,10.5 11,11H13C13,10.72 13.05,10.5 13.14,10.32C13.23,10.15 13.4,10 13.66,9.85C14.12,9.64 14.5,9.36 14.79,9C15.08,8.63 15.23,8.24 15.23,7.8C15.23,7.1 14.96,6.54 14.42,6.12C13.88,5.71 13.13,5.5 12.19,5.5M11,12V14H13V12H11Z</Geometry>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -65,6 +65,9 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
|
||||
public bool IsExitConfirmed { get; set; }
|
||||
|
||||
/// <summary>Bouton « Aide » affiché (adresse de l'API présente dans launcher.json).</summary>
|
||||
public bool IsHelpAvailable { get; set; }
|
||||
|
||||
public RelayCommand MinimizeCommand { get; }
|
||||
public RelayCommand CloseCommand { get; }
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal">
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ui:WheelAssist.IsEnabled="True"
|
||||
ui:WindowModeAssist.AllowFullScreen="True"
|
||||
ui:WindowModeAssist.PersistenceKey="admin">
|
||||
|
||||
<!--
|
||||
Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo).
|
||||
@@ -26,6 +29,29 @@
|
||||
• AdminWorkspaceViewModel → navigation entre les pages des modules + page affichée.
|
||||
-->
|
||||
<Window.Resources>
|
||||
<!--
|
||||
Mode fenêtré : ombre et coins arrondis. En plein écran (F11, bouton de la barre de titre), WindowModeAssist met ces
|
||||
ressources « WindowChrome.* » à 0. Toute page d'un module (présente ou future) s'affiche dans cette fenêtre et
|
||||
profite donc du plein écran sans rien déclarer.
|
||||
-->
|
||||
<Thickness x:Key="WindowChrome.ShadowMargin">20</Thickness>
|
||||
<CornerRadius x:Key="WindowChrome.Radius">20</CornerRadius>
|
||||
<CornerRadius x:Key="WindowChrome.NavRadius">0,0,0,20</CornerRadius>
|
||||
|
||||
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
|
||||
<Style x:Key="WindowShadowStyle" TargetType="Border">
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
|
||||
<Setter Property="Effect" Value="{x:Null}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type avm:LoginViewModel}">
|
||||
<local:LoginView />
|
||||
</DataTemplate>
|
||||
@@ -43,7 +69,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Navigation -->
|
||||
<Border Background="{DynamicResource RailBrush}" CornerRadius="0,0,0,20"
|
||||
<Border Background="{DynamicResource RailBrush}" CornerRadius="{DynamicResource WindowChrome.NavRadius}"
|
||||
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0">
|
||||
<DockPanel Margin="16,20,16,18">
|
||||
<!-- Compte connecté -->
|
||||
@@ -92,29 +118,64 @@
|
||||
<!-- Page affichée -->
|
||||
<DockPanel Grid.Column="1" Margin="32,22,18,18">
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,18">
|
||||
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding PanelDescription}" FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0">
|
||||
<!-- Barre d'actions de la page (« Recharger », « Publier »…) : toujours visible, sous la zone qui défile
|
||||
(modèle « AdminFooter.<type de la page> » de Themes/Admin.xaml) -->
|
||||
<ContentControl DockPanel.Dock="Bottom" Margin="0,0,14,0" Focusable="False"
|
||||
Content="{Binding SelectedPanel.ViewModel}"
|
||||
ContentTemplateSelector="{StaticResource AdminFooterSelector}" />
|
||||
<!-- Retour en haut : page changée, ou demandé par la page (après « Recharger », « Publier »…).
|
||||
Page en deux colonnes (HasSplitLayout) : la page reçoit la hauteur visible et chaque colonne a son propre
|
||||
ascenseur (Themes/Admin.xaml) ; sur un très petit écran (moins de 360 px de haut), la page entière défile
|
||||
en plus, pour que rien ne soit jamais rogné (bandeaux en haut compris). -->
|
||||
<ScrollViewer Focusable="False" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||
ui:ScrollAssist.ResetOn="{Binding SelectedPanel}"
|
||||
ui:ScrollAssist.ScrollToTopToken="{Binding SelectedPanel.ViewModel.ScrollToTopToken}">
|
||||
<ScrollViewer.Style>
|
||||
<Style TargetType="ScrollViewer">
|
||||
<Setter Property="Padding" Value="0,0,14,0" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ScrollViewer.Style>
|
||||
<!-- Modèle choisi selon le type de la page (Themes/Admin.xaml) -->
|
||||
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False" />
|
||||
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False">
|
||||
<ContentControl.Style>
|
||||
<Style TargetType="ContentControl">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
|
||||
<Setter Property="Height"
|
||||
Value="{Binding ViewportHeight, RelativeSource={RelativeSource AncestorType=ScrollViewer}, Converter={StaticResource ViewportHeight}, ConverterParameter=360}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ContentControl.Style>
|
||||
</ContentControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
|
||||
<!-- Ombre portée -->
|
||||
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
|
||||
Style="{StaticResource WindowShadowStyle}" />
|
||||
|
||||
<Border CornerRadius="20" Background="{DynamicResource SurfaceSolidBrush}"
|
||||
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource SurfaceSolidBrush}"
|
||||
BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1">
|
||||
<!--
|
||||
Responsive (ScaleToFitDecorator) : 1038 × 678 en mode fenêtré. Plus grand (plein écran) : les pages s'étalent.
|
||||
Plus petit : jusqu'à 960 × 560 la mise en page s'adapte (les pages défilent) ; en dessous (petit écran, mise à
|
||||
l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
|
||||
-->
|
||||
<ui:ScaleToFitDecorator DesignWidth="960" DesignHeight="560">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="64" />
|
||||
@@ -149,6 +210,8 @@
|
||||
</Border>
|
||||
<Button Style="{StaticResource IconButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
|
||||
ToolTip="Réduire" Click="OnMinimize" />
|
||||
<!-- Plein écran / mode fenêtré (F11) -->
|
||||
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="9" Margin="4,0,0,0" />
|
||||
<Button Style="{StaticResource CloseButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Close}"
|
||||
ToolTip="Fermer (déconnexion)" Margin="4,0,0,0" Click="OnClose" />
|
||||
</StackPanel>
|
||||
@@ -158,6 +221,7 @@
|
||||
<!-- Écran courant -->
|
||||
<ContentControl Grid.Row="1" Content="{Binding Screen}" Focusable="False" />
|
||||
</Grid>
|
||||
</ui:ScaleToFitDecorator>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -29,7 +29,7 @@ public partial class AdminWindow : Window
|
||||
PreviewMouseDown += (_, _) => _shell.NotifyActivity();
|
||||
PreviewMouseWheel += (_, _) => _shell.NotifyActivity();
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
Loaded += (_, _) => FitToScreen();
|
||||
// Taille adaptée à l'écran (mode fenêtré) et plein écran : ui:WindowModeAssist (AdminWindow.xaml).
|
||||
|
||||
// Session Windows verrouillée (Win+L, mise en veille) : l'administration se verrouille aussi.
|
||||
SystemEvents.SessionSwitch += OnSessionSwitch;
|
||||
@@ -54,16 +54,6 @@ public partial class AdminWindow : Window
|
||||
_shell.NotifyActivity();
|
||||
}
|
||||
|
||||
/// <summary>Jamais plus haute que la zone utile de l'écran (petits écrans, mise à l'échelle 125-150 %).</summary>
|
||||
private void FitToScreen()
|
||||
{
|
||||
var work = SystemParameters.WorkArea;
|
||||
if (Height > work.Height) Height = work.Height;
|
||||
if (Width > work.Width) Width = work.Width;
|
||||
if (Top < work.Top) Top = work.Top;
|
||||
if (Left < work.Left) Left = work.Left;
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
_shell.NotifyActivity();
|
||||
@@ -87,10 +77,11 @@ public partial class AdminWindow : Window
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Déplacement par la barre de titre (mode fenêtré seulement).</summary>
|
||||
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
|
||||
DragMove();
|
||||
Controls.WindowModeAssist.DragMove(this);
|
||||
}
|
||||
|
||||
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
||||
|
||||
@@ -13,14 +13,17 @@ public partial class ChangelogWindow : Window
|
||||
InitializeComponent();
|
||||
SubtitleText.Text = string.IsNullOrWhiteSpace(gameName) ? "Nouveautés et correctifs" : $"{gameName} · nouveautés et correctifs";
|
||||
Entries.ItemsSource = entries;
|
||||
FitToScreen();
|
||||
SourceInitialized += (_, _) => FitToScreen();
|
||||
}
|
||||
|
||||
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran : au-delà, les notes défilent.</summary>
|
||||
/// <summary>
|
||||
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (celle-ci peut être en plein écran sur
|
||||
/// un autre écran que l'écran principal) : au-delà, les notes défilent.
|
||||
/// </summary>
|
||||
private void FitToScreen()
|
||||
{
|
||||
const double chrome = 250; // en-tête, bouton, marges et ombre
|
||||
EntriesScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
|
||||
EntriesScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
|
||||
}
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<Window x:Class="GameLauncher.Views.FeedbackWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls"
|
||||
xmlns:fb="clr-namespace:GameLauncher.Modules.Feedback;assembly=GameLauncher.Modules.Feedback"
|
||||
Title="Aide"
|
||||
Width="680"
|
||||
SizeToContent="Height"
|
||||
WindowStyle="None"
|
||||
AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
ResizeMode="NoResize"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
PreviewKeyDown="OnPreviewKeyDown">
|
||||
|
||||
<!--
|
||||
Bouton « Aide » de la fenêtre principale : avis, demande d'évolution, bug ou autre, pour le jeu sélectionné.
|
||||
ViewModel : FeedbackFormViewModel (GameLauncher.Modules.Feedback) ; formulaires décrits dans GameLauncher.Contracts.
|
||||
Tout le texte saisi est affiché tel quel (jamais interprété) ; l'envoi est anonyme.
|
||||
-->
|
||||
<Window.Resources>
|
||||
<!-- Tuile du choix du type de demande -->
|
||||
<Style x:Key="FeedbackKindTileStyle" TargetType="Button">
|
||||
<Setter Property="Margin" Value="0,0,12,12" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" CornerRadius="16" Padding="16,14" BorderThickness="1" SnapsToDevicePixels="True"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.8" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Étoile d'une note : bouton sans cadre -->
|
||||
<Style x:Key="FeedbackStarButtonStyle" TargetType="Button">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Padding" Value="3" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FeedbackLabelStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FeedbackFieldErrorStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Margin" Value="2,5,0,0" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type fb:RatingStar}">
|
||||
<Button Style="{StaticResource FeedbackStarButtonStyle}" Command="{Binding SelectCommand}" ToolTip="{Binding ToolTip}">
|
||||
<Path x:Name="StarIcon" Width="26" Height="26" Stretch="Uniform" Data="{StaticResource Icon.Star}"
|
||||
Fill="{DynamicResource BorderStrongBrush}" />
|
||||
</Button>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsOn}" Value="True">
|
||||
<Setter TargetName="StarIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Champ du formulaire : texte, liste de choix ou note (une seule partie visible) -->
|
||||
<DataTemplate DataType="{x:Type fb:FeedbackFieldViewModel}">
|
||||
<StackPanel Margin="0,0,0,14">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource FeedbackLabelStyle}" />
|
||||
|
||||
<Grid Visibility="{Binding IsMultiline, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="84" MaxHeight="180"
|
||||
MaxLength="{Binding MaxLength}" SpellCheck.IsEnabled="True"
|
||||
ui:TextAssist.Placeholder="{Binding Hint}"
|
||||
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock Grid.Row="1" Text="{Binding LengthText}" FontSize="11" Margin="0,4,4,0" HorizontalAlignment="Right"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" MaxWidth="380" HorizontalAlignment="Left" MinWidth="260"
|
||||
ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption, Mode=TwoWay}" DisplayMemberPath="Label"
|
||||
Visibility="{Binding IsChoice, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Visibility="{Binding IsRating, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl ItemsSource="{Binding Stars}" Focusable="False" Margin="-3,0,0,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="{Binding RatingText}" FontSize="12.5" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding Error}" Style="{StaticResource FeedbackFieldErrorStyle}"
|
||||
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
<!-- Ombre -->
|
||||
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource DialogCardStyle}" Padding="28,26,14,22" MouseLeftButtonDown="OnDragMove">
|
||||
<StackPanel>
|
||||
<!-- En-tête : type de demande et jeu concerné (figé à l'ouverture) -->
|
||||
<Grid Margin="0,0,14,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="48" Height="48" CornerRadius="14" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{Binding FormIconKey, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
|
||||
Width="22" Height="22" Stretch="Uniform" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding FormTitle}" FontSize="19" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding GameText}" FontSize="12.5" Margin="0,3,0,0" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Style="{StaticResource IconButtonStyle}" VerticalAlignment="Top"
|
||||
Width="32" Height="32" Padding="9" Margin="0,-6,-10,0" ToolTip="Fermer"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding CloseCommand}" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer x:Name="BodyScroll" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||
Focusable="False" Padding="0,0,14,0">
|
||||
<StackPanel>
|
||||
<!-- ============================================ Étape 1 : type de demande -->
|
||||
<StackPanel Visibility="{Binding IsChoosingKind, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Que souhaitez-vous nous transmettre ?" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,12" />
|
||||
<ItemsControl ItemsSource="{Binding Kinds}" Focusable="False" Margin="0,0,-12,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="2" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Style="{StaticResource FeedbackKindTileStyle}" Command="{Binding SelectCommand}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Right" Width="10" Height="10" Stretch="Uniform" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" Fill="{DynamicResource TextMutedBrush}"
|
||||
Data="{StaticResource Icon.ChevronRight}" />
|
||||
<Border DockPanel.Dock="Left" Width="40" Height="40" CornerRadius="12" Margin="0,0,14,0"
|
||||
VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
|
||||
<Path Width="19" Height="19" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Label}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Description}" FontSize="12" Margin="0,3,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="{Binding PrivacyText}" FontSize="11.5" Margin="0,4,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============================================ Étape 2 : formulaire -->
|
||||
<StackPanel Visibility="{Binding IsFilling, Converter={StaticResource BoolToVisibility}}">
|
||||
<!-- Erreur (contrôle local ou refus du serveur) : la saisie est conservée -->
|
||||
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#26E5484D"
|
||||
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="1"
|
||||
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
|
||||
Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel IsEnabled="{Binding IsEditable}">
|
||||
<!-- Titre -->
|
||||
<StackPanel Margin="0,0,0,14">
|
||||
<TextBlock Text="Titre *" Style="{StaticResource FeedbackLabelStyle}" />
|
||||
<TextBox x:Name="TitleBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding TitleMaxLength}"
|
||||
SpellCheck.IsEnabled="True" ui:TextAssist.Placeholder="{Binding TitleHint}"
|
||||
Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<DockPanel Margin="0,4,4,0">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding TitleLengthText}" FontSize="11"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding TitleError}" Style="{StaticResource FeedbackFieldErrorStyle}" Margin="2,0,8,0"
|
||||
Visibility="{Binding HasTitleError, Converter={StaticResource BoolToVisibility}}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Champs prédéfinis du type choisi -->
|
||||
<ItemsControl ItemsSource="{Binding Fields}" Focusable="False" />
|
||||
|
||||
<!-- Informations techniques : facultatives, contenu exact affiché -->
|
||||
<Border CornerRadius="14" Padding="14,4,14,12" Margin="0,4,0,12" BorderThickness="1"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<StackPanel>
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,8" IsChecked="{Binding IncludeTech}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Joindre les informations techniques" FontSize="13" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Aide à reproduire un bug. Rien d'autre que ce qui est affiché ci-dessous n'est envoyé."
|
||||
FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
<TextBlock Text="{Binding TechPreview}" FontSize="11.5" Margin="2,2,0,0" TextWrapping="Wrap"
|
||||
FontFamily="Cascadia Mono, Consolas, Courier New"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding IncludeTech, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<Path DockPanel.Dock="Left" Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,0,0"
|
||||
Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ShieldCheck}" />
|
||||
<TextBlock Text="{Binding PrivacyText}" FontSize="11.5" Margin="8,0,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============================================ Étape 3 : envoyée -->
|
||||
<StackPanel Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}}" Margin="0,6,0,6">
|
||||
<Path Width="44" Height="44" Stretch="Uniform" HorizontalAlignment="Center" Fill="{DynamicResource SuccessBrush}"
|
||||
Data="{StaticResource Icon.CheckCircle}" />
|
||||
<TextBlock Text="Merci, votre demande a bien été envoyée." FontSize="16" FontWeight="SemiBold" Margin="0,12,0,0"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="Conservez sa référence si vous souhaitez en parler avec l'équipe (Discord, e-mail…)."
|
||||
FontSize="12.5" Margin="0,6,0,14" HorizontalAlignment="Center" TextWrapping="Wrap" TextAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
<Border HorizontalAlignment="Center" CornerRadius="14" Padding="18,10,10,10" BorderThickness="1"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderStrongBrush}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Reference}" FontSize="22" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
FontFamily="Cascadia Mono, Consolas, Courier New" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="34" Height="34" Padding="9" Margin="12,0,0,0"
|
||||
ToolTip="Copier la référence" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Click="OnCopyReference" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock x:Name="CopiedText" Text="Référence copiée." FontSize="11.5" Margin="0,6,0,0" HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource SuccessBrush}" Visibility="Collapsed" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Boutons -->
|
||||
<DockPanel Margin="0,16,14,0">
|
||||
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Changer de type" FontSize="12.5"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ArrowLeft}" Command="{Binding BackCommand}"
|
||||
Visibility="{Binding IsFilling, Converter={StaticResource BoolToVisibility}}" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" Margin="0,0,14,0"
|
||||
VerticalAlignment="Center" IsIndeterminate="{Binding IsSending}"
|
||||
Visibility="{Binding IsSending, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,10,0"
|
||||
Command="{Binding CloseCommand}"
|
||||
Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Envoyer" IsDefault="False"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}" Command="{Binding SendCommand}"
|
||||
Visibility="{Binding IsFilling, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Fermer" IsDefault="True"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding CloseCommand}"
|
||||
Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Modules.Feedback;
|
||||
|
||||
namespace GameLauncher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Bouton « Aide » : avis, demande d'évolution, bug ou autre, pour le jeu sélectionné (voir <see cref="FeedbackFormViewModel"/>).
|
||||
/// Fermer la fenêtre abandonne un envoi en cours.
|
||||
/// </summary>
|
||||
public partial class FeedbackWindow : Window
|
||||
{
|
||||
private readonly FeedbackFormViewModel _viewModel;
|
||||
|
||||
public FeedbackWindow(FeedbackFormViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = viewModel;
|
||||
DataContext = viewModel;
|
||||
viewModel.CloseRequested += Close;
|
||||
viewModel.PropertyChanged += (_, e) =>
|
||||
{
|
||||
// Formulaire affiché : le curseur se place dans le titre.
|
||||
if (e.PropertyName == nameof(FeedbackFormViewModel.IsFilling) && viewModel.IsFilling)
|
||||
Dispatcher.InvokeAsync(() => TitleBox.Focus(), System.Windows.Threading.DispatcherPriority.Input);
|
||||
};
|
||||
SourceInitialized += (_, _) => FitToScreen();
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
viewModel.CloseRequested -= Close;
|
||||
viewModel.Dispose();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente : au-delà, le formulaire défile.</summary>
|
||||
private void FitToScreen()
|
||||
{
|
||||
const double chrome = 230; // en-tête, boutons, marges et ombre
|
||||
BodyScroll.MaxHeight = Math.Max(260, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
|
||||
}
|
||||
|
||||
private void OnCopyReference(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_viewModel.Reference)) return;
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(_viewModel.Reference);
|
||||
CopiedText.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch (System.Runtime.InteropServices.ExternalException)
|
||||
{
|
||||
// presse-papiers occupé par une autre application : la référence reste affichée
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Échap ferme la fenêtre, sauf pendant la saisie d'un formulaire (pas de perte du texte par mégarde).</summary>
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Escape || _viewModel.IsFilling || _viewModel.IsSending) return;
|
||||
Close();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDragMove(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ButtonState == MouseButtonState.Pressed && e.OriginalSource is not System.Windows.Controls.TextBox) DragMove();
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,34 @@
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal">
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ui:WindowModeAssist.AllowFullScreen="True"
|
||||
ui:WindowModeAssist.PersistenceKey="main">
|
||||
|
||||
<Window.Resources>
|
||||
<!--
|
||||
Mode fenêtré : ombre autour de la fenêtre et coins arrondis. En plein écran (F11, bouton de la barre de titre),
|
||||
WindowModeAssist met ces ressources « WindowChrome.* » à 0 : la fenêtre occupe toute la zone utile de l'écran
|
||||
(barre des tâches visible), sans marge ni arrondi.
|
||||
-->
|
||||
<Thickness x:Key="WindowChrome.ShadowMargin">16</Thickness>
|
||||
<CornerRadius x:Key="WindowChrome.Radius">22</CornerRadius>
|
||||
<CornerRadius x:Key="WindowChrome.RailRadius">22,0,0,22</CornerRadius>
|
||||
|
||||
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
|
||||
<Style x:Key="WindowShadowStyle" TargetType="Border">
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
|
||||
<Setter Property="Effect" Value="{x:Null}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ChipStyle" TargetType="Border">
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="Background" Value="{DynamicResource ChipBrush}" />
|
||||
@@ -166,24 +191,21 @@
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="16">
|
||||
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
|
||||
<Grid x:Name="Root">
|
||||
<!-- ===================================================== Fond (thème) : ombre, image, lueur, dégradés -->
|
||||
<Border CornerRadius="22" Background="{DynamicResource BackgroundBrush}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
|
||||
Style="{StaticResource WindowShadowStyle}" />
|
||||
<!-- Illustration du thème (Default : Assets/background.png, Exovival : Assets/Themes/exovival-background.jpg) -->
|
||||
<Border x:Name="BackgroundLayer" CornerRadius="22">
|
||||
<Border x:Name="BackgroundLayer" CornerRadius="{DynamicResource WindowChrome.Radius}">
|
||||
<Border.Background>
|
||||
<ImageBrush ImageSource="{DynamicResource BackgroundImage}" Stretch="UniformToFill" AlignmentX="Right" />
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<Border CornerRadius="22" Background="{DynamicResource AmbientGlowBrush}" />
|
||||
<Border CornerRadius="22" Background="{DynamicResource HeroOverlayBrush}" />
|
||||
<Border CornerRadius="22" Background="{DynamicResource VignetteBrush}" />
|
||||
<Border CornerRadius="22" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
|
||||
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource AmbientGlowBrush}" />
|
||||
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource HeroOverlayBrush}" />
|
||||
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource VignetteBrush}" />
|
||||
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
@@ -192,7 +214,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ===================================================== Liste des jeux (si plusieurs jeux) -->
|
||||
<Border Width="88" CornerRadius="22,0,0,22" Background="{DynamicResource RailBrush}"
|
||||
<Border Width="88" CornerRadius="{DynamicResource WindowChrome.RailRadius}" Background="{DynamicResource RailBrush}"
|
||||
MouseLeftButtonDown="OnTitleBarMouseDown"
|
||||
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0"
|
||||
Visibility="{Binding IsMultiGame, Converter={StaticResource BoolToVisibility}}">
|
||||
@@ -208,7 +230,13 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="1" DataContext="{Binding SelectedGame}">
|
||||
<!--
|
||||
Responsive (ScaleToFitDecorator) : zone du jeu de 1168 × 708 en mode fenêtré. Plus grand (plein écran) : le contenu
|
||||
s'étale (colonnes fluides). Plus petit : jusqu'à 1100 × 620 la mise en page s'adapte (la présentation et les notes
|
||||
défilent) ; en dessous (petit écran, mise à l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
|
||||
-->
|
||||
<ui:ScaleToFitDecorator Grid.Column="1" DesignWidth="1100" DesignHeight="620">
|
||||
<Grid DataContext="{Binding SelectedGame}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="76" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -243,6 +271,10 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json) -->
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Aide : avis, idée, bug…" Margin="0,0,4,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.MessageQuestion}" Click="OnHelpClick"
|
||||
Visibility="{Binding DataContext.IsHelpAvailable, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Web}"
|
||||
Command="{Binding OpenWebsiteCommand}"
|
||||
@@ -255,6 +287,8 @@
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Réduire" Padding="12"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
|
||||
Command="{Binding DataContext.MinimizeCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
|
||||
<!-- Plein écran / mode fenêtré (F11) -->
|
||||
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="11" Margin="4,0,0,0" />
|
||||
<Button Style="{StaticResource CloseButtonStyle}" ToolTip="{Binding DataContext.CloseToolTip, RelativeSource={RelativeSource AncestorType=Window}}" Padding="12" Margin="4,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}"
|
||||
Command="{Binding DataContext.CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
|
||||
@@ -265,11 +299,13 @@
|
||||
<Grid Grid.Row="1" Margin="58,6,34,20">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="370" />
|
||||
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
|
||||
<ColumnDefinition Width="0.5*" MinWidth="370" MaxWidth="560" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Présentation du jeu -->
|
||||
<StackPanel VerticalAlignment="Center" Margin="0,0,40,0">
|
||||
<!-- Présentation du jeu : défile si elle ne tient pas en hauteur (description, liens et bandeau à la fois) -->
|
||||
<ScrollViewer Margin="0,0,24,0" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
|
||||
<StackPanel VerticalAlignment="Center" Margin="0,0,16,0">
|
||||
<!-- Badge d'état -->
|
||||
<Border HorizontalAlignment="Left" CornerRadius="14" Padding="12,6"
|
||||
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
|
||||
@@ -404,6 +440,7 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Notes de version -->
|
||||
<Border Grid.Column="1" Margin="0,4" Padding="22,20,10,18" CornerRadius="20"
|
||||
@@ -537,6 +574,7 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ui:ScaleToFitDecorator>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -544,6 +582,6 @@
|
||||
<Image x:Name="TransitionImage" IsHitTestVisible="False" Visibility="Collapsed" Stretch="Fill" />
|
||||
|
||||
<!-- Voile sombre affiché derrière les boîtes de dialogue -->
|
||||
<Border x:Name="DimOverlay" CornerRadius="22" Background="#A6000000" Visibility="Collapsed" />
|
||||
<Border x:Name="DimOverlay" CornerRadius="{DynamicResource WindowChrome.Radius}" Background="#A6000000" Visibility="Collapsed" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -34,6 +34,14 @@ public partial class MainWindow : Window, IThemeTransition
|
||||
/// </summary>
|
||||
public event Action? AdminRequested;
|
||||
|
||||
/// <summary>Bouton « Aide » : demande (avis, évolution, bug…) pour le jeu sélectionné.</summary>
|
||||
public event Action<GameViewModel>? HelpRequested;
|
||||
|
||||
private void OnHelpClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: GameViewModel game }) HelpRequested?.Invoke(game);
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
// Avec Alt, WPF signale la touche dans SystemKey (Key.System).
|
||||
@@ -54,11 +62,11 @@ public partial class MainWindow : Window, IThemeTransition
|
||||
AdminRequested?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre.</summary>
|
||||
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre (mode fenêtré seulement).</summary>
|
||||
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
|
||||
DragMove();
|
||||
Controls.WindowModeAssist.DragMove(this);
|
||||
}
|
||||
|
||||
/// <summary>Ouvre le menu "…" au-dessus du bouton.</summary>
|
||||
|
||||
@@ -33,8 +33,12 @@
|
||||
FontSize="19" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock x:Name="MessageText" Margin="0,18,0,0" FontSize="14" LineHeight="21"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
|
||||
<!-- Message long (détail d'une erreur…) : il défile, la fenêtre ne dépasse jamais l'écran -->
|
||||
<ScrollViewer x:Name="MessageScroll" Margin="0,18,0,0" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled" Focusable="False">
|
||||
<TextBlock x:Name="MessageText" FontSize="14" LineHeight="21"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
|
||||
</ScrollViewer>
|
||||
|
||||
<Border x:Name="OptionPanel" Margin="0,20,0,0" Padding="16,12" CornerRadius="14"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||
|
||||
@@ -12,6 +12,12 @@ public partial class ModernDialog : Window
|
||||
private ModernDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
// Jamais plus haute que la zone utile de l'écran de sa fenêtre parente (au-delà, le message défile).
|
||||
SourceInitialized += (_, _) =>
|
||||
{
|
||||
const double chrome = 280; // titre, option, boutons, marges et ombre
|
||||
MessageScroll.MaxHeight = Math.Max(120, Controls.WindowModeAssist.WorkArea(Owner ?? (Visual)this).Height - chrome);
|
||||
};
|
||||
}
|
||||
|
||||
public static void ShowMessage(Window? owner, string title, string message, DialogKind kind)
|
||||
|
||||
@@ -10,17 +10,17 @@ public partial class SettingsWindow : Window
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
Loaded += (_, _) => FitToScreen();
|
||||
SourceInitialized += (_, _) => FitToScreen(); // avant le centrage sur la fenêtre parente
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La fenêtre ne dépasse jamais la zone utile de l'écran (mise à l'échelle 125-150 %) :
|
||||
/// au-delà, les sections défilent.
|
||||
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (mise à l'échelle 125-150 %, autre
|
||||
/// écran) : au-delà, les sections défilent.
|
||||
/// </summary>
|
||||
private void FitToScreen()
|
||||
{
|
||||
const double chrome = 260; // en-tête, boutons, marges et ombre
|
||||
SectionsScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
|
||||
SectionsScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
|
||||
@@ -23,12 +23,15 @@ public sealed class FakeDialogs : IModuleDialogs
|
||||
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) => Messages.Add($"{title} : {message}");
|
||||
/// <summary>Réponse aux confirmations (vrai par défaut).</summary>
|
||||
public bool ConfirmAnswer { get; set; } = true;
|
||||
|
||||
/// <summary>Réponse selon le titre de la confirmation (null : <see cref="ConfirmAnswer"/>).</summary>
|
||||
public Func<string, bool?>? AnswerFor { get; set; }
|
||||
public List<string> Confirmations { get; } = new();
|
||||
|
||||
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false)
|
||||
{
|
||||
Confirmations.Add($"{title} : {message}");
|
||||
return ConfirmAnswer;
|
||||
return AnswerFor?.Invoke(title) ?? ConfirmAnswer;
|
||||
}
|
||||
public string? PickOpenFile(string title, string filter, string? initialDirectory) => NextOpenFile;
|
||||
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => NextSaveFile;
|
||||
@@ -85,9 +88,11 @@ public static class AdminTestKit
|
||||
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
|
||||
IAdminTpm? tpm = null, IReadOnlyList<LauncherConfig>? games = null,
|
||||
IReadOnlyList<string>? themes = null,
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null) => new()
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null,
|
||||
PlayerSigningCheck? playerSigning = null) => new()
|
||||
{
|
||||
RootKeys = rootKeys ?? Array.Empty<(string, byte[])>(),
|
||||
PlayerSigning = playerSigning,
|
||||
Tpm = tpm,
|
||||
Client = api.CreateClient(),
|
||||
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user