Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc229de53 | ||
|
|
83e3d61634 | ||
|
|
b088c6caa7 | ||
|
|
658a1cce72 | ||
|
|
bac8ebeb55 | ||
|
|
76eaa48342 | ||
|
|
97df0ba721 | ||
|
|
f0f403ce66 | ||
|
|
977e2ae892 | ||
|
|
1913fcdb01 | ||
|
|
f25051c627 | ||
|
|
82ad4ec500 | ||
|
|
a4ef606ff7 | ||
|
|
fcb27d40b1 | ||
|
|
45140ba78a | ||
|
|
5457e66144 | ||
|
|
05c5e24327 | ||
|
|
d920702353 | ||
|
|
e96edfce4f | ||
|
|
11721e9260 | ||
|
|
5d9f413f57 | ||
|
|
cde3b337e7 | ||
|
|
1c198f1204 | ||
|
|
2d26416a7a | ||
|
|
24de56cdcb | ||
|
|
8a7e51fa07 | ||
|
|
e18ebf818f | ||
|
|
821d31ce30 | ||
|
|
db4c874e13 | ||
|
|
64fb944038 | ||
|
|
bc3bf4fbba | ||
|
|
ade18e84d2 | ||
|
|
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,37 @@ 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`. **Captures d'écran** : 3 au plus (fichier ou Ctrl+V), image reconnue à
|
||||
son contenu, réencodée en PNG/JPEG **sans métadonnées** (ni GPS, ni appareil), revérifiée par le serveur (PNG/JPEG
|
||||
seulement, 4 Mo). 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 « Formulaires des demandes »** (permission `feedback.forms`) : types de demande (1 à 8 : ajout, suppression,
|
||||
ordre d'affichage, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées ou non) et
|
||||
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
|
||||
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
|
||||
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi.
|
||||
* **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, captures d'écran (miniature, agrandissement). Une
|
||||
demande contenant un **lien** (http, https, www, domaine, IP, e-mail, formes déguisées…) est signalée par un
|
||||
avertissement, liens désamorcés et jamais cliquables. Statut, note et suppression sont des requêtes **signées**.
|
||||
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 +565,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 +586,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 +607,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 +631,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 +669,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/` |
|
||||
+392
-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,273 @@ 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 (+ jeton d'envoi si des captures sont annoncées) |
|
||||
| `PUT v1/feedback/attachments/{n}` | anonyme + jeton | | Capture d'écran n° n (1 à 3) de la demande que le jeton désigne |
|
||||
| `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 |
|
||||
| `GET v1/games/{id}/feedback/{id}/attachments/{n}` | `feedback.read` | | Capture d'écran (octets bruts, `nosniff`) |
|
||||
| `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, 1 Go captures comprises, 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.
|
||||
|
||||
**Captures d'écran** (3 au plus par demande, 4 Mo chacune, **PNG ou JPEG uniquement**) : le joueur choisit une image
|
||||
(PNG, JPEG, BMP, GIF, WebP, reconnue à son **contenu**, jamais à l'extension ; 30 Mo au plus, dimensions lues avant
|
||||
tout décodage) ou la colle (Ctrl+V) ; Windows la décode (preuve que c'est une image), elle est réduite (3 840 px,
|
||||
surface de la limite serveur) et **réencodée sans métadonnées** (ni GPS, ni appareil, ni date) ; le nom du fichier
|
||||
n'est jamais envoyé. Envoi en deux temps : la demande annonce `AttachmentCount`, le serveur répond avec un **jeton
|
||||
d'envoi** `<id>.<secret>` (128 bits, seule son empreinte SHA-256 est gardée, en mémoire, 15 min, comparaison à temps
|
||||
constant) valable pour CETTE demande et les numéros annoncés ; chaque capture part en `PUT` (corps 4 Mo + 16 Ko,
|
||||
en-tête `X-GL-Feedback-Upload`). Le serveur contrôle **réellement** l'image sans bibliothèque de décodage
|
||||
(`FeedbackImages.Sanitize`) : PNG (signature, IHDR, CRC de chaque bloc, données zlib décompressées et taille exacte
|
||||
des lignes vérifiée, filtres valides, IEND final sans données après) ou JPEG (marqueurs, SOF0/1/2, tables, SOS, EOI,
|
||||
rien après) ; dimensions 16 à 4 096 px, 9,4 Mpx au plus ; puis **retire toutes les métadonnées** (blocs PNG
|
||||
facultatifs : textes, EXIF, profil… ; JPEG : APP1 à APP15 dont EXIF/XMP/GPS, commentaires). Une capture refusée
|
||||
n'annule pas la demande (le joueur est prévenu). Audit `feedback.attachment` sans IP. Dans le menu, chaque capture est
|
||||
**décodée à nouveau** par le launcher (contrôle + dimensions) avant affichage ; aucun fichier n'est écrit sur le PC.
|
||||
|
||||
**Liens** (`FeedbackLinks`) : toute demande contenant un lien est signalée (icône dans la liste, bandeau
|
||||
d'avertissement et champs concernés surlignés dans la fiche ; liens affichés **désamorcés**, ex. `hxxps[://]site[.]com`,
|
||||
jamais cliquables). Formes reconnues : `http(s)://`, `ftp://`, `file:`, schémas dangereux (`javascript:`, `data:`,
|
||||
`vbscript:`, `ms-*:`, `steam:`…), `www.`, domaines nus avec extension connue (`site.ru`, `exemple.com/chemin`), adresses
|
||||
IP, chemins réseau `\\serveur\partage`, adresses e-mail, formes déguisées (`hxxp`, `[.]`, `(dot)`, ` dot `,
|
||||
caractères pleine chasse).
|
||||
|
||||
**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` (captures :
|
||||
`<id>-<n>.png|jpg` à côté, supprimées avec la demande) 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. Captures : fichiers également (des blobs binaires
|
||||
en base alourdiraient sauvegardes et purge sans rien apporter) (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. Comme partout dans le menu, les **écritures**
|
||||
(`PUT`, `DELETE`) sont **signées** par la clé de l'administrateur (horodatage + nonce, rejeu refusé) ; les lectures
|
||||
(liste, détail, captures) exigent la session et `feedback.read`, revérifiés à chaque requête, et sont journalisées.
|
||||
Le journal d'audit du menu affiche des libellés lisibles (« Demande de joueur reçue », « … consultée », « … traitée »,
|
||||
« Capture d'écran reçue »…) et une catégorie « Demandes des joueurs ».
|
||||
|
||||
**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). Formulaire limité à une hauteur de base
|
||||
(500 px, moins sur un petit écran) avec ascenseur ; la fenêtre reste centrée quand l'étape change ; molette gérée
|
||||
par `WheelAssist` comme dans le menu. 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).
|
||||
|
||||
### 11 sexies. Formulaires des demandes modifiables (7e ✅)
|
||||
|
||||
**Besoin** : gérer depuis le menu les types de demande (**1 à 8** : ajout, suppression, **ordre d'affichage**, nom,
|
||||
description, icône, exemple de titre, informations techniques cochées ou non, **captures d'écran autorisées ou non**,
|
||||
type masqué) et leurs champs (ajout, retrait, ordre, libellé, aide, obligatoire, longueur, choix), **communs à tous
|
||||
les jeux**. Les types de champs proposés restent ceux ci-dessous.
|
||||
|
||||
**Icônes** (`Contracts/Feedback/FeedbackIcons`) : l'une des **quatre icônes d'origine** (`Star`, `Lightbulb`, `Bug`,
|
||||
`Forum`), ou une icône **importée depuis un fichier .ico** (extension et en-tête vérifiés ; 1 Mo au plus) qui doit
|
||||
contenir une image de **64 × 64 pixels** (PNG intégré, revérifié, ou bitmap 24/32 bits avec son masque ; 256 couleurs
|
||||
et moins refusés ; les autres tailles sont ignorées). Lecture sans bibliothèque, puis réécriture en **PNG 64 × 64
|
||||
neuf, sans métadonnées**, intégré en base64 aux formulaires (`FeedbackForm.CustomIcon`) : il est donc **couvert par la
|
||||
signature** ; serveur et launchers le revérifient (`CheckCustom` : PNG valide, exactement 64 × 64, 20 Ko au plus,
|
||||
déjà nettoyé).
|
||||
|
||||
**Captures d'écran par type** (`FeedbackForm.AllowAttachments`, vrai par défaut) : faux = section masquée dans la
|
||||
fenêtre « Aide », aucune capture envoyée, et le serveur refuse une demande qui en annonce (`FeedbackRules.Validate`).
|
||||
Mêmes règles qu'avant sinon (3 au plus, conversion, contrôles).
|
||||
|
||||
**Types de champs** (`FeedbackFieldKind`, écrits en toutes lettres) : `Multiline` (zone de texte, 20 à 2 000
|
||||
caractères), `Text` (texte court, 1 à 200), `Choice` (liste déroulante, choix unique), `Checkboxes` (cases à cocher :
|
||||
identifiants séparés par des virgules, remis dans l'ordre du formulaire), `YesNo` (`yes`/`no` ; obligatoire = doit
|
||||
être cochée), `Rating` (3, 5 ou 10 étoiles). `ShowInList` : la valeur d'un champ (liste, note ou oui/non) sert
|
||||
d'aperçu dans la liste du menu (remplace la règle figée « note » / « gravité »).
|
||||
|
||||
**Document** (`Contracts/Feedback/FeedbackFormsDocument`) : `feedback-forms.json` = format, version, **révision**,
|
||||
formulaires, signature ; révision 0 = formulaires d'origine (`FeedbackForms`, jamais publiés). Règles
|
||||
(`FeedbackFormRules.Validate`, communes au menu, au serveur et au launcher) : 1 à 8 types (identifiants
|
||||
`[a-z][a-z0-9_]{0,31}` uniques ; ordre du document = ordre d'affichage), au moins un proposé ; 15 champs au plus ;
|
||||
identifiants de champ uniques ; 2 à 20 choix ; textes déjà propres (une ligne ; aide : 4 lignes, 200 caractères), sans
|
||||
caractère invisible ni **aucun lien** (`FeedbackLinks`) ; icône d'origine parmi les quatre, icône importée contrôlée ;
|
||||
**256 Ko** au plus (icônes comprises : route `PUT v1/feedback/forms` à 264 Ko, bloc nginx dédié). Condensé signé : format, version, révision et formulaires (la date de publication n'en fait
|
||||
pas partie).
|
||||
|
||||
**Signature** : nouveau type de contenu `feedback-forms` (identifiant signé `feedback-forms`) et nouvelle autorisation
|
||||
du trousseau **`forms`** (permission `feedback.forms`, Propriétaire et Administrateur par défaut, pas l'Éditeur).
|
||||
Une autorisation inconnue mais bien formée est désormais **acceptée** dans le trousseau (sans effet : `Verify` exige
|
||||
l'autorisation exacte), pour que les prochaines n'invalident plus le trousseau des launchers ; les launchers 3.1 et
|
||||
avant refusent encore un trousseau contenant `forms` et gardent le précédent (publier la 3.2 avant de re-signer).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
|
||||
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
|
||||
|
||||
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
|
||||
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
|
||||
Formulaires signés obligatoires (pas de mode « non signé »). SQLite écarté : un seul petit document, rarement modifié,
|
||||
publié en fichier de toute façon.
|
||||
|
||||
**Validation des demandes** : `FeedbackSubmission.FormRevision`. Révision 0 (launchers 3.0 / 3.1, ou formulaires
|
||||
publiés illisibles chez le joueur) : formulaires d'origine, toujours acceptés sauf type masqué depuis. Révision en
|
||||
vigueur : son formulaire, **conservé avec la demande** (`FeedbackRecord.Form`, `FeedbackDetail.Form`) pour l'afficher
|
||||
avec ses libellés d'alors. Autre révision : **409 `feedback_form_changed`** (avant le comptage des limites par
|
||||
adresse).
|
||||
|
||||
**Launcher (joueur)** : `FeedbackFormsService` (`Modules.Feedback`) lit `feedback-forms.json` (adresse déduite du
|
||||
trousseau, sinon du catalogue) **à l'ouverture de la fenêtre « Aide » seulement**, 256 Ko au plus (icônes importées comprises), 8 s au plus ;
|
||||
règles contrôlées même si signé ; signature vérifiée par `ContentTrustService.VerifyFeedbackFormsAsync` selon
|
||||
`contentSigning` (clé autorisée `forms`, date de signature non antérieure) ; révision inférieure à la copie gardée
|
||||
refusée ; copie gardée dans `feedback\forms.json` et revérifiée à la lecture. Rien de valide : formulaires d'origine.
|
||||
Fenêtre : « Chargement des formulaires… », types masqués absents, nouveaux champs (texte court, cases à cocher
|
||||
arrondies, case oui/non, 3 à 10 étoiles), aide affichée sous les champs sans zone de saisie. Sur 409 : formulaires
|
||||
relus, type reconstruit, saisie des champs de même identifiant et de même type reprise, message au joueur ; type masqué
|
||||
depuis : retour au choix du type.
|
||||
|
||||
**Menu d'administration** : la fiche d'une demande utilise le formulaire conservé avec elle (libellés, nom du type).
|
||||
|
||||
Page **« Formulaires des demandes »** (`Feedback/FeedbackFormsPanelViewModel`, `FeedbackFormEditors`, permission
|
||||
`feedback.forms`, ordre 16) : trois colonnes, types de demande (proposé / masqué, nouveau / modifié ; « Ajouter un
|
||||
type de demande », 8 au plus, identifiant généré `type_1`… jamais celui d'un type publié ou d'origine, avec un champ
|
||||
« Message ») ; réglages du type (monter, descendre, supprimer avec confirmation — les demandes reçues restent
|
||||
consultables ; nom ; icône d'origine ou « Importer un fichier .ico… » / « Retirer l'icône importée » ; description,
|
||||
exemple de titre, informations techniques cochées par défaut, captures d'écran autorisées, masqué) et champs repliables
|
||||
(monter, descendre, retirer ; libellé, aide, obligatoire, aperçu dans la liste, longueur, nombre d'étoiles, choix
|
||||
ordonnés) ; « Ajouter un champ » par type, identifiant généré et fixe (`texte_1`, `cases_2`…, jamais un identifiant
|
||||
publié) ; **aperçu** rendu avec les modèles de la fenêtre « Aide » (`Themes/FeedbackForms.xaml`, partagé). Contrôle
|
||||
en direct avec `FeedbackFormRules` (publication bloquée tant qu'il reste une erreur) ; avertissements sans refus :
|
||||
libellé qui semble demander une donnée personnelle (`PersonalDataWords` : e-mail, mot de passe, téléphone…), type sans
|
||||
champ. « Publier » : confirmation (types masqués rappelés), contrôle du trousseau publié (`PlayerSigningCheck` :
|
||||
autorisation « formulaires »), signature par la clé de ce PC, requête signée, révision attendue (409 : bandeau
|
||||
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
|
||||
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
|
||||
|
||||
## 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 :
|
||||
@@ -535,6 +894,13 @@ côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish`
|
||||
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
|
||||
permettrait de calculer l'empreinte à dicter).
|
||||
|
||||
**Contenu en ligne** (page Signature, `GET v1/signing` : `SigningState`) : versions en ligne de chaque jeu, catalogue,
|
||||
puis **formulaires des demandes** (`FeedbackFormsSignatureState` : révision, condensé, signature, signataire ;
|
||||
révision 0 = formulaires d'origine intégrés au launcher, rien à signer ; fichier de référence illisible signalé).
|
||||
Chaque contenu est vérifié par le launcher avec le trousseau publié (accepté ou refusé par les joueurs, et pourquoi)
|
||||
et avec le trousseau proposé (« sa clé ne sera plus dans le trousseau proposé » ; l'export de la demande le rappelle).
|
||||
Un serveur antérieur à la 3.2.1 n'envoie pas les formulaires : la ligne n'apparaît pas.
|
||||
|
||||
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
|
||||
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
|
||||
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
|
||||
@@ -560,6 +926,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 +955,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 +973,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 +987,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; } = 1024; // captures d'écran comprises (4 Mo chacune au plus)
|
||||
|
||||
/// <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,25 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
|
||||
public string KeyringPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Fichier public des formulaires des demandes (feedback-forms.json, à côté du trousseau ; null : publication
|
||||
/// désactivée), calculé par <see cref="Validate"/>. Les launchers le lisent à la même adresse que le trousseau.
|
||||
/// </summary>
|
||||
public string? FeedbackFormsPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
|
||||
public string FeedbackFormsPublicUrl { 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 +287,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 +300,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()
|
||||
@@ -282,8 +397,17 @@ public sealed class LauncherApiOptions
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
|
||||
// Formulaires des demandes : toujours à côté du trousseau (les launchers en déduisent l'adresse).
|
||||
FeedbackFormsPublicFileFullPath = KeyringPublicFileFullPath is { } keyringFile
|
||||
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
|
||||
: null;
|
||||
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
|
||||
}
|
||||
|
||||
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
|
||||
public const string FeedbackFormsFileName = "feedback-forms.json";
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -308,6 +432,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,352 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
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 »).
|
||||
/// • Formulaires (communs à tous les jeux, <see cref="FeedbackFormsStore"/>) : lecture par tout administrateur (contenu
|
||||
/// public), publication par feedback.forms, requête signée ET formulaires signés par la clé de la session
|
||||
/// (autorisation « forms » du trousseau, vérifiée par les launchers). Chaque demande est validée avec le formulaire
|
||||
/// de la révision qu'elle annonce (0 : formulaires d'origine) ; une révision dépassée répond 409.
|
||||
/// </summary>
|
||||
public sealed class FeedbackModule : IApiModule
|
||||
{
|
||||
public string Name => "Feedback";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<FeedbackStore>();
|
||||
services.AddSingleton<FeedbackFormsStore>();
|
||||
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.MapPut(ApiRoutes.FeedbackAttachmentPattern, UploadAttachment).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
|
||||
.WithBodyLimit(FeedbackImages.MaxRequestBytes);
|
||||
|
||||
api.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.FeedbackForms, GetForms).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.FeedbackForms, PublishForms).RequirePermission(AdminPermissions.FeedbackForms).RequireSignedRequest()
|
||||
.WithBodyLimit(FeedbackFormRules.MaxRequestBytes); // icônes importées comprises (bloc nginx dédié)
|
||||
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackAttachmentPattern, GetAttachment).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, FeedbackFormsStore forms, 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. Formulaire de la révision annoncée (409 si les formulaires ont été republiés pendant la saisie), puis contenu
|
||||
// (mêmes règles que le launcher).
|
||||
FeedbackForm form;
|
||||
bool snapshot;
|
||||
try
|
||||
{
|
||||
(form, snapshot) = forms.Resolve(submission.FormRevision, submission.Kind);
|
||||
}
|
||||
catch (ApiRejection ex)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null,
|
||||
ex.Status == 409 ? $"formulaire dépassé (révision {submission.FormRevision})" : "type de demande refusé");
|
||||
throw;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, form, 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, snapshot ? form : null);
|
||||
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} ; {KindLabel(record)}" +
|
||||
(record.FormRevision > 0 ? $" ; formulaires révision {record.FormRevision}" : "") + (record.Tech is null ? "" : " ; informations techniques jointes") +
|
||||
(record.AttachmentCount > 0 ? $" ; {record.AttachmentCount} capture(s) annoncée(s)" : ""));
|
||||
return Results.Json(new FeedbackSubmitted
|
||||
{
|
||||
Reference = record.Reference,
|
||||
UploadToken = record.AttachmentCount > 0 ? guard.CreateUploadToken(record.Id, record.AttachmentCount) : null
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture d'écran d'une demande qui vient d'être envoyée (jeton d'envoi remis à ce seul launcher). L'image est
|
||||
/// contrôlée (vraie image PNG ou JPEG, dimensions, structure) puis nettoyée de ses métadonnées avant d'être
|
||||
/// enregistrée ; sans adresse IP dans l'audit, comme la demande.
|
||||
/// </summary>
|
||||
private static IResult UploadAttachment(int index, HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit,
|
||||
TimeProvider time)
|
||||
{
|
||||
if (!store.Enabled) return NotConfigured();
|
||||
var id = guard.CheckUpload(context.Request.Headers[FeedbackUpload.TokenHeader].ToString(), index);
|
||||
if (id is null || store.FindById(id) is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.attachment", null, null, "jeton d'envoi absent, expiré ou invalide");
|
||||
return ApiResults.Error(403, ApiErrorCodes.Forbidden, "Envoi de la capture refusé (délai dépassé ?).");
|
||||
}
|
||||
|
||||
var error = FeedbackImages.Sanitize(context.CapturedBody(), out var info, out var clean);
|
||||
if (error is not null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.attachment", null, null, $"capture refusée ({error})");
|
||||
return ApiResults.BadRequest(error);
|
||||
}
|
||||
var record = store.AddAttachment(id, index, clean!, info!, time.GetUtcNow());
|
||||
if (record is null)
|
||||
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull, "Le service des demandes est momentanément saturé : capture non enregistrée.");
|
||||
audit.Write("feedback.attachment", AuditResults.Ok, target: record.Reference,
|
||||
details: $"jeu {record.GameId} ; capture {index} : {info!.Format.ToUpperInvariant()} {info.Width} × {info.Height}, {clean!.Length / 1024} Ko (métadonnées retirées)");
|
||||
return Results.Json(new FeedbackAttachmentStored { Index = index }, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés), sinon nom d'origine.</summary>
|
||||
private static string KindLabel(FeedbackRecord record) => record.Form?.Label ?? FeedbackForms.KindLabel(record.Kind);
|
||||
|
||||
// ------------------------------------------------------------------ Formulaires (communs à tous les jeux)
|
||||
|
||||
private static IResult GetForms(FeedbackFormsStore forms, AdminStore admins) => Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
|
||||
private static FeedbackFormsState FormsState(FeedbackFormsStore forms, AdminStore admins)
|
||||
{
|
||||
var current = forms.Current;
|
||||
return new FeedbackFormsState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = forms.PublishingEnabled,
|
||||
PublicUrl = forms.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
|
||||
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// </summary>
|
||||
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Formulaires absents.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Les formulaires doivent être signés.");
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId,
|
||||
FeedbackFormRules.Digest(document), actor, time.GetUtcNow());
|
||||
|
||||
FeedbackFormsDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = forms.Publish(document, request.BaseRevision, time.GetUtcNow());
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication des formulaires des demandes");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; signé (clé {signature.KeyId})");
|
||||
return Results.Json(FormsState(forms, admins), 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 && !FeedbackFormRules.IsValidId(kind)) 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} ; {KindLabel(record)}");
|
||||
return Results.Json(record.ToDetail(), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult GetAttachment(string gameId, string id, int index, HttpContext context, FeedbackStore store, AuditLog audit,
|
||||
FeedbackConsultations consultations)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || store.Find(game, id) is not { } record || store.ReadAttachment(record, index) is not { } bytes)
|
||||
return ApiResults.NotFound("Capture introuvable.");
|
||||
var actor = context.Admin();
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, $"attachment:{record.Id}:{index}", continuation: true))
|
||||
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
record.Reference, $"jeu {game} ; capture {index}");
|
||||
// Type générique : le contenu n'est jamais interprété comme une page par un navigateur.
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
return Results.Bytes(bytes, FeedbackUpload.ContentType);
|
||||
}
|
||||
|
||||
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} ; {KindLabel(deleted)} ; {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)";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
@@ -13,7 +14,7 @@ namespace GameLauncher.Server.Modules.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du contenu servi aux joueurs (phase 7) :
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue et des formulaires des demandes ;
|
||||
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
|
||||
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
|
||||
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
|
||||
@@ -33,8 +34,8 @@ public sealed class SigningModule : IApiModule
|
||||
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
|
||||
ILogger<SigningModule> logger)
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, AdminStore admins,
|
||||
IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
@@ -78,6 +79,30 @@ public sealed class SigningModule : IApiModule
|
||||
};
|
||||
}
|
||||
|
||||
// Formulaires des demandes en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
FeedbackFormsSignatureState? feedbackForms = null;
|
||||
if (forms.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = forms.Current;
|
||||
feedbackForms = current.Revision == 0
|
||||
? new FeedbackFormsSignatureState()
|
||||
: new FeedbackFormsSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Digest = FeedbackFormRules.Digest(current),
|
||||
Signature = current.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, current.Signature)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature des formulaires des demandes");
|
||||
feedbackForms = new FeedbackFormsSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
@@ -97,7 +122,8 @@ public sealed class SigningModule : IApiModule
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
Catalog = catalog,
|
||||
FeedbackForms = feedbackForms
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ using Microsoft.Extensions.Options;
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
|
||||
/// <param name="Permissions">Permissions de son groupe, lues au moment de l'authentification de la requête.</param>
|
||||
/// <param name="RoleName">Nom affiché de son groupe.</param>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions,
|
||||
string? RoleName = null)
|
||||
{
|
||||
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
@@ -19,6 +22,7 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
|
||||
Id = Admin.Id,
|
||||
DisplayName = Admin.DisplayName,
|
||||
Role = Admin.Role,
|
||||
RoleName = RoleName ?? AdminRoles.DisplayName(Admin.Role),
|
||||
Permissions = Permissions,
|
||||
KeyId = Key.KeyId,
|
||||
KeyLabel = Key.Label
|
||||
@@ -28,8 +32,9 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
|
||||
/// <summary>
|
||||
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
|
||||
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le rôle est relu
|
||||
/// à chaque requête : un changement de rôle s'applique aussitôt.
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le groupe et ses
|
||||
/// permissions sont relus à chaque requête : un changement de groupe, ou des permissions d'un groupe,
|
||||
/// s'applique aussitôt.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
@@ -61,7 +66,8 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var match = _store.Read().FindKey(session.KeyId);
|
||||
var database = _store.Read();
|
||||
var match = database.FindKey(session.KeyId);
|
||||
if (match is not { } found || found.Admin.Id != session.AdminId ||
|
||||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
|
||||
{
|
||||
@@ -69,7 +75,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
|
||||
}
|
||||
|
||||
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
|
||||
var permissions = database.PermissionsOf(found.Admin.Role);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
|
||||
@@ -80,7 +86,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
};
|
||||
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
|
||||
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions, database.GroupName(found.Admin.Role));
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jetons d'envoi des captures
|
||||
|
||||
private sealed record PendingUpload(byte[] SecretHash, DateTimeOffset Expires, int Count);
|
||||
|
||||
private readonly ConcurrentDictionary<string, PendingUpload> _uploads = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Jeton d'envoi des captures d'une demande qui vient d'être enregistrée : « <id>.<secret 256 bits> ». Seule
|
||||
/// l'empreinte du secret est gardée, en mémoire (un redémarrage du service annule les envois en attente).
|
||||
/// </summary>
|
||||
public string CreateUploadToken(string id, int count)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_uploads.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var (key, pending) in _uploads)
|
||||
if (pending.Expires <= now) _uploads.TryRemove(key, out _);
|
||||
if (_uploads.Count >= MaxTracked) throw new StoreBusyException("Trop d'envois de captures en attente.");
|
||||
}
|
||||
var secret = RandomNumberGenerator.GetBytes(32);
|
||||
_uploads[id] = new PendingUpload(SHA256.HashData(secret), now + TimeSpan.FromMinutes(FeedbackUpload.TokenLifetimeMinutes), count);
|
||||
return id + "." + Base64Url.Encode(secret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de la demande si le jeton est valable pour cette capture (numéro dans le nombre annoncé, délai non
|
||||
/// dépassé), sinon null. Comparaison en temps constant.
|
||||
/// </summary>
|
||||
public string? CheckUpload(string? token, int index)
|
||||
{
|
||||
if (token is not { Length: > 33 and < 120 }) return null;
|
||||
var dot = token.IndexOf('.');
|
||||
if (dot != 32) return null;
|
||||
var id = token[..dot];
|
||||
if (!FeedbackRules.IsValidId(id) || !_uploads.TryGetValue(id, out var pending)) return null;
|
||||
if (pending.Expires <= _time.GetUtcNow())
|
||||
{
|
||||
_uploads.TryRemove(id, out _);
|
||||
return null;
|
||||
}
|
||||
if (!Base64Url.TryDecode(token[(dot + 1)..], out var secret) || secret.Length != 32 ||
|
||||
!CryptographicOperations.FixedTimeEquals(SHA256.HashData(secret), pending.SecretHash))
|
||||
return null;
|
||||
return index >= 1 && index <= pending.Count ? id : null;
|
||||
}
|
||||
|
||||
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,189 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes des joueurs (communs à tous les jeux), publiés depuis le menu d'administration.
|
||||
/// • Référence : feedback-forms.json dans les données privées (+ historique des 20 dernières révisions) ;
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
|
||||
/// l'adresse), écrite atomiquement, lisible par tous ;
|
||||
/// • Toujours signés (autorisation « forms » du trousseau) : le serveur vérifie que la signature est celle de la
|
||||
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
|
||||
/// Rien de publié : les formulaires d'origine (révision 0) s'appliquent.
|
||||
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormsStore
|
||||
{
|
||||
public const string FileName = "feedback-forms.json";
|
||||
private const string HistoryFolder = "feedback-forms-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _lock = new();
|
||||
private FeedbackFormsDocument? _current;
|
||||
|
||||
public FeedbackFormsStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.FeedbackFormsPublicFileFullPath, options.Value.FeedbackFormsPublicUrl) { }
|
||||
|
||||
public FeedbackFormsStore(string dataDirectory, string? publicFile, string? publicUrl = null)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
PublicUrl = publicUrl;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
public string? PublicUrl { get; }
|
||||
|
||||
/// <summary>Formulaires en vigueur : ceux publiés, sinon ceux d'origine (révision 0).</summary>
|
||||
public FeedbackFormsDocument Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _current ??= Read() ?? FeedbackFormRules.Defaults();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaire à utiliser pour valider une demande, ou la raison du refus. Révision 0 (formulaires d'origine) :
|
||||
/// toujours acceptée (launchers 3.0 / 3.1, formulaires publiés illisibles chez le joueur), sauf pour un type
|
||||
/// masqué ou supprimé depuis. Autre révision : seule celle en vigueur est acceptée (409 : le launcher recharge les
|
||||
/// formulaires).
|
||||
/// </summary>
|
||||
/// <returns>Le formulaire du type, et s'il faut le conserver avec la demande (formulaires publiés).</returns>
|
||||
public (FeedbackForm Form, bool Snapshot) Resolve(long revision, string? kind)
|
||||
{
|
||||
var current = Current;
|
||||
if (current.Find(kind) is { Hidden: true } ||
|
||||
(current.Revision > 0 && current.Find(kind) is null && FeedbackForms.Find(kind) is not null))
|
||||
throw ApiRejection.BadRequest("Ce type de demande n'est plus proposé.");
|
||||
if (revision == 0)
|
||||
return (FeedbackForms.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), false);
|
||||
if (revision != current.Revision)
|
||||
throw new ApiRejection(409, ApiErrorCodes.FeedbackFormChanged,
|
||||
"Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis envoyez de nouveau.");
|
||||
return (current.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), current.Revision > 0);
|
||||
}
|
||||
|
||||
/// <summary>Formulaires publiés (null : aucun).</summary>
|
||||
private FeedbackFormsDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return FeedbackFormRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie des formulaires signés (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue
|
||||
/// (409 si une autre publication a eu lieu), révision suivante obligatoire.
|
||||
/// </summary>
|
||||
/// <returns>Formulaires publiés (avec la date) et ceux d'avant.</returns>
|
||||
public (FeedbackFormsDocument Published, FeedbackFormsDocument Previous) Publish(FeedbackFormsDocument document, long baseRevision,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication des formulaires non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
|
||||
"LauncherApi:Catalog:PublicFile) est nécessaire, les formulaires sont publiés à côté.");
|
||||
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Formulaires non signés.");
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors))
|
||||
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
lock (_lock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var previous = Read() ?? FeedbackFormRules.Defaults();
|
||||
if (baseRevision != previous.Revision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Les formulaires ont été publiés entre-temps (révision {previous.Revision}) : rechargez la page, puis refaites vos modifications.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (document.Revision != previous.Revision + 1)
|
||||
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
|
||||
|
||||
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
|
||||
var json = FeedbackFormRules.Serialize(published);
|
||||
if (Encoding.UTF8.GetByteCount(json) > FeedbackFormRules.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Formulaires trop volumineux (au plus {FeedbackFormRules.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "formulaires des demandes");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(published.Revision, json);
|
||||
_current = published;
|
||||
return (published, previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"feedback-forms-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "feedback-forms-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
|
||||
.Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
|
||||
/// retirés, modifiés ; captures d'écran, icône).
|
||||
/// </summary>
|
||||
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<string>();
|
||||
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
|
||||
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
|
||||
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
|
||||
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) continue;
|
||||
var parts = new List<string>();
|
||||
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
|
||||
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
|
||||
old.TechByDefault != form.TechByDefault)
|
||||
parts.Add("présentation modifiée");
|
||||
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
|
||||
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var addedFields = newIds.Count(id => !oldIds.Contains(id));
|
||||
var removedFields = oldIds.Count(id => !newIds.Contains(id));
|
||||
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
|
||||
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
|
||||
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
|
||||
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
|
||||
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
parts.Add("ordre modifié");
|
||||
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
|
||||
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
|
||||
}
|
||||
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
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; }
|
||||
|
||||
/// <summary>Révision des formulaires utilisés (0 : formulaires d'origine).</summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
/// <summary>Formulaire tel qu'à l'envoi (formulaires publiés depuis le menu) ; null : formulaires d'origine.</summary>
|
||||
public FeedbackForm? Form { 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; }
|
||||
|
||||
/// <summary>Captures annoncées à l'envoi (celles réellement reçues : <see cref="Attachments"/>).</summary>
|
||||
public int AttachmentCount { get; init; }
|
||||
|
||||
/// <summary>Captures reçues, nettoyées (fichiers <c><id>-<n>.png|jpg</c> à côté de la demande).</summary>
|
||||
public List<FeedbackAttachmentInfo> Attachments { get; init; } = new();
|
||||
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 = (Form ?? FeedbackForms.Find(Kind))?.Badge(Fields),
|
||||
HasNote = !string.IsNullOrEmpty(AdminNote),
|
||||
HasLinks = FeedbackLinks.Contains(Title + "\n" + string.Join("\n", Fields.Values)),
|
||||
AttachmentCount = Attachments.Count,
|
||||
Revision = Revision
|
||||
};
|
||||
|
||||
public FeedbackDetail ToDetail() => new()
|
||||
{
|
||||
Summary = ToSummary(),
|
||||
FormVersion = FormVersion,
|
||||
FormRevision = FormRevision,
|
||||
Form = Form,
|
||||
Fields = Fields,
|
||||
Tech = Tech,
|
||||
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
|
||||
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, long extraBytes = 0)
|
||||
{
|
||||
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 + extraBytes >= (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>
|
||||
/// <param name="form">Formulaire utilisé, conservé avec la demande (null : formulaires d'origine).</param>
|
||||
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now, FeedbackForm? form = null)
|
||||
{
|
||||
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,
|
||||
FormRevision = form is null ? 0 : submission.FormRevision,
|
||||
Form = form,
|
||||
ReceivedAt = now,
|
||||
Title = submission.Title,
|
||||
Fields = new Dictionary<string, string>(submission.Fields, StringComparer.Ordinal),
|
||||
Tech = submission.Tech,
|
||||
AttachmentCount = submission.AttachmentCount,
|
||||
Status = FeedbackStatuses.New,
|
||||
Revision = 1
|
||||
};
|
||||
Write(record);
|
||||
game[record.Id] = record;
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Captures d'écran
|
||||
|
||||
/// <summary>Demande (tous jeux confondus) d'après son identifiant interne.</summary>
|
||||
public FeedbackRecord? FindById(string id)
|
||||
{
|
||||
if (!FeedbackRules.IsValidId(id)) return null;
|
||||
lock (_lock)
|
||||
foreach (var game in Index().Values)
|
||||
if (game.TryGetValue(id, out var record)) return record;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre une capture DÉJÀ contrôlée et nettoyée (<see cref="FeedbackImages.Sanitize"/>). Null : plafond atteint.
|
||||
/// Une capture de même numéro est remplacée (nouvel essai après une coupure).
|
||||
/// </summary>
|
||||
public FeedbackRecord? AddAttachment(string id, int index, byte[] clean, FeedbackImages.ImageInfo info, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var record = FindById(id) ?? throw ApiRejection.NotFound("Demande introuvable.");
|
||||
if (index < 1 || index > record.AttachmentCount) throw ApiRejection.BadRequest("Numéro de capture invalide.");
|
||||
var previous = record.Attachments.FirstOrDefault(a => a.Index == index);
|
||||
if (FullReason(record.GameId, now, clean.Length - (previous?.Bytes ?? 0)) is not null) return null;
|
||||
|
||||
var path = AttachmentPath(record, index, info.Format);
|
||||
if (previous is not null && previous.Format != info.Format) DeleteAttachmentFile(record, previous);
|
||||
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
SecureFiles.WriteAllBytesAtomic(path, clean);
|
||||
_totalBytes += clean.Length - oldSize;
|
||||
|
||||
var attachments = record.Attachments.Where(a => a.Index != index)
|
||||
.Append(new FeedbackAttachmentInfo { Index = index, Format = info.Format, Width = info.Width, Height = info.Height, Bytes = clean.Length })
|
||||
.OrderBy(a => a.Index).ToList();
|
||||
var updated = record with { Attachments = attachments };
|
||||
Write(updated);
|
||||
Index()[record.GameId][id] = updated;
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'une capture (null : absente).</summary>
|
||||
public byte[]? ReadAttachment(FeedbackRecord record, int index)
|
||||
{
|
||||
var info = record.Attachments.FirstOrDefault(a => a.Index == index);
|
||||
if (info is null) return null;
|
||||
var path = AttachmentPath(record, index, info.Format);
|
||||
if (!File.Exists(path) || IsLink(path) || new FileInfo(path).Length > FeedbackImages.MaxBytes) return null;
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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 string AttachmentPath(FeedbackRecord record, int index, string format)
|
||||
{
|
||||
if (format is not (FeedbackImages.Png or FeedbackImages.Jpeg) || index is < 1 or > FeedbackImages.MaxAttachments)
|
||||
throw new InvalidOperationException("Capture invalide.");
|
||||
return Path.Combine(Path.GetDirectoryName(PathOf(record))!, $"{record.Id}-{index}.{format}");
|
||||
}
|
||||
|
||||
private void DeleteAttachmentFile(FeedbackRecord record, FeedbackAttachmentInfo attachment)
|
||||
{
|
||||
var path = AttachmentPath(record, attachment.Index, attachment.Format);
|
||||
if (!File.Exists(path)) return;
|
||||
var size = new FileInfo(path).Length;
|
||||
File.Delete(path);
|
||||
_totalBytes -= size;
|
||||
}
|
||||
|
||||
private void DeleteFile(FeedbackRecord record)
|
||||
{
|
||||
foreach (var attachment in record.Attachments) DeleteAttachmentFile(record, attachment);
|
||||
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");
|
||||
var attachments = (record.Attachments ?? new List<FeedbackAttachmentInfo>())
|
||||
.Where(a => a.Format is FeedbackImages.Png or FeedbackImages.Jpeg && a.Index is >= 1 and <= FeedbackImages.MaxAttachments)
|
||||
.ToList();
|
||||
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>(), Attachments = attachments };
|
||||
total += info.Length;
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var attachmentPath = Path.Combine(month, $"{id}-{attachment.Index}.{attachment.Format}");
|
||||
if (File.Exists(attachmentPath)) total += new FileInfo(attachmentPath).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
|
||||
|
||||
@@ -33,6 +33,18 @@ internal static class SecureFiles
|
||||
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
|
||||
}
|
||||
|
||||
/// <summary>Écrit un fichier binaire de façon atomique (fichier temporaire 0600 puis renommage).</summary>
|
||||
public static void WriteAllBytesAtomic(string path, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var temp = path + ".tmp";
|
||||
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
|
||||
public static bool TryCreateNew(string path, string content)
|
||||
{
|
||||
|
||||
@@ -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": 1024,
|
||||
"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.
|
||||
|
||||
+177
-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,167 @@ 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 1 Go au plus (captures comprises), 500 Mo d'espace disque libre minimum, corps de 16 Ko,
|
||||
contrôle complet du contenu (caractères invisibles refusés). **Captures d'écran** : 3 par demande, PNG ou JPEG de
|
||||
4 Mo au plus, envoyées avec un jeton remis au seul launcher qui a envoyé la demande (15 min) ; le serveur vérifie
|
||||
que ce sont de vraies images (structure complète) et **retire leurs métadonnées** (EXIF, position GPS…). **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 **trois** blocs `location … /api/v1/feedback…` de `server/deploy/nginx-api.conf` (défi, envoi,
|
||||
captures d'écran à 5 Mo) à 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`).
|
||||
|
||||
## 3 nonies. Formulaires des demandes modifiables depuis le menu (PC et VPS)
|
||||
|
||||
Les types de demande du bouton **Aide** (avis, demande d'évolution, bug, autre au départ ; 1 à 8) se gèrent depuis le
|
||||
menu d'administration : ajout, suppression, ordre d'affichage, nom, description, icône (l'une des quatre d'origine ou
|
||||
un fichier **.ico** contenant une image 64 × 64), captures d'écran autorisées ou non, champs (zone de texte, texte
|
||||
court, liste déroulante, cases à cocher, case oui/non, étoiles), types masqués. Les formulaires sont **communs à tous les jeux** et **signés** : un serveur
|
||||
compromis ne peut pas faire afficher aux joueurs un faux formulaire (« entrez votre mot de passe »). Les libellés et
|
||||
aides ne peuvent contenir **aucun lien**.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue), 3 quinquies (trousseau) et 3 octies (demandes).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : les formulaires sont
|
||||
écrits à côté du trousseau (`/srv/exovival/www/catalog/feedback-forms.json`, 644).
|
||||
2. **nginx** : ajoute
|
||||
- le bloc `location = /catalog/feedback-forms.json { … }` de `server/deploy/nginx-catalog.conf`, à côté de celui
|
||||
du trousseau (lecture par les launchers) ;
|
||||
- le bloc `location = /api/v1/feedback/forms { … }` de `server/deploy/nginx-api.conf` (publication depuis le
|
||||
menu : formulaires et icônes importées, 300 Ko au plus ; sans lui, une publication de plus de 64 Ko est refusée) ;
|
||||
|
||||
puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. **Launcher 3.2** : publie-le (mise à jour signée habituelle) **avant** l'étape 4, et laisse aux joueurs le temps de
|
||||
le recevoir. Les launchers plus anciens ne connaissent pas la nouvelle autorisation « formulaires » du trousseau :
|
||||
ils refusent le nouveau trousseau et gardent le précédent (aucune gêne pour jouer, mais un appareil ajouté au
|
||||
trousseau en même temps ne serait pas reconnu par eux).
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `feedback.forms` (« Formulaires des demandes ») au groupe qui doit modifier
|
||||
les formulaires (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
|
||||
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« formulaires des demandes »** des
|
||||
appareils concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Publier** depuis le menu, page **« Formulaires des demandes »** (modifier, masquer, aperçu, « Publier »). Les launchers lisent le fichier à l'ouverture de
|
||||
la fenêtre « Aide » et vérifient la signature comme pour le catalogue (`contentSigning`) ; s'ils ne peuvent pas
|
||||
(hors ligne, signature refusée), ils proposent les formulaires d'origine, toujours acceptés par le serveur.
|
||||
|
||||
Compatibilité : chaque demande annonce la révision des formulaires utilisés. Les launchers 3.0 / 3.1 (révision 0,
|
||||
formulaires d'origine) restent acceptés, sauf pour un type masqué. Si les formulaires sont republiés pendant qu'un
|
||||
joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formulaires et garde la saisie des champs
|
||||
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
|
||||
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
|
||||
|
||||
## 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 +538,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 +557,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,14 +575,20 @@ 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)
|
||||
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
|
||||
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
|
||||
├─ feedback-forms-history/ 20 dernières révisions publiées
|
||||
└─ 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)
|
||||
├─ keyring.json trousseau publié (écrit par le service, 644)
|
||||
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
|
||||
└─ images/ images du catalogue (envoyées en SFTP)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
#
|
||||
# 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 ;
|
||||
# les suivants, demandes des joueurs, LISEZMOI.md étape 3 octies ; le dernier, formulaires des demandes,
|
||||
# LISEZMOI.md étape 3 nonies)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
@@ -32,3 +34,110 @@ 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;
|
||||
}
|
||||
|
||||
# Captures d'écran d'une demande (jeton d'envoi remis au seul launcher qui a envoyé la demande) : 4 Mo par image.
|
||||
location ^~ /api/v1/feedback/attachments/ {
|
||||
limit_req zone=feedback_par_ip burst=6 nodelay;
|
||||
limit_req_status 429;
|
||||
access_log off;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
# Publication des formulaires des demandes (page « Formulaires des demandes », administrateurs) : formulaires signés
|
||||
# avec leurs icônes importées (256 Ko au plus). Correspondance EXACTE : la lecture (GET) passe aussi par ce bloc,
|
||||
# avec les limites de débit des administrateurs ; la route exige une session, feedback.forms et une requête signée.
|
||||
location = /api/v1/feedback/forms {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 300k;
|
||||
|
||||
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 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
|
||||
# Catalogue des jeux, trousseau et formulaires des demandes (écrits par la Launcher API) — voir LISEZMOI.md,
|
||||
# étapes 3 bis, 3 quinquies et 3 nonies :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
@@ -26,3 +27,12 @@ location = /catalog/keyring.json {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Formulaires des demandes (bouton « Aide ») : publiés depuis le menu, signés (autorisation « forms » du trousseau),
|
||||
# lus par les launchers à l'ouverture de la fenêtre « Aide ».
|
||||
location = /catalog/feedback-forms.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
@@ -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,56 @@ 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);
|
||||
|
||||
/// <summary>Formulaires des demandes en vigueur (communs à tous les jeux ; révision 0 : formulaires d'origine).</summary>
|
||||
public Task<FeedbackFormsState> GetFeedbackFormsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackFormsState>(ApiRoutes.FeedbackForms, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Publie les formulaires : révision <paramref name="baseRevision"/> + 1, signés par la clé de cette session (autorisation
|
||||
/// « forms » du trousseau requise chez les joueurs), puis envoyés en requête signée.
|
||||
/// </summary>
|
||||
public async Task<FeedbackFormsState> PublishFeedbackFormsAsync(IReadOnlyList<FeedbackForm> forms, long baseRevision,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var document = new FeedbackFormsDocument { Revision = baseRevision + 1, Forms = forms.ToList() };
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(forms));
|
||||
var signature = await SignContentAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(document), ct)
|
||||
.ConfigureAwait(false);
|
||||
return await SendSignedAsync<FeedbackFormsState>(HttpMethod.Put, ApiRoutes.FeedbackForms,
|
||||
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
|
||||
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
|
||||
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
|
||||
signed: false, ct, maxResponseBytes: FeedbackImages.MaxBytes);
|
||||
|
||||
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));
|
||||
|
||||
@@ -236,7 +344,8 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
|
||||
string contentType = "application/json", TimeSpan? timeout = null)
|
||||
string contentType = "application/json", TimeSpan? timeout = null,
|
||||
long maxResponseBytes = ApiJson.MaxResponseBytes)
|
||||
{
|
||||
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
|
||||
|
||||
@@ -262,7 +371,7 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
|
||||
}
|
||||
}, ct, contentType, timeout).ConfigureAwait(false);
|
||||
}, ct, contentType, timeout, maxResponseBytes).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
|
||||
@@ -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,65 @@ 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) =>
|
||||
(await SendFeedbackAsync(submission, Array.Empty<byte[]>(), ct).ConfigureAwait(false)).Submitted;
|
||||
|
||||
/// <summary>
|
||||
/// Envoie une demande puis ses captures d'écran (déjà nettoyées par <see cref="FeedbackImages.Sanitize"/>), une par
|
||||
/// une, avec le jeton remis par le serveur. Une capture refusée ou perdue n'annule pas la demande : le résultat
|
||||
/// indique combien ont été reçues et pourquoi les autres ne l'ont pas été.
|
||||
/// </summary>
|
||||
public async Task<FeedbackSendResult> SendFeedbackAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (attachments.Count > FeedbackImages.MaxAttachments) throw new ArgumentException("Trop de captures.", nameof(attachments));
|
||||
var submitted = await SendSubmissionAsync(submission with { AttachmentCount = attachments.Count }, ct).ConfigureAwait(false);
|
||||
var sent = 0;
|
||||
string? error = null;
|
||||
for (var i = 0; i < attachments.Count && submitted.UploadToken is { } token; i++)
|
||||
{
|
||||
var index = i + 1;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendAsync<FeedbackAttachmentStored>(HttpMethod.Put, ApiRoutes.FeedbackAttachment(index), attachments[i],
|
||||
request => request.Headers.Add(FeedbackUpload.TokenHeader, token), ct, FeedbackUpload.ContentType,
|
||||
TimeSpan.FromMinutes(2)).ConfigureAwait(false);
|
||||
sent++;
|
||||
break;
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 0 && attempt < 2)
|
||||
{
|
||||
// coupure : un nouvel essai (même numéro : la capture est remplacée si elle était arrivée)
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new FeedbackSendResult(submitted, sent, error);
|
||||
}
|
||||
|
||||
private async Task<FeedbackSubmitted> SendSubmissionAsync(FeedbackSubmission submission, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
@@ -131,7 +191,8 @@ public sealed class LauncherApiClient : IDisposable
|
||||
|
||||
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
|
||||
Action<HttpRequestMessage>? prepare, CancellationToken ct,
|
||||
string contentType = "application/json", TimeSpan? timeout = null)
|
||||
string contentType = "application/json", TimeSpan? timeout = null,
|
||||
long maxResponseBytes = ApiJson.MaxResponseBytes)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, Resolve(route));
|
||||
if (body is not null)
|
||||
@@ -164,7 +225,7 @@ public sealed class LauncherApiClient : IDisposable
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
|
||||
bytes = await ReadBoundedAsync(response, deadline.Token, maxResponseBytes).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -176,6 +237,7 @@ public sealed class LauncherApiClient : IDisposable
|
||||
}
|
||||
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
|
||||
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
|
||||
if (typeof(T) == typeof(byte[])) return (T)(object)bytes; // contenu binaire (capture d'écran…)
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
|
||||
@@ -189,9 +251,9 @@ public sealed class LauncherApiClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct, long maxBytes)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
|
||||
if (response.Content.Headers.ContentLength > maxBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
@@ -199,7 +261,7 @@ public sealed class LauncherApiClient : IDisposable
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > ApiJson.MaxResponseBytes)
|
||||
if (buffer.Length + read > maxBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
@@ -261,3 +323,8 @@ public sealed class NoContent
|
||||
public static readonly NoContent Value = new();
|
||||
private NoContent() { }
|
||||
}
|
||||
|
||||
/// <summary>Résultat de l'envoi d'une demande et de ses captures d'écran.</summary>
|
||||
/// <param name="AttachmentsSent">Captures reçues par le serveur.</param>
|
||||
/// <param name="AttachmentError">Raison de l'échec de la première capture non reçue (null : toutes reçues).</param>
|
||||
public sealed record FeedbackSendResult(FeedbackSubmitted Submitted, int AttachmentsSent, string? AttachmentError);
|
||||
@@ -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,45 @@ 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>Capture d'écran d'une demande (anonyme, jeton d'envoi ; corps : l'image, 4 Mo au plus).</summary>
|
||||
public const string FeedbackAttachmentPattern = "v1/feedback/attachments/{index:int}";
|
||||
|
||||
public static string FeedbackAttachment(int index) => $"v1/feedback/attachments/{index}";
|
||||
|
||||
/// <summary>Compteurs par jeu (feedback.read).</summary>
|
||||
public const string FeedbackOverview = "v1/feedback/overview";
|
||||
|
||||
/// <summary>Formulaires des demandes : lecture (feedback.forms ou feedback.read), publication signée (PUT, feedback.forms).</summary>
|
||||
public const string FeedbackForms = "v1/feedback/forms";
|
||||
|
||||
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}";
|
||||
|
||||
public const string GameFeedbackAttachmentPattern = "v1/games/{gameId}/feedback/{id}/attachments/{index:int}";
|
||||
|
||||
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
|
||||
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
|
||||
|
||||
// ------------------------------------------------------------------ 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,13 @@ 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>Les formulaires des demandes ont été republiés pendant la saisie : le launcher les recharge.</summary>
|
||||
public const string FeedbackFormChanged = "feedback_form_changed";
|
||||
|
||||
/// <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,237 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Révision des formulaires utilisés (<see cref="FeedbackFormsDocument.Revision"/>) ; 0 : formulaires d'origine
|
||||
/// (launchers 3.0 / 3.1, ou formulaires publiés illisibles). Une autre révision que celle en vigueur : 409.
|
||||
/// </summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
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>Captures d'écran annoncées (0 à <see cref="FeedbackImages.MaxAttachments"/>), envoyées ensuite une par une.</summary>
|
||||
public int AttachmentCount { 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; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Jeton d'envoi des captures annoncées (null sans capture) : à joindre à chaque envoi (en-tête
|
||||
/// <see cref="FeedbackUpload.TokenHeader"/>). Valable <see cref="FeedbackUpload.TokenLifetimeMinutes"/> minutes,
|
||||
/// seulement pour cette demande et le nombre de captures annoncé ; connu du seul launcher qui a envoyé la demande.
|
||||
/// </summary>
|
||||
public string? UploadToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Envoi des captures d'écran d'une demande (PUT v1/feedback/attachments/{index}, corps : l'image).</summary>
|
||||
public static class FeedbackUpload
|
||||
{
|
||||
public const string TokenHeader = "X-GL-Feedback-Upload";
|
||||
public const int TokenLifetimeMinutes = 15;
|
||||
public const string ContentType = "application/octet-stream";
|
||||
}
|
||||
|
||||
/// <summary>Capture d'écran enregistrée (nettoyée de ses métadonnées par le serveur).</summary>
|
||||
public sealed record FeedbackAttachmentInfo
|
||||
{
|
||||
/// <summary>Numéro (1 à <see cref="FeedbackImages.MaxAttachments"/>).</summary>
|
||||
public int Index { get; init; }
|
||||
|
||||
/// <summary>« png » ou « jpg ».</summary>
|
||||
public string Format { get; init; } = "";
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public long Bytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Capture reçue (réponse du serveur).</summary>
|
||||
public sealed record FeedbackAttachmentStored
|
||||
{
|
||||
public int Index { 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; }
|
||||
|
||||
/// <summary>Le titre ou un champ contient un lien ou une adresse (<see cref="FeedbackLinks"/>) : prudence.</summary>
|
||||
public bool HasLinks { get; init; }
|
||||
|
||||
/// <summary>Captures d'écran reçues.</summary>
|
||||
public int AttachmentCount { 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; }
|
||||
|
||||
/// <summary>Révision des formulaires avec laquelle la demande a été envoyée (0 : formulaires d'origine).</summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Formulaire tel qu'il était à l'envoi (libellés, types, choix) : une demande reste lisible même si le formulaire a
|
||||
/// changé depuis. Null : demande reçue avant la 3.2 (formulaires d'origine).
|
||||
/// </summary>
|
||||
public FeedbackForm? Form { get; init; }
|
||||
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
|
||||
/// <summary>Captures d'écran reçues (contenu : GET …/attachments/{index}).</summary>
|
||||
public IReadOnlyList<FeedbackAttachmentInfo> Attachments { get; init; } = Array.Empty<FeedbackAttachmentInfo>();
|
||||
|
||||
/// <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,199 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>Type d'un champ de formulaire (écrit en toutes lettres dans le fichier des formulaires).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FeedbackFieldKind>))]
|
||||
public enum FeedbackFieldKind
|
||||
{
|
||||
/// <summary>Zone de texte sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
Multiline,
|
||||
|
||||
/// <summary>Liste déroulante : un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
|
||||
Choice,
|
||||
|
||||
/// <summary>Note en étoiles, de 1 à <see cref="FeedbackField.RatingMax"/> (valeur : « 1 », « 2 »…).</summary>
|
||||
Rating,
|
||||
|
||||
/// <summary>Texte court sur une ligne (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
Text,
|
||||
|
||||
/// <summary>Cases à cocher : plusieurs choix (valeur : identifiants des options cochées, séparés par des virgules).</summary>
|
||||
Checkboxes,
|
||||
|
||||
/// <summary>Case oui/non (valeur : « yes » ou « no » ; obligatoire = doit être cochée).</summary>
|
||||
YesNo
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="RatingMax">Nombre d'étoiles d'une note (3, 5 ou 10).</param>
|
||||
/// <param name="ShowInList">Valeur affichée en aperçu dans la liste des demandes du menu (un champ par formulaire au plus).</param>
|
||||
public sealed record FeedbackField(
|
||||
string Id,
|
||||
string Label,
|
||||
FeedbackFieldKind Kind,
|
||||
bool Required,
|
||||
string? Hint = null,
|
||||
int MaxLength = FeedbackRules.MaxMultilineLength,
|
||||
IReadOnlyList<FeedbackOption>? Options = null,
|
||||
int RatingMax = 5,
|
||||
bool ShowInList = false)
|
||||
{
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<FeedbackOption> Choices => Options ?? Array.Empty<FeedbackOption>();
|
||||
|
||||
/// <summary>Champ à saisir au clavier (zone de texte ou texte court).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsText => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
|
||||
|
||||
/// <summary>Libellé d'une valeur (option(s) choisie(s), « 4/5 » pour une note, « Oui »/« Non », 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.Checkboxes => string.Join(", ", value!.Split(',').Select(id => Choices.FirstOrDefault(o => o.Id == id)?.Label ?? id)),
|
||||
FeedbackFieldKind.Rating => $"{value}/{RatingMax}",
|
||||
FeedbackFieldKind.YesNo => value == FeedbackRules.Yes ? "Oui" : "Non",
|
||||
_ => 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>
|
||||
/// <param name="Hidden">Type masqué : n'est plus proposé aux joueurs (les demandes déjà reçues restent consultables).</param>
|
||||
/// <param name="AllowAttachments">Captures d'écran proposées pour ce type (3 au plus, contrôlées et nettoyées).</param>
|
||||
/// <param name="CustomIcon">Icône importée (PNG 64 × 64 en base64, <see cref="FeedbackIcons"/>) ; null : <paramref name="IconKey"/>.</param>
|
||||
public sealed record FeedbackForm(
|
||||
string Kind,
|
||||
string Label,
|
||||
string Description,
|
||||
string IconKey,
|
||||
string TitleHint,
|
||||
bool TechByDefault,
|
||||
IReadOnlyList<FeedbackField> Fields,
|
||||
bool Hidden = false,
|
||||
bool AllowAttachments = true,
|
||||
string? CustomIcon = null)
|
||||
{
|
||||
/// <summary>Octets de l'icône importée (null : icône d'origine <see cref="IconKey"/>).</summary>
|
||||
public byte[]? CustomIconBytes() => FeedbackIcons.Decode(CustomIcon);
|
||||
|
||||
public FeedbackField? Field(string id) => (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.Id == id);
|
||||
|
||||
/// <summary>Valeur montrée en aperçu dans la liste du menu (champ « afficher en aperçu »), ou null.</summary>
|
||||
public string? Badge(IReadOnlyDictionary<string, string>? values)
|
||||
{
|
||||
var field = (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.ShowInList);
|
||||
return field is not null && values is not null && values.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value)
|
||||
? field.DisplayValue(value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 D'ORIGINE des demandes des joueurs (révision 0). Ils servent tant qu'aucun formulaire n'a été publié
|
||||
/// depuis le menu d'administration (<see cref="FeedbackFormsDocument"/>), aux launchers qui n'ont pas pu lire les
|
||||
/// formulaires publiés, aux launchers 3.0 / 3.1, et à l'affichage des demandes reçues avant la 3.2.
|
||||
/// Ne pas les modifier : les formulaires se changent désormais depuis le menu (page « Formulaires des demandes »).
|
||||
/// </summary>
|
||||
public static class FeedbackForms
|
||||
{
|
||||
/// <summary>Version du format des demandes (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, ShowInList: 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, ShowInList: 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,283 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, communs à tous les jeux).
|
||||
/// Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée « forms » dans le
|
||||
/// trousseau : le launcher des joueurs le vérifie comme le catalogue avant d'afficher quoi que ce soit.
|
||||
/// Révision 0 = formulaires d'origine (<see cref="FeedbackForms"/>), jamais publiés.
|
||||
/// </summary>
|
||||
public sealed record FeedbackFormsDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-feedback-forms";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Révision : +1 à chaque publication (couverte par la signature). Envoyée avec chaque demande.</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Types de demande (1 à 8), dans l'ordre d'affichage aux joueurs, masqués ou non.</summary>
|
||||
public List<FeedbackForm> Forms { get; init; } = new();
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
|
||||
public FeedbackForm? Find(string? kind) => (Forms ?? new List<FeedbackForm>()).FirstOrDefault(f => f.Kind == kind);
|
||||
|
||||
/// <summary>Types proposés aux joueurs (non masqués), dans l'ordre du document.</summary>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<FeedbackForm> Visible => (Forms ?? new List<FeedbackForm>()).Where(f => f is not null && !f.Hidden);
|
||||
}
|
||||
|
||||
/// <summary>État des formulaires pour le menu d'administration (GET v1/feedback/forms).</summary>
|
||||
public sealed record FeedbackFormsState
|
||||
{
|
||||
/// <summary>Formulaires en vigueur (ceux d'origine, révision 0, si rien n'a été publié).</summary>
|
||||
public FeedbackFormsDocument Document { get; init; } = new();
|
||||
|
||||
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
|
||||
public bool PublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
|
||||
public string? PublicUrl { get; init; }
|
||||
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication des formulaires (PUT v1/feedback/forms, requête signée).</summary>
|
||||
public sealed record FeedbackFormsPublishRequest
|
||||
{
|
||||
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
/// <summary>Formulaires signés, révision <see cref="BaseRevision"/> + 1.</summary>
|
||||
public FeedbackFormsDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles des formulaires publiés, communes au menu (contrôle avant de signer), au serveur (refus avant publication)
|
||||
/// et au launcher (refus d'un fichier hors limites, même signé). Les textes saisis par l'administrateur doivent être
|
||||
/// déjà propres (une ligne, sans caractère invisible) et ne contiennent AUCUN lien : rien ne doit pouvoir renvoyer le
|
||||
/// joueur vers un site depuis un formulaire.
|
||||
/// </summary>
|
||||
public static partial class FeedbackFormRules
|
||||
{
|
||||
/// <summary>Taille du fichier (icônes importées comprises). Publication : route dédiée de nginx et de l'API.</summary>
|
||||
public const int MaxBytes = 256 * 1024;
|
||||
|
||||
/// <summary>Corps de la requête de publication (formulaires + révision de base).</summary>
|
||||
public const int MaxRequestBytes = MaxBytes + 8 * 1024;
|
||||
|
||||
/// <summary>Nombre de types de demande (au moins un proposé aux joueurs).</summary>
|
||||
public const int MaxForms = 8;
|
||||
|
||||
public const int MaxFields = 15;
|
||||
public const int MinOptions = 2;
|
||||
public const int MaxOptions = 20;
|
||||
public const int MaxFormLabelLength = 40;
|
||||
public const int MaxDescriptionLength = 120;
|
||||
public const int MaxTitleHintLength = 80;
|
||||
public const int MaxFieldLabelLength = 80;
|
||||
public const int MaxHintLength = 200;
|
||||
public const int MaxHintLines = 4;
|
||||
public const int MaxOptionLabelLength = 60;
|
||||
public const int MaxTextLength = 200;
|
||||
public const int DefaultTextLength = 120;
|
||||
public const int MinMultilineLength = 20;
|
||||
|
||||
/// <summary>Nombres d'étoiles proposés pour une note.</summary>
|
||||
public static readonly IReadOnlyList<int> RatingSizes = new[] { 3, 5, 10 };
|
||||
|
||||
/// <summary>Icônes proposées pour un type de demande : les quatre d'origine (sinon icône importée, <see cref="FeedbackIcons"/>).</summary>
|
||||
public static IReadOnlyList<string> Icons => FeedbackIcons.DefaultIcons;
|
||||
|
||||
/// <summary>Identifiant d'un champ ou d'un choix : stable, jamais affiché (clé des valeurs envoyées).</summary>
|
||||
[GeneratedRegex(@"\A[a-z][a-z0-9_]{0,31}\z")]
|
||||
private static partial Regex IdRegex();
|
||||
|
||||
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
|
||||
|
||||
/// <summary>Formulaires d'origine (révision 0).</summary>
|
||||
public static FeedbackFormsDocument Defaults() => new() { Revision = 0, Forms = FeedbackForms.All.ToList() };
|
||||
|
||||
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Lit feedback-forms.json (null : vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static FeedbackFormsDocument? Parse(ReadOnlySpan<byte> json) => JsonSerializer.Deserialize<FeedbackFormsDocument>(json, ApiJson.Options);
|
||||
|
||||
public static string Serialize(FeedbackFormsDocument document) => JsonSerializer.Serialize(document, FileOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Condensé signé : format, version, révision et formulaires (tels quels, dans l'ordre ; valeurs vides ramenées à
|
||||
/// une forme unique). La date de publication et la signature n'en font pas partie.
|
||||
/// </summary>
|
||||
public static string Digest(FeedbackFormsDocument document)
|
||||
{
|
||||
var forms = (document.Forms ?? new List<FeedbackForm>()).Where(f => f is not null).Select(f => f with
|
||||
{
|
||||
CustomIcon = string.IsNullOrEmpty(f.CustomIcon) ? null : f.CustomIcon,
|
||||
Fields = (f.Fields ?? Array.Empty<FeedbackField>()).Where(x => x is not null).Select(x => x with
|
||||
{
|
||||
Hint = string.IsNullOrEmpty(x.Hint) ? null : x.Hint,
|
||||
Options = x.Options is { Count: > 0 } options ? options.ToList() : null
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
var canonical = new CanonicalForms(document.Format ?? "", document.Version, document.Revision, forms);
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
|
||||
}
|
||||
|
||||
private sealed record CanonicalForms(string Format, int Version, long Revision, List<FeedbackForm> Forms);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'un document publié (révision 1 au moins). Les erreurs, en français, désignent le type et le
|
||||
/// champ concernés ; elles sont ajoutées à <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
public static bool Validate(FeedbackFormsDocument? document, List<string> errors)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (document is null)
|
||||
{
|
||||
errors.Add("Formulaires absents.");
|
||||
return false;
|
||||
}
|
||||
if (document.Format != FeedbackFormsDocument.FormatName || document.Version != FeedbackFormsDocument.CurrentVersion)
|
||||
errors.Add($"Format de formulaires non pris en charge ({document.Format} v{document.Version}).");
|
||||
if (document.Revision < 1) errors.Add("Révision des formulaires invalide (1 au moins).");
|
||||
|
||||
var forms = document.Forms ?? new List<FeedbackForm>();
|
||||
if (forms.Count is 0 or > MaxForms || forms.Any(f => f is null))
|
||||
{
|
||||
errors.Add($"Les formulaires doivent comprendre de 1 à {MaxForms} types de demande.");
|
||||
return false;
|
||||
}
|
||||
var kinds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var form in forms)
|
||||
{
|
||||
if (!IsValidId(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant invalide (minuscules, chiffres, « _ »).");
|
||||
else if (!kinds.Add(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant « {form.Kind} » en double.");
|
||||
}
|
||||
if (forms.All(f => f.Hidden)) errors.Add("Au moins un type de demande doit rester proposé aux joueurs.");
|
||||
|
||||
foreach (var form in forms) ValidateForm(form, errors);
|
||||
if (errors.Count == count && Encoding.UTF8.GetByteCount(Serialize(document)) > MaxBytes)
|
||||
errors.Add($"Formulaires trop volumineux (au plus {MaxBytes / 1024} Ko).");
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
private static void ValidateForm(FeedbackForm form, List<string> errors)
|
||||
{
|
||||
var where = $"Type « {form.Label} »";
|
||||
CheckLine(form.Label, MaxFormLabelLength, required: true, $"{where} : nom", errors);
|
||||
CheckLine(form.Description, MaxDescriptionLength, required: true, $"{where} : description", errors);
|
||||
CheckLine(form.TitleHint, MaxTitleHintLength, required: false, $"{where} : exemple de titre", errors);
|
||||
if (!Icons.Contains(form.IconKey, StringComparer.Ordinal)) errors.Add($"{where} : icône inconnue.");
|
||||
if (form.CustomIcon is not null && FeedbackIcons.CheckCustom(form.CustomIcon) is { } iconError) errors.Add($"{where} : {iconError}");
|
||||
|
||||
var fields = form.Fields ?? Array.Empty<FeedbackField>();
|
||||
if (fields.Count > MaxFields) errors.Add($"{where} : {MaxFields} champs au plus.");
|
||||
if (fields.Any(f => f is null))
|
||||
{
|
||||
errors.Add($"{where} : champ vide.");
|
||||
return;
|
||||
}
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var label = $"{where}, champ « {field.Label} »";
|
||||
if (!IsValidId(field.Id)) errors.Add($"{label} : identifiant invalide (minuscules, chiffres, « _ », 32 caractères au plus).");
|
||||
else if (!ids.Add(field.Id)) errors.Add($"{label} : identifiant « {field.Id} » en double.");
|
||||
CheckLine(field.Label, MaxFieldLabelLength, required: true, $"{label} : libellé", errors);
|
||||
CheckHint(field.Hint, $"{label} : aide", errors);
|
||||
if (!Enum.IsDefined(field.Kind)) errors.Add($"{label} : type de champ inconnu.");
|
||||
|
||||
var minLength = field.Kind == FeedbackFieldKind.Multiline ? MinMultilineLength : 1;
|
||||
var maxLength = field.Kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => FeedbackRules.MaxMultilineLength,
|
||||
FeedbackFieldKind.Text => MaxTextLength,
|
||||
_ => FeedbackRules.MaxMultilineLength
|
||||
};
|
||||
if (field.MaxLength < minLength || field.MaxLength > maxLength)
|
||||
errors.Add($"{label} : longueur maximale de {minLength} à {maxLength} caractères.");
|
||||
|
||||
var options = field.Options ?? Array.Empty<FeedbackOption>();
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes)
|
||||
{
|
||||
if (options.Count < MinOptions || options.Count > MaxOptions)
|
||||
errors.Add($"{label} : {MinOptions} à {MaxOptions} choix.");
|
||||
var optionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var option in options)
|
||||
{
|
||||
if (option is null || !IsValidId(option.Id) || !optionIds.Add(option.Id))
|
||||
{
|
||||
errors.Add($"{label} : identifiant de choix invalide ou en double.");
|
||||
continue;
|
||||
}
|
||||
CheckLine(option.Label, MaxOptionLabelLength, required: true, $"{label} : choix « {option.Label} »", errors);
|
||||
}
|
||||
}
|
||||
else if (options.Count > 0)
|
||||
{
|
||||
errors.Add($"{label} : seules les listes et les cases à cocher ont des choix.");
|
||||
}
|
||||
|
||||
if (field.Kind == FeedbackFieldKind.Rating && !RatingSizes.Contains(field.RatingMax))
|
||||
errors.Add($"{label} : nombre d'étoiles invalide ({string.Join(", ", RatingSizes)}).");
|
||||
if (field.ShowInList && field.Kind is not (FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo))
|
||||
errors.Add($"{label} : seules une liste, une note ou une case oui/non peuvent être affichées en aperçu.");
|
||||
}
|
||||
if (fields.Count(f => f.ShowInList) > 1) errors.Add($"{where} : un seul champ peut être affiché en aperçu dans la liste.");
|
||||
}
|
||||
|
||||
/// <summary>Texte d'une ligne déjà propre (tel qu'il sera affiché), sans lien.</summary>
|
||||
private static void CheckLine(string? value, int maxLength, bool required, string what, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (required) errors.Add($"{what} : obligatoire.");
|
||||
return;
|
||||
}
|
||||
if (ApiText.CleanLine(value, maxLength) != value)
|
||||
errors.Add($"{what} : {maxLength} caractères au plus, sur une ligne, sans espace en trop ni caractère spécial invisible.");
|
||||
else if (FeedbackLinks.Contains(value))
|
||||
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
|
||||
}
|
||||
|
||||
/// <summary>Aide d'un champ : quelques lignes déjà propres, sans lien.</summary>
|
||||
private static void CheckHint(string? value, string what, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return;
|
||||
var clean = FeedbackRules.CleanMultiline(value, MaxHintLength, out var problem);
|
||||
if (problem is not null || clean != value || value.Count(c => c == '\n') + 1 > MaxHintLines)
|
||||
errors.Add($"{what} : {MaxHintLength} caractères et {MaxHintLines} lignes au plus, sans caractère spécial invisible.");
|
||||
else if (FeedbackLinks.Contains(value))
|
||||
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mots qui laissent penser qu'un champ demande des données personnelles ou secrètes : AVERTISSEMENT seulement
|
||||
/// dans le menu (pas un refus). Les demandes sont anonymes : ne rien demander qui identifie le joueur.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> PersonalDataWords { get; } = new[]
|
||||
{
|
||||
"mot de passe", "password", "e-mail", "email", "courriel", "adresse", "téléphone", "telephone", "nom complet",
|
||||
"date de naissance", "carte", "iban", "identifiant de connexion", "pseudo steam", "compte"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Icônes des types de demande : l'une des quatre icônes d'origine, ou une icône importée depuis un fichier .ico
|
||||
/// (OBLIGATOIREMENT) contenant une image de 64 × 64 pixels.
|
||||
/// • L'import (menu d'administration) lit le fichier .ico sans bibliothèque : en-tête et répertoire contrôlés, image
|
||||
/// 64 × 64 au format PNG (revérifiée) ou bitmap 32 ou 24 bits (avec son masque de transparence) ; tout le reste est
|
||||
/// refusé. L'image est réécrite en PNG 64 × 64 neuf, sans aucune métadonnée.
|
||||
/// • Le PNG obtenu est intégré (base64) aux formulaires SIGNÉS : le serveur et les launchers le revérifient
|
||||
/// (<see cref="CheckCustom"/> : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé).
|
||||
/// </summary>
|
||||
public static class FeedbackIcons
|
||||
{
|
||||
/// <summary>Côté de l'image attendue dans le fichier .ico et de l'icône publiée.</summary>
|
||||
public const int Size = 64;
|
||||
|
||||
/// <summary>Taille d'un fichier .ico accepté à l'import.</summary>
|
||||
public const int MaxIcoBytes = 1024 * 1024;
|
||||
|
||||
/// <summary>Taille de l'icône publiée (PNG).</summary>
|
||||
public const int MaxPngBytes = 20 * 1024;
|
||||
|
||||
/// <summary>Les quatre icônes d'origine des types de demande (ressources « Icon.<clé> » du launcher).</summary>
|
||||
public static readonly IReadOnlyList<string> DefaultIcons = new[] { "Star", "Lightbulb", "Bug", "Forum" };
|
||||
|
||||
public static string DefaultIconLabel(string? key) => key switch
|
||||
{
|
||||
"Star" => "Étoile",
|
||||
"Lightbulb" => "Ampoule",
|
||||
"Bug" => "Insecte (bug)",
|
||||
"Forum" => "Bulles de discussion",
|
||||
_ => key ?? ""
|
||||
};
|
||||
|
||||
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
/// <summary>
|
||||
/// Convertit un fichier .ico en icône publiable (PNG 64 × 64 sans métadonnées). Renvoie null si tout va bien, sinon
|
||||
/// la raison du refus (française, lisible).
|
||||
/// </summary>
|
||||
public static string? FromIco(ReadOnlySpan<byte> ico, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (ico.Length > MaxIcoBytes) return $"Fichier trop volumineux (au plus {MaxIcoBytes / 1024} Ko).";
|
||||
// ICONDIR : réservé = 0, type = 1 (icône), nombre d'images.
|
||||
if (ico.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(ico) != 0 || BinaryPrimitives.ReadUInt16LittleEndian(ico[2..]) != 1)
|
||||
return "Ce fichier n'est pas une icône .ico.";
|
||||
var count = BinaryPrimitives.ReadUInt16LittleEndian(ico[4..]);
|
||||
if (count is 0 or > 64 || ico.Length < 6 + count * 16) return "Icône .ico endommagée (répertoire des images).";
|
||||
|
||||
var sizes = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entry = ico.Slice(6 + i * 16, 16);
|
||||
var width = entry[0] == 0 ? 256 : entry[0];
|
||||
var height = entry[1] == 0 ? 256 : entry[1];
|
||||
sizes.Add($"{width} × {height}");
|
||||
if (width != Size || height != Size) continue;
|
||||
|
||||
var length = BinaryPrimitives.ReadUInt32LittleEndian(entry[8..]);
|
||||
var offset = BinaryPrimitives.ReadUInt32LittleEndian(entry[12..]);
|
||||
if (length == 0 || offset < 6 + (uint)count * 16 || (ulong)offset + length > (ulong)ico.Length)
|
||||
return "Icône .ico endommagée (image 64 × 64 hors du fichier).";
|
||||
var image = ico.Slice((int)offset, (int)length);
|
||||
return image.StartsWith(PngSignature) ? FromPng(image, out png) : FromBitmap(image, out png);
|
||||
}
|
||||
return $"Le fichier .ico doit contenir une image de {Size} × {Size} pixels (images trouvées : {string.Join(", ", sizes.Distinct())}).";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Icône publiée (base64) contrôlée : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé (aucune
|
||||
/// métadonnée). Null si elle est acceptable, sinon la raison du refus.
|
||||
/// </summary>
|
||||
public static string? CheckCustom(string? base64)
|
||||
{
|
||||
var bytes = Decode(base64);
|
||||
if (bytes is null) return "Icône importée illisible.";
|
||||
if (bytes.Length > MaxPngBytes) return $"Icône importée trop volumineuse (au plus {MaxPngBytes / 1024} Ko).";
|
||||
if (FeedbackImages.Sanitize(bytes, out var info, out var clean) is { } error) return $"Icône importée refusée : {error}";
|
||||
if (info!.Format != FeedbackImages.Png || info.Width != Size || info.Height != Size)
|
||||
return $"L'icône importée doit être une image PNG de {Size} × {Size} pixels.";
|
||||
if (!clean!.AsSpan().SequenceEqual(bytes)) return "Icône importée non nettoyée (métadonnées).";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Octets d'une icône publiée (null : absente ou base64 invalide).</summary>
|
||||
public static byte[]? Decode(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64) || base64.Length > MaxPngBytes * 2) return null;
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images de l'icône
|
||||
|
||||
/// <summary>Image PNG de l'icône : revérifiée et nettoyée (métadonnées retirées), 64 × 64 exigé.</summary>
|
||||
private static string? FromPng(ReadOnlySpan<byte> image, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (FeedbackImages.Sanitize(image, out var info, out var clean) is { } error) return $"Image 64 × 64 de l'icône refusée : {error}";
|
||||
if (info!.Width != Size || info.Height != Size) return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
|
||||
if (clean!.Length > MaxPngBytes) return $"Image de l'icône trop volumineuse (au plus {MaxPngBytes / 1024} Ko une fois nettoyée).";
|
||||
png = clean;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image bitmap de l'icône (BITMAPINFOHEADER, sans compression, 32 ou 24 bits) : pixels lus ligne par ligne (de bas
|
||||
/// en haut), transparence prise du canal alpha (32 bits) ou du masque 1 bit, puis réécrits en PNG neuf.
|
||||
/// </summary>
|
||||
private static string? FromBitmap(ReadOnlySpan<byte> image, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (image.Length < 40 || BinaryPrimitives.ReadUInt32LittleEndian(image) != 40)
|
||||
return "Image de l'icône dans un format non pris en charge (PNG ou bitmap 24/32 bits seulement).";
|
||||
var width = BinaryPrimitives.ReadInt32LittleEndian(image[4..]);
|
||||
var height = BinaryPrimitives.ReadInt32LittleEndian(image[8..]);
|
||||
var planes = BinaryPrimitives.ReadUInt16LittleEndian(image[12..]);
|
||||
var bits = BinaryPrimitives.ReadUInt16LittleEndian(image[14..]);
|
||||
var compression = BinaryPrimitives.ReadUInt32LittleEndian(image[16..]);
|
||||
if (width != Size || height != Size * 2 || planes != 1)
|
||||
return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
|
||||
if (compression != 0 || bits is not (32 or 24))
|
||||
return "Image de l'icône en 256 couleurs ou moins : enregistrez l'icône en couleurs 24 ou 32 bits (ou PNG).";
|
||||
|
||||
var stride = (Size * bits / 8 + 3) & ~3;
|
||||
var maskStride = (Size + 31) / 32 * 4;
|
||||
var pixels = image[40..];
|
||||
if (pixels.Length < stride * Size) return "Icône .ico endommagée (image incomplète).";
|
||||
var mask = pixels.Length >= stride * Size + maskStride * Size ? pixels.Slice(stride * Size, maskStride * Size) : ReadOnlySpan<byte>.Empty;
|
||||
|
||||
var rgba = new byte[Size * Size * 4];
|
||||
var hasAlpha = false;
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var row = pixels.Slice((Size - 1 - y) * stride, stride); // lignes de bas en haut
|
||||
for (var x = 0; x < Size; x++)
|
||||
{
|
||||
var source = x * (bits / 8);
|
||||
var target = (y * Size + x) * 4;
|
||||
rgba[target] = row[source + 2];
|
||||
rgba[target + 1] = row[source + 1];
|
||||
rgba[target + 2] = row[source];
|
||||
rgba[target + 3] = bits == 32 ? row[source + 3] : (byte)255;
|
||||
if (bits == 32 && row[source + 3] != 0) hasAlpha = true;
|
||||
}
|
||||
}
|
||||
// Pas de canal alpha utilisable : transparence donnée par le masque (bit à 1 = transparent).
|
||||
if (!hasAlpha && !mask.IsEmpty)
|
||||
{
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var row = mask.Slice((Size - 1 - y) * maskStride, maskStride);
|
||||
for (var x = 0; x < Size; x++)
|
||||
rgba[(y * Size + x) * 4 + 3] = (row[x / 8] & (0x80 >> (x % 8))) != 0 ? (byte)0 : (byte)255;
|
||||
}
|
||||
}
|
||||
else if (!hasAlpha)
|
||||
{
|
||||
for (var i = 3; i < rgba.Length; i += 4) rgba[i] = 255;
|
||||
}
|
||||
|
||||
var encoded = EncodePng(rgba);
|
||||
// Contrôle final avec les règles des images (comme toute icône publiée).
|
||||
if (FeedbackImages.Sanitize(encoded, out _, out var clean) is { } error) return $"Conversion de l'icône impossible : {error}";
|
||||
if (clean!.Length > MaxPngBytes) return $"Icône trop détaillée (au plus {MaxPngBytes / 1024} Ko une fois convertie en PNG).";
|
||||
png = clean;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PNG 64 × 64 RVBA (8 bits par canal, sans entrelacement, filtre « Sub »), sans aucun bloc facultatif.</summary>
|
||||
private static byte[] EncodePng(byte[] rgba)
|
||||
{
|
||||
var raw = new byte[Size * (1 + Size * 4)];
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var rowStart = y * (1 + Size * 4);
|
||||
raw[rowStart] = 1; // Sub : meilleure compression des aplats
|
||||
for (var i = 0; i < Size * 4; i++)
|
||||
{
|
||||
var value = rgba[y * Size * 4 + i];
|
||||
var left = i >= 4 ? rgba[y * Size * 4 + i - 4] : (byte)0;
|
||||
raw[rowStart + 1 + i] = unchecked((byte)(value - left));
|
||||
}
|
||||
}
|
||||
byte[] compressed;
|
||||
using (var buffer = new MemoryStream())
|
||||
{
|
||||
using (var zlib = new ZLibStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true)) zlib.Write(raw);
|
||||
compressed = buffer.ToArray();
|
||||
}
|
||||
|
||||
using var png = new MemoryStream();
|
||||
png.Write(PngSignature);
|
||||
var header = new byte[13];
|
||||
BinaryPrimitives.WriteInt32BigEndian(header, Size);
|
||||
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), Size);
|
||||
header[8] = 8; // bits par canal
|
||||
header[9] = 6; // RVBA
|
||||
WriteChunk(png, "IHDR", header);
|
||||
WriteChunk(png, "IDAT", compressed);
|
||||
WriteChunk(png, "IEND", Array.Empty<byte>());
|
||||
return png.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteChunk(Stream stream, string type, byte[] body)
|
||||
{
|
||||
Span<byte> number = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(number, body.Length);
|
||||
stream.Write(number);
|
||||
var typeBytes = System.Text.Encoding.ASCII.GetBytes(type);
|
||||
stream.Write(typeBytes);
|
||||
stream.Write(body);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(number, Crc32(typeBytes, body));
|
||||
stream.Write(number);
|
||||
}
|
||||
|
||||
private static readonly uint[] CrcTable = Enumerable.Range(0, 256).Select(n =>
|
||||
{
|
||||
var c = (uint)n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
||||
return c;
|
||||
}).ToArray();
|
||||
|
||||
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var c = 0xFFFFFFFFu;
|
||||
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
return c ^ 0xFFFFFFFFu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Captures d'écran jointes à une demande : contrôle ET nettoyage, mêmes règles au launcher (avant l'envoi), au serveur
|
||||
/// (refus de tout fichier non conforme) et dans le menu d'administration (avant l'affichage).
|
||||
/// • <b>PNG ou JPEG uniquement</b>, reconnus à leur contenu, jamais à l'extension. Le launcher convertit les autres
|
||||
/// formats courants (BMP, GIF, WebP) en PNG avant l'envoi ;
|
||||
/// • <b>vraie image</b> : structure complète vérifiée. PNG : blocs et CRC, en-tête cohérent, palette si nécessaire,
|
||||
/// données décompressées de la taille exacte de l'image, filtres de lignes valides. JPEG : segments, tables,
|
||||
/// une trame baseline ou progressive, scans et fin d'image ;
|
||||
/// • dimensions bornées (<see cref="MaxSide"/>, <see cref="MaxPixels"/>), taille bornée (<see cref="MaxBytes"/>),
|
||||
/// aucune donnée après la fin de l'image, décompression interrompue au-delà de la taille attendue (bombe zlib) ;
|
||||
/// • <b>métadonnées retirées</b> : PNG : blocs texte, eXIf, date… (seuls IHDR, PLTE, tRNS, IDAT, IEND sont
|
||||
/// gardés) ; JPEG : EXIF (position GPS, appareil…), XMP, commentaires… (seuls JFIF et Adobe sont gardés).
|
||||
/// </summary>
|
||||
public static class FeedbackImages
|
||||
{
|
||||
public const int MaxAttachments = 3;
|
||||
public const int MaxBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Corps d'une requête d'envoi d'une capture (image + marge) ; voir nginx-api.conf.</summary>
|
||||
public const int MaxRequestBytes = MaxBytes + 16 * 1024;
|
||||
|
||||
public const int MinSide = 16;
|
||||
public const int MaxSide = 4096;
|
||||
public const long MaxPixels = 4096L * 2304;
|
||||
|
||||
/// <summary>Le launcher réduit les captures plus grandes à cette taille (côté le plus long) avant l'envoi.</summary>
|
||||
public const int TargetMaxSide = 3840;
|
||||
|
||||
public const string Png = "png";
|
||||
public const string Jpeg = "jpg";
|
||||
|
||||
/// <summary>Formats acceptés en entrée par le launcher (convertis en PNG/JPEG avant l'envoi).</summary>
|
||||
public static readonly IReadOnlyList<string> InputExtensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
|
||||
|
||||
public sealed record ImageInfo(string Format, int Width, int Height);
|
||||
|
||||
/// <summary>Format reconnu au contenu parmi les formats d'entrée du launcher (null : autre chose).</summary>
|
||||
public static string? DetectInputFormat(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.StartsWith(PngSignature)) return Png;
|
||||
if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) return Jpeg;
|
||||
if (data.Length >= 6 && (data[..6].SequenceEqual("GIF87a"u8) || data[..6].SequenceEqual("GIF89a"u8))) return "gif";
|
||||
if (data.Length >= 14 && data[0] == (byte)'B' && data[1] == (byte)'M') return "bmp";
|
||||
if (data.Length >= 12 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) return "webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet et nettoyage. Null si l'image est acceptée (<paramref name="clean"/> : l'image sans
|
||||
/// métadonnées, à enregistrer ou afficher à la place de l'original), sinon la raison du refus (français).
|
||||
/// </summary>
|
||||
public static string? Sanitize(ReadOnlySpan<byte> data, out ImageInfo? info, out byte[]? clean)
|
||||
{
|
||||
info = null;
|
||||
clean = null;
|
||||
if (data.Length == 0) return "Fichier vide.";
|
||||
if (data.Length > MaxBytes) return $"Image trop volumineuse : {MaxBytes / 1024 / 1024} Mo au plus.";
|
||||
string? error;
|
||||
if (data.StartsWith(PngSignature)) (info, clean, error) = SanitizePng(data);
|
||||
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) (info, clean, error) = SanitizeJpeg(data);
|
||||
else return "Ce fichier n'est pas une image PNG ou JPEG.";
|
||||
if (error is not null)
|
||||
{
|
||||
info = null;
|
||||
clean = null;
|
||||
return error;
|
||||
}
|
||||
if (info!.Width < MinSide || info.Height < MinSide || info.Width > MaxSide || info.Height > MaxSide ||
|
||||
(long)info.Width * info.Height > MaxPixels)
|
||||
{
|
||||
var refused = info;
|
||||
info = null;
|
||||
clean = null;
|
||||
return $"Dimensions refusées : {refused.Width} × {refused.Height} px ({MinSide} à {MaxSide} px de côté).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ PNG
|
||||
|
||||
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
private static (ImageInfo?, byte[]?, string?) SanitizePng(ReadOnlySpan<byte> data)
|
||||
{
|
||||
const string invalid = "Image PNG invalide ou incomplète.";
|
||||
var output = new MemoryStream(data.Length);
|
||||
output.Write(PngSignature);
|
||||
var idat = new MemoryStream();
|
||||
int width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
|
||||
bool header = false, palette = false, ended = false, idatDone = false, inIdat = false;
|
||||
var pos = 8;
|
||||
while (pos < data.Length)
|
||||
{
|
||||
if (ended) return (null, null, "Image PNG suivie de données étrangères à l'image.");
|
||||
if (pos + 12 > data.Length) return (null, null, invalid);
|
||||
var length = BinaryPrimitives.ReadUInt32BigEndian(data[pos..]);
|
||||
if (length > int.MaxValue || pos + 12 + (long)length > data.Length) return (null, null, invalid);
|
||||
var type = data.Slice(pos + 4, 4);
|
||||
foreach (var b in type)
|
||||
if (b is not ((>= (byte)'A' and <= (byte)'Z') or (>= (byte)'a' and <= (byte)'z'))) return (null, null, invalid);
|
||||
var body = data.Slice(pos + 8, (int)length);
|
||||
var crc = BinaryPrimitives.ReadUInt32BigEndian(data[(pos + 8 + (int)length)..]);
|
||||
if (Crc32(type, body) != crc) return (null, null, "Image PNG endommagée (somme de contrôle incorrecte).");
|
||||
var chunk = data.Slice(pos, 12 + (int)length);
|
||||
pos += 12 + (int)length;
|
||||
|
||||
var name = System.Text.Encoding.ASCII.GetString(type);
|
||||
if (!header && name != "IHDR") return (null, null, invalid);
|
||||
if (inIdat && name != "IDAT") idatDone = true;
|
||||
inIdat = false;
|
||||
switch (name)
|
||||
{
|
||||
case "IHDR":
|
||||
if (header || length != 13) return (null, null, invalid);
|
||||
header = true;
|
||||
var w = BinaryPrimitives.ReadUInt32BigEndian(body);
|
||||
var h = BinaryPrimitives.ReadUInt32BigEndian(body[4..]);
|
||||
if (w is 0 or > MaxSide || h is 0 or > MaxSide)
|
||||
return (null, null, $"Dimensions refusées ({MinSide} à {MaxSide} px de côté).");
|
||||
(width, height, depth, colorType, interlace) = ((int)w, (int)h, body[8], body[9], body[12]);
|
||||
var validDepth = colorType switch
|
||||
{
|
||||
0 => depth is 1 or 2 or 4 or 8 or 16,
|
||||
3 => depth is 1 or 2 or 4 or 8,
|
||||
2 or 4 or 6 => depth is 8 or 16,
|
||||
_ => false
|
||||
};
|
||||
if (!validDepth || body[10] != 0 || body[11] != 0 || interlace > 1) return (null, null, invalid);
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "PLTE":
|
||||
if (palette || idat.Length > 0 || length == 0 || length % 3 != 0 || length > 768 || colorType is 0 or 4)
|
||||
return (null, null, invalid);
|
||||
palette = true;
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "tRNS":
|
||||
if (idat.Length > 0) return (null, null, invalid);
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "IDAT":
|
||||
if (idatDone) return (null, null, invalid); // blocs IDAT non consécutifs
|
||||
inIdat = true;
|
||||
idat.Write(body);
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "IEND":
|
||||
if (length != 0 || idat.Length == 0) return (null, null, invalid);
|
||||
ended = true;
|
||||
output.Write(chunk);
|
||||
break;
|
||||
default:
|
||||
// Bloc critique inconnu (majuscule) : image non standard, refusée. Bloc auxiliaire (texte, eXIf,
|
||||
// date, profil…) : retiré.
|
||||
if (char.IsUpper(name[0])) return (null, null, "Image PNG non standard.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ended) return (null, null, "Image PNG incomplète.");
|
||||
if (colorType == 3 && !palette) return (null, null, invalid);
|
||||
|
||||
var channels = colorType switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, _ => 4 };
|
||||
var bitsPerPixel = channels * depth;
|
||||
if (!CheckPixels(idat.ToArray(), width, height, bitsPerPixel, interlace == 1))
|
||||
return (null, null, "Image PNG endommagée (données de l'image incorrectes).");
|
||||
return (new ImageInfo(Png, width, height), output.ToArray(), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Décompresse les données (sans dépasser la taille attendue + 1 octet) et vérifie qu'elles ont exactement la taille
|
||||
/// de l'image, ligne par ligne, avec un type de filtre valide (0 à 4) au début de chaque ligne.
|
||||
/// </summary>
|
||||
private static bool CheckPixels(byte[] compressed, int width, int height, int bitsPerPixel, bool interlaced)
|
||||
{
|
||||
var rows = new List<long>(); // octets par ligne (filtre compris), dans l'ordre
|
||||
if (!interlaced) AddRows(rows, width, height, bitsPerPixel);
|
||||
else
|
||||
{
|
||||
int[] x0 = { 0, 4, 0, 2, 0, 1, 0 }, y0 = { 0, 0, 4, 0, 2, 0, 1 }, dx = { 8, 8, 4, 4, 2, 2, 1 }, dy = { 8, 8, 8, 4, 4, 2, 2 };
|
||||
for (var p = 0; p < 7; p++)
|
||||
{
|
||||
var pw = width > x0[p] ? (width - x0[p] + dx[p] - 1) / dx[p] : 0;
|
||||
var ph = height > y0[p] ? (height - y0[p] + dy[p] - 1) / dy[p] : 0;
|
||||
if (pw > 0 && ph > 0) AddRows(rows, pw, ph, bitsPerPixel);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var zlib = new ZLibStream(new MemoryStream(compressed, writable: false), CompressionMode.Decompress);
|
||||
var buffer = new byte[64 * 1024];
|
||||
var row = 0;
|
||||
long left = rows.Count > 0 ? rows[0] : 0; // octets restants dans la ligne en cours
|
||||
int read;
|
||||
while ((read = zlib.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
for (var i = 0; i < read;)
|
||||
{
|
||||
if (row >= rows.Count) return false; // plus de données que l'image
|
||||
if (left == rows[row] && buffer[i] > 4) return false; // type de filtre de la ligne
|
||||
var take = (int)Math.Min(left, read - i);
|
||||
i += take;
|
||||
left -= take;
|
||||
if (left == 0 && ++row < rows.Count) left = rows[row];
|
||||
}
|
||||
}
|
||||
return row == rows.Count;
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRows(List<long> rows, int width, int height, int bitsPerPixel)
|
||||
{
|
||||
var bytesPerRow = 1 + ((long)width * bitsPerPixel + 7) / 8;
|
||||
for (var y = 0; y < height; y++) rows.Add(bytesPerRow);
|
||||
}
|
||||
|
||||
private static readonly uint[] CrcTable = BuildCrcTable();
|
||||
|
||||
private static uint[] BuildCrcTable()
|
||||
{
|
||||
var table = new uint[256];
|
||||
for (uint n = 0; n < 256; n++)
|
||||
{
|
||||
var c = n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var c = 0xFFFFFFFFu;
|
||||
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
return c ^ 0xFFFFFFFFu;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ JPEG
|
||||
|
||||
private static (ImageInfo?, byte[]?, string?) SanitizeJpeg(ReadOnlySpan<byte> data)
|
||||
{
|
||||
const string invalid = "Image JPEG invalide ou incomplète.";
|
||||
var output = new MemoryStream(data.Length);
|
||||
output.Write(data[..2]); // SOI
|
||||
int width = 0, height = 0, scans = 0;
|
||||
bool frame = false, tables = false, ended = false;
|
||||
var pos = 2;
|
||||
while (pos < data.Length)
|
||||
{
|
||||
if (ended) return (null, null, "Image JPEG suivie de données étrangères à l'image.");
|
||||
if (pos + 2 > data.Length || data[pos] != 0xFF) return (null, null, invalid);
|
||||
var marker = data[pos + 1];
|
||||
if (marker == 0xFF)
|
||||
{
|
||||
pos++; // octet de remplissage
|
||||
continue;
|
||||
}
|
||||
if (marker == 0xD9)
|
||||
{
|
||||
if (!frame || scans == 0) return (null, null, invalid);
|
||||
output.Write(data.Slice(pos, 2));
|
||||
pos += 2;
|
||||
ended = true;
|
||||
continue;
|
||||
}
|
||||
if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) return (null, null, invalid);
|
||||
if (pos + 4 > data.Length) return (null, null, invalid);
|
||||
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]);
|
||||
if (length < 2 || pos + 2 + length > data.Length) return (null, null, invalid);
|
||||
var segment = data.Slice(pos, 2 + length);
|
||||
var body = data.Slice(pos + 4, length - 2);
|
||||
pos += 2 + length;
|
||||
|
||||
switch (marker)
|
||||
{
|
||||
case 0xE0: // APP0 (JFIF) : gardé
|
||||
case 0xEE: // APP14 (Adobe : transformation des couleurs) : gardé
|
||||
output.Write(segment);
|
||||
break;
|
||||
case >= 0xE1 and <= 0xEF: // APP1 à APP15 : EXIF (position, appareil), XMP, profils… : retirés
|
||||
case 0xFE: // commentaire : retiré
|
||||
break;
|
||||
case 0xDB: // tables de quantification
|
||||
case 0xC4: // tables de Huffman
|
||||
case 0xDD: // intervalle de resynchronisation
|
||||
if (marker != 0xDD) tables = true;
|
||||
output.Write(segment);
|
||||
break;
|
||||
case 0xC0 or 0xC1 or 0xC2: // trame baseline, étendue ou progressive (Huffman)
|
||||
if (frame || body.Length < 6) return (null, null, invalid);
|
||||
frame = true;
|
||||
if (body[0] != 8) return (null, null, "Image JPEG non prise en charge (précision).");
|
||||
height = BinaryPrimitives.ReadUInt16BigEndian(body[1..]);
|
||||
width = BinaryPrimitives.ReadUInt16BigEndian(body[3..]);
|
||||
var components = body[5];
|
||||
if (width == 0 || height == 0 || components is not (1 or 3) || body.Length != 6 + 3 * components)
|
||||
return (null, null, invalid);
|
||||
output.Write(segment);
|
||||
break;
|
||||
case 0xDA: // début d'un scan : en-tête, puis données compressées jusqu'au marqueur suivant
|
||||
if (!frame || !tables || body.Length < 6) return (null, null, invalid);
|
||||
scans++;
|
||||
output.Write(segment);
|
||||
var start = pos;
|
||||
while (true)
|
||||
{
|
||||
if (pos + 1 >= data.Length) return (null, null, "Image JPEG incomplète.");
|
||||
if (data[pos] == 0xFF && data[pos + 1] != 0x00 && data[pos + 1] is not (>= 0xD0 and <= 0xD7) && data[pos + 1] != 0xFF)
|
||||
break;
|
||||
pos++;
|
||||
}
|
||||
if (pos == start) return (null, null, invalid); // scan vide
|
||||
output.Write(data[start..pos]);
|
||||
break;
|
||||
default:
|
||||
// Autres trames (sans perte, arithmétiques, hiérarchiques), marqueurs réservés : refusés.
|
||||
return (null, null, "Image JPEG non prise en charge.");
|
||||
}
|
||||
}
|
||||
if (!ended) return (null, null, "Image JPEG incomplète.");
|
||||
return (new ImageInfo(Jpeg, width, height), output.ToArray(), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Repère les liens et adresses dans le texte d'une demande (envoyé par un inconnu) pour avertir l'administrateur
|
||||
/// qui la lit : un lien peut mener à un site d'hameçonnage ou à un fichier malveillant. Rien n'est jamais rendu
|
||||
/// cliquable ; l'avertissement montre les liens « désamorcés » (<see cref="Defang"/>).
|
||||
///
|
||||
/// Formes reconnues, y compris déguisées (« hxxp », « http[:]// », « [.] », « (dot) », caractères pleine chasse) :
|
||||
/// • adresses avec schéma : http://, https://, ftp://, file://, ws://… et toute forme « schéma:// » ;
|
||||
/// • schémas dangereux sans « // » : javascript:, data:, vbscript:, file:, mailto:, ms-…:, search-ms:, steam:… ;
|
||||
/// • « www. » suivi d'un nom ;
|
||||
/// • noms de domaine nus avec une extension courante (« exemple.com/page », « bit.ly/x », « discord.gg/… ») ;
|
||||
/// • adresses IP (avec port ou chemin éventuels) ; chemins réseau Windows (\\serveur\partage) ;
|
||||
/// • adresses e-mail.
|
||||
/// </summary>
|
||||
public static partial class FeedbackLinks
|
||||
{
|
||||
/// <summary>Liens rapportés au plus (le nombre total reste exact).</summary>
|
||||
public const int MaxReported = 10;
|
||||
|
||||
// « . » collé, ou déguisé : « [.] », « (.) », « {.} », « [dot] », « (dot) » (espaces permis autour), « dot » entre espaces.
|
||||
private const string Dot = @"(?:\.|\s*(?:\[\.\]|\(\.\)|\{\.\}|\[dot\]|\(dot\))\s*|\s+dot\s+)";
|
||||
|
||||
private const string Tlds =
|
||||
"com|net|org|info|biz|xyz|top|site|online|app|dev|io|gg|co|me|tv|cc|ly|to|ai|gl|link|click|live|shop|store|" +
|
||||
"page|zip|mov|pw|tk|ml|ga|cf|gq|su|ru|cn|fr|be|ch|ca|de|uk|eu|us|es|it|nl|pl|pt|br|in|jp|kr|au|io|lu|re|tech|" +
|
||||
"space|website|club|fun|icu|win|bid|work|ink|lol|zone|cloud|download|stream|games|game|social|chat|email";
|
||||
|
||||
[GeneratedRegex(@"\b[a-z][a-z0-9+.\-]{0,20}\s*(?::|\[:\])\s*(?:/|\\){2}\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SchemeUrl();
|
||||
|
||||
[GeneratedRegex(@"\b(?:javascript|vbscript|data|file|mailto|steam|discord|tel|sms|skype|magnet|ms-[a-z\-]+|search-ms|shell)\s*:\s*\S+",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex DangerousScheme();
|
||||
|
||||
[GeneratedRegex(@"\bh(?:xx|\*\*|XX)ps?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex DisguisedHttp();
|
||||
|
||||
[GeneratedRegex(@"\bwww\d{0,3}\s*" + Dot + @"\s*[a-z0-9\-]+\S*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Www();
|
||||
|
||||
[GeneratedRegex(@"\b[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?(?:" + Dot + @"[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*" + Dot +
|
||||
"(?:" + Tlds + @")\b(?:[/:?#]\S*)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex BareDomain();
|
||||
|
||||
[GeneratedRegex(@"\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:" + Dot + @"(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b(?::\d{1,5})?(?:/\S*)?",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex IpAddress();
|
||||
|
||||
[GeneratedRegex(@"\\\\[a-z0-9.\-_$]+\\\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex UncPath();
|
||||
|
||||
[GeneratedRegex(@"\b[a-z0-9._%+\-]+\s*(?:@|\[at\]|\(at\))\s*[a-z0-9\-]+(?:" + Dot + @"[a-z0-9\-]+)+\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Email();
|
||||
|
||||
/// <summary>Le texte contient-il au moins un lien ou une adresse ?</summary>
|
||||
public static bool Contains(string? text) => Find(text).Count > 0;
|
||||
|
||||
/// <summary>Liens et adresses trouvés (sans doublon, dans l'ordre d'apparition, <see cref="MaxReported"/> au plus).</summary>
|
||||
public static IReadOnlyList<string> Find(string? text) => Find(text, out _);
|
||||
|
||||
public static IReadOnlyList<string> Find(string? text, out int total)
|
||||
{
|
||||
total = 0;
|
||||
if (string.IsNullOrWhiteSpace(text)) return Array.Empty<string>();
|
||||
var normalized = Normalize(text);
|
||||
var found = new List<(int Start, int End, string Value)>();
|
||||
|
||||
void Collect(Regex regex)
|
||||
{
|
||||
foreach (Match match in regex.Matches(normalized))
|
||||
{
|
||||
var value = TrimEnd(match.Value);
|
||||
if (value.Length == 0) continue;
|
||||
var end = match.Index + value.Length;
|
||||
if (found.Any(f => match.Index < f.End && end > f.Start)) continue; // déjà couvert par un motif plus large
|
||||
found.Add((match.Index, end, value));
|
||||
}
|
||||
}
|
||||
|
||||
Collect(SchemeUrl());
|
||||
Collect(DangerousScheme());
|
||||
Collect(UncPath());
|
||||
Collect(Email());
|
||||
Collect(Www());
|
||||
Collect(IpAddress());
|
||||
Collect(BareDomain());
|
||||
Collect(DisguisedHttp());
|
||||
|
||||
var distinct = found.OrderBy(f => f.Start).Select(f => f.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
total = distinct.Count;
|
||||
return distinct.Take(MaxReported).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forme « désamorcée » à afficher (« hxxps[://]exemple[.]com ») : ni cliquable, ni ouverte par un copier-coller
|
||||
/// distrait dans la barre d'adresse.
|
||||
/// </summary>
|
||||
public static string Defang(string link)
|
||||
{
|
||||
var text = link.Length > 120 ? link[..117] + "…" : link;
|
||||
text = Regex.Replace(text, @"(?i)\bhttp", "hxxp");
|
||||
text = Regex.Replace(text, @"(?i)\bftp", "fxp");
|
||||
return text.Replace("://", "[://]").Replace(".", "[.]").Replace("@", "[@]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caractères pleine chasse (« https:// ») ramenés à leur équivalent ASCII, et « ⁄ ∕ / » à « / » : les
|
||||
/// déguisements les plus courants ne passent pas inaperçus. (Pas de normalisation Unicode complète : indisponible
|
||||
/// sur le serveur en mode « culture invariante » ; les caractères invisibles sont déjà refusés à l'envoi.)
|
||||
/// </summary>
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
var sb = new StringBuilder(text.Length);
|
||||
foreach (var c in text)
|
||||
sb.Append(c switch
|
||||
{
|
||||
>= '!' and <= '~' => (char)(c - 0xFEE0),
|
||||
'。' or '。' or '․' => '.', // points idéographique, demi-chasse, « point de conduite »
|
||||
'⁄' or '∕' => '/',
|
||||
_ => c
|
||||
});
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Ponctuation de fin de phrase retirée ; une parenthèse ou un crochet fermant seulement s'il n'est pas ouvert dans le lien.</summary>
|
||||
private static string TrimEnd(string value)
|
||||
{
|
||||
while (value.Length > 0)
|
||||
{
|
||||
var last = value[^1];
|
||||
var trim = last switch
|
||||
{
|
||||
'.' or ',' or ';' or ':' or '!' or '?' or '»' or '"' or '\'' or '>' or '…' => true,
|
||||
')' => value.Count(c => c == '(') < value.Count(c => c == ')'),
|
||||
']' => value.Count(c => c == '[') < value.Count(c => c == ']'),
|
||||
'}' => value.Count(c => c == '{') < value.Count(c => c == '}'),
|
||||
_ => false
|
||||
};
|
||||
if (!trim) break;
|
||||
value = value[..^1];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -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,318 @@
|
||||
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>Valeurs d'une case oui/non.</summary>
|
||||
public const string Yes = "yes";
|
||||
public const string No = "no";
|
||||
|
||||
/// <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 avec les formulaires d'origine (révision 0).</summary>
|
||||
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors) =>
|
||||
Validate(submission, FeedbackForms.Find(submission?.Kind), errors);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'une demande avec le formulaire de son type (celui de la révision annoncée). 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, FeedbackForm? form, 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.");
|
||||
if (submission.FormRevision < 0) errors.Add("Révision des formulaires invalide.");
|
||||
if (form is null || !string.Equals(form.Kind, submission.Kind, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add("Type de demande inconnu.");
|
||||
return null;
|
||||
}
|
||||
if (!form.AllowAttachments && submission.AttachmentCount > 0)
|
||||
errors.Add("Les captures d'écran ne sont pas acceptées pour ce type de demande.");
|
||||
|
||||
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).");
|
||||
|
||||
if (submission.AttachmentCount is < 0 or > FeedbackImages.MaxAttachments)
|
||||
errors.Add($"Captures d'écran : {FeedbackImages.MaxAttachments} 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.Length <= 2 && raw.All(char.IsAsciiDigit) && raw[0] != '0' &&
|
||||
int.Parse(raw, CultureInfo.InvariantCulture) <= field.RatingMax
|
||||
: field.Choices.Any(o => string.Equals(o.Id, raw, StringComparison.Ordinal));
|
||||
if (!valid)
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
|
||||
case FeedbackFieldKind.Checkboxes:
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : cochez au moins un choix.");
|
||||
return null;
|
||||
}
|
||||
var ids = raw.Split(',');
|
||||
if (ids.Length > field.Choices.Count || ids.Distinct(StringComparer.Ordinal).Count() != ids.Length ||
|
||||
ids.Any(id => !field.Choices.Any(o => string.Equals(o.Id, id, StringComparison.Ordinal))))
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
// Ordre des options du formulaire : une même sélection donne toujours la même valeur.
|
||||
return string.Join(',', field.Choices.Select(o => o.Id).Where(id => ids.Contains(id, StringComparer.Ordinal)));
|
||||
|
||||
case FeedbackFieldKind.YesNo:
|
||||
if (string.IsNullOrEmpty(raw)) raw = No;
|
||||
if (raw is not (Yes or No))
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
if (field.Required && raw != Yes) errors.Add($"{field.Label} : case à cocher obligatoire.");
|
||||
return raw;
|
||||
|
||||
case FeedbackFieldKind.Text:
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : champ obligatoire.");
|
||||
return null;
|
||||
}
|
||||
var line = ApiText.CleanLine(raw, field.MaxLength);
|
||||
if (line is null)
|
||||
{
|
||||
errors.Add($"{field.Label} : {field.MaxLength} caractères au plus, sur une ligne, sans caractère spécial invisible.");
|
||||
return null;
|
||||
}
|
||||
return line;
|
||||
|
||||
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,57 @@ 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";
|
||||
|
||||
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
|
||||
public const string FeedbackForms = "feedback.forms";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms
|
||||
};
|
||||
|
||||
/// <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)",
|
||||
FeedbackForms => "Formulaires des demandes",
|
||||
_ => 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).",
|
||||
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
|
||||
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
|
||||
_ => 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, AdminPermissions.FeedbackForms
|
||||
},
|
||||
[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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,21 @@ public static class ContentKinds
|
||||
{
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
public const string FeedbackForms = "feedback-forms";
|
||||
|
||||
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
|
||||
public const string CatalogId = "catalog";
|
||||
|
||||
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
|
||||
public const string FeedbackFormsId = "feedback-forms";
|
||||
|
||||
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
|
||||
public static string ScopeOf(string kind) => kind == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
|
||||
public static string ScopeOf(string kind) => kind switch
|
||||
{
|
||||
Manifest => SigningScopes.Releases,
|
||||
FeedbackForms => SigningScopes.Forms,
|
||||
_ => SigningScopes.Catalog
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Autorisations d'une clé du trousseau.</summary>
|
||||
@@ -45,7 +54,17 @@ public static class SigningScopes
|
||||
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog };
|
||||
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
|
||||
public const string Forms = "forms";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms };
|
||||
|
||||
/// <summary>
|
||||
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
|
||||
/// acceptée dans le trousseau mais n'autorise rien ici (<see cref="ContentSigning.Verify"/> exige l'autorisation exacte).
|
||||
/// </summary>
|
||||
public static bool IsWellFormed(string? scope) =>
|
||||
scope is { Length: >= 1 and <= 32 } && scope[0] is >= 'a' and <= 'z' && scope.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
|
||||
|
||||
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
|
||||
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
|
||||
@@ -54,6 +73,7 @@ public static class SigningScopes
|
||||
var scopes = new List<string>();
|
||||
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
|
||||
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
|
||||
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
@@ -61,6 +81,7 @@ public static class SigningScopes
|
||||
{
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
Forms => "formulaires des demandes",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
@@ -194,7 +215,12 @@ public static class ContentSigning
|
||||
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
|
||||
|
||||
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
|
||||
public static string SignedId(string kind, string id) => kind == ContentKinds.Catalog ? ContentKinds.CatalogId : id.ToLowerInvariant();
|
||||
public static string SignedId(string kind, string id) => kind switch
|
||||
{
|
||||
ContentKinds.Catalog => ContentKinds.CatalogId,
|
||||
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
|
||||
_ => id.ToLowerInvariant()
|
||||
};
|
||||
|
||||
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
|
||||
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
|
||||
|
||||
@@ -127,8 +127,9 @@ public static class Keyring
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
|
||||
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
|
||||
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.All.Contains(s, StringComparer.Ordinal)))
|
||||
errors.Add($"Clé {label} : autorisations absentes ou inconnues.");
|
||||
// Autorisation inconnue mais bien formée (ajoutée par une version plus récente) : acceptée, sans effet ici.
|
||||
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.IsWellFormed(s)))
|
||||
errors.Add($"Clé {label} : autorisations absentes ou invalides.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ public sealed record SigningState
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
|
||||
/// <summary>Formulaires des demandes (null : publication des formulaires non configurée, ou serveur antérieur à la 3.2.1).</summary>
|
||||
public FeedbackFormsSignatureState? FeedbackForms { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
@@ -64,3 +67,19 @@ public sealed record CatalogSignatureState
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature des formulaires des demandes en vigueur (feedback-forms.json).</summary>
|
||||
public sealed record FeedbackFormsSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : formulaires d'origine, intégrés au launcher, jamais publiés).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : formulaires d'origine, ou fichier illisible).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
|
||||
/// <summary>Vrai : le fichier de référence des formulaires est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -190,6 +191,37 @@ public sealed class ContentTrustService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie les formulaires des demandes téléchargés (feedback-forms.json) : clé du trousseau autorisée « forms »,
|
||||
/// signature valide, pas plus anciens que les derniers acceptés. Faux : à ignorer (formulaires précédents).
|
||||
/// </summary>
|
||||
public async Task<bool> VerifyFeedbackFormsAsync(FeedbackFormsDocument forms, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
try
|
||||
{
|
||||
await VerifyAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms), forms.Signature,
|
||||
$"Formulaires des demandes (révision {forms.Revision})", ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
|
||||
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Formulaires des demandes en cache (révision {forms.Revision})";
|
||||
var keyring = LoadState().Keyring;
|
||||
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
|
||||
var check = ContentSigning.Verify(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms),
|
||||
forms.Signature, keyring);
|
||||
return check.Valid || Report(what, check.Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
|
||||
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
|
||||
|
||||
@@ -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,10 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
new("catalog.", "Informations des jeux"),
|
||||
new("changelog.", "Notes de version"),
|
||||
new("release.", "Publications"),
|
||||
new("files.", "Fichiers du serveur"),
|
||||
new("keyring.", "Signature (trousseau)"),
|
||||
new("feedback.", "Demandes des joueurs"),
|
||||
new("audit.", "Journal d'audit (consultations, exports, purges)"),
|
||||
new("security", "Refus de sécurité")
|
||||
};
|
||||
Results = new List<Choice>
|
||||
@@ -53,9 +67,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 +88,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 +267,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 +277,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 +303,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 +322,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 +349,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 +375,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 +430,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 +452,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,19 +478,39 @@ 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",
|
||||
"release.sign" => "Version en ligne signée",
|
||||
"keyring.publish" => "Trousseau de signature publié",
|
||||
"feedback.submit" => "Demande de joueur reçue",
|
||||
"feedback.attachment" => "Capture d'écran reçue",
|
||||
"feedback.view" => "Demande de joueur consultée",
|
||||
"feedback.update" => "Demande de joueur traitée (statut ou note)",
|
||||
"feedback.delete" => "Demande de joueur supprimée",
|
||||
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
|
||||
"feedback.forms" => "Formulaires des demandes publiés",
|
||||
"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",
|
||||
"audit.purge" => "Anciens mois du journal supprimés (conservation)",
|
||||
_ => action
|
||||
};
|
||||
|
||||
@@ -291,11 +522,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" or "audit.purge",
|
||||
_ => action.StartsWith(category, StringComparison.Ordinal)
|
||||
};
|
||||
|
||||
@@ -308,14 +543,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,36 @@
|
||||
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), et page « Formulaires des demandes »
|
||||
/// (feedback.forms : types de demande et champs proposés aux joueurs, publication signée). 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)));
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
FeedbackFormsPanelViewModel.PanelId,
|
||||
"Formulaires des demandes",
|
||||
"Les quatre types de demande du bouton « Aide » et leurs champs, communs à tous les jeux : modifier, masquer, publier (signé).",
|
||||
"TextBoxOutline",
|
||||
AdminPermissions.FeedbackForms,
|
||||
Order: 16,
|
||||
context => new FeedbackFormsPanelViewModel(context)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>Type de champ proposé par « Ajouter un champ ».</summary>
|
||||
public sealed class FeedbackFieldKindChoice
|
||||
{
|
||||
public FeedbackFieldKindChoice(FeedbackFieldKind kind, Action<FeedbackFieldKind> add)
|
||||
{
|
||||
Kind = kind;
|
||||
Label = FeedbackFieldEditor.KindLabel(kind);
|
||||
IconKey = FeedbackFieldEditor.KindIcon(kind);
|
||||
AddCommand = new RelayCommand(() => add(kind));
|
||||
}
|
||||
|
||||
public FeedbackFieldKind Kind { get; }
|
||||
public string Label { get; }
|
||||
public string IconKey { get; }
|
||||
public RelayCommand AddCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Choix d'une liste déroulante ou d'une case à cocher : identifiant fixe (clé des valeurs envoyées), libellé modifiable.</summary>
|
||||
public sealed class FeedbackOptionEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private string _label;
|
||||
|
||||
public FeedbackOptionEditor(string id, string label, Action changed, Action<FeedbackOptionEditor, int> move, Action<FeedbackOptionEditor> remove)
|
||||
{
|
||||
Id = id;
|
||||
_label = label;
|
||||
_changed = changed;
|
||||
MoveUpCommand = new RelayCommand(() => move(this, -1));
|
||||
MoveDownCommand = new RelayCommand(() => move(this, 1));
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _label, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
public FeedbackOption Build() => new(Id, ApiText.CleanLine(_label, FeedbackFormRules.MaxOptionLabelLength) ?? _label);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Champ d'un formulaire en cours de modification. Son identifiant (clé des valeurs envoyées par les joueurs) et son
|
||||
/// type sont fixés à la création : changer de type revient à retirer le champ et à en ajouter un autre, pour que les
|
||||
/// demandes déjà reçues restent lisibles.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFieldEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private string _label;
|
||||
private string _hint;
|
||||
private bool _required;
|
||||
private int _maxLength;
|
||||
private Choice _ratingMax;
|
||||
private bool _showInList;
|
||||
private bool _isExpanded;
|
||||
private int _optionCounter;
|
||||
|
||||
public FeedbackFieldEditor(FeedbackField field, bool isNew, Action changed, Action<FeedbackFieldEditor, int> move,
|
||||
Action<FeedbackFieldEditor> remove)
|
||||
{
|
||||
Id = field.Id;
|
||||
Kind = field.Kind;
|
||||
IsNew = isNew;
|
||||
_changed = changed;
|
||||
_label = field.Label;
|
||||
_hint = field.Hint ?? "";
|
||||
_required = field.Required;
|
||||
_maxLength = field.MaxLength;
|
||||
RatingSizes = FeedbackFormRules.RatingSizes.Select(n => new Choice(n.ToString(System.Globalization.CultureInfo.InvariantCulture), $"{n} étoiles")).ToList();
|
||||
_ratingMax = RatingSizes.FirstOrDefault(c => c.Value == field.RatingMax.ToString(System.Globalization.CultureInfo.InvariantCulture)) ?? RatingSizes[1];
|
||||
_showInList = field.ShowInList;
|
||||
_isExpanded = isNew;
|
||||
foreach (var option in field.Choices) Options.Add(NewOption(option.Id, option.Label));
|
||||
_optionCounter = field.Choices.Count;
|
||||
Options.CollectionChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
};
|
||||
|
||||
MoveUpCommand = new RelayCommand(() => move(this, -1));
|
||||
MoveDownCommand = new RelayCommand(() => move(this, 1));
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded);
|
||||
AddOptionCommand = new RelayCommand(AddOption, () => Options.Count < FeedbackFormRules.MaxOptions);
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public FeedbackFieldKind Kind { get; }
|
||||
|
||||
/// <summary>Champ ajouté depuis la dernière publication.</summary>
|
||||
public bool IsNew { get; }
|
||||
|
||||
public string KindText => KindLabel(Kind);
|
||||
public string KindIconKey => KindIcon(Kind);
|
||||
|
||||
public bool HasOptions => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes;
|
||||
public bool HasMaxLength => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
|
||||
public bool IsRating => Kind == FeedbackFieldKind.Rating;
|
||||
|
||||
/// <summary>Seuls une liste, une note ou une case oui/non peuvent servir d'aperçu dans la liste des demandes.</summary>
|
||||
public bool CanShowInList => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo;
|
||||
|
||||
public string RequiredText => Kind == FeedbackFieldKind.YesNo ? "Obligatoire (la case doit être cochée)" : "Obligatoire";
|
||||
|
||||
public string MaxLengthText => Kind == FeedbackFieldKind.Text
|
||||
? $"Longueur maximale (1 à {FeedbackFormRules.MaxTextLength})"
|
||||
: $"Longueur maximale ({FeedbackFormRules.MinMultilineLength} à {FeedbackRules.MaxMultilineLength})";
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _label, value ?? "")) return;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Titre de la ligne repliée (libellé, sinon « Champ sans libellé »).</summary>
|
||||
public string Title => string.IsNullOrWhiteSpace(_label) ? "Champ sans libellé" : _label;
|
||||
|
||||
public string Hint
|
||||
{
|
||||
get => _hint;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _hint, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Required
|
||||
{
|
||||
get => _required;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _required, value)) return;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public int MaxLength
|
||||
{
|
||||
get => _maxLength;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _maxLength, value)) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> RatingSizes { get; }
|
||||
|
||||
public Choice RatingMax
|
||||
{
|
||||
get => _ratingMax;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _ratingMax, value)) return;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowInList
|
||||
{
|
||||
get => _showInList;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showInList, value)) return;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsExpanded
|
||||
{
|
||||
get => _isExpanded;
|
||||
set => SetProperty(ref _isExpanded, value);
|
||||
}
|
||||
|
||||
/// <summary>Résumé de la ligne repliée : type, obligatoire, choix, aperçu.</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string> { KindText };
|
||||
if (_required) parts.Add("obligatoire");
|
||||
if (HasOptions) parts.Add($"{Options.Count} choix");
|
||||
if (IsRating) parts.Add(_ratingMax.Label);
|
||||
if (_showInList && CanShowInList) parts.Add("aperçu dans la liste");
|
||||
if (IsNew) parts.Add("nouveau");
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<FeedbackOptionEditor> Options { get; } = new();
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
public RelayCommand ToggleCommand { get; }
|
||||
public RelayCommand AddOptionCommand { get; }
|
||||
|
||||
/// <summary>Champ tel qu'il sera publié (espaces en trop retirés ; le reste est contrôlé par FeedbackFormRules).</summary>
|
||||
public FeedbackField Build() => new(
|
||||
Id,
|
||||
ApiText.CleanLine(_label, FeedbackFormRules.MaxFieldLabelLength) ?? _label,
|
||||
Kind,
|
||||
_required,
|
||||
string.IsNullOrWhiteSpace(_hint) ? null : FeedbackRules.CleanMultiline(_hint, FeedbackFormRules.MaxHintLength, out _) ?? _hint,
|
||||
HasMaxLength ? _maxLength : FeedbackRules.MaxMultilineLength,
|
||||
HasOptions ? Options.Select(o => o.Build()).ToList() : null,
|
||||
IsRating && int.TryParse(_ratingMax.Value, out var max) ? max : 5,
|
||||
_showInList && CanShowInList);
|
||||
|
||||
private void AddOption()
|
||||
{
|
||||
var id = NextOptionId();
|
||||
Options.Add(NewOption(id, $"Choix {Options.Count + 1}"));
|
||||
AddOptionCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private string NextOptionId()
|
||||
{
|
||||
string id;
|
||||
do id = $"choix_{++_optionCounter}";
|
||||
while (Options.Any(o => o.Id == id));
|
||||
return id;
|
||||
}
|
||||
|
||||
private FeedbackOptionEditor NewOption(string id, string label) => new(id, label, _changed, MoveOption, option =>
|
||||
{
|
||||
Options.Remove(option);
|
||||
AddOptionCommand.RaiseCanExecuteChanged();
|
||||
});
|
||||
|
||||
private void MoveOption(FeedbackOptionEditor option, int delta)
|
||||
{
|
||||
var index = Options.IndexOf(option);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= Options.Count) return;
|
||||
Options.Move(index, target);
|
||||
}
|
||||
|
||||
public static string KindLabel(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "Zone de texte",
|
||||
FeedbackFieldKind.Text => "Texte court",
|
||||
FeedbackFieldKind.Choice => "Liste déroulante",
|
||||
FeedbackFieldKind.Checkboxes => "Cases à cocher",
|
||||
FeedbackFieldKind.YesNo => "Case oui/non",
|
||||
FeedbackFieldKind.Rating => "Étoiles",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
|
||||
public static string KindIcon(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "TextBoxOutline",
|
||||
FeedbackFieldKind.Text => "Minus",
|
||||
FeedbackFieldKind.Choice => "ChevronRight",
|
||||
FeedbackFieldKind.Checkboxes => "CheckCircle",
|
||||
FeedbackFieldKind.YesNo => "Check",
|
||||
FeedbackFieldKind.Rating => "Star",
|
||||
_ => "Info"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Actions d'un type de demande qui touchent la liste (ordre, suppression) ou demandent un fichier (icône).</summary>
|
||||
public sealed record FeedbackFormEditorActions(
|
||||
Action<FeedbackFormEditor, int> Move,
|
||||
Action<FeedbackFormEditor> Remove,
|
||||
Action<FeedbackFormEditor> ImportIcon);
|
||||
|
||||
/// <summary>
|
||||
/// Type de demande en cours de modification (ajouté, supprimé, réordonné, masqué) : présentation (nom, description,
|
||||
/// icône d'origine ou importée, captures d'écran autorisées…) et champs.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private readonly HashSet<string> _publishedIds;
|
||||
private string _label;
|
||||
private string _description;
|
||||
private Choice _icon;
|
||||
private string _titleHint;
|
||||
private bool _techByDefault;
|
||||
private bool _hidden;
|
||||
private bool _allowAttachments;
|
||||
private string? _customIcon;
|
||||
private bool _isDirty;
|
||||
private int _fieldCounter;
|
||||
|
||||
/// <param name="isNew">Type ajouté depuis la dernière publication.</param>
|
||||
public FeedbackFormEditor(FeedbackForm form, Action changed, FeedbackFormEditorActions? actions = null, bool isNew = false)
|
||||
{
|
||||
Kind = form.Kind;
|
||||
IsNew = isNew;
|
||||
_changed = changed;
|
||||
_label = form.Label;
|
||||
_description = form.Description;
|
||||
Icons = FeedbackFormRules.Icons.Select(i => new Choice(i, FeedbackIcons.DefaultIconLabel(i))).ToList();
|
||||
_icon = Icons.FirstOrDefault(i => i.Value == form.IconKey) ?? Icons[^1];
|
||||
_titleHint = form.TitleHint;
|
||||
_techByDefault = form.TechByDefault;
|
||||
_hidden = form.Hidden;
|
||||
_allowAttachments = form.AllowAttachments;
|
||||
_customIcon = string.IsNullOrEmpty(form.CustomIcon) ? null : form.CustomIcon;
|
||||
MoveUpCommand = new RelayCommand(() => actions?.Move(this, -1));
|
||||
MoveDownCommand = new RelayCommand(() => actions?.Move(this, 1));
|
||||
RemoveCommand = new RelayCommand(() => actions?.Remove(this));
|
||||
ImportIconCommand = new RelayCommand(() => actions?.ImportIcon(this));
|
||||
RemoveIconCommand = new RelayCommand(() => SetCustomIcon(null), () => _customIcon is not null);
|
||||
_publishedIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var field in form.Fields) Fields.Add(NewField(field, isNew: false));
|
||||
Fields.CollectionChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(FieldsText));
|
||||
AddFieldsEnabled = Fields.Count < FeedbackFormRules.MaxFields;
|
||||
_changed();
|
||||
};
|
||||
FieldKinds = Enum.GetValues<FeedbackFieldKind>().Select(k => new FeedbackFieldKindChoice(k, AddField)).ToList();
|
||||
}
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
/// <summary>Type ajouté depuis la dernière publication.</summary>
|
||||
public bool IsNew { get; }
|
||||
|
||||
/// <summary>Repère fixe dans la liste, même renommé : nom d'origine, ou « Type ajouté ».</summary>
|
||||
public string OriginalLabel => FeedbackForms.Find(Kind)?.Label ?? (IsNew ? "Type ajouté" : $"Type « {Kind} »");
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
public RelayCommand ImportIconCommand { get; }
|
||||
public RelayCommand RemoveIconCommand { get; }
|
||||
|
||||
/// <summary>Icône importée (PNG 64 × 64 en base64, issu d'un fichier .ico) ; null : icône d'origine choisie.</summary>
|
||||
public string? CustomIcon => _customIcon;
|
||||
public byte[]? CustomIconBytes => FeedbackIcons.Decode(_customIcon);
|
||||
public bool HasCustomIcon => _customIcon is not null;
|
||||
|
||||
/// <summary>Icône d'origine utilisée (liste des quatre icônes active).</summary>
|
||||
public bool UsesDefaultIcon => _customIcon is null;
|
||||
|
||||
public void SetCustomIcon(string? base64)
|
||||
{
|
||||
if (_customIcon == base64) return;
|
||||
_customIcon = base64;
|
||||
foreach (var name in new[] { nameof(CustomIcon), nameof(CustomIconBytes), nameof(HasCustomIcon), nameof(UsesDefaultIcon) }) OnPropertyChanged(name);
|
||||
RemoveIconCommand.RaiseCanExecuteChanged();
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// <summary>Captures d'écran proposées aux joueurs pour ce type (3 au plus, contrôlées et nettoyées).</summary>
|
||||
public bool AllowAttachments
|
||||
{
|
||||
get => _allowAttachments;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _allowAttachments, value)) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _label, value ?? "")) return;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Title => string.IsNullOrWhiteSpace(_label) ? OriginalLabel : _label;
|
||||
|
||||
public string Description
|
||||
{
|
||||
get => _description;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _description, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> Icons { get; }
|
||||
|
||||
public Choice Icon
|
||||
{
|
||||
get => _icon;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _icon, value)) return;
|
||||
OnPropertyChanged(nameof(IconKey));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string IconKey => _icon.Value ?? "Forum";
|
||||
|
||||
public string TitleHint
|
||||
{
|
||||
get => _titleHint;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _titleHint, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TechByDefault
|
||||
{
|
||||
get => _techByDefault;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _techByDefault, value)) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Type masqué : plus proposé aux joueurs (les demandes déjà reçues restent consultables).</summary>
|
||||
public bool Hidden
|
||||
{
|
||||
get => _hidden;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _hidden, value)) return;
|
||||
OnPropertyChanged(nameof(StateText));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Différent des formulaires publiés (calculé par la page).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isDirty, value)) OnPropertyChanged(nameof(StateText));
|
||||
}
|
||||
}
|
||||
|
||||
public string StateText => (_hidden ? "Masqué" : "Proposé aux joueurs") + (IsNew ? " · nouveau" : _isDirty ? " · modifié" : "");
|
||||
|
||||
public string FieldsText => Fields.Count switch
|
||||
{
|
||||
0 => "Aucun champ (titre seulement)",
|
||||
1 => "1 champ",
|
||||
var n => $"{n} champs"
|
||||
};
|
||||
|
||||
public ObservableCollection<FeedbackFieldEditor> Fields { get; } = new();
|
||||
|
||||
/// <summary>Types proposés par « Ajouter un champ ».</summary>
|
||||
public IReadOnlyList<FeedbackFieldKindChoice> FieldKinds { get; }
|
||||
|
||||
private bool _addFieldsEnabled = true;
|
||||
|
||||
public bool AddFieldsEnabled
|
||||
{
|
||||
get => _addFieldsEnabled;
|
||||
private set => SetProperty(ref _addFieldsEnabled, value);
|
||||
}
|
||||
|
||||
/// <summary>Formulaire tel qu'il sera publié.</summary>
|
||||
public FeedbackForm Build() => new(
|
||||
Kind,
|
||||
ApiText.CleanLine(_label, FeedbackFormRules.MaxFormLabelLength) ?? _label,
|
||||
ApiText.CleanLine(_description, FeedbackFormRules.MaxDescriptionLength) ?? _description,
|
||||
IconKey,
|
||||
ApiText.CleanLine(_titleHint, FeedbackFormRules.MaxTitleHintLength) ?? _titleHint,
|
||||
_techByDefault,
|
||||
Fields.Select(f => f.Build()).ToList(),
|
||||
_hidden,
|
||||
_allowAttachments,
|
||||
_customIcon);
|
||||
|
||||
/// <summary>Nouveau champ (identifiant généré, jamais réutilisé dans ce type), déplié et placé à la fin.</summary>
|
||||
private void AddField(FeedbackFieldKind kind)
|
||||
{
|
||||
if (Fields.Count >= FeedbackFormRules.MaxFields) return;
|
||||
var prefix = kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "texte",
|
||||
FeedbackFieldKind.Text => "ligne",
|
||||
FeedbackFieldKind.Choice => "liste",
|
||||
FeedbackFieldKind.Checkboxes => "cases",
|
||||
FeedbackFieldKind.YesNo => "oui_non",
|
||||
_ => "note"
|
||||
};
|
||||
string id;
|
||||
do id = $"{prefix}_{++_fieldCounter}";
|
||||
while (Fields.Any(f => f.Id == id) || _publishedIds.Contains(id));
|
||||
|
||||
var field = new FeedbackField(id, FeedbackFieldEditor.KindLabel(kind), kind, Required: false,
|
||||
MaxLength: kind == FeedbackFieldKind.Text ? FeedbackFormRules.DefaultTextLength : FeedbackRules.MaxMultilineLength,
|
||||
Options: kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes
|
||||
? new[] { new FeedbackOption("choix_1", "Choix 1"), new FeedbackOption("choix_2", "Choix 2") }
|
||||
: null);
|
||||
Fields.Add(NewField(field, isNew: true));
|
||||
}
|
||||
|
||||
private FeedbackFieldEditor NewField(FeedbackField field, bool isNew) =>
|
||||
new(field, isNew, _changed, MoveField, f => Fields.Remove(f));
|
||||
|
||||
private void MoveField(FeedbackFieldEditor field, int delta)
|
||||
{
|
||||
var index = Fields.IndexOf(field);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= Fields.Count) return;
|
||||
Fields.Move(index, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Feedback;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Formulaires des demandes » (permission feedback.forms) : les types de demande du bouton « Aide » (1 à 8,
|
||||
/// communs à tous les jeux, dans l'ordre d'affichage aux joueurs) et leurs champs.
|
||||
/// • à gauche, les types (ajout, ordre, suppression ; proposé / masqué, modifié) ; au centre, la présentation du type
|
||||
/// (nom, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées…), ses champs
|
||||
/// (ajout, ordre, retrait, libellé, aide, obligatoire, longueur, étoiles, choix) ; à droite, l'aperçu tel que les
|
||||
/// joueurs le verront ;
|
||||
/// • contrôle en direct avec les règles des formulaires (FeedbackFormRules : celles du serveur et des launchers) :
|
||||
/// aucun lien dans les textes, limites ; avertissement (sans refus) si un libellé semble demander des données
|
||||
/// personnelles — les demandes sont anonymes ;
|
||||
/// • « Publier » : avertissement si la clé de ce PC n'a pas l'autorisation « formulaires » dans le trousseau publié,
|
||||
/// formulaires signés par la clé de ce PC, requête signée, révision attendue (409 si quelqu'un a publié entre-temps :
|
||||
/// rien n'est écrasé) ; « Annuler les modifications », « Revenir aux formulaires d'origine ».
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormsPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "feedback.forms";
|
||||
|
||||
private FeedbackFormsState? _state;
|
||||
private string _publishedJson = "";
|
||||
private Dictionary<string, string> _publishedForms = new();
|
||||
private FeedbackFormEditor? _selectedForm;
|
||||
private string? _statusMessage;
|
||||
private bool _hasConflict;
|
||||
private bool _isDirty;
|
||||
private bool _suspendChanges;
|
||||
private IReadOnlyList<string> _validationErrors = Array.Empty<string>();
|
||||
private IReadOnlyList<string> _warnings = Array.Empty<string>();
|
||||
|
||||
public FeedbackFormsPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
|
||||
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync),
|
||||
() => !IsBusy && _isDirty && !_hasConflict && _validationErrors.Count == 0 && _state is { PublishingEnabled: true });
|
||||
DiscardCommand = new RelayCommand(Discard, () => !IsBusy && _isDirty);
|
||||
RestoreOriginalCommand = new RelayCommand(RestoreOriginal, () => !IsBusy && _state is not null);
|
||||
AddFormCommand = new RelayCommand(AddForm, () => !IsBusy && _state is not null && Forms.Count < FeedbackFormRules.MaxForms);
|
||||
Forms.CollectionChanged += (_, _) =>
|
||||
{
|
||||
AddFormCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(FormsText));
|
||||
OnEdited();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Ajouter un type de demande (8 au plus).</summary>
|
||||
public RelayCommand AddFormCommand { get; }
|
||||
|
||||
public string FormsText => $"Types de demande · {Forms.Count} / {FeedbackFormRules.MaxForms}";
|
||||
|
||||
public override bool HasSplitLayout => Forms.Count > 0;
|
||||
|
||||
public ObservableCollection<FeedbackFormEditor> Forms { get; } = new();
|
||||
|
||||
public FeedbackFormEditor? SelectedForm
|
||||
{
|
||||
get => _selectedForm;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _selectedForm, value)) return;
|
||||
RefreshPreview();
|
||||
}
|
||||
}
|
||||
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
public AsyncRelayCommand PublishCommand { get; }
|
||||
public RelayCommand DiscardCommand { get; }
|
||||
public RelayCommand RestoreOriginalCommand { get; }
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
|
||||
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasConflict, value)) PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Modifications non publiées.</summary>
|
||||
public bool IsDirty => _isDirty;
|
||||
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
/// <summary>Publication non configurée sur le serveur (pas de fichier public du trousseau).</summary>
|
||||
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
|
||||
|
||||
public string PublishedText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is null) return "";
|
||||
var document = _state.Document;
|
||||
if (document.Revision == 0) return "Formulaires d'origine : rien n'a encore été publié depuis ce menu.";
|
||||
var when = document.UpdatedAt?.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
|
||||
return $"Révision {document.Revision} publiée" + (when is null ? "" : $" le {when}") +
|
||||
(_state.UpdatedBy is null ? "" : $" par {_state.UpdatedBy}") + ".";
|
||||
}
|
||||
}
|
||||
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
// ------------------------------------------------------------------ Contrôles
|
||||
|
||||
/// <summary>Erreurs qui empêchent la publication (règles des formulaires, celles du serveur et des launchers).</summary>
|
||||
public IReadOnlyList<string> ValidationErrors
|
||||
{
|
||||
get => _validationErrors;
|
||||
private set
|
||||
{
|
||||
_validationErrors = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasValidationErrors));
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasValidationErrors => _validationErrors.Count > 0;
|
||||
|
||||
/// <summary>Avertissements (sans refus) : libellé qui semble demander des données personnelles, type masqué…</summary>
|
||||
public IReadOnlyList<string> Warnings
|
||||
{
|
||||
get => _warnings;
|
||||
private set
|
||||
{
|
||||
_warnings = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasWarnings => _warnings.Count > 0;
|
||||
|
||||
// ------------------------------------------------------------------ Aperçu (mêmes champs que la fenêtre « Aide »)
|
||||
|
||||
/// <summary>Champs du type sélectionné tels que les joueurs les verront (saisie possible, rien n'est envoyé).</summary>
|
||||
public ObservableCollection<FeedbackFieldViewModel> Preview { get; } = new();
|
||||
|
||||
public string PreviewTitle => _selectedForm is null ? "" : $"Aperçu · {_selectedForm.Title}";
|
||||
|
||||
public string PreviewTitleHint => _selectedForm?.TitleHint ?? "";
|
||||
|
||||
public bool PreviewTechByDefault => _selectedForm?.TechByDefault ?? false;
|
||||
|
||||
public string PreviewNote => _selectedForm switch
|
||||
{
|
||||
{ Hidden: true } => "Ce type est masqué : les joueurs ne le voient pas.",
|
||||
{ AllowAttachments: true } => "Les joueurs voient aussi les captures d'écran (3 au plus) et les informations techniques.",
|
||||
_ => "Captures d'écran non proposées pour ce type. Les joueurs voient aussi les informations techniques."
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
protected override async Task OnActivatedAsync(bool firstTime)
|
||||
{
|
||||
if (firstTime) await ReloadAsync();
|
||||
}
|
||||
|
||||
private Task ReloadAsync()
|
||||
{
|
||||
if (_isDirty && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les formulaires modifiés n'ont pas été publiés : les modifications seront perdues.", "Abandonner"))
|
||||
return Task.CompletedTask;
|
||||
return RunAsync(async () =>
|
||||
{
|
||||
StatusMessage = null;
|
||||
Apply(await Context.Session.GetFeedbackFormsAsync());
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Formulaires en vigueur sur le serveur : éditeurs reconstruits, rien de modifié.</summary>
|
||||
private void Apply(FeedbackFormsState state)
|
||||
{
|
||||
_state = state;
|
||||
HasConflict = false;
|
||||
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, Snapshot, StringComparer.Ordinal);
|
||||
_publishedJson = Snapshot(state.Document.Forms);
|
||||
LoadEditors(state.Document.Forms);
|
||||
foreach (var name in new[] { nameof(ShowPublishingDisabled), nameof(PublishedText), nameof(PublicUrl) }) OnPropertyChanged(name);
|
||||
RestoreOriginalCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void LoadEditors(IEnumerable<FeedbackForm> forms)
|
||||
{
|
||||
var selectedKind = _selectedForm?.Kind;
|
||||
_suspendChanges = true;
|
||||
try
|
||||
{
|
||||
Forms.Clear();
|
||||
// Ordre du document : celui affiché aux joueurs.
|
||||
foreach (var form in forms)
|
||||
Forms.Add(new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: !_publishedForms.ContainsKey(form.Kind)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suspendChanges = false;
|
||||
}
|
||||
_selectedForm = Forms.FirstOrDefault(f => f.Kind == selectedKind) ?? Forms.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(SelectedForm));
|
||||
SplitLayoutChanged();
|
||||
OnEdited();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
/// <summary>Une valeur a changé : état « modifié », contrôles et aperçu recalculés.</summary>
|
||||
private void OnEdited()
|
||||
{
|
||||
if (_suspendChanges) return;
|
||||
OnPropertyChanged(nameof(PreviewNote));
|
||||
var forms = Forms.Select(f => f.Build()).ToList();
|
||||
foreach (var (editor, form) in Forms.Zip(forms))
|
||||
editor.IsDirty = !_publishedForms.TryGetValue(form.Kind, out var published) || published != Snapshot(form);
|
||||
SetDirty(_state is not null && Snapshot(forms) != _publishedJson);
|
||||
|
||||
var errors = new List<string>();
|
||||
FeedbackFormRules.Validate(new FeedbackFormsDocument { Revision = (_state?.Document.Revision ?? 0) + 1, Forms = forms }, errors);
|
||||
ValidationErrors = errors.Distinct().ToList();
|
||||
Warnings = WarningsOf(forms);
|
||||
RefreshPreview();
|
||||
}
|
||||
|
||||
private void SetDirty(bool value)
|
||||
{
|
||||
if (_isDirty == value) return;
|
||||
_isDirty = value;
|
||||
OnPropertyChanged(nameof(IsDirty));
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
DiscardCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Avertissements sans refus : données personnelles demandées (les demandes sont anonymes), tout masqué sauf un…</summary>
|
||||
private static IReadOnlyList<string> WarningsOf(IReadOnlyList<FeedbackForm> forms)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
foreach (var form in forms)
|
||||
{
|
||||
var texts = new[] { form.Label, form.Description, form.TitleHint }
|
||||
.Concat(form.Fields.SelectMany(f => new[] { f.Label, f.Hint ?? "" }.Concat(f.Choices.Select(o => o.Label))));
|
||||
var words = FeedbackFormRules.PersonalDataWords
|
||||
.Where(w => texts.Any(t => t.Contains(w, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
if (words.Count > 0)
|
||||
warnings.Add($"« {form.Label} » mentionne « {string.Join(" », « ", words)} » : les demandes sont anonymes, ne demandez " +
|
||||
"aucune donnée personnelle (adresse e-mail, mot de passe, identifiant de compte, téléphone…).");
|
||||
if (!form.Hidden && form.Fields.Count == 0)
|
||||
warnings.Add($"« {form.Label} » n'a aucun champ : les joueurs n'écriront qu'un titre.");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
Preview.Clear();
|
||||
if (_selectedForm is not null)
|
||||
foreach (var field in _selectedForm.Build().Fields) Preview.Add(new FeedbackFieldViewModel(field));
|
||||
foreach (var name in new[] { nameof(PreviewTitle), nameof(PreviewTitleHint), nameof(PreviewTechByDefault), nameof(PreviewNote) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
private void Discard()
|
||||
{
|
||||
if (_state is null) return;
|
||||
if (!Context.Dialogs.Confirm("Annuler les modifications ?",
|
||||
"Les formulaires reviennent à ceux actuellement publiés. Les modifications non publiées seront perdues.", "Annuler les modifications"))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
LoadEditors(_state.Document.Forms);
|
||||
}
|
||||
|
||||
/// <summary>Formulaires d'origine dans l'éditeur (à publier ensuite) : les quatre types, leurs champs d'origine.</summary>
|
||||
private void RestoreOriginal()
|
||||
{
|
||||
if (!Context.Dialogs.Confirm("Revenir aux formulaires d'origine ?",
|
||||
"Les types de demande redeviennent les quatre d'origine (avis, demande d'évolution, bug, autre), avec leurs noms, icônes " +
|
||||
"et champs d'origine ; les types ajoutés disparaissent. " +
|
||||
"Rien n'est envoyé avant « Publier » ; les demandes déjà reçues ne changent pas.", "Revenir à l'origine"))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
LoadEditors(FeedbackForms.All);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Types de demande (ajout, ordre, suppression, icône)
|
||||
|
||||
private FeedbackFormEditorActions EditorActions => new(MoveForm, RemoveForm, ImportIcon);
|
||||
|
||||
/// <summary>
|
||||
/// Nouveau type de demande (identifiant généré, jamais celui d'un type publié ou d'origine), avec un champ « Message »
|
||||
/// obligatoire ; placé à la fin et sélectionné.
|
||||
/// </summary>
|
||||
private void AddForm()
|
||||
{
|
||||
if (Forms.Count >= FeedbackFormRules.MaxForms) return;
|
||||
var taken = Forms.Select(f => f.Kind).Concat(_publishedForms.Keys).Concat(FeedbackForms.All.Select(f => f.Kind))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var n = 1;
|
||||
while (taken.Contains($"type_{n}")) n++;
|
||||
var form = new FeedbackForm($"type_{n}", "Nouveau type", "Décrivez votre demande.", "Forum", "", TechByDefault: false,
|
||||
new[] { new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true) });
|
||||
var editor = new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: true);
|
||||
Forms.Add(editor);
|
||||
SelectedForm = editor;
|
||||
}
|
||||
|
||||
private void MoveForm(FeedbackFormEditor form, int delta)
|
||||
{
|
||||
var index = Forms.IndexOf(form);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= Forms.Count) return;
|
||||
Forms.Move(index, target);
|
||||
}
|
||||
|
||||
private void RemoveForm(FeedbackFormEditor form)
|
||||
{
|
||||
if (Forms.Count <= 1) return;
|
||||
if (!Context.Dialogs.Confirm("Supprimer ce type de demande ?",
|
||||
$"« {form.Title} » ne sera plus proposé aux joueurs après la publication. Les demandes déjà reçues de ce type restent " +
|
||||
"consultables (avec leur formulaire d'alors). Pour le retirer seulement un temps, masquez-le plutôt.",
|
||||
"Supprimer", danger: true))
|
||||
return;
|
||||
var index = Forms.IndexOf(form);
|
||||
Forms.Remove(form);
|
||||
if (ReferenceEquals(_selectedForm, form)) SelectedForm = Forms[Math.Min(index, Forms.Count - 1)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Icône importée : fichier .ico OBLIGATOIREMENT, contenant une image de 64 × 64 pixels, convertie en PNG neuf sans
|
||||
/// métadonnées (<see cref="FeedbackIcons.FromIco"/>) ; tout autre fichier est refusé avec la raison.
|
||||
/// </summary>
|
||||
private void ImportIcon(FeedbackFormEditor form)
|
||||
{
|
||||
var path = Context.Dialogs.PickOpenFile($"Icône du type « {form.Title} » : fichier .ico contenant une image de {FeedbackIcons.Size} × {FeedbackIcons.Size} pixels",
|
||||
"Icône Windows (*.ico)|*.ico", null);
|
||||
if (path is null) return;
|
||||
string? error;
|
||||
byte[]? png = null;
|
||||
try
|
||||
{
|
||||
if (!string.Equals(Path.GetExtension(path), ".ico", StringComparison.OrdinalIgnoreCase))
|
||||
error = "Seuls les fichiers .ico sont acceptés.";
|
||||
else if (new FileInfo(path).Length > FeedbackIcons.MaxIcoBytes)
|
||||
error = $"Fichier trop volumineux (au plus {FeedbackIcons.MaxIcoBytes / 1024} Ko).";
|
||||
else
|
||||
error = FeedbackIcons.FromIco(File.ReadAllBytes(path), out png);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
error = $"Lecture du fichier impossible : {ex.Message}";
|
||||
}
|
||||
if (error is not null)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Icône refusée", error, ModuleMessageKind.Error);
|
||||
return;
|
||||
}
|
||||
form.SetCustomIcon(Convert.ToBase64String(png!));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
if (_state is null || _validationErrors.Count > 0) return;
|
||||
var forms = Forms.Select(f => f.Build()).ToList();
|
||||
var hidden = forms.Where(f => f.Hidden).Select(f => $"« {f.Label} »").ToList();
|
||||
var message = "Les formulaires seront signés avec la clé de ce PC puis publiés : les launchers des joueurs les utiliseront " +
|
||||
"à la prochaine ouverture de la fenêtre « Aide ». Les demandes déjà reçues gardent leur formulaire d'origine." +
|
||||
(hidden.Count > 0 ? $"\n\nTypes masqués (plus proposés aux joueurs) : {string.Join(", ", hidden)}." : "");
|
||||
if (!Context.Dialogs.Confirm("Publier les formulaires ?", message, "Publier")) return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.FeedbackForms)) return;
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
StatusMessage = null;
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.PublishFeedbackFormsAsync(forms, _state.Document.Revision);
|
||||
Apply(state);
|
||||
StatusMessage = $"Formulaires publiés (révision {state.Document.Revision}) : les joueurs les verront à la prochaine " +
|
||||
"ouverture de la fenêtre « Aide ».";
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
DiscardCommand.RaiseCanExecuteChanged();
|
||||
RestoreOriginalCommand.RaiseCanExecuteChanged();
|
||||
AddFormCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
}
|
||||
|
||||
private static string Snapshot(object value) => JsonSerializer.Serialize(value, ApiJson.Options);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
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;
|
||||
/// <summary>Formulaire publié du type (nom, icône) ; null : formulaires d'origine.</summary>
|
||||
public FeedbackForm? Form
|
||||
{
|
||||
get => _form;
|
||||
set
|
||||
{
|
||||
_form = value;
|
||||
foreach (var name in new[] { nameof(KindLabel), nameof(KindIconKey), nameof(KindCustomIcon), nameof(HasKindCustomIcon), nameof(DetailText) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
}
|
||||
private FeedbackForm? _form;
|
||||
|
||||
public string KindLabel => _form?.Label ?? FeedbackForms.KindLabel(_summary.Kind);
|
||||
public string KindIconKey => _form?.IconKey ?? FeedbackForms.Find(_summary.Kind)?.IconKey ?? "Forum";
|
||||
|
||||
/// <summary>Icône importée du type (PNG 64 × 64) ; null : icône d'origine.</summary>
|
||||
public byte[]? KindCustomIcon => _form?.CustomIconBytes();
|
||||
public bool HasKindCustomIcon => KindCustomIcon is not null;
|
||||
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>Contient un lien ou une adresse : icône d'avertissement dans la liste.</summary>
|
||||
public bool HasLinks => _summary.HasLinks;
|
||||
|
||||
public bool HasAttachments => _summary.AttachmentCount > 0;
|
||||
public string AttachmentsToolTip => $"{_summary.AttachmentCount} capture(s) d'écran";
|
||||
|
||||
/// <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>
|
||||
/// <param name="HasLink">Le texte contient un lien ou une adresse (cadre d'avertissement).</param>
|
||||
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong, bool HasLink = false);
|
||||
|
||||
/// <summary>
|
||||
/// Capture d'écran d'une demande : chargée après la fiche, REVÉRIFIÉE (<see cref="FeedbackImages.Sanitize"/> : vraie
|
||||
/// image PNG ou JPEG, sans métadonnées) avant d'être affichée, réduite pour l'aperçu, agrandie à la demande.
|
||||
/// </summary>
|
||||
public sealed class FeedbackAttachmentView : ObservableObject
|
||||
{
|
||||
private byte[]? _content;
|
||||
private string? _error;
|
||||
private bool _isExpanded;
|
||||
|
||||
public FeedbackAttachmentView(FeedbackAttachmentInfo info)
|
||||
{
|
||||
Info = info;
|
||||
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded, () => HasContent);
|
||||
}
|
||||
|
||||
public FeedbackAttachmentInfo Info { get; }
|
||||
|
||||
/// <summary>« Capture 1 · 1920 × 1080 · PNG · 845 Ko ».</summary>
|
||||
public string Label => $"Capture {Info.Index} · {Info.Width} × {Info.Height} · {Info.Format.ToUpperInvariant()} · {Math.Max(1, Info.Bytes / 1024)} Ko";
|
||||
|
||||
public byte[]? Content
|
||||
{
|
||||
get => _content;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _content, value)) return;
|
||||
OnPropertyChanged(nameof(HasContent));
|
||||
OnPropertyChanged(nameof(IsLoading));
|
||||
ToggleCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasContent => _content is not null;
|
||||
|
||||
public string? Error
|
||||
{
|
||||
get => _error;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _error, value)) return;
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
OnPropertyChanged(nameof(IsLoading));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(_error);
|
||||
public bool IsLoading => _content is null && _error is null;
|
||||
|
||||
public bool IsExpanded
|
||||
{
|
||||
get => _isExpanded;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isExpanded, value)) OnPropertyChanged(nameof(ToggleText));
|
||||
}
|
||||
}
|
||||
|
||||
public string ToggleText => _isExpanded ? "Réduire" : "Agrandir";
|
||||
public RelayCommand ToggleCommand { get; }
|
||||
|
||||
/// <summary>Contenu reçu du serveur : affiché seulement s'il passe le contrôle commun.</summary>
|
||||
public void Load(byte[] received)
|
||||
{
|
||||
var error = FeedbackImages.Sanitize(received, out var info, out var clean);
|
||||
if (error is not null || info is null) Error = $"Image refusée : {error}";
|
||||
else if (info.Width != Info.Width || info.Height != Info.Height) Error = "Image refusée : dimensions différentes de celles annoncées.";
|
||||
else Content = clean;
|
||||
}
|
||||
|
||||
public void Fail(string message) => Error = message;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
Attachments = detail.Attachments.Select(a => new FeedbackAttachmentView(a)).ToList();
|
||||
var text = detail.Summary.Title + "\n" + string.Join("\n", detail.Fields.Values);
|
||||
Links = FeedbackLinks.Find(text, out var total).Select(FeedbackLinks.Defang).ToList();
|
||||
LinkCount = total;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Liens (avertissement)
|
||||
|
||||
/// <summary>Liens et adresses trouvés, sous forme désamorcée (« hxxps[://]exemple[.]com »).</summary>
|
||||
public IReadOnlyList<string> Links { get; }
|
||||
|
||||
public int LinkCount { get; }
|
||||
public bool HasLinks => LinkCount > 0;
|
||||
|
||||
public string LinksWarning => LinkCount == 0 ? "" :
|
||||
$"Cette demande contient {(LinkCount == 1 ? "un lien ou une adresse" : $"{LinkCount} liens ou adresses")}. Elle vient d'un " +
|
||||
"inconnu : n'ouvrez pas ces liens et ne les copiez pas dans un navigateur, ils peuvent mener à un site d'hameçonnage ou " +
|
||||
"à un fichier malveillant. En cas de doute, supprimez la demande.";
|
||||
|
||||
public string LinksText => string.Join("\n", Links.Select(l => "• " + l)) +
|
||||
(LinkCount > Links.Count ? $"\n… et {LinkCount - Links.Count} autre(s)" : "");
|
||||
|
||||
// ------------------------------------------------------------------ Captures
|
||||
|
||||
public IReadOnlyList<FeedbackAttachmentView> Attachments { get; }
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
|
||||
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;
|
||||
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés depuis le menu), sinon nom d'origine.</summary>
|
||||
public string KindLabel => _detail.Form?.Label ?? FeedbackForms.KindLabel(_detail.Summary.Kind);
|
||||
public string KindIconKey => _detail.Form?.IconKey ?? FeedbackForms.Find(_detail.Summary.Kind)?.IconKey ?? "Forum";
|
||||
|
||||
/// <summary>Icône importée du type tel qu'à l'envoi ; null : icône d'origine.</summary>
|
||||
public byte[]? KindCustomIcon => _detail.Form?.CustomIconBytes();
|
||||
public bool HasKindCustomIcon => KindCustomIcon is not null;
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Champs avec les libellés du formulaire TEL QU'À L'ENVOI (conservé avec la demande), ou ceux d'origine pour une
|
||||
/// demande reçue avant la 3.2 : une demande reste lisible même si le formulaire a changé depuis.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<FeedbackFieldLine> BuildLines(FeedbackDetail detail)
|
||||
{
|
||||
var form = detail.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,
|
||||
field.IsText && FeedbackLinks.Contains(value)));
|
||||
// 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'), FeedbackLinks.Contains(value)));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
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 ObservableCollection<Choice>(new[] { new Choice(null, "Tous les types") }
|
||||
.Concat(FeedbackForms.All.Select(f => new Choice(f.Kind, f.Label))));
|
||||
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
|
||||
|
||||
/// <summary>Types de demande du filtre : ceux publiés depuis le menu (même masqués), puis ceux d'origine absents.</summary>
|
||||
public ObservableCollection<Choice> Kinds { get; }
|
||||
|
||||
/// <summary>Formulaires publiés (noms et icônes des types dans la liste) ; vide : formulaires d'origine.</summary>
|
||||
private Dictionary<string, FeedbackForm> _publishedForms = new(StringComparer.Ordinal);
|
||||
|
||||
private FeedbackForm? FormOf(string kind) =>
|
||||
_publishedForms.TryGetValue(kind, out var form) ? form : FeedbackForms.Find(kind);
|
||||
|
||||
/// <summary>
|
||||
/// Noms et icônes des types publiés depuis la page « Formulaires des demandes » (lecture permise à tout
|
||||
/// administrateur : contenu public). En cas d'échec, les noms d'origine restent affichés.
|
||||
/// </summary>
|
||||
private async Task LoadKindsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetFeedbackFormsAsync();
|
||||
if (state.Document.Revision == 0) return;
|
||||
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, StringComparer.Ordinal);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var selected = _kind.Value;
|
||||
var choices = new[] { new Choice(null, "Tous les types") }
|
||||
.Concat(_publishedForms.Values.Select(f => new Choice(f.Kind, f.Hidden ? $"{f.Label} (masqué)" : f.Label)))
|
||||
.Concat(FeedbackForms.All.Where(f => !_publishedForms.ContainsKey(f.Kind)).Select(f => new Choice(f.Kind, $"{f.Label} (supprimé)")))
|
||||
.ToList();
|
||||
Kinds.Clear();
|
||||
foreach (var choice in choices) Kinds.Add(choice);
|
||||
_kind = Kinds.FirstOrDefault(k => k.Value == selected) ?? Kinds[0];
|
||||
OnPropertyChanged(nameof(Kind));
|
||||
foreach (var item in _loaded) item.Form = FormOf(item.Kind);
|
||||
}
|
||||
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 async Task OnActivatedAsync(bool firstTime)
|
||||
{
|
||||
if (!firstTime) return;
|
||||
await LoadKindsAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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;
|
||||
var view = NewDetail(detail);
|
||||
Detail = view;
|
||||
await LoadAttachmentsAsync(gameId, view);
|
||||
});
|
||||
|
||||
/// <summary>Captures de la fiche affichée, une par une (chacune revérifiée avant l'affichage).</summary>
|
||||
private async Task LoadAttachmentsAsync(string gameId, FeedbackDetailView view)
|
||||
{
|
||||
foreach (var attachment in view.Attachments)
|
||||
{
|
||||
if (!ReferenceEquals(view, _detail)) return; // autre demande affichée entre-temps
|
||||
try
|
||||
{
|
||||
attachment.Load(await Context.Session.GetFeedbackAttachmentAsync(gameId, view.Id, attachment.Info.Index));
|
||||
}
|
||||
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
attachment.Fail(ApiErrorText.Describe(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) { Form = FormOf(s.Kind) }));
|
||||
_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);
|
||||
await LoadAttachmentsAsync(gameId, _detail!);
|
||||
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;
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
<!-- Aperçu des formulaires des demandes : mêmes champs que la fenêtre « Aide » des joueurs -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace GameLauncher.Modules.Admin.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne.
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne (versions en
|
||||
/// ligne, catalogue, formulaires des demandes).
|
||||
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
|
||||
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
|
||||
/// </summary>
|
||||
@@ -25,9 +26,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 +142,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 +152,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)
|
||||
@@ -192,6 +194,16 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
|
||||
if (state.FeedbackForms is { } forms)
|
||||
items.Add(forms.Unreadable
|
||||
? SignedContentItem.Failed("Formulaires des demandes",
|
||||
"Fichier des formulaires illisible sur le serveur (voir ses journaux) : restaurez-le depuis feedback-forms-history/ " +
|
||||
"dans les données privées du serveur.")
|
||||
: new SignedContentItem(
|
||||
forms.Revision > 0 ? $"Formulaires des demandes (révision {forms.Revision})" : "Formulaires des demandes",
|
||||
ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, forms.Digest, forms.Signature, forms.SignerName, state.Keyring, proposed,
|
||||
"Republiez-les depuis la page « Formulaires des demandes » (ils sont signés à chaque publication).",
|
||||
"Formulaires d'origine, intégrés au launcher : rien à signer tant que rien n'est publié."));
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -318,12 +330,12 @@ public sealed class KeyringKeyItem
|
||||
public sealed class SignedContentItem
|
||||
{
|
||||
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign)
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign, string nothingPublished = "Rien n'est publié.")
|
||||
{
|
||||
Title = title;
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
Text = "Rien n'est publié.";
|
||||
Text = nothingPublished;
|
||||
IsValid = true;
|
||||
return;
|
||||
}
|
||||
@@ -341,6 +353,15 @@ public sealed class SignedContentItem
|
||||
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
|
||||
}
|
||||
|
||||
private SignedContentItem(string title, string text)
|
||||
{
|
||||
Title = title;
|
||||
Text = text;
|
||||
}
|
||||
|
||||
/// <summary>Contenu dont l'état ne peut pas être lu (refusé, à corriger sur le serveur).</summary>
|
||||
internal static SignedContentItem Failed(string title, string text) => new(title, text);
|
||||
|
||||
public string Title { get; }
|
||||
public string Text { get; }
|
||||
|
||||
|
||||
@@ -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,46 @@
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>Image préparée par le launcher (convertie en PNG ou JPEG, réduite si besoin), ou raison du refus.</summary>
|
||||
/// <param name="Name">Nom affiché au joueur (jamais envoyé).</param>
|
||||
public sealed record ScreenshotResult(byte[]? Bytes, string Name, string? Error)
|
||||
{
|
||||
public static ScreenshotResult Refused(string name, string error) => new(null, name, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source des captures d'écran (fenêtre WPF : explorateur Windows, presse-papiers). Elle décode l'image choisie (PNG,
|
||||
/// JPEG, BMP, GIF, WebP reconnus à leur contenu), la réduit si besoin et la réencode en PNG ou JPEG SANS ses
|
||||
/// métadonnées ; le module la revérifie ensuite avec <see cref="FeedbackImages.Sanitize"/>.
|
||||
/// </summary>
|
||||
public interface IScreenshotSource
|
||||
{
|
||||
/// <summary>Explorateur Windows (null : annulé).</summary>
|
||||
ScreenshotResult? PickFile();
|
||||
|
||||
/// <summary>Image du presse-papiers (Win+Maj+S, Impr. écran) ; null : aucune image.</summary>
|
||||
ScreenshotResult? FromClipboard();
|
||||
}
|
||||
|
||||
/// <summary>Capture jointe à la demande (déjà contrôlée et nettoyée).</summary>
|
||||
public sealed class FeedbackAttachmentDraft
|
||||
{
|
||||
public FeedbackAttachmentDraft(string name, byte[] content, FeedbackImages.ImageInfo info, Action<FeedbackAttachmentDraft> remove)
|
||||
{
|
||||
Name = name;
|
||||
Content = content;
|
||||
Info = info;
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public byte[] Content { get; }
|
||||
public FeedbackImages.ImageInfo Info { get; }
|
||||
|
||||
/// <summary>« 1920 × 1080 · PNG · 845 Ko ».</summary>
|
||||
public string Details => $"{Info.Width} × {Info.Height} · {Info.Format.ToUpperInvariant()} · {Math.Max(1, Content.Length / 1024)} Ko";
|
||||
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>Étoile d'un champ « note » (1 à 3, 5 ou 10 étoiles).</summary>
|
||||
public sealed class RatingStar : ObservableObject
|
||||
{
|
||||
private bool _isOn;
|
||||
|
||||
public RatingStar(int value, Action<int> select, int max = 5)
|
||||
{
|
||||
Value = value;
|
||||
Max = max;
|
||||
SelectCommand = new RelayCommand(() => select(value));
|
||||
}
|
||||
|
||||
public int Value { get; }
|
||||
public int Max { get; }
|
||||
|
||||
/// <summary>Appréciation (sur 5 étoiles), sinon « 7 / 10 ».</summary>
|
||||
public string ToolTip => Max != 5 ? $"{Value} / {Max}" : 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>Case d'un champ « cases à cocher ».</summary>
|
||||
public sealed class FeedbackCheckOption : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private bool _isChecked;
|
||||
|
||||
public FeedbackCheckOption(FeedbackOption option, Action changed)
|
||||
{
|
||||
Option = option;
|
||||
_changed = changed;
|
||||
}
|
||||
|
||||
public FeedbackOption Option { get; }
|
||||
public string Label => Option.Label;
|
||||
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isChecked, value)) _changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Champ du formulaire, généré à partir de sa description (<see cref="FeedbackField"/>, formulaires publiés ou
|
||||
/// d'origine) : zone de texte, texte court, liste déroulante, cases à cocher, case oui/non ou note en étoiles.
|
||||
/// </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 bool _isChecked;
|
||||
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;
|
||||
var max = field.Kind == FeedbackFieldKind.Rating ? Math.Clamp(field.RatingMax, 1, 10) : 5;
|
||||
Stars = Enumerable.Range(1, max).Select(i => new RatingStar(i, value => Rating = value, max)).ToList();
|
||||
CheckOptions = field.Kind == FeedbackFieldKind.Checkboxes
|
||||
? field.Choices.Select(o => new FeedbackCheckOption(o, () => Error = null)).ToList()
|
||||
: Array.Empty<FeedbackCheckOption>();
|
||||
}
|
||||
|
||||
public FeedbackField Field { get; }
|
||||
public string Id => Field.Id;
|
||||
|
||||
/// <summary>Libellé (« * » : obligatoire ; une case oui/non porte son libellé à côté de la case).</summary>
|
||||
public string Label => Field.Required ? Field.Label + " *" : Field.Label;
|
||||
public string? Hint => Field.Hint;
|
||||
|
||||
public bool IsMultiline => Field.Kind == FeedbackFieldKind.Multiline;
|
||||
public bool IsText => Field.Kind == FeedbackFieldKind.Text;
|
||||
public bool IsChoice => Field.Kind == FeedbackFieldKind.Choice;
|
||||
public bool IsRating => Field.Kind == FeedbackFieldKind.Rating;
|
||||
public bool IsCheckboxes => Field.Kind == FeedbackFieldKind.Checkboxes;
|
||||
public bool IsYesNo => Field.Kind == FeedbackFieldKind.YesNo;
|
||||
|
||||
/// <summary>Libellé au-dessus du champ (tous sauf la case oui/non, dont le libellé est à côté de la case).</summary>
|
||||
public bool ShowLabel => !IsYesNo;
|
||||
|
||||
/// <summary>Aide affichée sous le champ (champs sans zone de saisie ; sinon elle sert de texte indicatif).</summary>
|
||||
public bool ShowHintText => !Field.IsText && !string.IsNullOrEmpty(Field.Hint);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reprend la saisie d'un champ du même identifiant et du même type (formulaires republiés pendant la saisie) :
|
||||
/// seules les valeurs encore valables sont reprises (choix toujours proposés, note dans la nouvelle échelle).
|
||||
/// </summary>
|
||||
public void RestoreFrom(FeedbackFieldViewModel previous)
|
||||
{
|
||||
if (previous.Field.Kind != Field.Kind) return;
|
||||
switch (Field.Kind)
|
||||
{
|
||||
case FeedbackFieldKind.Choice:
|
||||
if (Options.FirstOrDefault(o => o.Id == previous.SelectedOption?.Id) is { } option) SelectedOption = option;
|
||||
break;
|
||||
case FeedbackFieldKind.Checkboxes:
|
||||
var chosen = previous.CheckOptions.Where(o => o.IsChecked).Select(o => o.Option.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var box in CheckOptions) box.IsChecked = chosen.Contains(box.Option.Id);
|
||||
break;
|
||||
case FeedbackFieldKind.Rating:
|
||||
if (previous.Rating <= Stars.Count) Rating = previous.Rating;
|
||||
break;
|
||||
case FeedbackFieldKind.YesNo:
|
||||
IsChecked = previous.IsChecked;
|
||||
break;
|
||||
default:
|
||||
Text = previous.Text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RatingStar> Stars { get; }
|
||||
|
||||
/// <summary>Cases d'un champ « cases à cocher ».</summary>
|
||||
public IReadOnlyList<FeedbackCheckOption> CheckOptions { get; }
|
||||
|
||||
/// <summary>Case oui/non cochée.</summary>
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isChecked, value)) Error = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int Rating
|
||||
{
|
||||
get => _rating;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _rating, Math.Clamp(value, 0, Stars.Count))) 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),
|
||||
FeedbackFieldKind.Checkboxes => CheckOptions.Any(o => o.IsChecked)
|
||||
? string.Join(',', CheckOptions.Where(o => o.IsChecked).Select(o => o.Option.Id))
|
||||
: null,
|
||||
FeedbackFieldKind.YesNo => _isChecked ? FeedbackRules.Yes : FeedbackRules.No,
|
||||
_ => 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,561 @@
|
||||
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>Résultat d'un envoi : référence, captures reçues, raison de l'échec d'une capture.</summary>
|
||||
public sealed record FeedbackSent(string Reference, int AttachmentsSent, string? AttachmentError);
|
||||
|
||||
/// <summary>Envoi d'une demande et de ses captures (Launcher API en production, simulé dans les tests).</summary>
|
||||
public interface IFeedbackSender
|
||||
{
|
||||
Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, 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<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct)
|
||||
{
|
||||
var result = await _client.SendFeedbackAsync(submission, attachments, ct).ConfigureAwait(false);
|
||||
return new FeedbackSent(result.Submitted.Reference, result.AttachmentsSent, result.AttachmentError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
CustomIcon = form.CustomIconBytes();
|
||||
SelectCommand = new RelayCommand(() => select(form));
|
||||
}
|
||||
|
||||
public FeedbackForm Form { get; }
|
||||
public string Label => Form.Label;
|
||||
public string Description => Form.Description;
|
||||
public string IconKey => Form.IconKey;
|
||||
|
||||
/// <summary>Icône importée depuis le menu (PNG 64 × 64, vérifiée avec les formulaires signés) ; null : icône d'origine.</summary>
|
||||
public byte[]? CustomIcon { get; } = null;
|
||||
public bool HasCustomIcon => CustomIcon is not null;
|
||||
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 publiés depuis le menu, ou
|
||||
/// de ceux d'origine), envoi, référence.
|
||||
/// • le jeu est celui sélectionné à l'ouverture, affiché et non modifiable ;
|
||||
/// • formulaires lus à l'ouverture (<see cref="IFeedbackFormsSource"/> : publiés et vérifiés, sinon d'origine) ; s'ils
|
||||
/// sont republiés pendant la saisie (409), ils sont relus et la saisie des champs restés identiques est reprise ;
|
||||
/// • 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 IScreenshotSource? _screenshots;
|
||||
private readonly IFeedbackFormsSource? _formsSource;
|
||||
private FeedbackFormsDocument _forms = FeedbackFormRules.Defaults();
|
||||
private bool _isLoadingForms;
|
||||
private string? _attachmentError;
|
||||
private string? _attachmentWarning;
|
||||
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;
|
||||
|
||||
/// <param name="forms">Formulaires publiés (null : formulaires d'origine, affichés tout de suite).</param>
|
||||
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null,
|
||||
IScreenshotSource? screenshots = null, IFeedbackFormsSource? forms = null)
|
||||
{
|
||||
Target = target;
|
||||
_sender = sender;
|
||||
_screenshots = screenshots;
|
||||
_formsSource = forms;
|
||||
AddFileCommand = new RelayCommand(() => AddScreenshot(_screenshots?.PickFile()), () => CanAddScreenshot);
|
||||
PasteCommand = new RelayCommand(() => AddScreenshot(_screenshots?.FromClipboard(), fromClipboard: true), () => CanAddScreenshot);
|
||||
Attachments.CollectionChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(HasAttachments));
|
||||
OnPropertyChanged(nameof(AttachmentsText));
|
||||
OnPropertyChanged(nameof(CanAddScreenshot));
|
||||
AddFileCommand.RaiseCanExecuteChanged();
|
||||
PasteCommand.RaiseCanExecuteChanged();
|
||||
};
|
||||
_tech = tech ?? FeedbackTech.Collect(target.LauncherVersion, target.InstalledVersion);
|
||||
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());
|
||||
ApplyForms(_forms);
|
||||
FormsLoad = forms is null ? Task.CompletedTask : LoadFormsAsync(refresh: false);
|
||||
}
|
||||
|
||||
/// <summary>Lecture des formulaires lancée à l'ouverture (tests, captures d'écran de l'interface).</summary>
|
||||
public Task FormsLoad { get; private set; }
|
||||
|
||||
/// <summary>Révision des formulaires affichés (0 : formulaires d'origine), envoyée avec la demande.</summary>
|
||||
public long FormsRevision => _forms.Revision;
|
||||
|
||||
/// <summary>Formulaires en cours de lecture (les types de demande apparaissent ensuite).</summary>
|
||||
public bool IsLoadingForms
|
||||
{
|
||||
get => _isLoadingForms;
|
||||
private set => SetProperty(ref _isLoadingForms, value);
|
||||
}
|
||||
|
||||
private async Task LoadFormsAsync(bool refresh)
|
||||
{
|
||||
IsLoadingForms = true;
|
||||
try
|
||||
{
|
||||
ApplyForms(await _formsSource!.LoadAsync(refresh, CancellationToken.None));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Formulaires des demandes : {ex.Message} (formulaires d'origine utilisés).");
|
||||
ApplyForms(FeedbackFormRules.Defaults());
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingForms = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Types proposés : ceux non masqués des formulaires donnés.</summary>
|
||||
private void ApplyForms(FeedbackFormsDocument forms)
|
||||
{
|
||||
_forms = forms;
|
||||
Kinds.Clear();
|
||||
foreach (var form in forms.Visible) Kinds.Add(new FeedbackKindChoice(form, Choose));
|
||||
OnPropertyChanged(nameof(FormsRevision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires republiés pendant la saisie : le type en cours est reconstruit avec sa nouvelle définition, et la
|
||||
/// saisie des champs de même identifiant et de même type est reprise. Type masqué depuis : retour au choix du type.
|
||||
/// </summary>
|
||||
private void RebuildCurrentForm()
|
||||
{
|
||||
if (_form is null) return;
|
||||
var updated = _forms.Visible.FirstOrDefault(f => f.Kind == _form.Kind);
|
||||
if (updated is null)
|
||||
{
|
||||
Form = null;
|
||||
Fields.Clear();
|
||||
Step = FeedbackStep.ChooseKind;
|
||||
ErrorMessage = "Ce type de demande n'est plus proposé : choisissez-en un autre.";
|
||||
return;
|
||||
}
|
||||
var previous = Fields.ToDictionary(f => f.Id, StringComparer.Ordinal);
|
||||
Form = updated;
|
||||
Fields.Clear();
|
||||
foreach (var field in updated.Fields)
|
||||
{
|
||||
var view = new FeedbackFieldViewModel(field);
|
||||
if (previous.TryGetValue(field.Id, out var old)) view.RestoreFrom(old);
|
||||
Fields.Add(view);
|
||||
}
|
||||
ErrorMessage = "Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis cliquez de nouveau sur « Envoyer ».";
|
||||
}
|
||||
|
||||
public FeedbackTarget Target { get; }
|
||||
|
||||
/// <summary>« Demande pour : Exovival Zombie Reborn ».</summary>
|
||||
public string GameText => $"Demande pour : {Target.GameName}";
|
||||
|
||||
public ObservableCollection<FeedbackKindChoice> Kinds { get; } = new();
|
||||
|
||||
/// <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(FormCustomIcon));
|
||||
OnPropertyChanged(nameof(HasFormCustomIcon));
|
||||
OnPropertyChanged(nameof(TitleHint));
|
||||
OnPropertyChanged(nameof(AttachmentsAllowed));
|
||||
OnPropertyChanged(nameof(CanAddScreenshot));
|
||||
AddFileCommand.RaiseCanExecuteChanged();
|
||||
PasteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string FormTitle => _form?.Label ?? "Aide";
|
||||
public string FormIconKey => _form?.IconKey ?? "MessageQuestion";
|
||||
|
||||
/// <summary>Icône importée du type choisi (null : icône d'origine <see cref="FormIconKey"/>).</summary>
|
||||
public byte[]? FormCustomIcon => _form?.CustomIconBytes();
|
||||
public bool HasFormCustomIcon => FormCustomIcon is not null;
|
||||
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.";
|
||||
|
||||
// ------------------------------------------------------------------ Captures d'écran
|
||||
|
||||
public ObservableCollection<FeedbackAttachmentDraft> Attachments { get; } = new();
|
||||
|
||||
/// <summary>Captures possibles (source disponible : fenêtre WPF).</summary>
|
||||
public bool ScreenshotsAvailable => _screenshots is not null;
|
||||
|
||||
/// <summary>Captures proposées : source disponible ET autorisées pour ce type de demande (réglage du menu).</summary>
|
||||
public bool AttachmentsAllowed => _screenshots is not null && (_form?.AllowAttachments ?? true);
|
||||
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
public bool CanAddScreenshot => AttachmentsAllowed && !_isSending && Attachments.Count < FeedbackImages.MaxAttachments;
|
||||
|
||||
public string AttachmentsText =>
|
||||
$"{Attachments.Count} / {FeedbackImages.MaxAttachments} · PNG, JPEG, BMP, GIF ou WebP · {FeedbackImages.MaxBytes / 1024 / 1024} Mo au plus après conversion";
|
||||
|
||||
public string ScreenshotPrivacyText =>
|
||||
"Les captures sont converties et débarrassées de leurs métadonnées (position, appareil, date) avant l'envoi. " +
|
||||
"Vérifiez qu'elles ne montrent pas d'informations personnelles (pseudo, e-mail, fenêtres ouvertes…).";
|
||||
|
||||
public RelayCommand AddFileCommand { get; }
|
||||
public RelayCommand PasteCommand { get; }
|
||||
|
||||
/// <summary>Image refusée (pas une image, format non pris en charge, trop grande…).</summary>
|
||||
public string? AttachmentError
|
||||
{
|
||||
get => _attachmentError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _attachmentError, value)) OnPropertyChanged(nameof(HasAttachmentError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasAttachmentError => !string.IsNullOrEmpty(_attachmentError);
|
||||
|
||||
/// <summary>Après l'envoi : captures non reçues par le serveur (la demande, elle, est enregistrée).</summary>
|
||||
public string? AttachmentWarning
|
||||
{
|
||||
get => _attachmentWarning;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _attachmentWarning, value)) OnPropertyChanged(nameof(HasAttachmentWarning));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasAttachmentWarning => !string.IsNullOrEmpty(_attachmentWarning);
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute une image préparée par la source, après le contrôle commun (vraie image PNG ou JPEG, dimensions, taille,
|
||||
/// structure) et le retrait des métadonnées.
|
||||
/// </summary>
|
||||
public void AddScreenshot(ScreenshotResult? result, bool fromClipboard = false)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
if (fromClipboard) AttachmentError = "Le presse-papiers ne contient pas d'image.";
|
||||
return;
|
||||
}
|
||||
if (!CanAddScreenshot)
|
||||
{
|
||||
AttachmentError = $"{FeedbackImages.MaxAttachments} captures au plus.";
|
||||
return;
|
||||
}
|
||||
if (result.Error is not null || result.Bytes is null)
|
||||
{
|
||||
AttachmentError = $"{result.Name} : {result.Error ?? "image illisible."}";
|
||||
return;
|
||||
}
|
||||
var error = FeedbackImages.Sanitize(result.Bytes, out var info, out var clean);
|
||||
if (error is not null)
|
||||
{
|
||||
AttachmentError = $"{result.Name} : {error}";
|
||||
return;
|
||||
}
|
||||
AttachmentError = null;
|
||||
Attachments.Add(new FeedbackAttachmentDraft(result.Name, clean!, info!, draft =>
|
||||
{
|
||||
if (!_isSending) Attachments.Remove(draft);
|
||||
}));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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));
|
||||
OnPropertyChanged(nameof(CanAddScreenshot));
|
||||
AddFileCommand.RaiseCanExecuteChanged();
|
||||
PasteCommand.RaiseCanExecuteChanged();
|
||||
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,
|
||||
FormRevision = _forms.Revision,
|
||||
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, _form, 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
|
||||
{
|
||||
// Captures seulement si ce type de demande les accepte (réglage du menu ; le serveur le revérifie).
|
||||
IReadOnlyList<byte[]> attachments = AttachmentsAllowed ? Attachments.Select(a => a.Content).ToList() : Array.Empty<byte[]>();
|
||||
var sent = await _sender.SendAsync(submission, attachments, _sending.Token);
|
||||
Reference = sent.Reference;
|
||||
AttachmentWarning = sent.AttachmentsSent < attachments.Count
|
||||
? $"{attachments.Count - sent.AttachmentsSent} capture(s) sur {attachments.Count} n'ont pas pu être envoyées" +
|
||||
(sent.AttachmentError is null ? "." : $" ({sent.AttachmentError}).") + " La demande, elle, est bien enregistrée."
|
||||
: null;
|
||||
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}, {sent.AttachmentsSent} capture(s)");
|
||||
Step = FeedbackStep.Done;
|
||||
}
|
||||
catch (OperationCanceledException) when (_sending.IsCancellationRequested)
|
||||
{
|
||||
// fenêtre fermée pendant l'envoi
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.FeedbackFormChanged && _formsSource is not null)
|
||||
{
|
||||
Log.Info($"[{Target.GameId}] Formulaires des demandes republiés pendant la saisie : relus.");
|
||||
await LoadFormsAsync(refresh: true);
|
||||
RebuildCurrentForm();
|
||||
}
|
||||
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 { Code: ApiErrorCodes.FeedbackFormChanged } =>
|
||||
"Le formulaire vient d'être mis à jour : fermez puis rouvrez la fenêtre « Aide » (votre saisie est à refaire).",
|
||||
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,157 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>Formulaires à proposer au joueur (publiés et vérifiés, sinon ceux d'origine).</summary>
|
||||
public interface IFeedbackFormsSource
|
||||
{
|
||||
/// <param name="refresh">Relire le fichier publié (formulaires republiés pendant la saisie : réponse 409 du serveur).</param>
|
||||
Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, à côté du trousseau), lus à
|
||||
/// l'ouverture de la fenêtre « Aide » seulement :
|
||||
/// • fichier borné en taille, contrôlé avec les règles des formulaires (même signé, un fichier hors limites est refusé) ;
|
||||
/// • signature vérifiée avec le trousseau (autorisation « forms »), retour en arrière refusé (révision et date de
|
||||
/// signature), selon le mode « contentSigning » du launcher, comme le catalogue ;
|
||||
/// • dernière copie acceptée gardée dans les données du launcher (feedback\forms.json), revérifiée à la lecture ;
|
||||
/// • rien de valide (serveur injoignable, rien de publié, signature refusée) : formulaires d'origine (révision 0),
|
||||
/// toujours acceptés par le serveur.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormsService : IFeedbackFormsSource
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri? _url;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private FeedbackFormsDocument? _loaded;
|
||||
|
||||
/// <param name="url">Adresse du fichier publié (null : formulaires d'origine seulement).</param>
|
||||
/// <param name="trust">Vérification des signatures (null : mode « off », formulaires utilisés sans signature).</param>
|
||||
public FeedbackFormsService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, string dataDirectory,
|
||||
ContentTrustService? trust)
|
||||
{
|
||||
_download = download;
|
||||
_url = url;
|
||||
_trust = trust;
|
||||
CachePath = Path.Combine(dataDirectory, "feedback", "forms.json");
|
||||
}
|
||||
|
||||
public string CachePath { get; }
|
||||
|
||||
/// <summary>Adresse du fichier publié, déduite de celle du trousseau (ou, à défaut, du catalogue) : même dossier.</summary>
|
||||
public static Uri? PublishedUrl(string? keyringUrl, string? catalogUrl)
|
||||
{
|
||||
var baseUrl = keyringUrl ?? catalogUrl;
|
||||
return baseUrl is not null && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ? new Uri(uri, "feedback-forms.json") : null;
|
||||
}
|
||||
|
||||
public async Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_loaded is not null && !refresh) return _loaded;
|
||||
var cached = ReadCache();
|
||||
var fallback = cached ?? FeedbackFormRules.Defaults();
|
||||
if (_url is null) return _loaded = fallback;
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
bytes = await _download(_url, FeedbackFormRules.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
Log.Info("Formulaires des demandes : aucun formulaire publié, formulaires d'origine utilisés.");
|
||||
return _loaded = fallback;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande : pas mémorisé, nouvel essai à la prochaine ouverture.
|
||||
Log.Warn($"Formulaires des demandes injoignables : {ex.Message}" +
|
||||
(cached is null ? " (formulaires d'origine utilisés)." : $" (révision {cached.Revision} en cache utilisée)."));
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var published = await AcceptAsync(bytes, ct).ConfigureAwait(false);
|
||||
if (published is null) return _loaded = fallback;
|
||||
if (cached is not null && published.Revision < cached.Revision)
|
||||
{
|
||||
Log.Error($"Formulaires des demandes révision {published.Revision} refusés : plus anciens que la révision {cached.Revision} " +
|
||||
"déjà vue (retour en arrière).");
|
||||
return _loaded = cached;
|
||||
}
|
||||
WriteCache(bytes);
|
||||
Log.Info($"Formulaires des demandes : révision {published.Revision}.");
|
||||
return _loaded = published;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier publié lisible, dans les règles et à la signature acceptée ; sinon null (raison au journal).</summary>
|
||||
private async Task<FeedbackFormsDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
FeedbackFormsDocument? document;
|
||||
try
|
||||
{
|
||||
document = FeedbackFormRules.Parse(bytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log.Error($"Formulaires des demandes illisibles ({ex.Message}) : ignorés.");
|
||||
return null;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors))
|
||||
{
|
||||
Log.Error($"Formulaires des demandes refusés : {errors[0]}");
|
||||
return null;
|
||||
}
|
||||
if (_trust is not null && !await _trust.VerifyFeedbackFormsAsync(document!, ct).ConfigureAwait(false)) return null;
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>Dernière copie acceptée, revérifiée (règles et signature avec le trousseau en cache), sinon null.</summary>
|
||||
private FeedbackFormsDocument? ReadCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > FeedbackFormRules.MaxBytes) return null;
|
||||
var document = FeedbackFormRules.Parse(File.ReadAllBytes(CachePath));
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors)) return null;
|
||||
return _trust is null || _trust.VerifyCachedFeedbackForms(document!) ? document : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"Formulaires des demandes en cache illisibles : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteCache(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, System.Text.Encoding.UTF8.GetString(bytes));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Formulaires des demandes non gardés en cache : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,125 @@
|
||||
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"/>, <see cref="ContentKinds.Manifest"/> ou <see cref="ContentKinds.FeedbackForms"/>.</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
|
||||
? kind == ContentKinds.FeedbackForms
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ces formulaires : la fenêtre « Aide » proposera " +
|
||||
"les derniers formulaires acceptés, ou ceux d'origine."
|
||||
: catalog
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
|
||||
"(celui intégré au launcher pour une installation neuve)."
|
||||
: "Les launchers des joueurs (signatures obligatoires) refuseront cette version : ni installation, ni mise à jour " +
|
||||
"(le jeu déjà installé reste jouable)."
|
||||
: "Les launchers des joueurs l'accepteront, mais inscriront une alerte de signature dans leur journal (vérification en mode « warn »).";
|
||||
return problem + "\n\n" + consequence + "\n\n" +
|
||||
"Le serveur accepte la publication quand même. Pour régler ce point, un propriétaire ajoute la clé de ce PC au trousseau " +
|
||||
"(page « Signature »), le fait signer avec la clé racine puis le publie : les launchers accepteront alors ce contenu " +
|
||||
"sans nouvelle publication.";
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
<ResourceDictionary Source="Themes/Controls.xaml" />
|
||||
<!-- Notes de version (format enrichi) : modèles partagés par la fenêtre principale et la fenêtre agrandie -->
|
||||
<ResourceDictionary Source="Themes/Changelog.xaml" />
|
||||
<!-- Champs des formulaires des demandes : fenêtre « Aide » et aperçu de la page du menu -->
|
||||
<ResourceDictionary Source="Themes/FeedbackForms.xaml" />
|
||||
<!-- Menu d'administration : styles et modèles des pages des modules (Views/Admin) -->
|
||||
<ResourceDictionary Source="Themes/Admin.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
@@ -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,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private AdminWindowService? _admin;
|
||||
private CatalogService? _catalogService;
|
||||
private ContentTrustService? _trust;
|
||||
|
||||
/// <summary>Adresse du trousseau (même dossier que les formulaires publiés des demandes).</summary>
|
||||
private string? _keyringUrl;
|
||||
private PlayerSigningCheck? _playerSigning;
|
||||
private Task<CatalogDocument?>? _catalogFetch;
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
@@ -253,6 +259,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 +364,17 @@ 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).
|
||||
// Formulaires publiés depuis le menu (feedback-forms.json, à côté du trousseau), vérifiés comme le catalogue.
|
||||
var formsUrl = GameLauncher.Modules.Feedback.FeedbackFormsService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
|
||||
var forms = _http is null ? null
|
||||
: new GameLauncher.Modules.Feedback.FeedbackFormsService(_http.GetBytesAsync, formsUrl, catalog.DataDirectory, _trust);
|
||||
var feedback = new FeedbackWindowService(window, apiUrl, version, forms);
|
||||
launcher.IsHelpAvailable = feedback.IsAvailable;
|
||||
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
|
||||
}
|
||||
|
||||
window.DataContext = launcher;
|
||||
@@ -469,14 +487,17 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private ContentTrustService? CreateContentTrust(LauncherCatalog catalog)
|
||||
{
|
||||
var (keyringUrl, mode, minSequence) = ResolveContentSigning(catalog);
|
||||
_keyringUrl = keyringUrl;
|
||||
if (mode == ContentSigningMode.Off) return null;
|
||||
var roots = LauncherUpdateService.LoadRootKeys();
|
||||
if (roots.Count == 0)
|
||||
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 +587,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 et la fenêtre « Aide » : 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.2.1</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();
|
||||
}
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user