Compare commits
91
Commits
v2.5.0
...
user/phase8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b7af8253 | ||
|
|
723df05cf3 | ||
|
|
85e69e6a2c | ||
|
|
7a6ad2247b | ||
|
|
19f207d430 | ||
|
|
a35e419133 | ||
|
|
a9d07f6df8 | ||
|
|
b398e4059a | ||
|
|
69f943ea53 | ||
|
|
4c368cb1e0 | ||
|
|
eca812a718 | ||
|
|
e4f2d443e8 | ||
|
|
6e7b480db5 | ||
|
|
1c7735d25a | ||
|
|
7b153b81ee | ||
|
|
8fe67053e1 | ||
|
|
927df6c2b2 | ||
|
|
197f5186d0 | ||
|
|
98cf17c294 | ||
|
|
4da86bcc6a | ||
|
|
52761f53b6 | ||
|
|
cf75a22f19 | ||
|
|
009c84de9f | ||
|
|
9f9b9b9d4d | ||
|
|
12ab8377c9 | ||
|
|
5aa22df409 | ||
|
|
3df009912b | ||
|
|
89570947aa | ||
|
|
14b2167218 | ||
|
|
2e4d2ee76e | ||
|
|
7d9032fe20 | ||
|
|
ccd6b1a430 | ||
|
|
e4a21c6f5d | ||
|
|
1a7f45c38b | ||
|
|
b48d4222db | ||
|
|
51eecbb57a | ||
|
|
edd2633455 | ||
|
|
0e26e6eb11 | ||
|
|
916b53446a | ||
|
|
0e371518b5 | ||
|
|
153e498345 | ||
|
|
1e9f49a733 | ||
|
|
5f6c667030 | ||
|
|
93dd844221 | ||
|
|
5ad8025946 | ||
|
|
080000e13c | ||
|
|
3422b92f81 | ||
|
|
683cc7a9dc | ||
|
|
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 |
No files matched your search
@@ -68,3 +68,4 @@
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
website/deploy/*.conf text eol=lf
|
||||
@@ -3,9 +3,9 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Company>Xbhost</Company>
|
||||
<Authors>Xbhost</Authors>
|
||||
<Copyright>© Xbhost</Copyright>
|
||||
<Company>LanisysGames</Company>
|
||||
<Authors>LanisysGames</Authors>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
tools\ui-snapshot.bat = tools\ui-snapshot.bat
|
||||
tools\sign-keyring.bat = tools\sign-keyring.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
tools\publish-site.bat = tools\publish-site.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
@@ -68,6 +69,21 @@ 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
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
|
||||
website\deploy\installer-site.sh = website\deploy\installer-site.sh
|
||||
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
|
||||
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
|
||||
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -164,6 +180,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
|
||||
@@ -172,6 +196,14 @@ Global
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -189,7 +221,10 @@ 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}
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -32,14 +32,16 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| Projet | Rôle |
|
||||
|---|---|
|
||||
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`), mode maintenance (`Maintenance/`) |
|
||||
| `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, 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) |
|
||||
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
|
||||
| `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`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
@@ -51,14 +53,17 @@ src/GameLauncher/
|
||||
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
|
||||
├─ 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
|
||||
│ dont les pages « Jeux », « Notes de version » et « Publier une version »),
|
||||
│ Maintenance.xaml (palette rouge / orange du mode maintenance)
|
||||
├─ 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)
|
||||
```
|
||||
@@ -363,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
|
||||
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
|
||||
| Icône | Remplacez `Assets/launcher.ico` |
|
||||
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
|
||||
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
|
||||
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
|
||||
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
|
||||
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
|
||||
@@ -425,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
|
||||
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
|
||||
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
|
||||
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
|
||||
simplement qu'une nouvelle version existe.
|
||||
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
|
||||
|
||||
### Sécurité
|
||||
|
||||
@@ -437,6 +442,7 @@ simplement qu'une nouvelle version existe.
|
||||
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
|
||||
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
|
||||
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
|
||||
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
|
||||
|
||||
### Déroulement
|
||||
|
||||
@@ -459,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
|
||||
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
|
||||
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
|
||||
|
||||
### Version prioritaire (correctif critique)
|
||||
|
||||
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
|
||||
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
|
||||
|
||||
| Point | Comportement |
|
||||
|---|---|
|
||||
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
|
||||
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
|
||||
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
|
||||
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
|
||||
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
|
||||
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
|
||||
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
|
||||
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
|
||||
|
||||
### Mise en place (une seule fois)
|
||||
|
||||
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
|
||||
@@ -486,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
|
||||
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
|
||||
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
|
||||
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
|
||||
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
|
||||
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
|
||||
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
|
||||
|
||||
Vérification à tout moment :
|
||||
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
|
||||
@@ -508,7 +533,8 @@ 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). Ajoutée ensuite : la page **Fichiers du serveur**.
|
||||
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**, puis le **mode maintenance** (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
|
||||
@@ -516,6 +542,41 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
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.
|
||||
|
||||
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
|
||||
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
|
||||
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
|
||||
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
|
||||
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
|
||||
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
|
||||
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
|
||||
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
|
||||
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
|
||||
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
|
||||
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 decies.
|
||||
* **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. Journal d'audit : chaque publication indique
|
||||
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
|
||||
* **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.
|
||||
@@ -601,7 +662,9 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
|
||||
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
|
||||
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
|
||||
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
|
||||
`"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
|
||||
@@ -646,3 +709,44 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
|
||||
| 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/` |
|
||||
|
||||
## 9. Site vitrine
|
||||
|
||||
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
|
||||
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
|
||||
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
|
||||
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
|
||||
|
||||
| Page | Contenu |
|
||||
|---|---|
|
||||
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
|
||||
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
|
||||
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
|
||||
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
|
||||
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
|
||||
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
|
||||
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
|
||||
|
||||
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
|
||||
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
|
||||
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
|
||||
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
|
||||
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
|
||||
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
|
||||
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
|
||||
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
|
||||
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
|
||||
pas affichées (`Site:Launcher:HiddenSections`).
|
||||
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
|
||||
toujours encodés (jamais interprétés comme du HTML).
|
||||
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
|
||||
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
|
||||
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
|
||||
aucune donnée écrite sur le disque.
|
||||
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
|
||||
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
|
||||
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
|
||||
liens `javascript:`/`data:` retirés, images du site uniquement.
|
||||
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
|
||||
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
|
||||
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
|
||||
+292
-8
@@ -67,7 +67,8 @@ 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.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `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/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
|
||||
| `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é |
|
||||
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
|
||||
@@ -77,9 +78,10 @@ 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`.
|
||||
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
|
||||
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
|
||||
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
|
||||
Paquets NuGet : xUnit (projet de tests) ; depuis la phase 8, le serveur utilise aussi `Microsoft.Data.Sqlite` (Microsoft)
|
||||
et `Konscious.Security.Cryptography.Argon2` (licence MIT) pour les comptes joueurs (`docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`).
|
||||
|
||||
## 5. Modules
|
||||
|
||||
@@ -321,6 +323,14 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
|
||||
| `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 |
|
||||
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
|
||||
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
|
||||
|
||||
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.
|
||||
@@ -672,6 +682,265 @@ Page « Jeux » › icône ou image de fond › **« Parcourir… »** (explorat
|
||||
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` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
|
||||
|
||||
**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 ».
|
||||
|
||||
### 11 septies. Mode maintenance du launcher (7f ✅)
|
||||
|
||||
**Besoin** : depuis le menu, un bouton pour mettre **tous les launchers** (tous les jeux) en maintenance : thème rouge /
|
||||
orange, « Maintenance en cours — merci de revenir plus tard » au centre, et **tout bloqué** sauf la **désinstallation
|
||||
des jeux** et le **menu d'administration** ; puis la terminer.
|
||||
|
||||
**Document** (`Contracts/Maintenance/MaintenanceDocument`) : `maintenance.json` = format `gamelauncher-maintenance`,
|
||||
version 1, **révision**, `enabled`, `message` (facultatif), `since` (début, à la seconde ; absent hors maintenance),
|
||||
`updatedAt` (posé par le serveur, hors signature), signature ; révision 0 = jamais publié (inactif). Règles
|
||||
(`MaintenanceRules`, communes au menu, au serveur et au launcher) : format et version, révision ≥ 1 ; message
|
||||
nettoyé comme les textes des demandes (`FeedbackRules.CleanMultiline` : caractères de contrôle, invisibles, de mise en
|
||||
forme, d'inversion du sens d'écriture, d'usage privé refusés), **300 caractères, 6 lignes, aucun lien**
|
||||
(`FeedbackLinks`) ; maintenance active : début présent, à la seconde, pas plus de 10 min dans le futur (serveur) ;
|
||||
inactive : ni début ni message ; **8 Ko** au plus (sous la limite de 64 Ko de l'API : pas de bloc nginx d'API). Titre et
|
||||
sous-titre affichés aux joueurs **fixes** (dans le code) : seul le message est publié. Condensé signé : format, version,
|
||||
révision, état, message, début.
|
||||
|
||||
**Signature** : type de contenu `maintenance` (identifiant signé `maintenance`) et autorisation du trousseau
|
||||
**`maintenance`** (permission `maintenance.manage` : Propriétaire d'office, Administrateur sur une installation neuve,
|
||||
pas l'Éditeur). Les launchers 3.2 acceptent un trousseau qui la contient (autorisation inconnue sans effet) ; ceux
|
||||
d'avant le refusent et gardent le précédent (publier la 3.3 avant de re-signer). Un serveur compromis ne peut donc ni
|
||||
déclencher une fausse maintenance, ni prolonger une maintenance chez les launchers en « require » (date de signature
|
||||
non antérieure à la dernière acceptée, révision non inférieure).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/maintenance` | administrateur connecté | | État en vigueur, publication possible, adresse publique, signataire (tableau de bord) |
|
||||
| `PUT v1/maintenance` | `maintenance.manage` | ✔ | `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 `revision_conflict` sinon), début inchangé pendant une maintenance ; audit `maintenance.enable`, `maintenance.disable` ou `maintenance.update` |
|
||||
|
||||
**Serveur** (`Modules/Maintenance/MaintenanceModule`, `Storage/MaintenanceStore`) : référence privée `maintenance.json`
|
||||
(+ `.bak`, historique de 20 révisions `maintenance-history/`), copie **publique** à côté du trousseau
|
||||
(`MaintenancePublicFileFullPath`, 644, atomique ; public d'abord) ; nom réservé (refus au démarrage si le catalogue ou
|
||||
le trousseau s'appellent ainsi). Document signé obligatoire. Le serveur ne bloque rien lui-même : l'API reste utilisable
|
||||
(c'est elle qui termine la maintenance). `GET v1/signing` renvoie aussi sa signature (`SigningState.Maintenance`).
|
||||
SQLite écarté : un seul petit document, publié en fichier de toute façon. nginx : `location = /catalog/maintenance.json`,
|
||||
sans cache.
|
||||
|
||||
**Launcher (joueur)** : `Core/Maintenance/MaintenanceService` lit `maintenance.json` (adresse déduite du trousseau,
|
||||
sinon du catalogue) **au démarrage**, en parallèle du catalogue et de la mise à jour du launcher, et l'attend **4 s au
|
||||
plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--uninstall`) ; puis toutes les **2 min** (1 min
|
||||
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
|
||||
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
|
||||
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
|
||||
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
|
||||
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
|
||||
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
|
||||
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
|
||||
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
|
||||
vérifié, un 404 termine la maintenance.
|
||||
|
||||
Blocage (`LauncherViewModel.ApplyMaintenance`, `GameViewModel.IsMaintenance`) : commandes désactivées (sauf
|
||||
`UninstallCommand`, l'annulation d'une opération commencée et la fermeture du bandeau) **et** refus à l'entrée de chaque
|
||||
action (installation, mise à jour, réparation, vérification, localisation, oubli, lancement, raccourci du Bureau,
|
||||
paramètres, recherche de mises à jour, `RunOperationAsync` sauf la désinstallation) avec un bandeau d'explication ;
|
||||
recherche automatique et mise à jour automatique suspendues, relancées à la fin de la maintenance ; paramètres du
|
||||
launcher, bouton « Aide » et site officiel masqués ou désactivés. Non interrompus : opération déjà commencée (toujours
|
||||
annulable), jeu déjà lancé, mise à jour du launcher lui-même (signée par la clé racine : moyen de corriger un launcher
|
||||
défectueux). Affichage : `Themes/Maintenance.xaml` (palette rouge / orange, mêmes clés que les thèmes) ajouté aux
|
||||
ressources de la fenêtre principale seulement, carte au centre (cône de chantier `Icon.TrafficCone`, bandes de
|
||||
chantier, titre, sous-titre, message en texte brut sélectionnable, « Depuis le … », rappel « la désinstallation reste
|
||||
possible »), bouton principal « MAINTENANCE » désactivé ; début et fin avec le fondu enchaîné d'un changement de jeu.
|
||||
**Ce n'est pas une barrière de sécurité** : un launcher modifié peut l'ignorer ; les serveurs du jeu doivent refuser
|
||||
eux-mêmes les connexions si nécessaire.
|
||||
|
||||
**Menu d'administration** : module `MaintenanceAdminModule`, page **« Maintenance »** (`MaintenancePanelViewModel`,
|
||||
permission `maintenance.manage`, ordre 5, icône cône) : état publié (en cours depuis…, par…, terminée le…), message
|
||||
(compteur, contrôle en direct, aperçu de la carte), « Activer la maintenance » (confirmation détaillée), « Modifier le
|
||||
message », « Terminer la maintenance » (confirmation), contrôle du trousseau des joueurs avant chaque publication
|
||||
(`PlayerSigningCheck`), 409 : état relu, saisie gardée. Tableau de bord : bandeau « Maintenance en cours » pour tout
|
||||
administrateur (bouton « Gérer » avec la permission). Page « Signature » : ligne « Mode maintenance ». Journal d'audit :
|
||||
catégorie « Mode maintenance ».
|
||||
|
||||
## 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 :
|
||||
@@ -700,6 +969,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
|
||||
@@ -743,8 +1019,12 @@ Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (s
|
||||
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
|
||||
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
|
||||
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
|
||||
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
|
||||
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
|
||||
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
|
||||
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
|
||||
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
|
||||
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
|
||||
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
|
||||
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
|
||||
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
|
||||
@@ -774,6 +1054,10 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
|
||||
| **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) | ✅ |
|
||||
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
|
||||
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
|
||||
| **8a** | Comptes joueurs — socle serveur (module `Accounts`, schéma d'authentification `PlayerSession` totalement séparé des administrateurs, base SQLite chiffrée, Argon2id + poivre, session mémorisée liée à une clé d'appareil, anti-force brute, journal des comptes, outil en ligne de commande) ; désactivé par défaut. Suite (8b à 8h) : `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`, § 12 | ✅ |
|
||||
|
||||
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.
|
||||
@@ -782,8 +1066,8 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
|
||||
|
||||
| Option | Pourquoi elle n'a pas été retenue |
|
||||
|---|---|
|
||||
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
|
||||
| Identifiant + mot de passe + OTP (administrateurs) | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur. Les **comptes joueurs** (phase 8), totalement séparés, utilisent adresse + mot de passe + OTP facultatif : ils n'ouvrent jamais le menu d'administration |
|
||||
| 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) ; **retenu pour les comptes joueurs** (relations, unicité, volumes : `PLAYER-ACCOUNTS-ARCHITECTURE.md` § 6) |
|
||||
| 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 |
|
||||
@@ -0,0 +1,247 @@
|
||||
# Comptes joueurs — Architecture (phase 8)
|
||||
|
||||
> Document de référence du module « comptes joueurs ». Plan validé le 2026-11-10 (copie du plan dans les documents du
|
||||
> projet : `GameLauncher-Comptes-Joueurs-Plan.md`). Statut des étapes : § 12.
|
||||
> À relire avec `ADMIN-ARCHITECTURE.md` (socle, principes de sécurité) et la fiche RGPD / CNIL du projet.
|
||||
|
||||
## 1. Besoin
|
||||
|
||||
* Compte joueur = **adresse e-mail + mot de passe**, **OTP (TOTP) facultatif** (étape 8c).
|
||||
* **Jamais obligatoire** pour lancer le launcher, installer ou jouer (jeux solo : anonyme). Obligatoire seulement pour
|
||||
la future partie sociale.
|
||||
* Bouton « Aide » : **anonyme** (comme aujourd'hui, aucune réponse) **ou connecté** (suivi + échanges avec les
|
||||
administrateurs) — étape 8f.
|
||||
* Menu admin (étape 8d), avec permissions : supprimer, désactiver / réactiver (durée ou indéfini), forcer le changement
|
||||
de mot de passe, mot de passe temporaire, réinitialiser l'OTP ; blocages par IP publique et par identifiants matériels
|
||||
du PC (étape 8e).
|
||||
* **Aucun lien** avec les comptes administrateurs : un compte joueur ne peut **jamais** ouvrir le menu d'administration.
|
||||
* Dictionnaire public des pires mots de passe ; anti-force brute (blocage temporaire puis suspension levée par un
|
||||
administrateur) ; **connexion mémorisée** (comme Steam) ; **audit** de chaque action joueur et administrateur.
|
||||
|
||||
## 2. Principes
|
||||
|
||||
1. **Le serveur est la seule barrière** : chaque règle est revérifiée côté serveur avec le même code partagé
|
||||
(`GameLauncher.Contracts.Accounts`).
|
||||
2. **Deux mondes étanches** : administrateurs = clé publique/privée et requêtes signées ; joueurs = mot de passe.
|
||||
Schémas d'authentification ASP.NET distincts (`AdminSession` / `PlayerSession`), jetons de préfixes distincts
|
||||
(`glpa_`, `glpr_`, `glpc_` pour les joueurs), stockages distincts (`admins.json` + sessions en mémoire / `players.db`),
|
||||
routes distinctes (`v1/account/…`). Chaque règle d'autorisation d'administration **désigne explicitement** le schéma
|
||||
`AdminSession` ; le gestionnaire administrateur ignore tout jeton commençant par `glp`. Un test essaie **toutes** les
|
||||
routes d'administration déclarées avec des jetons joueurs (accès, changement exigé, renouvellement) : 401 attendu
|
||||
partout (`PlayerSeparationTests`).
|
||||
3. **Minimisation** : ni nom, ni date de naissance, ni adresse postale, ni nom Windows du PC. Le pseudo viendra avec le
|
||||
module social.
|
||||
4. **Une copie des données du serveur ne livre rien d'exploitable** : adresses chiffrées, mots de passe en Argon2id avec
|
||||
un « poivre », clé maîtresse hors du dossier des données.
|
||||
5. **Désactivé par défaut** (`LauncherApi:Players:Enabled = false`) ; création de compte désactivable séparément.
|
||||
6. Le launcher ne contacte l'API que si le joueur l'a voulu (connexion, création) ou s'il a une session mémorisée.
|
||||
|
||||
## 3. Protocole
|
||||
|
||||
### 3.1 Clé d'appareil
|
||||
|
||||
Le launcher crée une clé ECDSA P-256 **par PC** (étape 8b : dans la puce TPM si elle existe, sans code PIN, non
|
||||
exportable ; sinon clé logicielle protégée par DPAPI). La création de compte, la connexion et chaque renouvellement de
|
||||
session sont **signés** par cette clé (`PlayerMessages.Device`) :
|
||||
|
||||
```
|
||||
GameLauncher/Player/Device/v1
|
||||
purpose:<register|login|refresh>
|
||||
origin:https://launcher.lanisys.fr
|
||||
server:<serverId>
|
||||
challenge:<challengeId>
|
||||
nonce:<nonce>
|
||||
device:<keyId de la clé d'appareil>
|
||||
```
|
||||
|
||||
En-tête propre aux joueurs (séparation de domaine avec `GameLauncher/Admin/…`). Bénéfices : un jeton de renouvellement
|
||||
copié sur un autre PC est inutilisable ; seul un appareil déjà connecté avec succès est « de confiance » pour
|
||||
l'anti-force brute.
|
||||
|
||||
### 3.2 Défi
|
||||
|
||||
`POST v1/account/challenge { purpose }` → `{ challengeId, nonce, purpose, difficulty, serverId, origin, expiresAt }`.
|
||||
Défi **sans état** (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine
|
||||
`GameLauncher/Player/Challenge/v1`), valable 5 min, **à usage unique** (consommé seulement avec une signature d'appareil
|
||||
valide). Preuve de travail (`FeedbackProof`) : 20 bits pour la création de compte, 16 pour la connexion, aucune pour le
|
||||
renouvellement. Un défi d'un usage ne sert pas à un autre.
|
||||
|
||||
### 3.3 Routes
|
||||
|
||||
| Route | Accès | Rôle |
|
||||
|---|---|---|
|
||||
| `POST v1/account/challenge` | anonyme | Défi (§ 3.2) |
|
||||
| `POST v1/account/register` | anonyme + défi + clé d'appareil | Création de compte → session ouverte |
|
||||
| `POST v1/account/login` | anonyme + défi + clé d'appareil | Connexion → session, ou jeton « changement exigé » |
|
||||
| `POST v1/account/refresh` | jeton de renouvellement + signature de l'appareil | Session renouvelée (jeton remplacé) |
|
||||
| `POST v1/account/logout` | session | Déconnexion de cet appareil, ou de tous (`allDevices`) |
|
||||
| `GET v1/account/me` | session | Compte (adresse **masquée**) et appareils connectés |
|
||||
| `POST v1/account/password` | session ou jeton « changement exigé » | Nouveau mot de passe |
|
||||
| `DELETE v1/account/devices/{deviceId}` | session | Déconnecter un appareil du compte |
|
||||
|
||||
Limite de débit propre (`account`, `Players:AuthRequestsPerMinute`, 20/min par IP, IPv6 par /64) sur le défi, la
|
||||
création, la connexion, le renouvellement et le changement de mot de passe, en plus de la limite générale. `GET v1/info`
|
||||
annonce `accounts` (activés, création ouverte, version des conditions, version des règles des mots de passe) ; absent
|
||||
si les comptes sont désactivés.
|
||||
|
||||
### 3.4 Jetons et session mémorisée
|
||||
|
||||
| Jeton | Format | Durée | Où |
|
||||
|---|---|---|---|
|
||||
| Accès | `glpa_` + 256 bits | 15 min | mémoire du launcher ; serveur : empreinte en mémoire |
|
||||
| Renouvellement | `glpr_` + identifiant d'appareil + `.` + 256 bits | 90 jours sans utilisation, 1 an au plus | PC (chiffré DPAPI, étape 8b) ; serveur : empreinte en base |
|
||||
| Changement exigé | `glpc_` + 256 bits | 10 min, usage unique | mémoire |
|
||||
|
||||
Renouvellement : jeton **remplacé à chaque fois** (l'ancien devient « précédent ») ; un jeton précédent présenté de
|
||||
nouveau = copie volée (ou réponse perdue) → l'appareil est déconnecté par précaution (`session.reuse`). Deux
|
||||
renouvellements simultanés avec le même jeton : un seul réussit. Un redémarrage du service efface les jetons d'accès : le
|
||||
launcher renouvelle sa session sans rien demander au joueur.
|
||||
|
||||
À chaque requête authentifiée : jeton valide **et** compte actif (ou suspendu, § 5) **et** appareil toujours enregistré
|
||||
**et** aucun changement de mot de passe exigé. Une désactivation, une déconnexion ou un changement de mot de passe
|
||||
prennent effet immédiatement.
|
||||
|
||||
## 4. Mots de passe et adresses
|
||||
|
||||
* **Règles** (`PasswordRules`, identiques launcher / serveur) : 12 caractères au moins avec 3 types parmi minuscules,
|
||||
majuscules, chiffres, symboles ; ou 16 caractères sans contrainte (phrase de passe) ; 128 au plus ; pas de caractère
|
||||
de contrôle. Refus : mots de passe du **dictionnaire** (« 100k-most-used-passwords-NCSC », 99 839 entrées, ressource
|
||||
intégrée compressée, + une liste de mots de passe français courants) et leurs variantes simples (minuscules,
|
||||
substitutions `0→o 1→i 3→e 4→a 5→s 7→t @→a $→s…`, chiffres et symboles retirés aux extrémités :
|
||||
« P@ssw0rd2024! » ≡ « password ») ; suites triviales (répétitions, rangées de claviers AZERTY / QWERTY / QWERTZ, suites
|
||||
de lettres ou de chiffres, motifs répétés) ; mot de passe tiré de l'adresse e-mail ou des noms du studio et des jeux
|
||||
(moins de 8 caractères restants une fois ces mots retirés).
|
||||
* **Aucune normalisation Unicode** : le serveur tourne en « globalisation invariante » (sans ICU), où elle n'est pas
|
||||
disponible ; le mot de passe est contrôlé et haché tel qu'il est saisi, de la même façon partout.
|
||||
* **Adresse** (`PlayerEmail`) : minuscules, caractères ASCII seulement (domaines internationaux sous forme `xn--`), un
|
||||
seul `@`, 254 caractères au plus ; masquée pour l'affichage (`n***@e***.fr`). Non vérifiée tant que le module e-mail
|
||||
n'existe pas (`emailVerified = false`).
|
||||
* **Empreinte** (`PlayerPasswordHasher`) : Argon2id (RFC 9106 ; paquet `Konscious.Security.Cryptography.Argon2`, MIT),
|
||||
19 Mio, 2 passes, parallélisme 1 (OWASP), sel 128 bits, **poivre = clé secrète d'Argon2** dérivée de la clé maîtresse.
|
||||
Format PHC `$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte>` : un relèvement des réglages est appliqué à la
|
||||
connexion suivante de chaque joueur. Calculs limités à 4 simultanés (file d'attente, 503 au-delà de 20 s) ; même calcul
|
||||
pour une adresse inconnue (la durée de réponse ne révèle rien). Vecteur officiel de la RFC vérifié par un test
|
||||
(`Argon2VectorTests`).
|
||||
|
||||
## 5. Anti-force brute
|
||||
|
||||
1. Limites de débit (nginx et ASP.NET) et preuve de travail.
|
||||
2. **Par adresse IP** (en mémoire) : 30 échecs de connexion / 15 min tous comptes confondus → 429 ; 3 créations de
|
||||
compte / heure et 5 / jour → 429 ; plafond global de 500 créations / jour → 503.
|
||||
3. **Par compte, depuis un appareil inconnu** : blocage après **5 échecs consécutifs** (15 min, puis 30, puis 60 :
|
||||
durée doublée à chaque blocage) ; au **4ᵉ blocage (20 échecs consécutifs)**, le compte est **suspendu**. Le blocage
|
||||
s'applique avant tout calcul (429 + `Retry-After`).
|
||||
4. **Appareils de confiance** (déjà connectés, prouvés par leur clé) : leurs échecs sont comptés à part (blocage de
|
||||
15 min après 10 échecs), **jamais de suspension**.
|
||||
5. **Suspension = connexions depuis un NOUVEL appareil refusées** jusqu'à ce qu'un administrateur la lève (outil en ligne
|
||||
de commande dès maintenant, menu d'administration à l'étape 8d). Les appareils déjà connectés continuent de
|
||||
fonctionner (`newDevicesSuspended` dans `GET v1/account/me`) : un tiers qui connaît l'adresse peut déclencher la
|
||||
suspension, mais pas priver le joueur de son compte. Inscrite au journal d'audit (`players.suspend`, par référence).
|
||||
6. **Rien ne révèle qu'une adresse a un compte** : message identique pour une adresse inconnue et un mauvais mot de
|
||||
passe, même durée de calcul, et une adresse inconnue se **bloque exactement comme un compte réel** (blocage « miroir »
|
||||
en mémoire). L'état du compte (désactivé, suspendu) n'est révélé qu'après un **mot de passe correct**. Seule exception
|
||||
inévitable sans e-mail de confirmation : la création de compte indique qu'une adresse est déjà prise (atténué par les
|
||||
points 1 et 2).
|
||||
7. Une connexion réussie remet les compteurs à zéro.
|
||||
|
||||
## 6. Stockage
|
||||
|
||||
**SQLite retenu** pour ce module (analyse « module par module ») : relations (compte ⇄ appareils ⇄ journal, puis
|
||||
conversations et blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par index.
|
||||
`Microsoft.Data.Sqlite` (Microsoft), sans Entity Framework, requêtes paramétrées uniquement.
|
||||
|
||||
* Fichier : `/var/lib/launcher-api/players/players.db` (dossier 0700, fichier 0600 créé avant l'ouverture ; les fichiers
|
||||
`-wal` et `-shm` reprennent ses droits). Journal WAL, clés étrangères, `secure_delete` (lignes supprimées écrasées),
|
||||
écriture synchrone complète. Schéma versionné (`PRAGMA user_version`, version 1). Une seule connexion protégée par un
|
||||
verrou ; l'outil en ligne de commande peut ouvrir la base en même temps.
|
||||
* Tables : `accounts` (identifiant 128 bits, référence `PL-XXXX-XXXX`, index aveugle et adresse chiffrée, empreinte du
|
||||
mot de passe, « changement exigé », champs OTP réservés à l'étape 8c, état, échéance de désactivation, motif chiffré,
|
||||
dates, version des conditions acceptée, compteurs d'échecs et blocages) ; `devices` (clé publique, nom générique,
|
||||
empreintes du jeton de renouvellement en vigueur et précédent, échéances) ; `account_events` (journal, § 7).
|
||||
* **Chiffrement** (`PlayerSecrets`) : clé maîtresse de 256 bits → sous-clés HKDF-SHA256 par usage (chiffrement des
|
||||
adresses, index aveugle, poivre, champs libres). Adresse : AES-256-GCM, nonce aléatoire, **liée au compte** (données
|
||||
associées : une valeur copiée sur un autre compte ne se déchiffre pas), préfixe de version de clé (`1.`). Recherche et
|
||||
unicité par **index aveugle** HMAC-SHA256 de l'adresse normalisée.
|
||||
* **Clé maîtresse hors du dossier des données** : credential systemd chiffré (`LoadCredentialEncrypted`,
|
||||
`players-master-key`, lu dans `$CREDENTIALS_DIRECTORY`) ou `Players:MasterKeyFile` (chemin absolu). Le serveur refuse
|
||||
de démarrer si la clé manque, est dans le dossier des données, est accessible à d'autres comptes, ou n'est pas une clé
|
||||
de 32 octets. **À sauvegarder hors ligne** : perdue, les adresses sont illisibles et tous les mots de passe doivent
|
||||
être réinitialisés. Commande : `GameLauncher.Server players-key`.
|
||||
* Les demandes des joueurs restent en fichiers JSON (étape 8f : identifiant opaque du compte ajouté, messages dans
|
||||
SQLite, chiffrés).
|
||||
|
||||
## 7. Audit
|
||||
|
||||
* **Actions des joueurs et du système sur un compte** → table `account_events` : création, connexion réussie ou
|
||||
refusée, blocage, suspension, déconnexion (un appareil / tous), changement de mot de passe (dont exigé), appareil
|
||||
déconnecté, renouvellement (une ligne par appareil et par jour au plus), réutilisation d'un jeton. Avec l'adresse IP et
|
||||
l'identifiant de la clé d'appareil, **jamais l'adresse e-mail ni le mot de passe**. Les échecs sur une adresse sans
|
||||
compte sont inscrits sans compte.
|
||||
* **Actions des administrateurs et du système** → journal d'audit existant, par **référence** de compte : `players.suspend`
|
||||
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`.
|
||||
Les libellés lisibles et la catégorie « Comptes joueurs » du journal du menu arrivent avec l'étape 8d.
|
||||
* **Conservation** (`RetentionService`, au démarrage puis chaque jour) : journal des comptes 12 mois
|
||||
(`Players:EventsRetentionMonths`), adresses IP effacées au-delà de 6 mois (`Players:EventsIpRetentionMonths`).
|
||||
|
||||
## 8. Outil en ligne de commande (secours, sur le VPS)
|
||||
|
||||
Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais affichée (aucune clé maîtresse nécessaire).
|
||||
|
||||
| Commande | Rôle |
|
||||
|---|---|
|
||||
| `players-key` | Génère une clé maîtresse (sortie standard, à chiffrer avec `systemd-creds`) |
|
||||
| `players-show --ref PL-XXXX-XXXX` | État, dates, échecs, appareils, derniers événements |
|
||||
| `players-unlock --ref …` | Lève une suspension ou un blocage (compte réactivé) |
|
||||
| `players-disable --ref … [--until AAAA-MM-JJ[THH:MM]]` | Désactive (UTC ; sans date : indéfiniment) |
|
||||
| `players-enable --ref …` | Réactive |
|
||||
|
||||
## 9. Réglages (`LauncherApi:Players`)
|
||||
|
||||
| Réglage | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `Enabled` | `false` | Service des comptes |
|
||||
| `RegistrationEnabled` | `true` | Création de compte ouverte |
|
||||
| `MasterKeyFile` | vide | Clé maîtresse (vide : credential systemd `players-master-key`) |
|
||||
| `TermsVersion` | `1` | Version des conditions à accepter à la création |
|
||||
| `HashMemoryKib`, `HashIterations`, `HashParallelism` | 19456, 2, 1 | Argon2id (coût minimal refusé en dessous de 19 Mio × 2) |
|
||||
| `MaxConcurrentHashes` | 4 | Calculs simultanés |
|
||||
| `RegisterProofDifficulty`, `LoginProofDifficulty` | 20, 16 | Preuves de travail |
|
||||
| `FailuresPerLock`, `LockMinutes`, `SuspendAfterLocks` | 5, 15, 4 | Anti-force brute (appareils inconnus) |
|
||||
| `TrustedFailuresPerLock` | 10 | Appareils de confiance |
|
||||
| `FailedLoginsPerIpPer15Minutes` | 30 | Échecs par IP |
|
||||
| `RegistrationsPerIpPerHour`, `…PerDay`, `MaxRegistrationsPerDay` | 3, 5, 500 | Créations de compte |
|
||||
| `AuthRequestsPerMinute` | 20 | Limite de débit des routes `account` |
|
||||
| `AccessTokenMinutes`, `RefreshIdleDays`, `RefreshMaxDays` | 15, 90, 365 | Sessions |
|
||||
| `MaxDevicesPerAccount` | 20 | Au-delà, le moins récemment utilisé est déconnecté |
|
||||
| `EventsRetentionMonths`, `EventsIpRetentionMonths` | 12, 6 | Conservation du journal des comptes |
|
||||
|
||||
## 10. RGPD / CNIL
|
||||
|
||||
* Base légale : exécution du contrat (compte, échanges) ; intérêt légitime (journaux de sécurité, anti-force brute,
|
||||
blocages). Aucun traitement par consentement.
|
||||
* Étape 8b : export JSON de ses données et suppression du compte depuis le launcher ; interface commune
|
||||
`IPersonalDataProvider` ; comptes inactifs (désactivés après 2 ans, supprimés 1 an plus tard).
|
||||
* Étape 8g : registre des traitements, politique de confidentialité et CGU, brouillon d'AIPD (mineurs, identifiants
|
||||
matériels, messagerie).
|
||||
|
||||
## 11. Limites assumées
|
||||
|
||||
* Adresse non vérifiée tant que le module e-mail n'existe pas (étape 8h) : n'importe qui peut créer un compte avec une
|
||||
adresse qui n'est pas la sienne ; l'administrateur peut le supprimer (étape 8d).
|
||||
* La création de compte révèle qu'une adresse est déjà utilisée (§ 5, point 6).
|
||||
* Rotation de la clé maîtresse : prévue par le format (version de clé), pas encore outillée.
|
||||
|
||||
## 12. Étapes
|
||||
|
||||
| Étape | Contenu | Statut |
|
||||
|---|---|---|
|
||||
| **8a** | Socle serveur : contrats (`Contracts/Accounts`), module `Accounts`, schéma `PlayerSession`, base SQLite chiffrée, Argon2id + poivre, création / connexion / renouvellement / déconnexion / mot de passe / appareils, anti-force brute, journal des comptes, purge, outil en ligne de commande, tests (dont l'étanchéité sur toutes les routes d'administration) | ✅ |
|
||||
| 8b | Launcher : bouton Compte, connexion, création, session mémorisée (clé TPM / DPAPI), « Mon compte », export et suppression | à venir |
|
||||
| 8c | OTP (TOTP, codes de secours, QR maison) | à venir |
|
||||
| 8d | Menu d'administration « Comptes joueurs » | à venir |
|
||||
| 8e | Blocages par IP et par identifiants matériels (un blocage depuis une fiche vise les deux) | à venir |
|
||||
| 8f | Demandes connectées et échanges avec les administrateurs | à venir |
|
||||
| 8g | Mise en production (nginx, credential, sauvegardes, textes RGPD, AIPD, revue de sécurité indépendante) | à venir |
|
||||
| 8h | E-mails (vérification, réinitialisation autonome) | futur |
|
||||
|
||||
Activation en production : après 8a, 8b, 8c, 8d et 8g au minimum.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 416 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 395 KiB |
@@ -19,7 +19,8 @@ public static class AdminCli
|
||||
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
|
||||
|
||||
public static bool IsCommand(string[] args) =>
|
||||
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
|
||||
args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) ||
|
||||
PlayersCli.Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
|
||||
{
|
||||
@@ -29,6 +30,9 @@ public static class AdminCli
|
||||
var command = args[0].ToLowerInvariant();
|
||||
if (command == "help") return Help(o);
|
||||
var options = ReadOptions(args);
|
||||
// Clé maîtresse des comptes joueurs : n'utilise pas le dossier des données (peut être lancée par root, la sortie
|
||||
// étant directement chiffrée par systemd-creds).
|
||||
if (command == "players-key") return PlayersCli.Key(o, interactive: output is null);
|
||||
|
||||
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
|
||||
// appartiendraient à root et le service ne pourrait plus les lire.
|
||||
@@ -37,7 +41,7 @@ public static class AdminCli
|
||||
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
|
||||
|
||||
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
|
||||
if (command != "add-owner" && command != "server-id" &&
|
||||
if (command != "add-owner" && command != "server-id" && !command.StartsWith("players-", StringComparison.Ordinal) &&
|
||||
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
|
||||
throw new InvalidOperationException(
|
||||
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
|
||||
@@ -57,6 +61,11 @@ public static class AdminCli
|
||||
case "server-id":
|
||||
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
|
||||
return 0;
|
||||
case "players-show":
|
||||
case "players-unlock":
|
||||
case "players-disable":
|
||||
case "players-enable":
|
||||
return PlayersCli.Run(command, options, dataDirectory, audit, actor, o);
|
||||
default: return Help(o);
|
||||
}
|
||||
}
|
||||
@@ -219,6 +228,9 @@ public static class AdminCli
|
||||
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
|
||||
server-id Affiche l'identifiant de ce serveur.
|
||||
|
||||
Comptes joueurs (désignés par leur référence ; l'adresse e-mail n'est jamais affichée) :
|
||||
""" + "\n" + PlayersCli.HelpText + "\n" + """
|
||||
|
||||
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
|
||||
""");
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Server.Storage.Players;
|
||||
|
||||
namespace GameLauncher.Server.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Commandes de secours des comptes joueurs (sur le VPS, sous le compte du service). Elles désignent un compte par sa
|
||||
/// RÉFÉRENCE (« PL-7KQ2-M9XD ») et n'affichent jamais l'adresse e-mail : elles n'ont pas besoin de la clé maîtresse.
|
||||
/// Le service n'a pas besoin d'être arrêté (SQLite gère l'accès simultané) ; l'état du compte est relu à chaque requête.
|
||||
/// La gestion courante se fait depuis le menu d'administration (étape 8d).
|
||||
/// </summary>
|
||||
internal static class PlayersCli
|
||||
{
|
||||
public static readonly string[] Commands = { "players-key", "players-show", "players-unlock", "players-disable", "players-enable" };
|
||||
|
||||
public const string HelpText = """
|
||||
players-key Génère une clé maîtresse des comptes joueurs (à chiffrer avec systemd-creds, voir LISEZMOI).
|
||||
players-show --ref <PL-XXXX-XXXX> État d'un compte joueur (sans son adresse e-mail).
|
||||
players-unlock --ref <PL-XXXX-XXXX> Lève une suspension ou un blocage (compte réactivé).
|
||||
players-disable --ref <PL-XXXX-XXXX> [--until AAAA-MM-JJ[THH:MM]] Désactive un compte (UTC ; sans --until : indéfiniment).
|
||||
players-enable --ref <PL-XXXX-XXXX> Réactive un compte désactivé.
|
||||
""";
|
||||
|
||||
/// <summary>Nouvelle clé maîtresse : sur la sortie standard (à rediriger vers systemd-creds), mode d'emploi sur stderr.</summary>
|
||||
public static int Key(TextWriter o, bool interactive)
|
||||
{
|
||||
o.WriteLine(PlayerSecrets.NewMasterKey());
|
||||
if (interactive)
|
||||
Console.Error.WriteLine("Clé maîtresse générée (32 octets, Base64). À chiffrer immédiatement, sans la laisser sur le disque, ex. :\n" +
|
||||
" GameLauncher.Server players-key | sudo systemd-creds encrypt --name=players-master-key - " +
|
||||
"/etc/credstore.encrypted/players-master-key\n" +
|
||||
"Gardez-en une copie HORS LIGNE (gestionnaire de mots de passe) : perdue, les adresses e-mail sont " +
|
||||
"illisibles et tous les mots de passe doivent être réinitialisés.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int Run(string command, Dictionary<string, string> options, string dataDirectory, AuditLog audit, string actor, TextWriter o)
|
||||
{
|
||||
var path = Path.Combine(dataDirectory, PlayerStore.FolderName, PlayerStore.FileName);
|
||||
if (!File.Exists(path)) throw new InvalidOperationException($"aucune base des comptes joueurs ({path}).");
|
||||
using var store = new PlayerStore(dataDirectory, TimeProvider.System);
|
||||
var reference = PlayerIds.NormalizeReference(Get(options, "ref"))
|
||||
?? throw new ArgumentException("--ref <PL-XXXX-XXXX> obligatoire (référence du compte).");
|
||||
var account = store.FindByReference(reference) ?? throw new InvalidOperationException($"aucun compte {reference}.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "players-show":
|
||||
Show(store, account, o);
|
||||
return 0;
|
||||
|
||||
case "players-unlock":
|
||||
case "players-enable":
|
||||
store.SetStatus(account.Id, PlayerStatus.Active, null, null, actor, now);
|
||||
store.AddEvent(account.Id, command == "players-unlock" ? PlayerEvents.Unlocked : PlayerEvents.Enabled, AuditResults.Ok, null,
|
||||
details: $"par {actor} (outil en ligne de commande)");
|
||||
audit.Write("cli." + command.Replace('-', '_'), AuditResults.Ok, actorName: actor, target: reference,
|
||||
details: $"ancien état : {account.Status}");
|
||||
o.WriteLine($"Compte {reference} réactivé (blocages remis à zéro).");
|
||||
return 0;
|
||||
|
||||
case "players-disable":
|
||||
DateTimeOffset? until = null;
|
||||
if (Get(options, "until") is { Length: > 0 } text)
|
||||
{
|
||||
if (!DateTimeOffset.TryParseExact(text, new[] { "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm" }, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) || date <= now)
|
||||
throw new ArgumentException("--until : date future attendue, AAAA-MM-JJ ou AAAA-MM-JJTHH:MM (UTC).");
|
||||
until = date;
|
||||
}
|
||||
store.SetStatus(account.Id, PlayerStatus.Disabled, until, null, actor, now);
|
||||
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null,
|
||||
details: $"par {actor} (outil en ligne de commande){(until is { } u ? $" jusqu'au {u:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")}");
|
||||
audit.Write("cli.players_disable", AuditResults.Ok, actorName: actor, target: reference,
|
||||
details: until is { } end ? $"jusqu'au {end:yyyy-MM-dd HH:mm} UTC" : "indéfiniment");
|
||||
o.WriteLine($"Compte {reference} désactivé{(until is { } d ? $" jusqu'au {d:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")} " +
|
||||
"(ses sessions sont refusées dès la requête suivante).");
|
||||
return 0;
|
||||
}
|
||||
throw new ArgumentException($"commande inconnue : {command}");
|
||||
}
|
||||
|
||||
private static void Show(PlayerStore store, PlayerAccountRecord account, TextWriter o)
|
||||
{
|
||||
static string Date(DateTimeOffset? d) => d is { } v ? v.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "—";
|
||||
var devices = store.Devices(account.Id);
|
||||
o.WriteLine($"Compte {account.Reference}");
|
||||
o.WriteLine($" État : {account.Status}{(account.DisabledUntil is { } u ? $" jusqu'au {Date(u)}" : "")}");
|
||||
o.WriteLine($" Créé le : {Date(account.CreatedAt)}");
|
||||
o.WriteLine($" Dernière connexion : {Date(account.LastLoginAt)}");
|
||||
o.WriteLine($" Mot de passe changé : {Date(account.PasswordChangedAt)}{(account.MustChangePassword ? " (nouveau mot de passe exigé)" : "")}");
|
||||
o.WriteLine($" Échecs (inconnus) : {account.FailedCount} en cours, {account.LockLevel} blocage(s), bloqué jusqu'au {Date(account.LockedUntil)}");
|
||||
o.WriteLine($" Échecs (confiance) : {account.TrustedFailedCount} en cours, bloqué jusqu'au {Date(account.TrustedLockedUntil)}");
|
||||
o.WriteLine($" Appareils connectés : {devices.Count}");
|
||||
foreach (var device in devices)
|
||||
o.WriteLine($" · {device.Label} (clé {device.KeyId}) — dernière utilisation {Date(device.LastUsedAt)}");
|
||||
o.WriteLine(" Derniers événements :");
|
||||
foreach (var e in store.Events(account.Id, 10))
|
||||
o.WriteLine($" {Date(e.At)} {e.Type} {e.Result} {e.Details}");
|
||||
}
|
||||
|
||||
private static string? Get(Dictionary<string, string> options, string name) =>
|
||||
options.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
@@ -25,6 +25,18 @@
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Comptes joueurs (phase 8, docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
|
||||
• Microsoft.Data.Sqlite (Microsoft) : base players.db ; embarque la bibliothèque native SQLite (SQLitePCLRaw,
|
||||
bibliothèque e_sqlite3 de linux-x64 incluse à la publication autonome) ;
|
||||
• Konscious.Security.Cryptography.Argon2 (licence MIT, 100 % C#) : empreintes Argon2id des mots de passe (RFC 9106),
|
||||
absentes de .NET 8.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.34" />
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -31,7 +31,10 @@ public static class ApiModules
|
||||
new Modules.Changelog.ChangelogModule(),
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule(),
|
||||
new Modules.Files.FilesModule()
|
||||
new Modules.Files.FilesModule(),
|
||||
new Modules.Feedback.FeedbackModule(),
|
||||
new Modules.Maintenance.MaintenanceModule(),
|
||||
new Modules.Accounts.AccountsModule()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,4 +49,11 @@ 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";
|
||||
|
||||
/// <summary>Comptes joueurs : défis, création, connexion, renouvellement, changement de mot de passe (en plus de la
|
||||
/// limite générale).</summary>
|
||||
public const string Account = "account";
|
||||
}
|
||||
@@ -47,6 +47,15 @@ public sealed class LauncherApiOptions
|
||||
|
||||
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>Comptes joueurs (phase 8) : désactivés par défaut. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.</summary>
|
||||
public PlayersSettings Players { 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.
|
||||
@@ -119,6 +128,130 @@ public sealed class LauncherApiOptions
|
||||
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>
|
||||
/// Comptes joueurs : adresse e-mail + mot de passe, connexion mémorisée liée à une clé d'appareil. Données dans
|
||||
/// « players/players.db » (SQLite, dossier des données privées), adresses e-mail chiffrées, mots de passe hachés
|
||||
/// (Argon2id + clé secrète). La clé maîtresse est hors du dossier des données (credential systemd chiffré).
|
||||
/// </summary>
|
||||
public sealed class PlayersSettings
|
||||
{
|
||||
/// <summary>Service activé (faux par défaut : les routes « v1/account/… » répondent 503 « not_configured »).</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Création de compte ouverte (la connexion reste possible si faux).</summary>
|
||||
public bool RegistrationEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Fichier de la clé maîtresse (32 octets en Base64). Vide : « players-master-key » dans le dossier des credentials
|
||||
/// systemd ($CREDENTIALS_DIRECTORY, voir server/deploy/LISEZMOI.md). Jamais dans le dossier des données, jamais
|
||||
/// lisible par le groupe ou les autres comptes.
|
||||
/// </summary>
|
||||
public string MasterKeyFile { get; set; } = "";
|
||||
|
||||
/// <summary>Version des conditions d'utilisation / politique de confidentialité à accepter à la création du compte.</summary>
|
||||
public string TermsVersion { get; set; } = "1";
|
||||
|
||||
/// <summary>Argon2id : mémoire (Kio), passes, parallélisme. OWASP : 19 Mio et 2 passes au minimum (ou équivalent).</summary>
|
||||
public int HashMemoryKib { get; set; } = 19456;
|
||||
public int HashIterations { get; set; } = 2;
|
||||
public int HashParallelism { get; set; } = 1;
|
||||
|
||||
/// <summary>Calculs de mot de passe simultanés (les suivants attendent) : protège le VPS d'un afflux de connexions.</summary>
|
||||
public int MaxConcurrentHashes { get; set; } = 4;
|
||||
|
||||
/// <summary>Preuve de travail exigée à la création de compte et à la connexion (bits à zéro).</summary>
|
||||
public int RegisterProofDifficulty { get; set; } = 20;
|
||||
public int LoginProofDifficulty { get; set; } = 16;
|
||||
|
||||
/// <summary>Durée de validité d'un défi joueur.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>Anti-force brute (appareils inconnus) : blocage après N échecs consécutifs, durée doublée à chaque
|
||||
/// blocage ; au blocage n° SuspendAfterLocks, le compte est SUSPENDU pour les nouveaux appareils (seul un
|
||||
/// administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner).</summary>
|
||||
public int FailuresPerLock { get; set; } = 5;
|
||||
public int LockMinutes { get; set; } = 15;
|
||||
public int SuspendAfterLocks { get; set; } = 4;
|
||||
|
||||
/// <summary>Appareils de confiance (déjà connectés) : blocage temporaire après N échecs, jamais de suspension.</summary>
|
||||
public int TrustedFailuresPerLock { get; set; } = 10;
|
||||
|
||||
/// <summary>Échecs de connexion acceptés par adresse IP (IPv6 : /64) sur 15 minutes, tous comptes confondus.</summary>
|
||||
public int FailedLoginsPerIpPer15Minutes { get; set; } = 30;
|
||||
|
||||
/// <summary>Créations de compte par adresse IP (IPv6 : /64) par heure et par jour, et au total par jour.</summary>
|
||||
public int RegistrationsPerIpPerHour { get; set; } = 3;
|
||||
public int RegistrationsPerIpPerDay { get; set; } = 5;
|
||||
public int MaxRegistrationsPerDay { get; set; } = 500;
|
||||
|
||||
/// <summary>Requêtes par minute et par adresse IP sur les routes de défi, création, connexion et renouvellement.</summary>
|
||||
public int AuthRequestsPerMinute { get; set; } = 20;
|
||||
|
||||
/// <summary>Jeton d'accès (minutes) ; session mémorisée : jours sans utilisation, puis durée maximale (jours).</summary>
|
||||
public int AccessTokenMinutes { get; set; } = 15;
|
||||
public int RefreshIdleDays { get; set; } = 90;
|
||||
public int RefreshMaxDays { get; set; } = 365;
|
||||
|
||||
/// <summary>Appareils connectés par compte (au-delà, le moins récemment utilisé est déconnecté).</summary>
|
||||
public int MaxDevicesPerAccount { get; set; } = 20;
|
||||
|
||||
/// <summary>Journal des comptes : conservation (mois), et effacement des adresses IP au-delà de N mois.</summary>
|
||||
public int EventsRetentionMonths { get; set; } = 12;
|
||||
public int EventsIpRetentionMonths { get; set; } = 6;
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
@@ -181,6 +314,24 @@ 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>
|
||||
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
|
||||
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
|
||||
/// </summary>
|
||||
public string? MaintenancePublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
|
||||
public string MaintenancePublicUrl { 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; }
|
||||
|
||||
@@ -188,6 +339,12 @@ public sealed class LauncherApiOptions
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Fichier de la clé maîtresse des comptes joueurs (null : comptes désactivés), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? PlayersMasterKeyFullPath { get; private set; }
|
||||
|
||||
/// <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)
|
||||
{
|
||||
@@ -218,6 +375,8 @@ public sealed class LauncherApiOptions
|
||||
ValidateKeyring();
|
||||
ValidateGames();
|
||||
ValidateFiles();
|
||||
ValidateFeedback();
|
||||
ValidatePlayers();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
@@ -229,6 +388,94 @@ 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 ValidatePlayers()
|
||||
{
|
||||
var players = Players;
|
||||
PlayersMasterKeyFullPath = null;
|
||||
Require(players.HashMemoryKib is >= 7168 and <= 1_048_576, "Players:HashMemoryKib (7168 à 1048576)");
|
||||
Require(players.HashIterations is >= 1 and <= 20, "Players:HashIterations (1 à 20)");
|
||||
// OWASP : coût au moins équivalent à 19 Mio × 2 passes (ex. 12 Mio × 3, 9 Mio × 4, 7 Mio × 5).
|
||||
Require((long)players.HashMemoryKib * players.HashIterations >= 19456L * 2, "Players:HashMemoryKib × HashIterations (au moins 38912)");
|
||||
Require(players.HashParallelism is >= 1 and <= 16, "Players:HashParallelism (1 à 16)");
|
||||
Require(players.MaxConcurrentHashes is >= 1 and <= 64, "Players:MaxConcurrentHashes (1 à 64)");
|
||||
Require(players.RegisterProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty &&
|
||||
players.LoginProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
|
||||
$"Players:*ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
|
||||
Require(players.ChallengeLifetimeSeconds is >= 30 and <= 3600, "Players:ChallengeLifetimeSeconds (30 à 3600)");
|
||||
Require(players.FailuresPerLock is >= 3 and <= 20, "Players:FailuresPerLock (3 à 20)");
|
||||
Require(players.LockMinutes is >= 1 and <= 1440, "Players:LockMinutes (1 à 1440)");
|
||||
Require(players.SuspendAfterLocks is >= 2 and <= 20, "Players:SuspendAfterLocks (2 à 20)");
|
||||
Require(players.TrustedFailuresPerLock is >= 3 and <= 50, "Players:TrustedFailuresPerLock (3 à 50)");
|
||||
Require(players.FailedLoginsPerIpPer15Minutes is >= 5 and <= 10_000, "Players:FailedLoginsPerIpPer15Minutes (5 à 10000)");
|
||||
Require(players.RegistrationsPerIpPerHour is >= 1 and <= 1000 && players.RegistrationsPerIpPerDay >= players.RegistrationsPerIpPerHour &&
|
||||
players.RegistrationsPerIpPerDay <= 10_000, "Players:RegistrationsPerIp*");
|
||||
Require(players.MaxRegistrationsPerDay is >= 1 and <= 1_000_000, "Players:MaxRegistrationsPerDay");
|
||||
Require(players.AuthRequestsPerMinute is >= 1 and <= 1_000_000, "Players:AuthRequestsPerMinute");
|
||||
Require(players.AccessTokenMinutes is >= 1 and <= 60, "Players:AccessTokenMinutes (1 à 60)");
|
||||
Require(players.RefreshIdleDays is >= 1 and <= 365, "Players:RefreshIdleDays (1 à 365)");
|
||||
Require(players.RefreshMaxDays >= players.RefreshIdleDays && players.RefreshMaxDays <= 730, "Players:RefreshMaxDays (RefreshIdleDays à 730)");
|
||||
Require(players.MaxDevicesPerAccount is >= 1 and <= 100, "Players:MaxDevicesPerAccount (1 à 100)");
|
||||
Require(players.EventsRetentionMonths is >= 1 and <= 120, "Players:EventsRetentionMonths (1 à 120)");
|
||||
Require(players.EventsIpRetentionMonths is >= 1 && players.EventsIpRetentionMonths <= players.EventsRetentionMonths,
|
||||
"Players:EventsIpRetentionMonths (1 à EventsRetentionMonths)");
|
||||
if (Contracts.ApiText.CleanLine(players.TermsVersion, 32) is null)
|
||||
throw new InvalidOperationException($"{Section}:Players:TermsVersion : une version (ex. « 2026-11 ») est obligatoire.");
|
||||
if (!players.Enabled) return;
|
||||
|
||||
string file;
|
||||
if (!string.IsNullOrWhiteSpace(players.MasterKeyFile))
|
||||
{
|
||||
file = players.MasterKeyFile.Trim();
|
||||
if (!Path.IsPathRooted(file))
|
||||
throw new InvalidOperationException($"{Section}:Players:MasterKeyFile doit être un chemin absolu.");
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY") is { Length: > 0 } credentials)
|
||||
{
|
||||
file = Path.Combine(credentials, "players-master-key");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Players:Enabled = true, mais aucune clé maîtresse : credential systemd « players-master-key » " +
|
||||
"(LoadCredentialEncrypted, voir server/deploy/LISEZMOI.md) ou Players:MasterKeyFile.");
|
||||
}
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full) ?? "";
|
||||
var keyDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(keyDirectory, dataDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Players:MasterKeyFile : la clé maîtresse ne doit pas être dans le dossier des données (une copie des données ne doit pas la contenir).");
|
||||
PlayersMasterKeyFullPath = full;
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -299,8 +546,30 @@ 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;
|
||||
|
||||
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
|
||||
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
|
||||
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
|
||||
: null;
|
||||
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
|
||||
if (MaintenancePublicFileFullPath is { } maintenance &&
|
||||
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
|
||||
}
|
||||
|
||||
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
|
||||
public const string FeedbackFormsFileName = "feedback-forms.json";
|
||||
|
||||
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
|
||||
public const string MaintenanceFileName = "maintenance.json";
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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) ;
|
||||
/// • journal des comptes joueurs (si activés) : lignes au-delà de <c>Players:EventsRetentionMonths</c> (12), adresses IP
|
||||
/// effacées au-delà de <c>Players:EventsIpRetentionMonths</c> (6).
|
||||
/// 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;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
|
||||
ILogger<RetentionService> logger, IServiceProvider services)
|
||||
{
|
||||
_audit = audit;
|
||||
_feedback = feedback;
|
||||
_services = services;
|
||||
_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");
|
||||
}
|
||||
|
||||
if (!_options.Players.Enabled) return;
|
||||
try
|
||||
{
|
||||
var players = _services.GetRequiredService<Storage.Players.PlayerStore>();
|
||||
var (deleted, anonymized) = players.PurgeEvents(_time.GetUtcNow(), _options.Players.EventsRetentionMonths,
|
||||
_options.Players.EventsIpRetentionMonths);
|
||||
if (deleted > 0 || anonymized > 0)
|
||||
_audit.Write("players.events_purge", AuditResults.Ok,
|
||||
details: $"journal des comptes joueurs : {deleted} ligne(s) supprimée(s), {anonymized} adresse(s) IP effacée(s) ; " +
|
||||
$"conservation {_options.Players.EventsRetentionMonths} mois, IP {_options.Players.EventsIpRetentionMonths} mois");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Purge du journal des comptes joueurs impossible");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,10 @@ 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))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Account, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Players.AuthRequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
});
|
||||
|
||||
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
|
||||
@@ -98,6 +102,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();
|
||||
|
||||
@@ -119,6 +125,13 @@ public static class ServerApp
|
||||
var admins = app.Services.GetRequiredService<AdminStore>().Read();
|
||||
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
|
||||
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
|
||||
// Comptes joueurs : clé maîtresse et base ouvertes dès le démarrage (clé absente ou base illisible : pas de démarrage).
|
||||
if (options.Players.Enabled)
|
||||
{
|
||||
var players = app.Services.GetRequiredService<Storage.Players.PlayerStore>();
|
||||
app.Services.GetRequiredService<Storage.Players.PlayerPasswordHasher>();
|
||||
app.Logger.LogInformation("Comptes joueurs activés : {Count} compte(s), base {Database}", players.CountAccounts(), players.DatabasePath);
|
||||
}
|
||||
if (!admins.UsableOwners().Any())
|
||||
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Server.Storage.Players;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Comptes joueurs (phase 8a) : création de compte, connexion par adresse e-mail et mot de passe, connexion mémorisée
|
||||
/// (jeton de renouvellement lié à une clé d'appareil), déconnexion, changement de mot de passe, appareils connectés.
|
||||
///
|
||||
/// Sécurité (détail : docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
|
||||
/// • routes anonymes protégées par un défi sans état + preuve de travail + preuve de possession de la clé d'appareil
|
||||
/// (<see cref="PlayerGuard"/>), limites par adresse IP ;
|
||||
/// • anti-force brute : blocage temporaire (durée doublée), puis SUSPENSION que seul un administrateur lève. La
|
||||
/// suspension bloque les connexions depuis un NOUVEL appareil ; les appareils déjà connectés (« de confiance »,
|
||||
/// prouvés par leur clé) continuent de fonctionner : un tiers qui connaît l'adresse peut déclencher la suspension,
|
||||
/// mais pas priver le joueur de son compte. Une adresse inconnue se comporte exactement comme un compte réel ;
|
||||
/// • réponses identiques pour une adresse inconnue et un mauvais mot de passe (même durée de calcul) ; l'état du compte
|
||||
/// (désactivé, suspendu) n'est révélé qu'après un mot de passe correct ;
|
||||
/// • adresses e-mail chiffrées, mots de passe en Argon2id + poivre, clé maîtresse hors du dossier des données ;
|
||||
/// • journal des comptes (actions des joueurs) dans la base, sans adresse e-mail ; actions du système et des
|
||||
/// administrateurs dans le journal d'audit, par référence de compte.
|
||||
/// Totalement séparé des administrateurs : aucune route d'administration n'accepte un jeton joueur.
|
||||
/// </summary>
|
||||
public sealed class AccountsModule : IApiModule
|
||||
{
|
||||
public string Name => "Accounts";
|
||||
|
||||
/// <summary>Longueur maximale acceptée d'un mot de passe SAISI (bien au-delà des règles : borne le travail du serveur).</summary>
|
||||
private const int MaxSubmittedPasswordLength = 1024;
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<PlayerGuard>();
|
||||
services.AddSingleton<PlayerSessionStore>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var api = sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value;
|
||||
return PlayerSecrets.Load(api.PlayersMasterKeyFullPath
|
||||
?? throw new InvalidOperationException("Comptes joueurs désactivés : aucune clé maîtresse."));
|
||||
});
|
||||
services.AddSingleton(sp => new PlayerPasswordHasher(sp.GetRequiredService<PlayerSecrets>(),
|
||||
sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value.Players));
|
||||
services.AddSingleton<PlayerStore>();
|
||||
services.AddPlayerAuthentication();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapPost(ApiRoutes.AccountChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountRegister, Register).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountLogin, Login).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountRefresh, Refresh).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountLogout, Logout).RequirePlayer();
|
||||
api.MapGet(ApiRoutes.AccountMe, Me).RequirePlayer();
|
||||
api.MapPost(ApiRoutes.AccountPassword, ChangePassword).RequirePlayerOrPasswordChange().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapDelete(ApiRoutes.AccountDevicePattern, DeleteDevice).RequirePlayer();
|
||||
}
|
||||
|
||||
private static IResult Disabled() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Les comptes joueurs ne sont pas activés sur ce serveur.");
|
||||
|
||||
private static IResult LoginFailed() => ApiResults.Error(401, ApiErrorCodes.LoginFailed, "Adresse e-mail ou mot de passe incorrect.");
|
||||
|
||||
private static IResult TooManyAttempts(HttpContext context, DateTimeOffset until, DateTimeOffset now)
|
||||
{
|
||||
var wait = until - now;
|
||||
var seconds = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds));
|
||||
context.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
||||
var minutes = Math.Max(1, (int)Math.Ceiling(wait.TotalMinutes));
|
||||
return ApiResults.Error(429, ApiErrorCodes.LoginLocked,
|
||||
$"Trop d'essais : la connexion est bloquée pendant encore {minutes} minute{(minutes > 1 ? "s" : "")}.");
|
||||
}
|
||||
|
||||
private static IResult RateLimited(HttpContext context, TimeSpan retryAfter, string message)
|
||||
{
|
||||
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
return ApiResults.Error(429, ApiErrorCodes.RateLimited, message);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Défi
|
||||
|
||||
private static IResult Challenge(PlayerChallengeRequest request, IOptions<LauncherApiOptions> options, PlayerGuard guard,
|
||||
ServerIdentity server)
|
||||
{
|
||||
if (!options.Value.Players.Enabled) return Disabled();
|
||||
if (!PlayerChallengePurposes.IsValid(request.Purpose)) return ApiResults.BadRequest("Usage du défi inconnu.");
|
||||
return Results.Json(guard.CreateChallenge(request.Purpose, server.ServerId, server.Origin), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Création de compte
|
||||
|
||||
private static async Task<IResult> Register(PlayerRegisterRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
if (!settings.Enabled) return Disabled();
|
||||
if (!settings.RegistrationEnabled)
|
||||
return ApiResults.Error(403, ApiErrorCodes.RegistrationClosed, "La création de compte est momentanément fermée.");
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
|
||||
var ip = context.ClientIp();
|
||||
|
||||
// 1. Défi et preuve de travail, puis possession de la clé d'appareil, puis consommation du défi.
|
||||
var device = CheckDevice(guard, server, PlayerChallengePurposes.Register, request.ChallengeId, request.Solution, request.Device);
|
||||
if (device.Error is { } deviceError) return deviceError;
|
||||
var proof = device.Proof!;
|
||||
|
||||
// 2. Contenu : conditions acceptées, adresse, mot de passe (mêmes règles que le launcher).
|
||||
if (!string.Equals(request.AcceptedTermsVersion, settings.TermsVersion, StringComparison.Ordinal))
|
||||
return ApiResults.Error(400, ApiErrorCodes.TermsNotAccepted,
|
||||
"Acceptez les conditions d'utilisation et la politique de confidentialité en vigueur pour créer un compte.");
|
||||
if (PlayerEmail.Normalize(request.Email) is not { } email)
|
||||
return ApiResults.Error(400, ApiErrorCodes.EmailInvalid, "Adresse e-mail invalide.");
|
||||
if (request.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength })
|
||||
return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, "Mot de passe invalide.");
|
||||
var errors = PasswordRules.Check(request.Password, email);
|
||||
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
|
||||
|
||||
// 3. Limites par adresse IP (en mémoire) puis plafond quotidien global.
|
||||
var now = time.GetUtcNow();
|
||||
if (!guard.TryCountRegistration(context.RateLimitPartition(), out var retryAfter))
|
||||
return RateLimited(context, retryAfter, "Plusieurs comptes ont été créés depuis votre connexion récemment : réessayez plus tard.");
|
||||
if (store.CountCreatedSince(now - TimeSpan.FromDays(1)) >= settings.MaxRegistrationsPerDay)
|
||||
return ApiResults.Error(503, ApiErrorCodes.RegistrationClosed,
|
||||
"Trop de comptes ont été créés aujourd'hui : réessayez demain.");
|
||||
|
||||
// 4. Adresse libre ? (la création révèle forcément qu'une adresse est prise : limites ci-dessus + preuve de travail).
|
||||
var emailIndex = secrets.EmailIndex(email);
|
||||
if (store.FindByEmailIndex(emailIndex) is not null)
|
||||
{
|
||||
store.AddEvent(null, PlayerEvents.Register, AuditResults.Denied, ip, proof.KeyId, "adresse déjà utilisée");
|
||||
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
|
||||
}
|
||||
|
||||
// 5. Empreinte (calcul coûteux, hors verrou), puis création sous transaction (unicité revérifiée).
|
||||
var hash = await hasher.HashAsync(request.Password, context.RequestAborted);
|
||||
var accountId = PlayerIds.NewId();
|
||||
var account = new PlayerAccountRecord
|
||||
{
|
||||
Id = accountId,
|
||||
Reference = PlayerIds.NewReference(),
|
||||
EmailIndex = emailIndex,
|
||||
EmailEncrypted = secrets.EncryptEmail(accountId, email),
|
||||
PasswordHash = hash,
|
||||
PasswordChangedAt = now,
|
||||
CreatedAt = now,
|
||||
LastLoginAt = now,
|
||||
TermsVersion = settings.TermsVersion
|
||||
};
|
||||
var (deviceRecord, refreshToken) = NewDevice(accountId, proof, settings, now);
|
||||
if (!store.TryCreate(account, deviceRecord, PlayerIds.NewReference, out var created))
|
||||
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
|
||||
|
||||
store.AddEvent(created.Id, PlayerEvents.Register, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"conditions version {settings.TermsVersion} ; appareil « {proof.Label} »");
|
||||
return Results.Json(new PlayerLoginResponse
|
||||
{
|
||||
Status = PlayerLoginStatus.Ok,
|
||||
Session = OpenSession(created, deviceRecord, refreshToken, sessions, secrets)
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Connexion
|
||||
|
||||
private static async Task<IResult> Login(PlayerLoginRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
if (!settings.Enabled) return Disabled();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
|
||||
var ip = context.ClientIp();
|
||||
var partition = context.RateLimitPartition();
|
||||
|
||||
var device = CheckDevice(guard, server, PlayerChallengePurposes.Login, request.ChallengeId, request.Solution, request.Device);
|
||||
if (device.Error is { } deviceError) return deviceError;
|
||||
var proof = device.Proof!;
|
||||
if (guard.IsIpThrottled(partition, out var ipRetry))
|
||||
return RateLimited(context, ipRetry, "Trop d'échecs de connexion depuis votre connexion Internet : réessayez plus tard.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var password = request.Password is { Length: > 0 and <= MaxSubmittedPasswordLength } p ? p : null;
|
||||
var email = PlayerEmail.Normalize(request.Email);
|
||||
if (email is null || password is null)
|
||||
{
|
||||
if (password is not null) await hasher.DummyAsync(password, context.RequestAborted);
|
||||
guard.CountFailedLogin(partition);
|
||||
return LoginFailed();
|
||||
}
|
||||
|
||||
var emailIndex = secrets.EmailIndex(email);
|
||||
var account = store.FindByEmailIndex(emailIndex);
|
||||
if (account is null)
|
||||
{
|
||||
// Adresse sans compte : même calcul, même blocage, même réponse qu'un mauvais mot de passe.
|
||||
if (guard.UnknownLockedUntil(emailIndex) is { } unknownUntil) return TooManyAttempts(context, unknownUntil, now);
|
||||
await hasher.DummyAsync(password, context.RequestAborted);
|
||||
guard.CountFailedLogin(partition);
|
||||
store.AddEvent(null, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "adresse sans compte");
|
||||
return guard.CountUnknownFailure(emailIndex) is { } lockedNow ? TooManyAttempts(context, lockedNow, now) : LoginFailed();
|
||||
}
|
||||
|
||||
var trusted = store.FindDeviceByKey(account.Id, proof.KeyId) is not null;
|
||||
if ((trusted ? account.TrustedLockedUntil : account.LockedUntil) is { } until && until > now)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "connexion bloquée (trop d'essais)");
|
||||
return TooManyAttempts(context, until, now);
|
||||
}
|
||||
|
||||
var (valid, rehash) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
|
||||
if (!valid)
|
||||
{
|
||||
var outcome = store.RecordFailure(account.Id, trusted, now, settings);
|
||||
guard.CountFailedLogin(partition);
|
||||
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId,
|
||||
trusted ? "mot de passe incorrect (appareil de confiance)" : "mot de passe incorrect (appareil inconnu)");
|
||||
if (outcome.Suspended)
|
||||
{
|
||||
var count = settings.FailuresPerLock * settings.SuspendAfterLocks;
|
||||
store.AddEvent(account.Id, PlayerEvents.Suspended, AuditResults.Ok, null, null,
|
||||
$"{count} échecs de connexion consécutifs depuis des appareils inconnus");
|
||||
audit.Write("players.suspend", AuditResults.Ok, actorName: "système", ip: ip, target: account.Reference,
|
||||
details: $"compte joueur suspendu automatiquement (nouveaux appareils) : {count} échecs de connexion consécutifs " +
|
||||
"depuis des appareils inconnus");
|
||||
}
|
||||
if (outcome.LockedUntil is { } lockedUntil)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.Locked, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"blocage n° {outcome.LockLevel} jusqu'au {lockedUntil.UtcDateTime:yyyy-MM-dd HH:mm} UTC" +
|
||||
(trusted ? " (appareils de confiance)" : " (appareils inconnus)"));
|
||||
return TooManyAttempts(context, lockedUntil, now);
|
||||
}
|
||||
return LoginFailed();
|
||||
}
|
||||
|
||||
// Mot de passe correct : l'état du compte peut maintenant être révélé.
|
||||
if (StateRefusal(store, account, now, trusted) is { } refusal)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Denied, ip, proof.KeyId, refusal.Reason);
|
||||
return refusal.Result;
|
||||
}
|
||||
|
||||
store.RecordSuccess(account.Id, now, rehash ? await hasher.HashAsync(password, context.RequestAborted) : null);
|
||||
account = store.FindById(account.Id) ?? account;
|
||||
|
||||
if (account.MustChangePassword)
|
||||
{
|
||||
var (changeToken, change) = sessions.CreatePasswordChange(account.Id, proof.PublicKey, proof.KeyId, proof.Label);
|
||||
store.AddEvent(account.Id, PlayerEvents.PasswordChangeForced, AuditResults.Ok, ip, proof.KeyId,
|
||||
"connexion acceptée : nouveau mot de passe exigé avant d'ouvrir la session");
|
||||
return Results.Json(new PlayerLoginResponse
|
||||
{
|
||||
Status = PlayerLoginStatus.PasswordChangeRequired,
|
||||
PasswordChangeToken = changeToken,
|
||||
PasswordChangeExpiresAt = change.ExpiresAt
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
|
||||
var removed = store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
|
||||
sessions.RemoveDevices(removed);
|
||||
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"appareil « {proof.Label} »{(trusted ? "" : " (nouvel appareil)")}" +
|
||||
(account.Status == PlayerStatus.Suspended ? " ; compte suspendu pour les nouveaux appareils" : ""));
|
||||
return Results.Json(new PlayerLoginResponse
|
||||
{
|
||||
Status = PlayerLoginStatus.Ok,
|
||||
Session = OpenSession(account, deviceRecord, refreshToken, sessions, secrets)
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refus lié à l'état du compte, révélé seulement à qui connaît le mot de passe ou possède une session mémorisée :
|
||||
/// désactivé (tout est refusé), suspendu (seuls les appareils de confiance passent). Une désactivation arrivée à
|
||||
/// échéance est levée au passage.
|
||||
/// </summary>
|
||||
private static (IResult Result, string Reason)? StateRefusal(PlayerStore store, PlayerAccountRecord account, DateTimeOffset now,
|
||||
bool trustedDevice)
|
||||
{
|
||||
switch (account.Status)
|
||||
{
|
||||
case PlayerStatus.Active:
|
||||
return null;
|
||||
case PlayerStatus.Suspended when trustedDevice:
|
||||
return null;
|
||||
case PlayerStatus.Disabled when account.DisabledUntil is { } end && end <= now:
|
||||
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (fin de la désactivation)", now);
|
||||
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, "fin de la désactivation temporaire");
|
||||
return null;
|
||||
case PlayerStatus.Disabled:
|
||||
var until = account.DisabledUntil is { } date
|
||||
? $" jusqu'au {date.UtcDateTime.ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)} (UTC)"
|
||||
: "";
|
||||
return (ApiResults.Error(403, ApiErrorCodes.AccountDisabled,
|
||||
$"Ce compte est désactivé{until}. Contactez le support en indiquant la référence {account.Reference}."),
|
||||
"compte désactivé");
|
||||
default:
|
||||
return (ApiResults.Error(403, ApiErrorCodes.AccountSuspended,
|
||||
"Par sécurité, les connexions depuis un nouvel appareil sont suspendues sur ce compte après de nombreux " +
|
||||
"essais de mot de passe. Connectez-vous depuis un appareil déjà utilisé, ou contactez le support en indiquant " +
|
||||
$"la référence {account.Reference} : un administrateur pourra lever la suspension."),
|
||||
"compte suspendu (nouvel appareil)");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Renouvellement (connexion mémorisée)
|
||||
|
||||
private static IResult Refresh(PlayerRefreshRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
if (!settings.Enabled) return Disabled();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var ip = context.ClientIp();
|
||||
var revoked = ApiResults.Error(401, ApiErrorCodes.SessionRevoked, "Session expirée ou révoquée : reconnectez-vous.");
|
||||
|
||||
if (!PlayerTokens.TryParseRefresh(request.RefreshToken, out var deviceId, out var secret)) return revoked;
|
||||
if (guard.Validate(request.ChallengeId, PlayerChallengePurposes.Refresh, null) is not { } nonce)
|
||||
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi absent, expiré ou invalide : réessayez.");
|
||||
if (store.FindDevice(deviceId) is not { } device) return revoked;
|
||||
|
||||
// Possession de la clé de l'appareil : un jeton copié sur un autre PC ne sert à rien.
|
||||
var signature = AdminKeys.DecodeSignature(request.Signature);
|
||||
byte[] spki;
|
||||
try
|
||||
{
|
||||
spki = Convert.FromBase64String(device.PublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return revoked;
|
||||
}
|
||||
if (signature is null || !AdminKeys.Verify(spki,
|
||||
PlayerMessages.Device(PlayerChallengePurposes.Refresh, server.Origin, server.ServerId, request.ChallengeId, nonce, device.KeyId),
|
||||
signature))
|
||||
{
|
||||
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "signature de l'appareil invalide");
|
||||
return ApiResults.Error(401, ApiErrorCodes.DeviceProofInvalid, "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.");
|
||||
}
|
||||
if (!guard.TryConsume(request.ChallengeId))
|
||||
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi déjà utilisé : réessayez.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var hash = HashSecret(secret);
|
||||
if (device.PreviousRefreshHash is { } previous && FixedEquals(hash, previous))
|
||||
{
|
||||
// Jeton déjà remplacé et présenté de nouveau : copie volée (ou réponse perdue). L'appareil est déconnecté.
|
||||
store.DeleteDevice(device.Id);
|
||||
sessions.RemoveDevices(new[] { device.Id });
|
||||
store.AddEvent(device.AccountId, PlayerEvents.RefreshReuse, AuditResults.Denied, ip, device.KeyId,
|
||||
"jeton de renouvellement déjà utilisé : appareil déconnecté par précaution");
|
||||
return revoked;
|
||||
}
|
||||
if (!FixedEquals(hash, device.RefreshHash)) return revoked;
|
||||
if (now >= device.RefreshExpiresAt || now >= device.RefreshAbsoluteExpiresAt)
|
||||
{
|
||||
store.DeleteDevice(device.Id);
|
||||
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "session mémorisée expirée");
|
||||
return revoked;
|
||||
}
|
||||
|
||||
var account = store.FindById(device.AccountId);
|
||||
if (account is null) return revoked;
|
||||
if (StateRefusal(store, account, now, trustedDevice: true) is { } refusal)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, refusal.Reason);
|
||||
return refusal.Result;
|
||||
}
|
||||
if (account.MustChangePassword)
|
||||
return ApiResults.Error(403, ApiErrorCodes.PasswordChangeRequired,
|
||||
"Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.");
|
||||
|
||||
var newSecret = PlayerTokens.NewSecret();
|
||||
var idle = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), device.RefreshAbsoluteExpiresAt);
|
||||
if (!store.RotateRefresh(device.Id, device.RefreshHash, HashSecret(newSecret), idle, now)) return revoked;
|
||||
// Journal : un renouvellement par appareil et par jour au plus (le launcher en fait un à chaque lancement).
|
||||
if (device.LastUsedAt.UtcDateTime.Date != now.UtcDateTime.Date)
|
||||
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
|
||||
|
||||
var rotated = device with { RefreshExpiresAt = idle, LastUsedAt = now };
|
||||
return Results.Json(OpenSession(account, rotated, PlayerTokens.FormatRefresh(device.Id, newSecret), sessions, secrets), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Session ouverte
|
||||
|
||||
private static IResult Logout(PlayerLogoutRequest? request, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var ip = context.ClientIp();
|
||||
if (request?.AllDevices == true)
|
||||
{
|
||||
var removed = store.DeleteDevices(player.Account.Id);
|
||||
sessions.RemoveWhere(s => s.AccountId == player.Account.Id);
|
||||
store.AddEvent(player.Account.Id, PlayerEvents.LogoutAll, AuditResults.Ok, ip, player.Device?.KeyId,
|
||||
$"{removed.Count} appareil(s) déconnecté(s)");
|
||||
}
|
||||
else if (player.Device is { } device)
|
||||
{
|
||||
store.DeleteDevice(device.Id);
|
||||
sessions.RemoveDevices(new[] { device.Id });
|
||||
store.AddEvent(player.Account.Id, PlayerEvents.Logout, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
|
||||
}
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult Me(HttpContext context, IServiceProvider services)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
return Results.Json(new PlayerMeResponse
|
||||
{
|
||||
Account = Info(player.Account, secrets),
|
||||
Devices = store.Devices(player.Account.Id).Select(d => new PlayerDeviceInfo
|
||||
{
|
||||
Id = d.Id,
|
||||
Label = d.Label,
|
||||
CreatedAt = d.CreatedAt,
|
||||
LastUsedAt = d.LastUsedAt,
|
||||
Current = d.Id == player.Device?.Id
|
||||
}).ToList()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(PlayerChangePasswordRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
|
||||
var ip = context.ClientIp();
|
||||
var account = player.Account;
|
||||
var deviceKey = player.Device?.KeyId ?? player.Session.DeviceKeyId;
|
||||
var now = time.GetUtcNow();
|
||||
|
||||
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
|
||||
if (request.CurrentPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength } ||
|
||||
request.NewPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength })
|
||||
return ApiResults.BadRequest("Mot de passe actuel et nouveau mot de passe obligatoires.");
|
||||
|
||||
var (valid, _) = await hasher.VerifyAsync(request.CurrentPassword, account.PasswordHash, context.RequestAborted);
|
||||
if (!valid)
|
||||
{
|
||||
// Session volée qui tente de deviner le mot de passe : blocage puis fermeture des sessions du compte.
|
||||
var outcome = store.RecordFailure(account.Id, trusted: true, now, settings);
|
||||
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Denied, ip, deviceKey, "mot de passe actuel incorrect");
|
||||
if (outcome.LockedUntil is { } locked)
|
||||
{
|
||||
sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
return TooManyAttempts(context, locked, now);
|
||||
}
|
||||
return ApiResults.Error(400, ApiErrorCodes.LoginFailed, "Mot de passe actuel incorrect.");
|
||||
}
|
||||
|
||||
var email = secrets.DecryptEmail(account.Id, account.EmailEncrypted);
|
||||
var errors = PasswordRules.Check(request.NewPassword, email).ToList();
|
||||
if (errors.Count == 0 &&
|
||||
string.Equals(request.NewPassword, request.CurrentPassword, StringComparison.Ordinal))
|
||||
errors.Add("Le nouveau mot de passe doit être différent de l'actuel.");
|
||||
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
|
||||
|
||||
var hash = await hasher.HashAsync(request.NewPassword, context.RequestAborted);
|
||||
store.SetPassword(account.Id, hash, now);
|
||||
store.RecordSuccess(account.Id, now, null);
|
||||
|
||||
if (player.Session.Scope == PlayerScopes.PasswordChange)
|
||||
{
|
||||
// Changement EXIGÉ : tous les appareils sont déconnectés, puis la session s'ouvre sur cet appareil.
|
||||
var removed = store.DeleteDevices(account.Id);
|
||||
sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
var proof = new DeviceProofResult(player.Session.DevicePublicKey!, player.Session.DeviceKeyId!, player.Session.DeviceLabel!);
|
||||
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
|
||||
store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
|
||||
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"nouveau mot de passe (exigé) ; {removed.Count} autre(s) appareil(s) déconnecté(s)");
|
||||
var refreshed = store.FindById(account.Id) ?? account;
|
||||
return Results.Json(new PlayerChangePasswordResponse
|
||||
{
|
||||
Session = OpenSession(refreshed, deviceRecord, refreshToken, sessions, secrets),
|
||||
DevicesSignedOut = removed.Count
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
// Changement ordinaire : les AUTRES appareils sont déconnectés (celui-ci reste connecté).
|
||||
var others = store.DeleteDevices(account.Id, exceptDeviceId: player.Device!.Id);
|
||||
sessions.RemoveWhere(s => s.AccountId == account.Id && s.DeviceId != player.Device.Id);
|
||||
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, deviceKey,
|
||||
$"nouveau mot de passe ; {others.Count} autre(s) appareil(s) déconnecté(s)");
|
||||
return Results.Json(new PlayerChangePasswordResponse { DevicesSignedOut = others.Count }, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeleteDevice(string deviceId, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
if (!PlayerIds.IsValidId(deviceId) || store.FindDevice(deviceId) is not { } device || device.AccountId != player.Account.Id)
|
||||
return ApiResults.NotFound("Appareil introuvable.");
|
||||
store.DeleteDevice(device.Id);
|
||||
sessions.RemoveDevices(new[] { device.Id });
|
||||
store.AddEvent(player.Account.Id, PlayerEvents.DeviceRemoved, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId,
|
||||
$"appareil « {device.Label} » déconnecté{(device.Id == player.Device?.Id ? " (celui-ci)" : "")}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
/// <summary>Clé d'appareil vérifiée : clé publique (Base64 canonique), identifiant, nom générique.</summary>
|
||||
private sealed record DeviceProofResult(string PublicKey, string KeyId, string Label);
|
||||
|
||||
/// <summary>
|
||||
/// Défi (sceau, usage, échéance, preuve de travail) puis signature de la clé d'appareil, puis consommation du défi
|
||||
/// (un défi ne sert qu'une fois, et seulement avec une signature valide).
|
||||
/// </summary>
|
||||
private static (DeviceProofResult? Proof, IResult? Error) CheckDevice(PlayerGuard guard, ServerIdentity server, string purpose,
|
||||
string? challengeId, string? solution, PlayerDeviceProof? device)
|
||||
{
|
||||
if (guard.Validate(challengeId, purpose, solution) is not { } nonce)
|
||||
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-robot échouée ou expirée : réessayez."));
|
||||
var deviceError = ApiResults.Error(400, ApiErrorCodes.DeviceProofInvalid, "Clé de l'appareil invalide.");
|
||||
if (device is null) return (null, deviceError);
|
||||
byte[] spki;
|
||||
try
|
||||
{
|
||||
spki = AdminKeys.ParsePublicKey(device.PublicKey);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return (null, deviceError);
|
||||
}
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var signature = AdminKeys.DecodeSignature(device.Signature);
|
||||
if (signature is null ||
|
||||
!AdminKeys.Verify(spki, PlayerMessages.Device(purpose, server.Origin, server.ServerId, challengeId!, nonce, keyId), signature))
|
||||
return (null, deviceError);
|
||||
if (!guard.TryConsume(challengeId!))
|
||||
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification déjà utilisée : réessayez."));
|
||||
var label = ApiText.CleanLine(device.Label, ApiText.MaxKeyLabelLength) ?? "Appareil";
|
||||
return (new DeviceProofResult(Convert.ToBase64String(spki), keyId, label), null);
|
||||
}
|
||||
|
||||
private static (PlayerDeviceRecord Device, string RefreshToken) NewDevice(string accountId, DeviceProofResult proof,
|
||||
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
|
||||
{
|
||||
var deviceId = PlayerIds.NewId();
|
||||
var secret = PlayerTokens.NewSecret();
|
||||
var absolute = now + TimeSpan.FromDays(settings.RefreshMaxDays);
|
||||
var device = new PlayerDeviceRecord
|
||||
{
|
||||
Id = deviceId,
|
||||
AccountId = accountId,
|
||||
KeyId = proof.KeyId,
|
||||
PublicKey = proof.PublicKey,
|
||||
Label = proof.Label,
|
||||
CreatedAt = now,
|
||||
LastUsedAt = now,
|
||||
RefreshHash = HashSecret(secret),
|
||||
RefreshExpiresAt = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), absolute),
|
||||
RefreshAbsoluteExpiresAt = absolute
|
||||
};
|
||||
return (device, PlayerTokens.FormatRefresh(deviceId, secret));
|
||||
}
|
||||
|
||||
private static PlayerSession OpenSession(PlayerAccountRecord account, PlayerDeviceRecord device, string refreshToken,
|
||||
PlayerSessionStore sessions, PlayerSecrets secrets)
|
||||
{
|
||||
var (accessToken, access) = sessions.CreateAccess(account.Id, device.Id);
|
||||
return new PlayerSession
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
AccessExpiresAt = access.ExpiresAt,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshExpiresAt = device.RefreshExpiresAt,
|
||||
DeviceId = device.Id,
|
||||
Account = Info(account, secrets)
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayerAccountInfo Info(PlayerAccountRecord account, PlayerSecrets secrets) => new()
|
||||
{
|
||||
Reference = account.Reference,
|
||||
EmailMasked = PlayerEmail.Mask(secrets.DecryptEmail(account.Id, account.EmailEncrypted)),
|
||||
EmailVerified = account.EmailVerified,
|
||||
CreatedAt = account.CreatedAt,
|
||||
LastLoginAt = account.LastLoginAt,
|
||||
PasswordChangedAt = account.PasswordChangedAt,
|
||||
OtpEnabled = account.OtpEnabled,
|
||||
NewDevicesSuspended = account.Status == PlayerStatus.Suspended
|
||||
};
|
||||
|
||||
/// <summary>Empreinte d'un secret de jeton de renouvellement (seule valeur conservée).</summary>
|
||||
private static string HashSecret(string secret) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Player/Refresh/v1\n" + secret))).ToLowerInvariant();
|
||||
|
||||
private static bool FixedEquals(string a, string b) =>
|
||||
CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b));
|
||||
|
||||
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a <= b ? a : b;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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 détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
|
||||
/// </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).");
|
||||
}
|
||||
|
||||
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
|
||||
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
|
||||
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
|
||||
var changes = FeedbackFormsAudit.Changes(previous, published);
|
||||
foreach (var (action, name, details) in changes
|
||||
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
|
||||
.Reverse())
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"« {name} »", details);
|
||||
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; 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,104 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
|
||||
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
|
||||
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
|
||||
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
|
||||
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
|
||||
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
|
||||
/// maintenance (c'est elle qui permet de la terminer).
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModule : IApiModule
|
||||
{
|
||||
public string Name => "Maintenance";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<MaintenanceStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
|
||||
{
|
||||
var current = store.Current;
|
||||
return new MaintenanceState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = store.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
|
||||
var now = time.GetUtcNow();
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
|
||||
MaintenanceRules.Digest(document), actor, now);
|
||||
|
||||
MaintenanceDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(document, request.BaseRevision, now);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
|
||||
"(server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
var action = published.Enabled switch
|
||||
{
|
||||
true when !previous.Enabled => "maintenance.enable",
|
||||
false when previous.Enabled => "maintenance.disable",
|
||||
_ => "maintenance.update"
|
||||
};
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
|
||||
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
@@ -13,7 +15,8 @@ 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, des formulaires des demandes
|
||||
/// et du mode maintenance ;
|
||||
/// • 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 +36,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, MaintenanceStore maintenance,
|
||||
AdminStore admins, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
@@ -78,6 +81,55 @@ 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Mode maintenance en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
MaintenanceSignatureState? maintenanceState = null;
|
||||
if (maintenance.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = maintenance.Current;
|
||||
maintenanceState = current.Revision == 0
|
||||
? new MaintenanceSignatureState()
|
||||
: new MaintenanceSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Enabled = current.Enabled,
|
||||
Digest = MaintenanceRules.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 du mode maintenance");
|
||||
maintenanceState = new MaintenanceSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
@@ -97,7 +149,9 @@ public sealed class SigningModule : IApiModule
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
Catalog = catalog,
|
||||
FeedbackForms = feedbackForms,
|
||||
Maintenance = maintenanceState
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Reflection;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.System;
|
||||
|
||||
@@ -21,13 +23,23 @@ public sealed class SystemModule : IApiModule
|
||||
var plus = version.IndexOf('+');
|
||||
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
|
||||
|
||||
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
|
||||
api.MapGet(ApiRoutes.Info, (ServerIdentity server, IOptions<LauncherApiOptions> options) => Results.Json(new ServerInfo
|
||||
{
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ApiVersion = ApiRoutes.Version,
|
||||
ServerVersion = version,
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm }
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm },
|
||||
// Comptes joueurs : annoncés seulement s'ils sont activés (le launcher n'affiche le bouton « Compte » qu'alors).
|
||||
Accounts = options.Value.Players.Enabled
|
||||
? new PlayerAccountsInfo
|
||||
{
|
||||
Enabled = true,
|
||||
RegistrationOpen = options.Value.Players.RegistrationEnabled,
|
||||
TermsVersion = options.Value.Players.TermsVersion,
|
||||
PasswordRulesVersion = PasswordRules.Version
|
||||
}
|
||||
: null
|
||||
}, ApiJson.Options));
|
||||
|
||||
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
|
||||
|
||||
@@ -63,7 +63,13 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
|
||||
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
var token = header["Bearer ".Length..].Trim();
|
||||
// Jeton de JOUEUR (« glp… ») : jamais cherché parmi les sessions d'administrateurs. Un compte joueur ne peut ouvrir
|
||||
// aucune route d'administration (voir PlayerSessionHandler).
|
||||
if (token.StartsWith("glp", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var session = _sessions.Find(token);
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var database = _store.Read();
|
||||
@@ -115,12 +121,16 @@ public static class AdminAuthorization
|
||||
{
|
||||
services.AddAuthentication(AdminSessionHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
|
||||
// Schéma désigné explicitement dans chaque règle : une route d'administration n'accepte QUE la session d'un
|
||||
// administrateur, jamais celle d'un joueur (schéma « PlayerSession »), même si le schéma par défaut changeait.
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName,
|
||||
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser());
|
||||
foreach (var permission in AdminPermissions.All)
|
||||
options.AddPolicy(PolicyName(permission),
|
||||
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser()
|
||||
.RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage.Players;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Joueur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedPlayer(PlayerAccessSession Session, PlayerAccountRecord Account, PlayerDeviceRecord? Device);
|
||||
|
||||
/// <summary>
|
||||
/// Schéma « PlayerSession » : jeton joueur (« Authorization: Bearer glpa_… » ou « glpc_… »). TOTALEMENT distinct du
|
||||
/// schéma des administrateurs (<see cref="AdminSessionHandler"/>) : autre stockage, autres préfixes, et les routes
|
||||
/// d'administration n'utilisent jamais ce schéma (leurs règles désignent explicitement « AdminSession »). Un compte joueur
|
||||
/// ne peut donc ouvrir aucune route d'administration, quels que soient ses droits ou son état.
|
||||
///
|
||||
/// À chaque requête : jeton valide ET compte actif (ou suspendu : la suspension ne vise que les nouveaux appareils) ET
|
||||
/// (session complète) appareil toujours enregistré et aucun changement de mot de passe exigé. Une désactivation ou une
|
||||
/// déconnexion prend effet immédiatement.
|
||||
/// </summary>
|
||||
public sealed class PlayerSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "PlayerSession";
|
||||
public const string ScopeClaim = "player_scope";
|
||||
private const string ContextKey = "GameLauncher.Player";
|
||||
|
||||
private readonly PlayerSessionStore _sessions;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly LauncherApiOptions _options;
|
||||
|
||||
public PlayerSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
|
||||
PlayerSessionStore sessions, IServiceProvider services, IOptions<LauncherApiOptions> apiOptions)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_services = services;
|
||||
_options = apiOptions.Value;
|
||||
}
|
||||
|
||||
public static AuthenticatedPlayer? Get(HttpContext context) =>
|
||||
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedPlayer : null;
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!_options.Players.Enabled) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
var token = header["Bearer ".Length..].Trim();
|
||||
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
|
||||
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.NoResult()); // pas un jeton joueur
|
||||
|
||||
var session = _sessions.Find(token);
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session joueur absente ou expirée."));
|
||||
|
||||
var store = _services.GetRequiredService<PlayerStore>();
|
||||
var account = store.FindById(session.AccountId);
|
||||
// Suspendu : les appareils déjà connectés gardent l'accès (seuls les nouveaux appareils sont bloqués).
|
||||
if (account is null || account.Status is not (PlayerStatus.Active or PlayerStatus.Suspended))
|
||||
{
|
||||
_sessions.RemoveWhere(s => s.AccountId == session.AccountId);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Compte supprimé, désactivé ou suspendu."));
|
||||
}
|
||||
|
||||
PlayerDeviceRecord? device = null;
|
||||
if (session.Scope == PlayerScopes.Full)
|
||||
{
|
||||
device = session.DeviceId is { } deviceId ? store.FindDevice(deviceId) : null;
|
||||
if (device is null || device.AccountId != account.Id || account.MustChangePassword)
|
||||
{
|
||||
_sessions.Remove(session);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Appareil déconnecté ou nouveau mot de passe exigé."));
|
||||
}
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, account.Id),
|
||||
new(ScopeClaim, session.Scope)
|
||||
};
|
||||
Context.Items[ContextKey] = new AuthenticatedPlayer(session, account, device);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Connexion au compte requise (session absente ou expirée).");
|
||||
|
||||
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.PasswordChangeRequired,
|
||||
"Choisissez d'abord un nouveau mot de passe.");
|
||||
}
|
||||
|
||||
public static class PlayerAuthorization
|
||||
{
|
||||
public const string PlayerPolicy = "player";
|
||||
public const string PasswordChangePolicy = "player-password-change";
|
||||
|
||||
/// <summary>Route réservée à un joueur connecté (session complète).</summary>
|
||||
public static TBuilder RequirePlayer<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PlayerPolicy);
|
||||
|
||||
/// <summary>Route ouverte à une session complète OU au jeton limité « changement de mot de passe exigé ».</summary>
|
||||
public static TBuilder RequirePlayerOrPasswordChange<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PasswordChangePolicy);
|
||||
|
||||
public static IServiceCollection AddPlayerAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, PlayerSessionHandler>(PlayerSessionHandler.SchemeName, null);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(PlayerPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
|
||||
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full));
|
||||
options.AddPolicy(PasswordChangePolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
|
||||
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full, PlayerScopes.PasswordChange));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Joueur authentifié de la requête (les routes protégées en ont toujours un).</summary>
|
||||
public static AuthenticatedPlayer Player(this HttpContext context) =>
|
||||
PlayerSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequirePlayer.");
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Protections des routes ANONYMES des comptes joueurs (création, connexion, renouvellement) :
|
||||
/// • défis SANS ÉTAT (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine propre aux
|
||||
/// joueurs) : en demander ne coûte rien au serveur ; seuls les défis utilisés sont mémorisés (refus du rejeu) ;
|
||||
/// • preuve de travail (création de compte, connexion) : coûteuse pour un robot, négligeable pour un joueur ;
|
||||
/// • compteurs en mémoire par adresse IP (IPv6 : /64) : créations de compte, échecs de connexion ;
|
||||
/// • blocage « miroir » des adresses inconnues : une adresse sans compte se bloque exactement comme un compte réel
|
||||
/// (la réponse « trop d'essais » ne révèle donc pas qu'un compte existe).
|
||||
/// </summary>
|
||||
public sealed class PlayerGuard
|
||||
{
|
||||
private const int NonceLength = 16;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 1 + 8 + 1 + NonceLength; // usage + échéance + difficulté + nonce
|
||||
private const int MaxTracked = 100_000;
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Player/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly SlidingCounters _registrations = new();
|
||||
private readonly SlidingCounters _failedLogins = new();
|
||||
private readonly Dictionary<string, (int Failed, int Level, DateTimeOffset? Until)> _unknown = new(StringComparer.Ordinal);
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public PlayerGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_settings = options.Value.Players;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Défis
|
||||
|
||||
public PlayerChallengeResponse CreateChallenge(string purpose, string serverId, string origin)
|
||||
{
|
||||
var code = PurposeCode(purpose);
|
||||
var difficulty = purpose switch
|
||||
{
|
||||
PlayerChallengePurposes.Register => _settings.RegisterProofDifficulty,
|
||||
PlayerChallengePurposes.Login => _settings.LoginProofDifficulty,
|
||||
_ => 0
|
||||
};
|
||||
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
|
||||
var payload = new byte[PayloadLength];
|
||||
payload[0] = code;
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(1), expires.ToUnixTimeSeconds());
|
||||
payload[9] = (byte)difficulty;
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(10));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new PlayerChallengeResponse
|
||||
{
|
||||
ChallengeId = Base64Url.Encode(id),
|
||||
Nonce = Base64Url.Encode(payload.AsSpan(10)),
|
||||
Purpose = purpose,
|
||||
Difficulty = difficulty,
|
||||
ServerId = serverId,
|
||||
Origin = origin,
|
||||
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le défi (sceau, usage, échéance) et la preuve de travail, SANS le consommer. Renvoie le nonce (à vérifier
|
||||
/// dans la signature de l'appareil), ou null.
|
||||
/// </summary>
|
||||
public string? Validate(string? challengeId, string purpose, string? solution)
|
||||
{
|
||||
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) || id.Length != PayloadLength + MacLength)
|
||||
return null;
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return null;
|
||||
if (payload[0] != PurposeCode(purpose)) return null;
|
||||
var now = _time.GetUtcNow();
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[1..9]));
|
||||
if (expires <= now) return null;
|
||||
int difficulty = payload[9];
|
||||
if (purpose != PlayerChallengePurposes.Refresh)
|
||||
{
|
||||
// Difficulté scellée à la création ; jamais en dessous du minimum pour la création de compte et la connexion.
|
||||
if (difficulty < FeedbackProof.MinDifficulty || !FeedbackProof.Verify(payload[10..], difficulty, solution)) return null;
|
||||
}
|
||||
return Base64Url.Encode(payload[10..]);
|
||||
}
|
||||
|
||||
/// <summary>Consomme le défi (après vérification de la signature de l'appareil). Faux s'il a déjà servi (rejeu).</summary>
|
||||
public bool TryConsume(string challengeId)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
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 défis récents.");
|
||||
}
|
||||
return _used.TryAdd(challengeId, now + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds + 5));
|
||||
}
|
||||
|
||||
private static byte PurposeCode(string purpose) => purpose switch
|
||||
{
|
||||
PlayerChallengePurposes.Register => 1,
|
||||
PlayerChallengePurposes.Login => 2,
|
||||
PlayerChallengePurposes.Refresh => 3,
|
||||
_ => throw new ArgumentException("Usage de défi inconnu.", nameof(purpose))
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Créations de compte par adresse IP
|
||||
|
||||
/// <summary>Compte une création de compte pour cette adresse. Faux (rien n'est compté) si la limite horaire ou
|
||||
/// quotidienne est atteinte ; <paramref name="retryAfter"/> : délai avant la prochaine place.</summary>
|
||||
public bool TryCountRegistration(string partition, out TimeSpan retryAfter) =>
|
||||
_registrations.TryAdd(partition, _time.GetUtcNow(),
|
||||
(TimeSpan.FromHours(1), _settings.RegistrationsPerIpPerHour), (TimeSpan.FromDays(1), _settings.RegistrationsPerIpPerDay),
|
||||
out retryAfter);
|
||||
|
||||
// ------------------------------------------------------------------ Échecs de connexion par adresse IP
|
||||
|
||||
/// <summary>Vrai si cette adresse a trop échoué récemment (toutes adresses e-mail confondues).</summary>
|
||||
public bool IsIpThrottled(string partition, out TimeSpan retryAfter) =>
|
||||
_failedLogins.IsFull(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15), _settings.FailedLoginsPerIpPer15Minutes, out retryAfter);
|
||||
|
||||
public void CountFailedLogin(string partition) =>
|
||||
_failedLogins.Add(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15));
|
||||
|
||||
// ------------------------------------------------------------------ Adresses inconnues (blocage miroir)
|
||||
|
||||
/// <summary>Fin du blocage d'une adresse SANS compte (même règle que pour un compte réel), ou null.</summary>
|
||||
public DateTimeOffset? UnknownLockedUntil(string emailIndex)
|
||||
{
|
||||
lock (_unknown)
|
||||
return _unknown.TryGetValue(emailIndex, out var state) && state.Until > _time.GetUtcNow() ? state.Until : null;
|
||||
}
|
||||
|
||||
/// <summary>Échec sur une adresse sans compte : compté comme pour un compte (sans suspension, qui n'est jamais
|
||||
/// révélée avant un mot de passe correct). Renvoie la fin du blocage déclenché, ou null.</summary>
|
||||
public DateTimeOffset? CountUnknownFailure(string emailIndex)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_unknown)
|
||||
{
|
||||
if (_unknown.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var key in _unknown.Where(p => p.Value.Until is null || p.Value.Until <= now).Select(p => p.Key).ToList())
|
||||
_unknown.Remove(key);
|
||||
if (_unknown.Count >= MaxTracked) _unknown.Clear();
|
||||
}
|
||||
_unknown.TryGetValue(emailIndex, out var state);
|
||||
var failed = state.Failed + 1;
|
||||
var level = state.Level;
|
||||
DateTimeOffset? until = null;
|
||||
if (failed >= _settings.FailuresPerLock)
|
||||
{
|
||||
failed = 0;
|
||||
level++;
|
||||
until = now + Storage.Players.PlayerStore.LockDuration(_settings, level);
|
||||
}
|
||||
_unknown[emailIndex] = (failed, level, until ?? state.Until);
|
||||
return until;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Compteurs en mémoire sur des fenêtres glissantes (aucune adresse n'est écrite sur le disque).</summary>
|
||||
private sealed class SlidingCounters
|
||||
{
|
||||
private readonly Dictionary<string, List<DateTimeOffset>> _events = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TryAdd(string key, DateTimeOffset now, (TimeSpan Window, int Max) first, (TimeSpan Window, int Max) second,
|
||||
out TimeSpan retryAfter)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
var times = Get(key, now, second.Window);
|
||||
retryAfter = TimeSpan.Zero;
|
||||
foreach (var (window, max) in new[] { first, second })
|
||||
{
|
||||
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
|
||||
if (recent.Count >= max)
|
||||
{
|
||||
retryAfter = recent[recent.Count - max] + window - now;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
times.Add(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsFull(string key, DateTimeOffset now, TimeSpan window, int max, out TimeSpan retryAfter)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
retryAfter = TimeSpan.Zero;
|
||||
if (!_events.TryGetValue(key, out var times)) return false;
|
||||
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
|
||||
if (recent.Count < max) return false;
|
||||
retryAfter = recent[recent.Count - max] + window - now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(string key, DateTimeOffset now, TimeSpan window)
|
||||
{
|
||||
lock (_events) Get(key, now, window).Add(now);
|
||||
}
|
||||
|
||||
private List<DateTimeOffset> Get(string key, DateTimeOffset now, TimeSpan keep)
|
||||
{
|
||||
if (_events.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var stale in _events.Where(p => p.Value.All(t => now - t >= keep)).Select(p => p.Key).ToList())
|
||||
_events.Remove(stale);
|
||||
if (_events.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
|
||||
}
|
||||
if (!_events.TryGetValue(key, out var times)) _events[key] = times = new List<DateTimeOffset>();
|
||||
times.RemoveAll(t => now - t >= keep);
|
||||
return times;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Portée d'un jeton joueur.</summary>
|
||||
public static class PlayerScopes
|
||||
{
|
||||
/// <summary>Session complète (jeton « glpa_ »).</summary>
|
||||
public const string Full = "full";
|
||||
|
||||
/// <summary>Seulement le changement de mot de passe exigé (jeton « glpc_ »).</summary>
|
||||
public const string PasswordChange = "password_change";
|
||||
}
|
||||
|
||||
/// <summary>Jeton joueur valide (en mémoire uniquement : seule l'empreinte du jeton est gardée).</summary>
|
||||
public sealed class PlayerAccessSession
|
||||
{
|
||||
public required string TokenHash { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public required string AccountId { get; init; }
|
||||
|
||||
/// <summary>Appareil de la session (portée complète).</summary>
|
||||
public string? DeviceId { get; init; }
|
||||
|
||||
/// <summary>Clé publique et nom de l'appareil (portée « changement exigé » : l'appareil est enregistré une fois le
|
||||
/// mot de passe changé).</summary>
|
||||
public string? DevicePublicKey { get; init; }
|
||||
public string? DeviceKeyId { get; init; }
|
||||
public string? DeviceLabel { get; init; }
|
||||
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jetons d'accès des joueurs, en mémoire (un redémarrage du service les efface : le launcher renouvelle sa session avec
|
||||
/// son jeton de renouvellement, sans rien demander au joueur). Totalement séparés des sessions des administrateurs
|
||||
/// (<see cref="SessionStore"/>) : autre stockage, autre préfixe, autre schéma d'authentification.
|
||||
/// </summary>
|
||||
public sealed class PlayerSessionStore
|
||||
{
|
||||
private const int MaxSessions = 200_000;
|
||||
private static readonly TimeSpan PasswordChangeLifetime = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly ConcurrentDictionary<string, PlayerAccessSession> _byHash = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
|
||||
public PlayerSessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_settings = options.Value.Players;
|
||||
}
|
||||
|
||||
public int Count => _byHash.Count;
|
||||
|
||||
/// <summary>Jeton d'accès d'une session complète sur un appareil.</summary>
|
||||
public (string Token, PlayerAccessSession Session) CreateAccess(string accountId, string deviceId)
|
||||
{
|
||||
var token = PlayerTokens.AccessPrefix + PlayerTokens.NewSecret();
|
||||
var session = new PlayerAccessSession
|
||||
{
|
||||
TokenHash = Hash(token),
|
||||
Scope = PlayerScopes.Full,
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
ExpiresAt = _time.GetUtcNow() + TimeSpan.FromMinutes(_settings.AccessTokenMinutes)
|
||||
};
|
||||
Add(session);
|
||||
return (token, session);
|
||||
}
|
||||
|
||||
/// <summary>Jeton limité au changement de mot de passe exigé (10 min), qui retient l'appareil à enregistrer ensuite.</summary>
|
||||
public (string Token, PlayerAccessSession Session) CreatePasswordChange(string accountId, string devicePublicKey, string deviceKeyId,
|
||||
string deviceLabel)
|
||||
{
|
||||
var token = PlayerTokens.PasswordChangePrefix + PlayerTokens.NewSecret();
|
||||
var session = new PlayerAccessSession
|
||||
{
|
||||
TokenHash = Hash(token),
|
||||
Scope = PlayerScopes.PasswordChange,
|
||||
AccountId = accountId,
|
||||
DevicePublicKey = devicePublicKey,
|
||||
DeviceKeyId = deviceKeyId,
|
||||
DeviceLabel = deviceLabel,
|
||||
ExpiresAt = _time.GetUtcNow() + PasswordChangeLifetime
|
||||
};
|
||||
Add(session);
|
||||
return (token, session);
|
||||
}
|
||||
|
||||
/// <summary>Session du jeton, si elle est encore valide.</summary>
|
||||
public PlayerAccessSession? Find(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || token.Length > PlayerTokens.MaxLength) return null;
|
||||
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
|
||||
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
|
||||
return null;
|
||||
var hash = Hash(token);
|
||||
if (!_byHash.TryGetValue(hash, out var session)) return null;
|
||||
if (session.ExpiresAt <= _time.GetUtcNow())
|
||||
{
|
||||
_byHash.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
// Le préfixe doit correspondre à la portée (un jeton ne change jamais de portée).
|
||||
var expectedPrefix = session.Scope == PlayerScopes.Full ? PlayerTokens.AccessPrefix : PlayerTokens.PasswordChangePrefix;
|
||||
return token.StartsWith(expectedPrefix, StringComparison.Ordinal) ? session : null;
|
||||
}
|
||||
|
||||
public void Remove(PlayerAccessSession session) => _byHash.TryRemove(session.TokenHash, out _);
|
||||
|
||||
/// <summary>Ferme toutes les sessions d'un compte (ou d'appareils donnés).</summary>
|
||||
public int RemoveWhere(Func<PlayerAccessSession, bool> predicate)
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var (hash, session) in _byHash)
|
||||
if (predicate(session) && _byHash.TryRemove(hash, out _)) removed++;
|
||||
return removed;
|
||||
}
|
||||
|
||||
public int RemoveDevices(IEnumerable<string> deviceIds)
|
||||
{
|
||||
var set = deviceIds.ToHashSet(StringComparer.Ordinal);
|
||||
return set.Count == 0 ? 0 : RemoveWhere(s => s.DeviceId is { } id && set.Contains(id));
|
||||
}
|
||||
|
||||
private void Add(PlayerAccessSession session)
|
||||
{
|
||||
if (_byHash.Count >= MaxSessions)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
RemoveWhere(s => s.ExpiresAt <= now);
|
||||
if (_byHash.Count >= MaxSessions) throw new StoreBusyException("Trop de sessions de joueurs ouvertes.");
|
||||
}
|
||||
_byHash[session.TokenHash] = session;
|
||||
}
|
||||
|
||||
internal static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -143,6 +143,35 @@ public sealed class AuditLog
|
||||
details: suppressed > 0 ? $"{details} (+{suppressed} refus identique(s) la minute précédente)" : details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 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
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
|
||||
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
|
||||
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
|
||||
/// <param name="Details">Détail de chaque changement, en clair.</param>
|
||||
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
|
||||
|
||||
/// <summary>
|
||||
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
|
||||
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
|
||||
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
|
||||
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
|
||||
/// numérotées (1/2, 2/2…) plutôt que tronqué.
|
||||
/// </summary>
|
||||
public static class FeedbackFormsAudit
|
||||
{
|
||||
public const string Published = "feedback.forms";
|
||||
public const string Created = "feedback.form_create";
|
||||
public const string Updated = "feedback.form_update";
|
||||
public const string Deleted = "feedback.form_delete";
|
||||
|
||||
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
|
||||
internal const int MaxDetailsLength = 480;
|
||||
|
||||
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
|
||||
private const int MaxShownValueLength = 80;
|
||||
|
||||
private const string Separator = " ; ";
|
||||
|
||||
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
|
||||
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<FeedbackFormChange>();
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
|
||||
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
|
||||
}
|
||||
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
|
||||
changes.Add(new FeedbackFormChange(Deleted, form.Label,
|
||||
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
|
||||
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
|
||||
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
|
||||
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
|
||||
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)) parts.Add("ordre des types modifié");
|
||||
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
|
||||
}
|
||||
|
||||
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
|
||||
{
|
||||
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
|
||||
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
|
||||
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
|
||||
{
|
||||
// Place réservée au numéro « (12/12) ».
|
||||
var room = Math.Max(20, maxLength - 8);
|
||||
var lines = new List<string>();
|
||||
var current = "";
|
||||
foreach (var detail in details.SelectMany(d => Split(d, room)))
|
||||
{
|
||||
if (current.Length == 0) current = detail;
|
||||
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
|
||||
else
|
||||
{
|
||||
lines.Add(current);
|
||||
current = detail;
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current);
|
||||
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Split(string text, int room)
|
||||
{
|
||||
for (var i = 0; i < text.Length; i += room)
|
||||
yield return text.Substring(i, Math.Min(room, text.Length - i));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Types de demande
|
||||
|
||||
private static List<string> CreatedDetails(FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
|
||||
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
|
||||
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
|
||||
details.Add($"{form.Fields.Count} champ(s)");
|
||||
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
|
||||
return details;
|
||||
}
|
||||
|
||||
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
|
||||
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
|
||||
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
|
||||
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
|
||||
if (old.TechByDefault != form.TechByDefault)
|
||||
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
|
||||
if (old.CustomIcon != form.CustomIcon)
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
|
||||
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
|
||||
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
|
||||
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
|
||||
details.Add($"champ supprimé : « {field.Label} »");
|
||||
foreach (var field in form.Fields)
|
||||
{
|
||||
if (old.Field(field.Id) is not { } before)
|
||||
{
|
||||
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
|
||||
continue;
|
||||
}
|
||||
var parts = FieldDetails(before, field);
|
||||
if (parts.Count == 0) continue;
|
||||
var prefix = $"champ « {field.Label} » modifié : ";
|
||||
var combined = prefix + string.Join(", ", parts);
|
||||
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
|
||||
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
|
||||
else details.AddRange(parts.Select(p => prefix + p));
|
||||
}
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
details.Add("ordre des champs modifié");
|
||||
return details;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Champs
|
||||
|
||||
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
|
||||
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
|
||||
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
|
||||
if ((old.Hint ?? "") != (field.Hint ?? ""))
|
||||
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
|
||||
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
|
||||
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
|
||||
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
|
||||
if (old.ShowInList != field.ShowInList)
|
||||
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
|
||||
parts.AddRange(OptionDetails(old.Choices, field.Choices));
|
||||
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
|
||||
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
|
||||
{
|
||||
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
|
||||
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
|
||||
foreach (var option in now)
|
||||
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
|
||||
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
|
||||
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
|
||||
yield return "ordre des choix modifié";
|
||||
}
|
||||
|
||||
private static string FieldSummary(FeedbackField field)
|
||||
{
|
||||
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
|
||||
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
|
||||
if (field.ShowInList) parts.Add("en aperçu dans la liste");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string KindText(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "zone de texte",
|
||||
FeedbackFieldKind.Text => "texte court",
|
||||
FeedbackFieldKind.Choice => "liste de choix",
|
||||
FeedbackFieldKind.Checkboxes => "cases à cocher",
|
||||
FeedbackFieldKind.Rating => "note en étoiles",
|
||||
FeedbackFieldKind.YesNo => "case oui/non",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Texte
|
||||
|
||||
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
|
||||
private static string ValueChange(string name, bool feminine, string? before, string? after)
|
||||
{
|
||||
var e = feminine ? "e" : "";
|
||||
before = OneLine(before);
|
||||
after = OneLine(after);
|
||||
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
|
||||
if (after.Length == 0) return $"{name} retiré{e}";
|
||||
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
|
||||
? $"{name} : {Quote(before)} → {Quote(after)}"
|
||||
: $"{name} modifié{e}";
|
||||
}
|
||||
|
||||
private static string OneLine(string? value) =>
|
||||
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
private static string Quote(string? value) => $"« {OneLine(value)} »";
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text;
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux), activé ou désactivé depuis le menu d'administration.
|
||||
/// • Référence : maintenance.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é (autorisation « maintenance » 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é : maintenance inactive (révision 0).
|
||||
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceStore
|
||||
{
|
||||
public const string FileName = "maintenance.json";
|
||||
private const string HistoryFolder = "maintenance-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 MaintenanceDocument? _current;
|
||||
|
||||
public MaintenanceStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.MaintenancePublicFileFullPath, options.Value.MaintenancePublicUrl) { }
|
||||
|
||||
public MaintenanceStore(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>État en vigueur : celui publié, sinon maintenance inactive (révision 0).</summary>
|
||||
/// <exception cref="InvalidDataException">Fichier de référence illisible.</exception>
|
||||
public MaintenanceDocument Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _current ??= Read() ?? MaintenanceRules.Defaults();
|
||||
}
|
||||
}
|
||||
|
||||
private MaintenanceDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
try
|
||||
{
|
||||
return MaintenanceRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"{FileName} illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un document signé (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. Pendant une maintenance, le début annoncé ne
|
||||
/// change pas (modification du message seulement).
|
||||
/// </summary>
|
||||
/// <returns>Document publié (avec la date) et celui d'avant.</returns>
|
||||
public (MaintenanceDocument Published, MaintenanceDocument Previous) Publish(MaintenanceDocument document, long baseRevision, DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du mode maintenance non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
|
||||
"LauncherApi:Catalog:PublicFile) est nécessaire, le mode maintenance est publié à côté.");
|
||||
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Mode maintenance non signé.");
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors, now))
|
||||
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
|
||||
lock (_lock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var previous = Read() ?? MaintenanceRules.Defaults();
|
||||
if (baseRevision != previous.Revision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le mode maintenance a été modifié entre-temps (révision {previous.Revision}) : rechargez la page, puis recommencez.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (document.Revision != previous.Revision + 1)
|
||||
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
|
||||
if (document.Enabled && previous.Enabled && document.Since != previous.Since)
|
||||
throw ApiRejection.BadRequest("Maintenance déjà en cours : sa date de début ne change pas (seul le message peut être modifié).");
|
||||
|
||||
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
|
||||
var json = MaintenanceRules.Serialize(published);
|
||||
if (Encoding.UTF8.GetByteCount(json) > MaintenanceRules.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Mode maintenance trop volumineux (au plus {MaintenanceRules.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, "fichier public du mode maintenance");
|
||||
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, $"maintenance-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "maintenance-*.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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>
|
||||
/// Empreintes des mots de passe des joueurs : Argon2id (RFC 9106, cité par la CNIL et l'OWASP), sel aléatoire de 128 bits
|
||||
/// par compte, et « poivre » = clé secrète d'Argon2 dérivée de la clé maîtresse (hors du dossier des données). Format PHC :
|
||||
/// <c>$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte></c> (k : version du poivre). Les paramètres sont
|
||||
/// gardés avec l'empreinte : un relèvement des réglages s'applique à la connexion suivante de chaque joueur.
|
||||
///
|
||||
/// Les calculs (≈ 19 Mio de mémoire chacun) passent par une file bornée : un afflux de connexions ne peut pas épuiser
|
||||
/// la mémoire ou le processeur du VPS (les requêtes en trop attendent, puis reçoivent 503).
|
||||
/// </summary>
|
||||
public sealed class PlayerPasswordHasher
|
||||
{
|
||||
private const int SaltLength = 16;
|
||||
private const int HashLength = 32;
|
||||
private const string Prefix = "$argon2id$v=19$";
|
||||
private static readonly TimeSpan QueueTimeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly PlayerSecrets _secrets;
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
private readonly SemaphoreSlim _slots;
|
||||
|
||||
public PlayerPasswordHasher(PlayerSecrets secrets, LauncherApiOptions.PlayersSettings settings)
|
||||
{
|
||||
_secrets = secrets;
|
||||
_settings = settings;
|
||||
_slots = new SemaphoreSlim(settings.MaxConcurrentHashes, settings.MaxConcurrentHashes);
|
||||
}
|
||||
|
||||
/// <summary>Empreinte d'un nouveau mot de passe (UTF-8 du mot de passe tel que saisi).</summary>
|
||||
public async Task<string> HashAsync(string password, CancellationToken ct = default)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltLength);
|
||||
var m = _settings.HashMemoryKib;
|
||||
var t = _settings.HashIterations;
|
||||
var p = _settings.HashParallelism;
|
||||
var hash = await ComputeAsync(password, salt, m, t, p, ct);
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Prefix}m={m},t={t},p={p},k={PlayerSecrets.KeyVersion}${Base64Url.Encode(salt)}${Base64Url.Encode(hash)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie un mot de passe. <c>NeedsRehash</c> : empreinte calculée avec d'anciens réglages (à remplacer après une
|
||||
/// connexion réussie). Une empreinte illisible ne correspond à rien.
|
||||
/// </summary>
|
||||
public async Task<(bool Valid, bool NeedsRehash)> VerifyAsync(string password, string stored, CancellationToken ct = default)
|
||||
{
|
||||
if (!TryParse(stored, out var m, out var t, out var p, out var salt, out var expected))
|
||||
{
|
||||
await DummyAsync(password, ct);
|
||||
return (false, false);
|
||||
}
|
||||
var actual = await ComputeAsync(password, salt, m, t, p, ct);
|
||||
var valid = CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
var rehash = valid && (m != _settings.HashMemoryKib || t != _settings.HashIterations || p != _settings.HashParallelism);
|
||||
return (valid, rehash);
|
||||
}
|
||||
|
||||
/// <summary>Même calcul pour une adresse inconnue : la durée de réponse ne révèle pas si le compte existe.</summary>
|
||||
public Task DummyAsync(string password, CancellationToken ct = default) =>
|
||||
ComputeAsync(password, RandomNumberGenerator.GetBytes(SaltLength), _settings.HashMemoryKib, _settings.HashIterations,
|
||||
_settings.HashParallelism, ct);
|
||||
|
||||
private async Task<byte[]> ComputeAsync(string password, byte[] salt, int memoryKib, int iterations, int parallelism, CancellationToken ct)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(password);
|
||||
if (!await _slots.WaitAsync(QueueTimeout, ct))
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
throw new StoreBusyException("Trop de calculs de mots de passe en attente.");
|
||||
}
|
||||
try
|
||||
{
|
||||
using var argon2 = new Argon2id(bytes)
|
||||
{
|
||||
Salt = salt,
|
||||
KnownSecret = _secrets.PasswordPepper,
|
||||
MemorySize = memoryKib,
|
||||
Iterations = iterations,
|
||||
DegreeOfParallelism = parallelism
|
||||
};
|
||||
return argon2.GetBytes(HashLength);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_slots.Release();
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParse(string stored, out int m, out int t, out int p, out byte[] salt, out byte[] hash)
|
||||
{
|
||||
m = t = p = 0;
|
||||
salt = hash = Array.Empty<byte>();
|
||||
if (string.IsNullOrEmpty(stored) || !stored.StartsWith(Prefix, StringComparison.Ordinal)) return false;
|
||||
var parts = stored[Prefix.Length..].Split('$');
|
||||
if (parts.Length != 3) return false;
|
||||
int k = 0;
|
||||
foreach (var pair in parts[0].Split(','))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq <= 0 || !int.TryParse(pair[(eq + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var value)) return false;
|
||||
switch (pair[..eq])
|
||||
{
|
||||
case "m": m = value; break;
|
||||
case "t": t = value; break;
|
||||
case "p": p = value; break;
|
||||
case "k": k = value; break;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
if (m is < 8 or > 4_194_304 || t is < 1 or > 100 || p is < 1 or > 64 || k != PlayerSecrets.KeyVersion) return false;
|
||||
return Base64Url.TryDecode(parts[1], out salt) && salt.Length == SaltLength &&
|
||||
Base64Url.TryDecode(parts[2], out hash) && hash.Length == HashLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>États d'un compte joueur.</summary>
|
||||
public static class PlayerStatus
|
||||
{
|
||||
public const string Active = "active";
|
||||
|
||||
/// <summary>Désactivé par un administrateur (jusqu'à une date, ou indéfiniment).</summary>
|
||||
public const string Disabled = "disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Suspendu après trop d'échecs de connexion depuis des appareils inconnus : connexions depuis un NOUVEL appareil
|
||||
/// refusées jusqu'à ce qu'un administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner.
|
||||
/// </summary>
|
||||
public const string Suspended = "suspended";
|
||||
}
|
||||
|
||||
/// <summary>Compte joueur tel qu'enregistré (adresse chiffrée ; jamais renvoyé tel quel par l'API).</summary>
|
||||
public sealed record PlayerAccountRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Reference { get; init; }
|
||||
public required string EmailIndex { get; init; }
|
||||
public required string EmailEncrypted { get; init; }
|
||||
public bool EmailVerified { get; init; }
|
||||
public required string PasswordHash { get; init; }
|
||||
public DateTimeOffset PasswordChangedAt { get; init; }
|
||||
public bool MustChangePassword { get; init; }
|
||||
public bool OtpEnabled { get; init; }
|
||||
public string Status { get; init; } = PlayerStatus.Active;
|
||||
public DateTimeOffset? DisabledUntil { get; init; }
|
||||
public DateTimeOffset? StatusChangedAt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; init; }
|
||||
public required string TermsVersion { get; init; }
|
||||
|
||||
/// <summary>Échecs consécutifs depuis des appareils inconnus (série en cours), et nombre de blocages successifs.</summary>
|
||||
public int FailedCount { get; init; }
|
||||
public int LockLevel { get; init; }
|
||||
public DateTimeOffset? LockedUntil { get; init; }
|
||||
|
||||
/// <summary>Échecs consécutifs depuis des appareils de confiance, et leur blocage temporaire.</summary>
|
||||
public int TrustedFailedCount { get; init; }
|
||||
public DateTimeOffset? TrustedLockedUntil { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Appareil connecté (clé publique de l'appareil et session mémorisée).</summary>
|
||||
public sealed record PlayerDeviceRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string AccountId { get; init; }
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>Clé publique de l'appareil (SPKI P-256, Base64).</summary>
|
||||
public required string PublicKey { get; init; }
|
||||
|
||||
public required string Label { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset LastUsedAt { get; init; }
|
||||
|
||||
/// <summary>SHA-256 du jeton de renouvellement en vigueur, et du précédent (détection d'une réutilisation).</summary>
|
||||
public required string RefreshHash { get; init; }
|
||||
public string? PreviousRefreshHash { get; init; }
|
||||
|
||||
public DateTimeOffset RefreshExpiresAt { get; init; }
|
||||
public DateTimeOffset RefreshAbsoluteExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Ligne du journal des comptes.</summary>
|
||||
public sealed record PlayerEventRecord(long Id, string? AccountId, DateTimeOffset At, string Type, string Result, string? Ip, string? DeviceKeyId,
|
||||
string? Details);
|
||||
|
||||
/// <summary>Résultat d'un échec de connexion pour un compte existant.</summary>
|
||||
public sealed record PlayerFailureOutcome(DateTimeOffset? LockedUntil, bool Suspended, int LockLevel);
|
||||
|
||||
/// <summary>Types d'événements du journal des comptes (actions des joueurs et du système).</summary>
|
||||
public static class PlayerEvents
|
||||
{
|
||||
public const string Register = "account.register";
|
||||
public const string Login = "account.login";
|
||||
public const string LoginFailed = "account.login_failed";
|
||||
public const string Locked = "account.locked";
|
||||
public const string Suspended = "account.suspended";
|
||||
public const string Unlocked = "account.unlocked";
|
||||
public const string Disabled = "account.disabled";
|
||||
public const string Enabled = "account.enabled";
|
||||
public const string Logout = "account.logout";
|
||||
public const string LogoutAll = "account.logout_all";
|
||||
public const string PasswordChange = "account.password_change";
|
||||
public const string PasswordChangeForced = "account.password_change_required";
|
||||
public const string Refresh = "session.refresh";
|
||||
public const string RefreshReuse = "session.reuse";
|
||||
public const string DeviceRemoved = "device.removed";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>
|
||||
/// Clés des comptes joueurs, toutes dérivées (HKDF-SHA256) d'une clé maîtresse de 256 bits gardée HORS du dossier des
|
||||
/// données (credential systemd chiffré, déchiffré par systemd au démarrage dans un dossier en mémoire visible du seul
|
||||
/// service). Une copie de /var/lib/launcher-api ou de ses sauvegardes ne permet donc ni de lire les adresses e-mail, ni
|
||||
/// de tester des mots de passe hors ligne (le « poivre » des empreintes en dérive aussi).
|
||||
///
|
||||
/// Une sous-clé par usage : chiffrement des adresses, index aveugle (recherche par adresse sans la stocker en clair),
|
||||
/// poivre des mots de passe, et pour les étapes suivantes : secrets OTP, notes des administrateurs, messages.
|
||||
/// </summary>
|
||||
public sealed class PlayerSecrets
|
||||
{
|
||||
/// <summary>Version de la clé maîtresse, écrite devant chaque valeur chiffrée (rotation future).</summary>
|
||||
public const int KeyVersion = 1;
|
||||
|
||||
private const int KeyLength = 32;
|
||||
private const int NonceLength = 12;
|
||||
private const int TagLength = 16;
|
||||
private static readonly byte[] HkdfSalt = Encoding.ASCII.GetBytes("GameLauncher/Player/Keys/v1");
|
||||
|
||||
private readonly byte[] _emailKey;
|
||||
private readonly byte[] _indexKey;
|
||||
private readonly byte[] _pepper;
|
||||
private readonly byte[] _fieldKey;
|
||||
|
||||
public PlayerSecrets(ReadOnlySpan<byte> masterKey)
|
||||
{
|
||||
if (masterKey.Length != KeyLength) throw new CryptographicException("La clé maîtresse des comptes joueurs doit faire 32 octets.");
|
||||
if (masterKey.IndexOfAnyExcept((byte)0) < 0) throw new CryptographicException("Clé maîtresse vide (que des zéros).");
|
||||
_emailKey = Derive(masterKey, "email-encryption/v1");
|
||||
_indexKey = Derive(masterKey, "email-index/v1");
|
||||
_pepper = Derive(masterKey, "password-pepper/v1");
|
||||
_fieldKey = Derive(masterKey, "field-encryption/v1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lit la clé maîtresse (Base64 de 32 octets). Sous Linux, le fichier ne doit être ni lisible ni modifiable par les
|
||||
/// autres comptes, ni modifiable par le groupe (un credential systemd n'est lisible que par le service).
|
||||
/// </summary>
|
||||
public static PlayerSecrets Load(string path)
|
||||
{
|
||||
if (!File.Exists(path)) throw new InvalidOperationException($"Clé maîtresse des comptes joueurs introuvable : {path}");
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
const UnixFileMode forbidden = UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
|
||||
if ((mode & forbidden) != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Clé maîtresse des comptes joueurs accessible à d'autres comptes ({path}) : droits 0400 ou 0600 attendus.");
|
||||
}
|
||||
var text = File.ReadAllText(path).Trim();
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
key = Convert.FromBase64String(text);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new InvalidOperationException("Clé maîtresse des comptes joueurs illisible : 32 octets en Base64 attendus (commande players-key).");
|
||||
}
|
||||
try
|
||||
{
|
||||
return new PlayerSecrets(key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nouvelle clé maîtresse (commande « players-key »), en Base64.</summary>
|
||||
public static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(KeyLength));
|
||||
|
||||
/// <summary>Poivre des empreintes de mots de passe (« clé secrète » d'Argon2id).</summary>
|
||||
public byte[] PasswordPepper => _pepper;
|
||||
|
||||
/// <summary>
|
||||
/// Index aveugle d'une adresse NORMALISÉE : HMAC-SHA256 avec une clé secrète. Permet de retrouver un compte et
|
||||
/// d'imposer l'unicité des adresses sans conserver l'adresse en clair ; sans la clé, impossible de tester une adresse.
|
||||
/// </summary>
|
||||
public string EmailIndex(string normalizedEmail) =>
|
||||
Convert.ToHexString(HMACSHA256.HashData(_indexKey, Encoding.UTF8.GetBytes("GameLauncher/Player/Email/v1\n" + normalizedEmail)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
/// <summary>Chiffre l'adresse d'un compte (liée à ce compte : une valeur copiée sur un autre compte ne se déchiffre pas).</summary>
|
||||
public string EncryptEmail(string accountId, string normalizedEmail) =>
|
||||
Encrypt(_emailKey, Encoding.UTF8.GetBytes(normalizedEmail), Associated("email", accountId));
|
||||
|
||||
public string DecryptEmail(string accountId, string value) =>
|
||||
Encoding.UTF8.GetString(Decrypt(_emailKey, value, Associated("email", accountId)));
|
||||
|
||||
/// <summary>Chiffre un champ libre d'un compte (motifs, notes…), lié au compte et au nom du champ.</summary>
|
||||
public string EncryptField(string accountId, string field, string text) =>
|
||||
Encrypt(_fieldKey, Encoding.UTF8.GetBytes(text), Associated(field, accountId));
|
||||
|
||||
public string DecryptField(string accountId, string field, string value) =>
|
||||
Encoding.UTF8.GetString(Decrypt(_fieldKey, value, Associated(field, accountId)));
|
||||
|
||||
private static byte[] Associated(string field, string accountId) =>
|
||||
Encoding.UTF8.GetBytes($"GameLauncher/Player/Field/v1\n{field}\n{accountId}");
|
||||
|
||||
private static byte[] Derive(ReadOnlySpan<byte> masterKey, string purpose) =>
|
||||
HKDF.DeriveKey(HashAlgorithmName.SHA256, masterKey.ToArray(), KeyLength, HkdfSalt, Encoding.ASCII.GetBytes(purpose));
|
||||
|
||||
/// <summary>Format : « 1. » (version de la clé) + Base64Url(nonce ‖ chiffré ‖ étiquette), AES-256-GCM.</summary>
|
||||
private static string Encrypt(byte[] key, byte[] plaintext, byte[] associated)
|
||||
{
|
||||
var buffer = new byte[NonceLength + plaintext.Length + TagLength];
|
||||
var nonce = buffer.AsSpan(0, NonceLength);
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
using var aes = new AesGcm(key, TagLength);
|
||||
aes.Encrypt(nonce, plaintext, buffer.AsSpan(NonceLength, plaintext.Length), buffer.AsSpan(NonceLength + plaintext.Length), associated);
|
||||
return $"{KeyVersion}.{Base64Url.Encode(buffer)}";
|
||||
}
|
||||
|
||||
private static byte[] Decrypt(byte[] key, string value, byte[] associated)
|
||||
{
|
||||
var dot = value.IndexOf('.');
|
||||
if (dot <= 0 || value[..dot] != KeyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) ||
|
||||
!Base64Url.TryDecode(value[(dot + 1)..], out var buffer) || buffer.Length < NonceLength + TagLength)
|
||||
throw new CryptographicException("Valeur chiffrée illisible ou d'une autre version de clé.");
|
||||
var length = buffer.Length - NonceLength - TagLength;
|
||||
var plaintext = new byte[length];
|
||||
using var aes = new AesGcm(key, TagLength);
|
||||
aes.Decrypt(buffer.AsSpan(0, NonceLength), buffer.AsSpan(NonceLength, length), buffer.AsSpan(NonceLength + length), plaintext, associated);
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>
|
||||
/// Base des comptes joueurs : SQLite (players/players.db dans les données privées, dossier 0700, fichier 0600).
|
||||
///
|
||||
/// Pourquoi SQLite ici (et pas des fichiers JSON comme les autres modules) : relations (compte ⇄ appareils ⇄ journal, et
|
||||
/// bientôt conversations, blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par
|
||||
/// index. Réglages : journal WAL, clés étrangères, effacement sécurisé (secure_delete : une ligne supprimée est écrasée
|
||||
/// sur le disque), écriture synchrone complète. Requêtes PARAMÉTRÉES uniquement.
|
||||
///
|
||||
/// Une seule connexion, protégée par un verrou : SQLite n'accepte qu'un écrivain à la fois, les requêtes sont courtes,
|
||||
/// et les calculs coûteux (empreintes de mots de passe) sont faits AVANT d'entrer ici. L'outil en ligne de commande peut
|
||||
/// ouvrir la base en même temps (attente bornée).
|
||||
///
|
||||
/// Le contenu sensible est chiffré AVANT d'arriver ici (adresse e-mail : <see cref="PlayerSecrets"/>) : cette classe ne
|
||||
/// connaît pas la clé maîtresse.
|
||||
/// </summary>
|
||||
public sealed class PlayerStore : IDisposable
|
||||
{
|
||||
public const string FolderName = "players";
|
||||
public const string FileName = "players.db";
|
||||
|
||||
/// <summary>Version du schéma (PRAGMA user_version).</summary>
|
||||
public const int SchemaVersion = 1;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly SqliteConnection _db;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public PlayerStore(IOptions<LauncherApiOptions> options, TimeProvider time) : this(options.Value.DataDirectoryFullPath, time) { }
|
||||
|
||||
public PlayerStore(string dataDirectory, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
var folder = Path.Combine(dataDirectory, FolderName);
|
||||
SecureFiles.EnsureDirectory(folder);
|
||||
DatabasePath = Path.Combine(folder, FileName);
|
||||
// Fichier créé en 0600 AVANT que SQLite ne l'ouvre (les fichiers -wal et -shm reprennent ses droits).
|
||||
if (!File.Exists(DatabasePath)) SecureFiles.TryCreateNew(DatabasePath, "");
|
||||
|
||||
_db = new SqliteConnection(new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = DatabasePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = false
|
||||
}.ToString());
|
||||
_db.Open();
|
||||
try
|
||||
{
|
||||
Execute(null, "PRAGMA busy_timeout = 5000;");
|
||||
Scalar(null, "PRAGMA journal_mode = WAL;");
|
||||
Execute(null, "PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA synchronous = FULL;");
|
||||
Migrate();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_db.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public string DatabasePath { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock) _db.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Schéma
|
||||
|
||||
private void Migrate()
|
||||
{
|
||||
var version = Convert.ToInt64(Scalar(null, "PRAGMA user_version;"), CultureInfo.InvariantCulture);
|
||||
if (version > SchemaVersion)
|
||||
throw new InvalidOperationException(
|
||||
$"Base des comptes joueurs en version {version}, plus récente que ce serveur ({SchemaVersion}) : mettez le serveur à jour.");
|
||||
if (version == SchemaVersion) return;
|
||||
|
||||
using var tx = _db.BeginTransaction();
|
||||
if (version < 1)
|
||||
{
|
||||
Execute(tx, """
|
||||
CREATE TABLE accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
reference TEXT NOT NULL UNIQUE,
|
||||
email_index TEXT NOT NULL UNIQUE,
|
||||
email_enc TEXT NOT NULL,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_changed_at INTEGER NOT NULL,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
totp_secret_enc TEXT,
|
||||
totp_last_step INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
disabled_until INTEGER,
|
||||
status_reason_enc TEXT,
|
||||
status_changed_at INTEGER,
|
||||
status_changed_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER,
|
||||
terms_version TEXT NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
lock_level INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until INTEGER,
|
||||
trusted_failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
trusted_locked_until INTEGER
|
||||
);
|
||||
CREATE INDEX ix_accounts_created ON accounts(created_at);
|
||||
CREATE INDEX ix_accounts_status ON accounts(status);
|
||||
|
||||
CREATE TABLE devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
key_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL,
|
||||
refresh_hash TEXT NOT NULL,
|
||||
previous_refresh_hash TEXT,
|
||||
refresh_expires_at INTEGER NOT NULL,
|
||||
refresh_absolute_expires_at INTEGER NOT NULL,
|
||||
UNIQUE (account_id, key_id)
|
||||
);
|
||||
CREATE INDEX ix_devices_account ON devices(account_id);
|
||||
|
||||
CREATE TABLE account_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
at INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
device_key TEXT,
|
||||
details TEXT
|
||||
);
|
||||
CREATE INDEX ix_events_account ON account_events(account_id, at);
|
||||
CREATE INDEX ix_events_at ON account_events(at);
|
||||
""");
|
||||
}
|
||||
Execute(tx, $"PRAGMA user_version = {SchemaVersion};");
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
|
||||
private const string AccountColumns =
|
||||
"id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at, must_change_password, " +
|
||||
"totp_secret_enc IS NOT NULL, status, disabled_until, status_changed_at, created_at, last_login_at, terms_version, " +
|
||||
"failed_count, lock_level, locked_until, trusted_failed_count, trusted_locked_until";
|
||||
|
||||
public PlayerAccountRecord? FindByEmailIndex(string emailIndex) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE email_index = $v;", ReadAccount, ("$v", emailIndex)));
|
||||
|
||||
public PlayerAccountRecord? FindById(string accountId) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId)));
|
||||
|
||||
public PlayerAccountRecord? FindByReference(string reference) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE reference = $v;", ReadAccount, ("$v", reference)));
|
||||
|
||||
public int CountAccounts() => Locked(() => Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts;"), CultureInfo.InvariantCulture));
|
||||
|
||||
public int CountCreatedSince(DateTimeOffset since) => Locked(() =>
|
||||
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts WHERE created_at >= $t;", ("$t", since.ToUnixTimeSeconds())),
|
||||
CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>
|
||||
/// Crée le compte et son premier appareil, sous transaction. Faux si l'adresse (index) est déjà utilisée. La référence
|
||||
/// est retirée au sort de nouveau en cas de collision (40 bits : rare).
|
||||
/// </summary>
|
||||
public bool TryCreate(PlayerAccountRecord account, PlayerDeviceRecord device, Func<string> newReference, out PlayerAccountRecord created)
|
||||
{
|
||||
created = account;
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
if (Scalar(tx, "SELECT 1 FROM accounts WHERE email_index = $v;", ("$v", account.EmailIndex)) is not null) return false;
|
||||
var reference = account.Reference;
|
||||
for (var attempt = 0; Scalar(tx, "SELECT 1 FROM accounts WHERE reference = $v;", ("$v", reference)) is not null; attempt++)
|
||||
{
|
||||
if (attempt > 20) throw new InvalidOperationException("Impossible de tirer une référence de compte libre.");
|
||||
reference = newReference();
|
||||
}
|
||||
created = account with { Reference = reference };
|
||||
Execute(tx, """
|
||||
INSERT INTO accounts (id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at,
|
||||
must_change_password, status, created_at, last_login_at, terms_version)
|
||||
VALUES ($id, $ref, $idx, $enc, 0, $hash, $pwd, 0, $status, $created, $login, $terms);
|
||||
""",
|
||||
("$id", created.Id), ("$ref", created.Reference), ("$idx", created.EmailIndex), ("$enc", created.EmailEncrypted),
|
||||
("$hash", created.PasswordHash), ("$pwd", created.PasswordChangedAt.ToUnixTimeSeconds()), ("$status", PlayerStatus.Active),
|
||||
("$created", created.CreatedAt.ToUnixTimeSeconds()), ("$login", Db(created.LastLoginAt)), ("$terms", created.TermsVersion));
|
||||
InsertDevice(tx, device);
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Connexion réussie : compteurs d'échecs remis à zéro, date de connexion, empreinte éventuellement remplacée.</summary>
|
||||
public void RecordSuccess(string accountId, DateTimeOffset now, string? newPasswordHash) => Locked(() =>
|
||||
{
|
||||
Execute(null, """
|
||||
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0,
|
||||
trusted_locked_until = NULL, last_login_at = $now,
|
||||
password_hash = COALESCE($hash, password_hash)
|
||||
WHERE id = $id;
|
||||
""", ("$now", now.ToUnixTimeSeconds()), ("$hash", (object?)newPasswordHash ?? DBNull.Value), ("$id", accountId));
|
||||
return true;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Échec de connexion (mot de passe faux) sur un compte existant, sous transaction. Appareil inconnu : blocage tous les
|
||||
/// <paramref name="failuresPerLock"/> échecs (durée doublée à chaque fois), suspension au blocage n°
|
||||
/// <paramref name="suspendAfterLocks"/>. Appareil de confiance : blocage temporaire seulement.
|
||||
/// </summary>
|
||||
public PlayerFailureOutcome RecordFailure(string accountId, bool trusted, DateTimeOffset now, LauncherApiOptions.PlayersSettings settings)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
var account = QuerySingle(tx, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId))
|
||||
?? throw new InvalidOperationException("Compte disparu.");
|
||||
PlayerFailureOutcome outcome;
|
||||
if (trusted)
|
||||
{
|
||||
var count = account.TrustedFailedCount + 1;
|
||||
DateTimeOffset? until = null;
|
||||
if (count >= settings.TrustedFailuresPerLock)
|
||||
{
|
||||
until = now + TimeSpan.FromMinutes(settings.LockMinutes);
|
||||
count = 0;
|
||||
}
|
||||
Execute(tx, "UPDATE accounts SET trusted_failed_count = $c, trusted_locked_until = COALESCE($u, trusted_locked_until) WHERE id = $id;",
|
||||
("$c", count), ("$u", Db(until)), ("$id", accountId));
|
||||
outcome = new PlayerFailureOutcome(until, false, account.LockLevel);
|
||||
}
|
||||
else
|
||||
{
|
||||
var count = account.FailedCount + 1;
|
||||
var level = account.LockLevel;
|
||||
DateTimeOffset? until = null;
|
||||
var suspended = false;
|
||||
if (count >= settings.FailuresPerLock)
|
||||
{
|
||||
count = 0;
|
||||
level++;
|
||||
if (level >= settings.SuspendAfterLocks && account.Status != PlayerStatus.Suspended) suspended = true;
|
||||
// Le blocage temporaire s'applique AUSSI au moment de la suspension et après : vu de l'extérieur, un compte
|
||||
// suspendu se comporte comme une adresse inconnue (PlayerGuard.CountUnknownFailure), la suspension
|
||||
// n'est révélée qu'après un mot de passe correct.
|
||||
until = now + LockDuration(settings, level);
|
||||
}
|
||||
Execute(tx, """
|
||||
UPDATE accounts SET failed_count = $c, lock_level = $l, locked_until = COALESCE($u, locked_until),
|
||||
status = CASE WHEN $s = 1 THEN 'suspended' ELSE status END,
|
||||
status_changed_at = CASE WHEN $s = 1 THEN $now ELSE status_changed_at END,
|
||||
status_changed_by = CASE WHEN $s = 1 THEN 'système (échecs de connexion)' ELSE status_changed_by END,
|
||||
disabled_until = CASE WHEN $s = 1 THEN NULL ELSE disabled_until END
|
||||
WHERE id = $id;
|
||||
""", ("$c", count), ("$l", level), ("$u", Db(until)), ("$s", suspended ? 1 : 0), ("$now", now.ToUnixTimeSeconds()),
|
||||
("$id", accountId));
|
||||
outcome = new PlayerFailureOutcome(until, suspended, level);
|
||||
}
|
||||
tx.Commit();
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Durée du blocage n° <paramref name="level"/> (1, 2, 3…) : <c>LockMinutes</c> doublée à chaque blocage, plafonnée
|
||||
/// au niveau précédant la suspension et à 24 h. Même règle pour les adresses inconnues (blocage miroir).
|
||||
/// </summary>
|
||||
public static TimeSpan LockDuration(LauncherApiOptions.PlayersSettings settings, int level)
|
||||
{
|
||||
var capped = Math.Clamp(level, 1, Math.Max(1, settings.SuspendAfterLocks - 1));
|
||||
return TimeSpan.FromMinutes(Math.Min(24 * 60, settings.LockMinutes * Math.Pow(2, capped - 1)));
|
||||
}
|
||||
|
||||
/// <summary>Nouveau mot de passe : empreinte, date, « changement exigé » levé (ou posé).</summary>
|
||||
public void SetPassword(string accountId, string passwordHash, DateTimeOffset now, bool mustChange = false) => Locked(() =>
|
||||
{
|
||||
Execute(null, "UPDATE accounts SET password_hash = $h, password_changed_at = $now, must_change_password = $m WHERE id = $id;",
|
||||
("$h", passwordHash), ("$now", now.ToUnixTimeSeconds()), ("$m", mustChange ? 1 : 0), ("$id", accountId));
|
||||
return true;
|
||||
});
|
||||
|
||||
/// <summary>Exige (ou non) un nouveau mot de passe à la prochaine connexion.</summary>
|
||||
public bool SetMustChangePassword(string accountId, bool value) => Locked(() =>
|
||||
Execute(null, "UPDATE accounts SET must_change_password = $m WHERE id = $id;", ("$m", value ? 1 : 0), ("$id", accountId)) == 1);
|
||||
|
||||
/// <summary>Change l'état du compte (désactivation, réactivation, levée d'une suspension) ; remet à zéro les blocages
|
||||
/// quand le compte redevient actif.</summary>
|
||||
public bool SetStatus(string accountId, string status, DateTimeOffset? until, string? reasonEncrypted, string changedBy, DateTimeOffset now) =>
|
||||
Locked(() => Execute(null, """
|
||||
UPDATE accounts SET status = $s, disabled_until = $u, status_reason_enc = $r, status_changed_at = $now, status_changed_by = $by,
|
||||
failed_count = CASE WHEN $s = 'active' THEN 0 ELSE failed_count END,
|
||||
lock_level = CASE WHEN $s = 'active' THEN 0 ELSE lock_level END,
|
||||
locked_until = CASE WHEN $s = 'active' THEN NULL ELSE locked_until END,
|
||||
trusted_failed_count = CASE WHEN $s = 'active' THEN 0 ELSE trusted_failed_count END,
|
||||
trusted_locked_until = CASE WHEN $s = 'active' THEN NULL ELSE trusted_locked_until END
|
||||
WHERE id = $id;
|
||||
""", ("$s", status), ("$u", Db(until)), ("$r", (object?)reasonEncrypted ?? DBNull.Value), ("$now", now.ToUnixTimeSeconds()),
|
||||
("$by", changedBy), ("$id", accountId)) == 1);
|
||||
|
||||
// ------------------------------------------------------------------ Appareils
|
||||
|
||||
private const string DeviceColumns =
|
||||
"id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash, " +
|
||||
"refresh_expires_at, refresh_absolute_expires_at";
|
||||
|
||||
public PlayerDeviceRecord? FindDevice(string deviceId) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE id = $v;", ReadDevice, ("$v", deviceId)));
|
||||
|
||||
public PlayerDeviceRecord? FindDeviceByKey(string accountId, string keyId) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a AND key_id = $k;", ReadDevice,
|
||||
("$a", accountId), ("$k", keyId)));
|
||||
|
||||
public IReadOnlyList<PlayerDeviceRecord> Devices(string accountId) => Locked(() =>
|
||||
Query(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a ORDER BY last_used_at DESC;", ReadDevice, ("$a", accountId)));
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre une session mémorisée sur un appareil : appareil créé, ou remplacé s'il existait (même clé : nouvelle
|
||||
/// connexion depuis le même PC). Au-delà de <paramref name="maxDevices"/>, les appareils les moins récemment utilisés
|
||||
/// sont déconnectés. Renvoie les identifiants des appareils retirés (sessions à fermer).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SaveDevice(PlayerDeviceRecord device, int maxDevices)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
var removed = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND key_id = $k;", r => r.GetString(0),
|
||||
("$a", device.AccountId), ("$k", device.KeyId)).ToList();
|
||||
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND key_id = $k;", ("$a", device.AccountId), ("$k", device.KeyId));
|
||||
InsertDevice(tx, device);
|
||||
var excess = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $id ORDER BY last_used_at DESC, created_at DESC;",
|
||||
r => r.GetString(0), ("$a", device.AccountId), ("$id", device.Id)).Skip(Math.Max(0, maxDevices - 1)).ToList();
|
||||
foreach (var id in excess) Execute(tx, "DELETE FROM devices WHERE id = $id;", ("$id", id));
|
||||
tx.Commit();
|
||||
removed.AddRange(excess);
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace le jeton de renouvellement SI celui présenté est toujours celui en vigueur (deux renouvellements simultanés
|
||||
/// avec le même jeton : un seul réussit).
|
||||
/// </summary>
|
||||
public bool RotateRefresh(string deviceId, string expectedHash, string newHash, DateTimeOffset idleExpiry, DateTimeOffset now) => Locked(() =>
|
||||
Execute(null, """
|
||||
UPDATE devices SET previous_refresh_hash = refresh_hash, refresh_hash = $new, refresh_expires_at = $exp, last_used_at = $now
|
||||
WHERE id = $id AND refresh_hash = $old;
|
||||
""", ("$new", newHash), ("$exp", idleExpiry.ToUnixTimeSeconds()), ("$now", now.ToUnixTimeSeconds()), ("$id", deviceId),
|
||||
("$old", expectedHash)) == 1);
|
||||
|
||||
public bool DeleteDevice(string deviceId) => Locked(() => Execute(null, "DELETE FROM devices WHERE id = $id;", ("$id", deviceId)) == 1);
|
||||
|
||||
/// <summary>Déconnecte tous les appareils du compte (sauf <paramref name="exceptDeviceId"/>) ; renvoie leurs identifiants.</summary>
|
||||
public IReadOnlyList<string> DeleteDevices(string accountId, string? exceptDeviceId = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
var ids = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $e;", r => r.GetString(0),
|
||||
("$a", accountId), ("$e", exceptDeviceId ?? "")).ToList();
|
||||
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND id <> $e;", ("$a", accountId), ("$e", exceptDeviceId ?? ""));
|
||||
tx.Commit();
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertDevice(SqliteTransaction tx, PlayerDeviceRecord device) =>
|
||||
Execute(tx, """
|
||||
INSERT INTO devices (id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash,
|
||||
refresh_expires_at, refresh_absolute_expires_at)
|
||||
VALUES ($id, $a, $k, $pk, $label, $created, $used, $rh, NULL, $exp, $abs);
|
||||
""", ("$id", device.Id), ("$a", device.AccountId), ("$k", device.KeyId), ("$pk", device.PublicKey), ("$label", device.Label),
|
||||
("$created", device.CreatedAt.ToUnixTimeSeconds()), ("$used", device.LastUsedAt.ToUnixTimeSeconds()), ("$rh", device.RefreshHash),
|
||||
("$exp", device.RefreshExpiresAt.ToUnixTimeSeconds()), ("$abs", device.RefreshAbsoluteExpiresAt.ToUnixTimeSeconds()));
|
||||
|
||||
// ------------------------------------------------------------------ Journal des comptes
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute une ligne au journal des comptes. Jamais d'adresse e-mail ni de mot de passe : identifiant du compte, type,
|
||||
/// résultat, adresse IP (effacée au bout de quelques mois), clé d'appareil, détail court.
|
||||
/// </summary>
|
||||
public void AddEvent(string? accountId, string type, string result, string? ip, string? deviceKeyId = null, string? details = null)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
Locked(() => Execute(null, """
|
||||
INSERT INTO account_events (account_id, at, type, result, ip, device_key, details) VALUES ($a, $at, $t, $r, $ip, $k, $d);
|
||||
""", ("$a", (object?)accountId ?? DBNull.Value), ("$at", now.ToUnixTimeSeconds()), ("$t", type), ("$r", result),
|
||||
("$ip", (object?)Trim(ip, 64) ?? DBNull.Value), ("$k", (object?)Trim(deviceKeyId, 32) ?? DBNull.Value),
|
||||
("$d", (object?)Trim(details, 300) ?? DBNull.Value)));
|
||||
}
|
||||
|
||||
/// <summary>Journal d'un compte, le plus récent d'abord.</summary>
|
||||
public IReadOnlyList<PlayerEventRecord> Events(string accountId, int limit) => Locked(() =>
|
||||
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE account_id = $a ORDER BY id DESC LIMIT $l;",
|
||||
ReadEvent, ("$a", accountId), ("$l", Math.Clamp(limit, 1, 1000))));
|
||||
|
||||
/// <summary>Lignes du journal postérieures à <paramref name="afterId"/> (tous comptes, tests et outils).</summary>
|
||||
public IReadOnlyList<PlayerEventRecord> EventsAfter(long afterId, int limit) => Locked(() =>
|
||||
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE id > $i ORDER BY id LIMIT $l;",
|
||||
ReadEvent, ("$i", afterId), ("$l", Math.Clamp(limit, 1, 10_000))));
|
||||
|
||||
/// <summary>
|
||||
/// Conservation : lignes plus anciennes que <paramref name="months"/> supprimées, adresses IP plus anciennes que
|
||||
/// <paramref name="ipMonths"/> effacées. Renvoie (supprimées, anonymisées).
|
||||
/// </summary>
|
||||
public (int Deleted, int Anonymized) PurgeEvents(DateTimeOffset now, int months, int ipMonths) => Locked(() =>
|
||||
{
|
||||
var deleted = Execute(null, "DELETE FROM account_events WHERE at < $t;", ("$t", now.AddMonths(-months).ToUnixTimeSeconds()));
|
||||
var anonymized = Execute(null, "UPDATE account_events SET ip = NULL WHERE ip IS NOT NULL AND at < $t;",
|
||||
("$t", now.AddMonths(-ipMonths).ToUnixTimeSeconds()));
|
||||
return (deleted, anonymized);
|
||||
});
|
||||
|
||||
private static string? Trim(string? value, int max)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var clean = new string(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
|
||||
return clean.Length <= max ? clean : clean[..max];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Accès SQLite
|
||||
|
||||
private T Locked<T>(Func<T> work)
|
||||
{
|
||||
lock (_lock) return work();
|
||||
}
|
||||
|
||||
private int Execute(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
|
||||
{
|
||||
using var command = Command(tx, sql, parameters);
|
||||
return command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private object? Scalar(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
|
||||
{
|
||||
using var command = Command(tx, sql, parameters);
|
||||
return command.ExecuteScalar();
|
||||
}
|
||||
|
||||
private List<T> Query<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
|
||||
{
|
||||
using var command = Command(tx, sql, parameters);
|
||||
using var reader = command.ExecuteReader();
|
||||
var rows = new List<T>();
|
||||
while (reader.Read()) rows.Add(read(reader));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private T? QuerySingle<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
|
||||
where T : class =>
|
||||
Query(tx, sql, read, parameters).FirstOrDefault();
|
||||
|
||||
private SqliteCommand Command(SqliteTransaction? tx, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = _db.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Transaction = tx;
|
||||
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static object Db(DateTimeOffset? value) => value is { } date ? date.ToUnixTimeSeconds() : DBNull.Value;
|
||||
|
||||
private static DateTimeOffset Date(SqliteDataReader r, int i) => DateTimeOffset.FromUnixTimeSeconds(r.GetInt64(i));
|
||||
|
||||
private static DateTimeOffset? NullableDate(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : Date(r, i);
|
||||
|
||||
private static PlayerAccountRecord ReadAccount(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
Reference = r.GetString(1),
|
||||
EmailIndex = r.GetString(2),
|
||||
EmailEncrypted = r.GetString(3),
|
||||
EmailVerified = r.GetInt64(4) != 0,
|
||||
PasswordHash = r.GetString(5),
|
||||
PasswordChangedAt = Date(r, 6),
|
||||
MustChangePassword = r.GetInt64(7) != 0,
|
||||
OtpEnabled = r.GetInt64(8) != 0,
|
||||
Status = r.GetString(9),
|
||||
DisabledUntil = NullableDate(r, 10),
|
||||
StatusChangedAt = NullableDate(r, 11),
|
||||
CreatedAt = Date(r, 12),
|
||||
LastLoginAt = NullableDate(r, 13),
|
||||
TermsVersion = r.GetString(14),
|
||||
FailedCount = (int)r.GetInt64(15),
|
||||
LockLevel = (int)r.GetInt64(16),
|
||||
LockedUntil = NullableDate(r, 17),
|
||||
TrustedFailedCount = (int)r.GetInt64(18),
|
||||
TrustedLockedUntil = NullableDate(r, 19)
|
||||
};
|
||||
|
||||
private static PlayerDeviceRecord ReadDevice(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
AccountId = r.GetString(1),
|
||||
KeyId = r.GetString(2),
|
||||
PublicKey = r.GetString(3),
|
||||
Label = r.GetString(4),
|
||||
CreatedAt = Date(r, 5),
|
||||
LastUsedAt = Date(r, 6),
|
||||
RefreshHash = r.GetString(7),
|
||||
PreviousRefreshHash = r.IsDBNull(8) ? null : r.GetString(8),
|
||||
RefreshExpiresAt = Date(r, 9),
|
||||
RefreshAbsoluteExpiresAt = Date(r, 10)
|
||||
};
|
||||
|
||||
private static PlayerEventRecord ReadEvent(SqliteDataReader r) => new(
|
||||
r.GetInt64(0), r.IsDBNull(1) ? null : r.GetString(1), Date(r, 2), r.GetString(3), r.GetString(4),
|
||||
r.IsDBNull(5) ? null : r.GetString(5), r.IsDBNull(6) ? null : r.GetString(6), r.IsDBNull(7) ? null : r.GetString(7));
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -51,6 +51,44 @@
|
||||
"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
|
||||
},
|
||||
// Comptes joueurs (phase 8) : désactivés par défaut. NE PAS ACTIVER EN PRODUCTION avant la fin de l'étape 8g
|
||||
// (docs/PLAYER-ACCOUNTS-ARCHITECTURE.md). Activés, ils exigent la clé maîtresse : credential systemd chiffré
|
||||
// « players-master-key » (server/deploy/LISEZMOI.md, étape 3 undecies), jamais dans le dossier des données.
|
||||
"Players": {
|
||||
"Enabled": false,
|
||||
"RegistrationEnabled": true,
|
||||
"TermsVersion": "1",
|
||||
"HashMemoryKib": 19456,
|
||||
"HashIterations": 2,
|
||||
"FailuresPerLock": 5,
|
||||
"LockMinutes": 15,
|
||||
"SuspendAfterLocks": 4,
|
||||
"RefreshIdleDays": 90,
|
||||
"RefreshMaxDays": 365,
|
||||
"EventsRetentionMonths": 12,
|
||||
"EventsIpRetentionMonths": 6
|
||||
},
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
|
||||
|
||||
+188
-3
@@ -286,8 +286,11 @@ Limites :
|
||||
textes) ;
|
||||
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
|
||||
et au moins celui du dernier trousseau qu'il a vu ;
|
||||
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
|
||||
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
|
||||
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
|
||||
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
|
||||
fois par minute) ;
|
||||
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
|
||||
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
|
||||
|
||||
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
|
||||
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
|
||||
@@ -363,6 +366,174 @@ dans le dossier (les images inutilisées se suppriment dans « Fichiers du serve
|
||||
`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.
|
||||
|
||||
## 3 decies. Mode maintenance du launcher (PC et VPS)
|
||||
|
||||
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
|
||||
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
|
||||
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
|
||||
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
|
||||
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
|
||||
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
|
||||
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
|
||||
(`catalog.conf`, étape 3 bis).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
|
||||
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
|
||||
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
|
||||
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
|
||||
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
|
||||
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
|
||||
l'activer (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 **« mode maintenance »** des appareils
|
||||
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
|
||||
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
|
||||
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
|
||||
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
|
||||
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
|
||||
|
||||
Comportement des launchers :
|
||||
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
|
||||
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
|
||||
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
|
||||
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
|
||||
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
|
||||
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
|
||||
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
|
||||
du jeu doivent refuser les connexions, c'est à eux de le faire.
|
||||
|
||||
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
|
||||
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
|
||||
|
||||
## 3 undecies. Comptes joueurs : préparation (VPS)
|
||||
|
||||
> **Ne pas activer en production** avant la fin de l'étape 8g (launcher, OTP, menu d'administration, textes RGPD,
|
||||
> nginx). Cette étape sert à préparer le VPS ou à essayer le serveur sur un VPS de test. Architecture :
|
||||
> `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`.
|
||||
|
||||
Les comptes joueurs (adresse e-mail + mot de passe) sont stockés dans `/var/lib/launcher-api/players/players.db`
|
||||
(SQLite, 600) : adresses **chiffrées**, mots de passe hachés (Argon2id + « poivre »). Toutes les clés dérivent d'une
|
||||
**clé maîtresse** gardée **hors** du dossier des données, chiffrée par systemd (clé propre à ce VPS).
|
||||
|
||||
1. **Clé maîtresse** (une seule fois). Elle s'affiche une fois pour que tu en gardes une copie **hors ligne**
|
||||
(gestionnaire de mots de passe) : perdue, les adresses sont illisibles et tous les mots de passe doivent être
|
||||
réinitialisés ; sur un nouveau VPS, il faudra la rechiffrer avec `systemd-creds`.
|
||||
```sh
|
||||
sudo mkdir -p /etc/credstore.encrypted && sudo chmod 700 /etc/credstore.encrypted
|
||||
KEY=$(sudo -u deploy /opt/launcher-api/GameLauncher.Server players-key)
|
||||
echo "$KEY" # ← copie-la dans ton gestionnaire de mots de passe
|
||||
printf '%s' "$KEY" | sudo systemd-creds encrypt --name=players-master-key - /etc/credstore.encrypted/players-master-key
|
||||
unset KEY
|
||||
```
|
||||
2. **Complément systemd** : copie `server/deploy/launcher-api-players.conf` en
|
||||
`/etc/systemd/system/launcher-api.service.d/players.conf`, puis `sudo systemctl daemon-reload`.
|
||||
3. **Réglages** : dans `appsettings.Production.json`, section `LauncherApi` :
|
||||
`"Players": { "Enabled": true, "TermsVersion": "2026-11" }` (version des conditions d'utilisation que les joueurs
|
||||
acceptent). `"RegistrationEnabled": false` ferme la création de compte en gardant la connexion.
|
||||
4. `sudo systemctl restart launcher-api` puis `journalctl -u launcher-api -n 20` : la ligne « Comptes joueurs activés »
|
||||
doit apparaître. Clé absente, rangée dans le dossier des données ou lisible par d'autres comptes : le service refuse
|
||||
de démarrer et l'explique dans le journal.
|
||||
|
||||
Sauvegarde : `players.db` contient des données personnelles chiffrées ; une sauvegarde cohérente se fait service arrêté
|
||||
(ou, service en marche, avec `sqlite3 players.db ".backup /chemin"`, paquet `sqlite3`), et doit elle-même être chiffrée (étape 8g). Commandes de secours :
|
||||
étape 9.
|
||||
|
||||
## 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 :
|
||||
@@ -448,6 +619,9 @@ pas, le script remet la précédente en place.
|
||||
| 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) |
|
||||
| Compte joueur : voir son état (sans son adresse) | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-show --ref PL-XXXX-XXXX` |
|
||||
| Compte joueur suspendu (trop d'essais) ou bloqué | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-unlock --ref PL-XXXX-XXXX` |
|
||||
| Compte joueur à désactiver / réactiver | `… players-disable --ref PL-XXXX-XXXX [--until AAAA-MM-JJ]` / `… players-enable --ref PL-XXXX-XXXX` |
|
||||
|
||||
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
|
||||
@@ -466,6 +640,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` |
|
||||
|
||||
@@ -483,14 +658,24 @@ 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
|
||||
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
|
||||
├─ maintenance-history/ 20 dernières révisions publiées
|
||||
├─ players/players.db (+ -wal, -shm) comptes joueurs (SQLite, 600 ; adresses chiffrées, mots de passe hachés)
|
||||
└─ 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)
|
||||
├─ maintenance.json mode maintenance signé (é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é)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# =============================================================================
|
||||
# Comptes joueurs (phase 8) : clé maîtresse transmise au service par systemd.
|
||||
# /etc/systemd/system/launcher-api.service.d/players.conf
|
||||
#
|
||||
# La clé est chiffrée sur le disque (systemd-creds, clé de l'hôte) dans /etc/credstore.encrypted (root, 700) ;
|
||||
# systemd la déchiffre au démarrage dans un dossier en mémoire visible du seul service ($CREDENTIALS_DIRECTORY).
|
||||
# Elle n'est jamais dans /var/lib/launcher-api : une copie des données ou des sauvegardes ne la contient pas.
|
||||
# Mise en place : server/deploy/LISEZMOI.md, étape 3 undecies. Sans ce fichier, laisser Players:Enabled à false.
|
||||
# =============================================================================
|
||||
[Service]
|
||||
LoadCredentialEncrypted=players-master-key:/etc/credstore.encrypted/players-master-key
|
||||
@@ -7,7 +7,9 @@
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (les DEUX blocs ci-dessous ; le second, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies)
|
||||
# (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
|
||||
# =============================================================================
|
||||
@@ -53,3 +55,89 @@ location ^~ /api/v1/catalog/images/ {
|
||||
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, formulaires des demandes et mode maintenance (écrits par la Launcher API) —
|
||||
# voir LISEZMOI.md, étapes 3 bis, 3 quinquies, 3 nonies et 3 decies :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
@@ -26,3 +27,21 @@ 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;
|
||||
}
|
||||
|
||||
# Mode maintenance (launcher 3.3) : activé depuis le menu, signé (autorisation « maintenance » du trousseau), relu par les
|
||||
# launchers au démarrage puis toutes les 1 à 2 minutes. Jamais mis en cache : la fin de la maintenance doit être vue tout de suite.
|
||||
location = /catalog/maintenance.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;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
@@ -272,6 +274,87 @@ 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>Mode maintenance en vigueur (révision 0 : jamais publié, inactif).</summary>
|
||||
public Task<MaintenanceState> GetMaintenanceAsync(CancellationToken ct = default) =>
|
||||
GetAsync<MaintenanceState>(ApiRoutes.Maintenance, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Active, désactive ou modifie le mode maintenance : révision <paramref name="baseRevision"/> + 1, signé par la clé de
|
||||
/// cette session (autorisation « maintenance » du trousseau requise chez les joueurs), puis envoyé en requête signée.
|
||||
/// </summary>
|
||||
/// <param name="message">Précision affichée aux joueurs, déjà nettoyée (<see cref="MaintenanceRules.CleanMessage"/>) ; ignorée à la désactivation.</param>
|
||||
/// <param name="since">
|
||||
/// Début de la maintenance : celui de la maintenance en cours pour un nouveau message, null pour en commencer une
|
||||
/// (l'heure du serveur, à la seconde) ; ignoré à la désactivation.
|
||||
/// </param>
|
||||
public async Task<MaintenanceState> PublishMaintenanceAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var document = new MaintenanceDocument
|
||||
{
|
||||
Revision = baseRevision + 1,
|
||||
Enabled = enabled,
|
||||
Message = enabled ? message : null,
|
||||
Since = enabled ? since ?? ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset) : null
|
||||
};
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(message));
|
||||
var signature = await SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), ct)
|
||||
.ConfigureAwait(false);
|
||||
return await SendSignedAsync<MaintenanceState>(HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { 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));
|
||||
|
||||
@@ -293,7 +376,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.");
|
||||
|
||||
@@ -319,7 +403,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);
|
||||
@@ -0,0 +1,319 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Règles des mots de passe des comptes joueurs, appliquées À L'IDENTIQUE par le launcher (retour immédiat pendant la
|
||||
/// saisie) et par le serveur (seul juge). Inspirées de la recommandation CNIL n° 2022-100 et de l'OWASP :
|
||||
/// • 12 caractères au moins avec 3 types parmi minuscules, majuscules, chiffres, symboles ; ou 16 caractères au moins
|
||||
/// sans contrainte (phrase de passe) ; 128 au plus ;
|
||||
/// • refus des mots de passe les plus utilisés (dictionnaire public de 100 000 mots de passe) et de leurs variantes
|
||||
/// simples (« P@ssw0rd2024! ») ;
|
||||
/// • refus des suites triviales (répétitions, suites de clavier ou de chiffres) ;
|
||||
/// • refus d'un mot de passe tiré de l'adresse e-mail ou du nom du jeu / du studio.
|
||||
/// Le mot de passe est contrôlé et haché TEL QU'IL EST SAISI (aucune normalisation Unicode) : le serveur tourne en mode
|
||||
/// « globalisation invariante » (sans ICU), où la normalisation n'est pas disponible ; launcher et serveur appliquent
|
||||
/// donc exactement le même calcul. Les claviers Windows produisent des caractères accentués déjà composés.
|
||||
/// </summary>
|
||||
public static class PasswordRules
|
||||
{
|
||||
/// <summary>Version des règles (annoncée par le serveur ; changer si les règles évoluent).</summary>
|
||||
public const string Version = "1";
|
||||
|
||||
public const int MinLength = 12;
|
||||
public const int MinLengthWithoutComposition = 16;
|
||||
public const int MaxLength = 128;
|
||||
public const int RequiredClasses = 3;
|
||||
|
||||
/// <summary>Taille maximale d'un mot de passe en UTF-8 (128 caractères de 4 octets).</summary>
|
||||
public const int MaxUtf8Bytes = 512;
|
||||
|
||||
/// <summary>Longueur minimale d'un « cœur » comparé au dictionnaire (en dessous, la comparaison n'a pas de sens).</summary>
|
||||
private const int MinCoreLength = 4;
|
||||
|
||||
/// <summary>Après retrait de l'adresse e-mail ou du nom du jeu, il doit rester au moins ce nombre de caractères.</summary>
|
||||
private const int MinRemainingLength = 8;
|
||||
|
||||
/// <summary>Mots propres au studio et aux jeux : un mot de passe construit dessus est refusé.</summary>
|
||||
private static readonly string[] ProductWords =
|
||||
{
|
||||
"lanisysgames", "lanisys", "gamelauncher", "launcher", "exovivalzombiereborn", "exovivalzombie", "exovival",
|
||||
"zombiereborn", "zombie"
|
||||
};
|
||||
|
||||
/// <summary>Mots de passe français très courants (complément du dictionnaire, majoritairement anglophone).</summary>
|
||||
private static readonly string[] FrenchWords =
|
||||
{
|
||||
"azerty", "azertyuiop", "motdepasse", "monmotdepasse", "soleil", "doudou", "loulou", "chouchou", "bonjour",
|
||||
"coucou", "jetaime", "marseille", "paris", "camille", "chocolat", "princesse", "nicolas", "julien", "doudoune",
|
||||
"bisounours", "vacances", "football", "olympique", "toulouse", "bordeaux", "nantes", "liberte", "france",
|
||||
"motdepasse123", "123soleil", "chaton", "cheval", "minou", "nounours", "tresor", "amour", "ordinateur"
|
||||
};
|
||||
|
||||
/// <summary>Rangées de claviers (AZERTY, QWERTY, QWERTZ) et suites courantes.</summary>
|
||||
private static readonly string[] Sequences =
|
||||
{
|
||||
"azertyuiop", "qsdfghjklm", "wxcvbn", "qwertyuiop", "asdfghjkl", "zxcvbnm", "qwertzuiop", "yxcvbnm",
|
||||
"1234567890", "0123456789", "abcdefghijklmnopqrstuvwxyz", "&é\"'(-è_çà", "azertyuiopqsdfghjklmwxcvbn",
|
||||
"qwertyuiopasdfghjklzxcvbnm", "1qaz2wsx3edc4rfv", "1a2z3e4r5t6y", "a1z2e3r4t5y6"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Erreurs du mot de passe (messages en français, prêts à afficher). Liste vide : mot de passe accepté.
|
||||
/// <paramref name="email"/> : adresse du compte (facultative), pour refuser un mot de passe qui en est tiré.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Check(string? password, string? email = null)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
errors.Add("Le mot de passe est obligatoire.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
var normalized = password;
|
||||
var length = RuneCount(normalized);
|
||||
if (normalized.Any(char.IsControl) || HasInvalidUnicode(normalized))
|
||||
errors.Add("Le mot de passe contient un caractère non autorisé (retour à la ligne, tabulation…).");
|
||||
if (length > MaxLength || Encoding.UTF8.GetByteCount(normalized) > MaxUtf8Bytes)
|
||||
{
|
||||
errors.Add($"Le mot de passe ne doit pas dépasser {MaxLength} caractères.");
|
||||
return errors;
|
||||
}
|
||||
if (length < MinLength)
|
||||
{
|
||||
errors.Add($"Le mot de passe doit contenir au moins {MinLength} caractères.");
|
||||
return errors;
|
||||
}
|
||||
if (length < MinLengthWithoutComposition && CountClasses(normalized) < RequiredClasses)
|
||||
errors.Add($"Entre {MinLength} et {MinLengthWithoutComposition - 1} caractères, le mot de passe doit mélanger au moins " +
|
||||
$"{RequiredClasses} types de caractères parmi : minuscules, majuscules, chiffres, symboles. " +
|
||||
$"Vous pouvez aussi choisir une phrase de passe d'au moins {MinLengthWithoutComposition} caractères.");
|
||||
if (errors.Count > 0) return errors;
|
||||
|
||||
var lower = normalized.ToLowerInvariant();
|
||||
if (IsTrivial(lower))
|
||||
errors.Add("Le mot de passe est une suite trop simple (répétition, suite de clavier, de lettres ou de chiffres).");
|
||||
else if (IsCommon(lower))
|
||||
errors.Add("Ce mot de passe fait partie des mots de passe les plus utilisés (ou en est une simple variante) : " +
|
||||
"choisissez-en un autre.");
|
||||
else if (IsDerivedFrom(lower, email))
|
||||
errors.Add("Le mot de passe ressemble trop à votre adresse e-mail ou au nom du jeu : choisissez-en un autre.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si le mot de passe est accepté.</summary>
|
||||
public static bool IsAcceptable(string? password, string? email = null) => Check(password, email).Count == 0;
|
||||
|
||||
// ------------------------------------------------------------------ Composition
|
||||
|
||||
private static int RuneCount(string text)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var _ in text.EnumerateRunes()) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>Demi-caractère (surrogate) isolé : texte mal formé.</summary>
|
||||
private static bool HasInvalidUnicode(string text)
|
||||
{
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (char.IsHighSurrogate(text[i]))
|
||||
{
|
||||
if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return true;
|
||||
i++;
|
||||
}
|
||||
else if (char.IsLowSurrogate(text[i]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int CountClasses(string text)
|
||||
{
|
||||
bool lower = false, upper = false, digit = false, other = false;
|
||||
foreach (var rune in text.EnumerateRunes())
|
||||
{
|
||||
if (Rune.IsLower(rune)) lower = true;
|
||||
else if (Rune.IsUpper(rune)) upper = true;
|
||||
else if (Rune.IsDigit(rune)) digit = true;
|
||||
else other = true; // symboles, espaces, lettres sans casse
|
||||
}
|
||||
return (lower ? 1 : 0) + (upper ? 1 : 0) + (digit ? 1 : 0) + (other ? 1 : 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Suites triviales
|
||||
|
||||
private static bool IsTrivial(string lower)
|
||||
{
|
||||
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
if (compact.Distinct().Count() <= 3) return true; // « aaaaaaaaaaaa », « abababababab », « 121212121212 »
|
||||
|
||||
foreach (var candidate in new[] { compact, Core(compact), Leet(Core(compact)) })
|
||||
{
|
||||
if (candidate.Length < MinCoreLength) continue;
|
||||
if (IsRepeatedPattern(candidate)) return true;
|
||||
foreach (var sequence in Sequences)
|
||||
{
|
||||
if (IsInsideRepeated(candidate, sequence) || IsInsideRepeated(candidate, Reverse(sequence))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Texte formé d'un même motif répété (« azeazeazeaze », « 123412341234 »).</summary>
|
||||
private static bool IsRepeatedPattern(string text)
|
||||
{
|
||||
for (var size = 1; size <= text.Length / 2; size++)
|
||||
{
|
||||
if (text.Length % size != 0) continue;
|
||||
var pattern = text[..size];
|
||||
var repeated = true;
|
||||
for (var i = size; i < text.Length && repeated; i += size)
|
||||
repeated = string.CompareOrdinal(text, i, pattern, 0, size) == 0;
|
||||
if (repeated) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Le texte est contenu dans la suite répétée (« 345678901234 » dans « 1234567890 » × 3).</summary>
|
||||
private static bool IsInsideRepeated(string text, string sequence)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
while (builder.Length < text.Length + sequence.Length) builder.Append(sequence);
|
||||
return builder.ToString().Contains(text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Reverse(string text)
|
||||
{
|
||||
var chars = text.ToCharArray();
|
||||
Array.Reverse(chars);
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Dictionnaire
|
||||
|
||||
private static bool IsCommon(string lower)
|
||||
{
|
||||
var words = Blocklist.Value;
|
||||
foreach (var candidate in Candidates(lower))
|
||||
if (candidate.Length >= MinCoreLength && words.Contains(candidate)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Formes comparées au dictionnaire : telle quelle, sans chiffres/symboles au début et à la fin, puis
|
||||
/// avec les substitutions courantes (« p@ssw0rd » → « password »).</summary>
|
||||
private static IEnumerable<string> Candidates(string lower)
|
||||
{
|
||||
yield return lower;
|
||||
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
yield return compact;
|
||||
var core = Core(compact);
|
||||
yield return core;
|
||||
yield return Leet(core);
|
||||
yield return Core(Leet(compact));
|
||||
}
|
||||
|
||||
/// <summary>Retire chiffres, espaces et symboles au début et à la fin (« !!dragon2024 » → « dragon »).</summary>
|
||||
internal static string Core(string text)
|
||||
{
|
||||
var start = 0;
|
||||
var end = text.Length;
|
||||
while (start < end && !char.IsLetter(text[start])) start++;
|
||||
while (end > start && !char.IsLetter(text[end - 1])) end--;
|
||||
return text[start..end];
|
||||
}
|
||||
|
||||
/// <summary>Substitutions courantes chiffres/symboles → lettres.</summary>
|
||||
internal static string Leet(string text)
|
||||
{
|
||||
var chars = text.ToCharArray();
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
{
|
||||
chars[i] = chars[i] switch
|
||||
{
|
||||
'0' => 'o',
|
||||
'1' or '!' or '|' => 'i',
|
||||
'3' or '€' => 'e',
|
||||
'4' or '@' => 'a',
|
||||
'5' or '$' => 's',
|
||||
'7' or '+' => 't',
|
||||
'8' => 'b',
|
||||
'9' => 'g',
|
||||
_ => chars[i]
|
||||
};
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dictionnaire chargé à la première utilisation (ressource intégrée, ~100 000 entrées) : chaque entrée est gardée
|
||||
/// en minuscules et sous sa forme normalisée (sans chiffres/symboles aux extrémités, substitutions retirées).
|
||||
/// </summary>
|
||||
private static readonly Lazy<HashSet<string>> Blocklist = new(LoadBlocklist, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
/// <summary>Nombre de formes connues du dictionnaire (diagnostic et tests ; le charge s'il ne l'est pas encore).</summary>
|
||||
public static int BlocklistSize => Blocklist.Value.Count;
|
||||
|
||||
private static HashSet<string> LoadBlocklist()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
void Add(string word)
|
||||
{
|
||||
var lower = word.ToLowerInvariant();
|
||||
if (lower.Length >= MinCoreLength) set.Add(lower);
|
||||
var core = Leet(Core(lower));
|
||||
if (core.Length >= MinCoreLength) set.Add(core);
|
||||
}
|
||||
|
||||
using (var stream = typeof(PasswordRules).Assembly.GetManifestResourceStream(BlocklistResource)
|
||||
?? throw new InvalidOperationException("Dictionnaire des mots de passe absent de GameLauncher.Contracts."))
|
||||
using (var gzip = new GZipStream(stream, CompressionMode.Decompress))
|
||||
using (var reader = new StreamReader(gzip, Encoding.UTF8))
|
||||
{
|
||||
while (reader.ReadLine() is { } line)
|
||||
if (line.Length > 0) Add(line);
|
||||
}
|
||||
foreach (var word in FrenchWords) Add(word);
|
||||
return set; // les mots du studio et des jeux sont traités à part (IsDerivedFrom), avec un message plus précis
|
||||
|
||||
}
|
||||
|
||||
internal const string BlocklistResource = "GameLauncher.Contracts.Accounts.password-blocklist.txt.gz";
|
||||
|
||||
// ------------------------------------------------------------------ Adresse e-mail et nom du jeu
|
||||
|
||||
private static bool IsDerivedFrom(string lower, string? email)
|
||||
{
|
||||
var words = new List<string>(ProductWords);
|
||||
if (PlayerEmail.Normalize(email) is { } normalized)
|
||||
{
|
||||
var at = normalized.IndexOf('@');
|
||||
var local = normalized[..at];
|
||||
words.Add(local);
|
||||
words.AddRange(local.Split('.', '_', '-', '+').Where(p => p.Length >= 3));
|
||||
var domainLabel = normalized[(at + 1)..].Split('.')[0];
|
||||
if (domainLabel.Length >= 4) words.Add(domainLabel);
|
||||
}
|
||||
|
||||
foreach (var form in new[] { lower, Leet(lower) })
|
||||
{
|
||||
var remaining = new string(form.Where(char.IsLetterOrDigit).ToArray());
|
||||
var removedAny = false;
|
||||
foreach (var word in words.Where(w => w.Length >= 3).OrderByDescending(w => w.Length))
|
||||
{
|
||||
var clean = new string(word.Where(char.IsLetterOrDigit).ToArray());
|
||||
if (clean.Length < 3 || !remaining.Contains(clean, StringComparison.Ordinal)) continue;
|
||||
remaining = remaining.Replace(clean, "", StringComparison.Ordinal);
|
||||
removedAny = true;
|
||||
}
|
||||
if (removedAny && remaining.Length < MinRemainingLength) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
// Comptes joueurs (phase 8). Routes sous « v1/account/… » : aucune n'est une route d'administration, et aucune route
|
||||
// d'administration n'accepte un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
|
||||
|
||||
/// <summary>État du service des comptes, annoncé dans GET v1/info (null : serveur sans comptes joueurs).</summary>
|
||||
public sealed record PlayerAccountsInfo
|
||||
{
|
||||
/// <summary>Connexion possible (le launcher affiche le bouton « Compte »).</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Création de compte ouverte.</summary>
|
||||
public bool RegistrationOpen { get; init; }
|
||||
|
||||
/// <summary>Version des conditions d'utilisation et de la politique de confidentialité à accepter à la création.</summary>
|
||||
public string TermsVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Version des règles des mots de passe (<see cref="PasswordRules.Version"/>).</summary>
|
||||
public string PasswordRulesVersion { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Demande d'un défi (POST v1/account/challenge).</summary>
|
||||
public sealed record PlayerChallengeRequest
|
||||
{
|
||||
/// <summary>Une valeur de <see cref="PlayerChallengePurposes"/>.</summary>
|
||||
public string Purpose { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Défi joueur, SANS ÉTAT sur le serveur, à usage unique : il porte l'usage, l'échéance et la difficulté de la preuve de
|
||||
/// travail (0 : aucune, renouvellement de session). Le launcher résout la preuve de travail
|
||||
/// (<see cref="Feedback.FeedbackProof"/>) puis signe <see cref="PlayerMessages.Device"/> avec sa clé d'appareil.
|
||||
/// </summary>
|
||||
public sealed record PlayerChallengeResponse
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string Nonce { get; init; } = "";
|
||||
public string Purpose { get; init; } = "";
|
||||
public int Difficulty { get; init; }
|
||||
public string ServerId { get; init; } = "";
|
||||
public string Origin { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Preuve de possession de la clé d'appareil (création de compte, connexion).</summary>
|
||||
public sealed record PlayerDeviceProof
|
||||
{
|
||||
/// <summary>Clé publique de l'appareil (SPKI ECDSA P-256, Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Signature (Base64, 64 octets) de <see cref="PlayerMessages.Device"/>.</summary>
|
||||
public string Signature { get; init; } = "";
|
||||
|
||||
/// <summary>Nom générique de l'appareil (« Windows 11 · GameLauncher 3.4.0 »), jamais le nom Windows du PC.</summary>
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Création de compte (POST v1/account/register).</summary>
|
||||
public sealed record PlayerRegisterRequest
|
||||
{
|
||||
public string Email { get; init; } = "";
|
||||
public string Password { get; init; } = "";
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
/// <summary>Solution de la preuve de travail du défi.</summary>
|
||||
public string Solution { get; init; } = "";
|
||||
|
||||
/// <summary>Version des conditions acceptées par le joueur (doit être celle annoncée par le serveur).</summary>
|
||||
public string AcceptedTermsVersion { get; init; } = "";
|
||||
|
||||
public PlayerDeviceProof Device { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Connexion (POST v1/account/login).</summary>
|
||||
public sealed record PlayerLoginRequest
|
||||
{
|
||||
public string Email { get; init; } = "";
|
||||
public string Password { get; init; } = "";
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string Solution { get; init; } = "";
|
||||
public PlayerDeviceProof Device { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Renouvellement de session au lancement du launcher (POST v1/account/refresh).</summary>
|
||||
public sealed record PlayerRefreshRequest
|
||||
{
|
||||
public string RefreshToken { get; init; } = "";
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="PlayerMessages.Device"/> (usage « refresh ») par la clé de l'appareil.</summary>
|
||||
public string Signature { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Résultats possibles d'une connexion réussie (mot de passe correct).</summary>
|
||||
public static class PlayerLoginStatus
|
||||
{
|
||||
/// <summary>Connecté : <see cref="PlayerLoginResponse.Session"/>.</summary>
|
||||
public const string Ok = "ok";
|
||||
|
||||
/// <summary>
|
||||
/// Un administrateur exige un nouveau mot de passe : <see cref="PlayerLoginResponse.PasswordChangeToken"/> permet
|
||||
/// seulement d'appeler POST v1/account/password ; la session est ouverte une fois le mot de passe changé.
|
||||
/// </summary>
|
||||
public const string PasswordChangeRequired = "password_change_required";
|
||||
}
|
||||
|
||||
public sealed record PlayerLoginResponse
|
||||
{
|
||||
public string Status { get; init; } = PlayerLoginStatus.Ok;
|
||||
public PlayerSession? Session { get; init; }
|
||||
public string? PasswordChangeToken { get; init; }
|
||||
public DateTimeOffset? PasswordChangeExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Session ouverte (création de compte, connexion, renouvellement, changement de mot de passe exigé).</summary>
|
||||
public sealed record PlayerSession
|
||||
{
|
||||
/// <summary>Jeton d'accès (« Authorization: Bearer … »), court, à garder en mémoire uniquement.</summary>
|
||||
public string AccessToken { get; init; } = "";
|
||||
|
||||
public DateTimeOffset AccessExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Jeton de renouvellement (connexion mémorisée), remplacé à chaque renouvellement : à enregistrer chiffré sur le PC
|
||||
/// (DPAPI) à la place du précédent. Inutilisable sans la clé de l'appareil.
|
||||
/// </summary>
|
||||
public string RefreshToken { get; init; } = "";
|
||||
|
||||
/// <summary>Échéance du jeton de renouvellement (sans utilisation ; repoussée à chaque renouvellement, dans la limite d'un an).</summary>
|
||||
public DateTimeOffset RefreshExpiresAt { get; init; }
|
||||
|
||||
public string DeviceId { get; init; } = "";
|
||||
public PlayerAccountInfo Account { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Compte du joueur connecté (adresse masquée : l'adresse complète n'est jamais renvoyée).</summary>
|
||||
public sealed record PlayerAccountInfo
|
||||
{
|
||||
/// <summary>Référence du compte (« PL-7KQ2-M9XD »), à communiquer au support.</summary>
|
||||
public string Reference { get; init; } = "";
|
||||
|
||||
public string EmailMasked { get; init; } = "";
|
||||
public bool EmailVerified { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; init; }
|
||||
public DateTimeOffset PasswordChangedAt { get; init; }
|
||||
public bool OtpEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suspension de sécurité après de nombreux essais de mot de passe : les connexions depuis un NOUVEL appareil sont
|
||||
/// refusées jusqu'à ce qu'un administrateur la lève ; cet appareil-ci (déjà connecté) continue de fonctionner.
|
||||
/// </summary>
|
||||
public bool NewDevicesSuspended { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Un appareil connecté au compte (« Mes appareils »).</summary>
|
||||
public sealed record PlayerDeviceInfo
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset LastUsedAt { get; init; }
|
||||
|
||||
/// <summary>Appareil de la session en cours.</summary>
|
||||
public bool Current { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>GET v1/account/me.</summary>
|
||||
public sealed record PlayerMeResponse
|
||||
{
|
||||
public PlayerAccountInfo Account { get; init; } = new();
|
||||
public IReadOnlyList<PlayerDeviceInfo> Devices { get; init; } = Array.Empty<PlayerDeviceInfo>();
|
||||
}
|
||||
|
||||
/// <summary>Changement de mot de passe (POST v1/account/password), avec une session ou un jeton « changement exigé ».</summary>
|
||||
public sealed record PlayerChangePasswordRequest
|
||||
{
|
||||
public string CurrentPassword { get; init; } = "";
|
||||
public string NewPassword { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record PlayerChangePasswordResponse
|
||||
{
|
||||
/// <summary>Session ouverte après un changement EXIGÉ (jeton limité) ; null pour un changement ordinaire.</summary>
|
||||
public PlayerSession? Session { get; init; }
|
||||
|
||||
/// <summary>Nombre d'autres appareils déconnectés.</summary>
|
||||
public int DevicesSignedOut { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Déconnexion (POST v1/account/logout).</summary>
|
||||
public sealed record PlayerLogoutRequest
|
||||
{
|
||||
/// <summary>Vrai : tous les appareils du compte (« Déconnecter tous mes appareils »).</summary>
|
||||
public bool AllDevices { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Adresse e-mail d'un compte joueur : contrôle et forme normalisée (launcher et serveur). La forme normalisée sert
|
||||
/// d'identifiant de connexion : une même adresse saisie en majuscules ou avec des espaces donne le même compte.
|
||||
/// Règles volontairement simples et strictes : caractères latins sans accents (domaines internationaux sous leur forme
|
||||
/// « xn-- »), un seul « @ », 254 caractères au plus.
|
||||
/// </summary>
|
||||
public static class PlayerEmail
|
||||
{
|
||||
public const int MaxLength = 254;
|
||||
public const int MaxLocalLength = 64;
|
||||
|
||||
private const string LocalSymbols = "!#$%&'*+-/=?^_`{|}~.";
|
||||
|
||||
/// <summary>Adresse normalisée (minuscules, sans espaces autour), ou null si elle n'est pas valable.</summary>
|
||||
public static string? Normalize(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Length > MaxLength + 16) return null;
|
||||
// Pas de normalisation Unicode (indisponible sur le serveur en globalisation invariante) : seuls les caractères
|
||||
// ASCII sont acceptés, de la même façon par le launcher et par le serveur.
|
||||
var text = input.Trim().ToLowerInvariant();
|
||||
if (text.Length > MaxLength) return null;
|
||||
|
||||
var at = text.IndexOf('@');
|
||||
if (at <= 0 || at != text.LastIndexOf('@') || at == text.Length - 1) return null;
|
||||
var local = text[..at];
|
||||
var domain = text[(at + 1)..];
|
||||
if (domain.EndsWith('.')) domain = domain[..^1];
|
||||
|
||||
if (!IsValidLocal(local) || !IsValidDomain(domain)) return null;
|
||||
var result = local + "@" + domain;
|
||||
return result.Length <= MaxLength ? result : null;
|
||||
}
|
||||
|
||||
public static bool IsValid(string? input) => Normalize(input) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Adresse masquée pour l'affichage (« n***@e***.fr ») : première lettre de la partie locale et du domaine,
|
||||
/// extension conservée. Chaîne vide si l'adresse n'est pas valable.
|
||||
/// </summary>
|
||||
public static string Mask(string? email)
|
||||
{
|
||||
if (Normalize(email) is not { } normalized) return "";
|
||||
var at = normalized.IndexOf('@');
|
||||
var local = normalized[..at];
|
||||
var domain = normalized[(at + 1)..];
|
||||
var lastDot = domain.LastIndexOf('.');
|
||||
return $"{local[0]}***@{domain[0]}***{domain[lastDot..]}";
|
||||
}
|
||||
|
||||
private static bool IsValidLocal(string local)
|
||||
{
|
||||
if (local.Length is 0 or > MaxLocalLength) return false;
|
||||
if (local[0] == '.' || local[^1] == '.' || local.Contains("..", StringComparison.Ordinal)) return false;
|
||||
foreach (var c in local)
|
||||
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' || LocalSymbols.Contains(c))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsValidDomain(string domain)
|
||||
{
|
||||
if (domain.Length is 0 or > 253) return false;
|
||||
var labels = domain.Split('.');
|
||||
if (labels.Length < 2) return false;
|
||||
foreach (var label in labels)
|
||||
{
|
||||
if (label.Length is 0 or > 63 || label[0] == '-' || label[^1] == '-') return false;
|
||||
foreach (var c in label)
|
||||
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-')) return false;
|
||||
}
|
||||
var tld = labels[^1];
|
||||
return tld.StartsWith("xn--", StringComparison.Ordinal) || (tld.Length >= 2 && tld.All(c => c is >= 'a' and <= 'z'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Messages signés par la CLÉ D'APPAREIL d'un joueur (ECDSA P-256 créée par le launcher, dans la puce TPM si elle existe).
|
||||
/// En-tête propre aux joueurs (« GameLauncher/Player/… ») : une signature de joueur ne vaut jamais pour un message
|
||||
/// d'administrateur (« GameLauncher/Admin/… »), et inversement.
|
||||
///
|
||||
/// La signature prouve que la requête vient de l'appareil qui possède la clé : un fichier de session copié sur un autre PC
|
||||
/// ne permet pas de renouveler la session, et seul un appareil déjà utilisé avec succès est reconnu « de confiance » par
|
||||
/// la protection contre la force brute.
|
||||
/// </summary>
|
||||
public static class PlayerMessages
|
||||
{
|
||||
public const string DeviceHeader = "GameLauncher/Player/Device/v1";
|
||||
|
||||
/// <summary>Message signé par la clé d'appareil pour une création de compte, une connexion ou un renouvellement.</summary>
|
||||
/// <param name="purpose">Une valeur de <see cref="PlayerChallengePurposes"/>.</param>
|
||||
/// <param name="deviceKeyId">Identifiant de la clé d'appareil (<see cref="AdminKeys.ComputeKeyId"/>).</param>
|
||||
public static byte[] Device(string purpose, string origin, string serverId, string challengeId, string nonce, string deviceKeyId) =>
|
||||
SignedMessages.Build(DeviceHeader,
|
||||
("purpose", purpose), ("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce),
|
||||
("device", deviceKeyId));
|
||||
}
|
||||
|
||||
/// <summary>Usages d'un défi joueur (le défi est scellé pour un seul usage).</summary>
|
||||
public static class PlayerChallengePurposes
|
||||
{
|
||||
public const string Register = "register";
|
||||
public const string Login = "login";
|
||||
public const string Refresh = "refresh";
|
||||
|
||||
public static bool IsValid(string? purpose) => purpose is Register or Login or Refresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats des jetons joueurs. Préfixes distincts de tout jeton administrateur : le serveur rejette un jeton joueur
|
||||
/// présenté à une route d'administration sans même le chercher, et inversement.
|
||||
/// </summary>
|
||||
public static class PlayerTokens
|
||||
{
|
||||
/// <summary>Jeton d'accès (15 min, en mémoire seulement côté launcher) : « glpa_ » + 256 bits.</summary>
|
||||
public const string AccessPrefix = "glpa_";
|
||||
|
||||
/// <summary>Jeton de renouvellement (gardé chiffré sur le PC) : « glpr_ » + identifiant d'appareil + « . » + 256 bits.</summary>
|
||||
public const string RefreshPrefix = "glpr_";
|
||||
|
||||
/// <summary>Jeton limité au changement de mot de passe exigé (10 min) : « glpc_ » + 256 bits.</summary>
|
||||
public const string PasswordChangePrefix = "glpc_";
|
||||
|
||||
public const int MaxLength = 128;
|
||||
|
||||
public static string NewSecret() => Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
/// <summary>Jeton de renouvellement : identifiant de l'appareil (32 hex) et secret, ou faux si mal formé.</summary>
|
||||
public static bool TryParseRefresh(string? token, out string deviceId, out string secret)
|
||||
{
|
||||
deviceId = secret = "";
|
||||
if (token is not { Length: > 40 and <= MaxLength } || !token.StartsWith(RefreshPrefix, StringComparison.Ordinal)) return false;
|
||||
var body = token[RefreshPrefix.Length..];
|
||||
var dot = body.IndexOf('.');
|
||||
if (dot != 32) return false;
|
||||
deviceId = body[..dot];
|
||||
secret = body[(dot + 1)..];
|
||||
return PlayerIds.IsValidId(deviceId) && Base64Url.TryDecode(secret, out var bytes) && bytes.Length == 32;
|
||||
}
|
||||
|
||||
public static string FormatRefresh(string deviceId, string secret) => RefreshPrefix + deviceId + "." + secret;
|
||||
}
|
||||
|
||||
/// <summary>Identifiants des comptes joueurs.</summary>
|
||||
public static class PlayerIds
|
||||
{
|
||||
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// <summary>Identifiant interne (compte, appareil) : 128 bits en hexadécimal minuscule.</summary>
|
||||
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
|
||||
public static bool IsValidId(string? id) => id is { Length: 32 } && id.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
|
||||
|
||||
/// <summary>
|
||||
/// Référence affichée aux administrateurs et au joueur (« PL-7KQ2-M9XD », 40 bits, sans lettre ambiguë) : désigne un
|
||||
/// compte dans le journal d'audit sans jamais y écrire l'adresse e-mail.
|
||||
/// </summary>
|
||||
public static string NewReference()
|
||||
{
|
||||
var bytes = 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] = Crockford[(int)(value & 31)];
|
||||
value >>= 5;
|
||||
}
|
||||
return $"PL-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
|
||||
}
|
||||
|
||||
/// <summary>Référence normalisée (majuscules, tirets), ou null si elle n'est pas valable.</summary>
|
||||
public static string? NormalizeReference(string? reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.Length > 20) return null;
|
||||
var text = reference.Trim().ToUpperInvariant();
|
||||
if (text.Length != 12 || !text.StartsWith("PL-", StringComparison.Ordinal) || text[7] != '-') return null;
|
||||
foreach (var c in text[3..7] + text[8..])
|
||||
if (!Crockford.Contains(c)) return null;
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
password-blocklist.txt.gz — dictionnaire des mots de passe les plus utilisés
|
||||
=========================================================================
|
||||
|
||||
Contenu : les 99 839 mots de passe de la liste « 100k-most-used-passwords-NCSC.txt » (un par ligne, UTF-8, doublons
|
||||
retirés, ordre d'origine conservé), compressés en gzip.
|
||||
|
||||
Origine :
|
||||
• Liste publiée par le NCSC (National Cyber Security Centre, Royaume-Uni) : les 100 000 mots de passe les plus
|
||||
fréquents dans les fuites recensées par le service « Have I Been Pwned » (Pwned Passwords).
|
||||
• Copie utilisée : dépôt SecLists (https://github.com/danielmiessler/SecLists),
|
||||
fichier Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt — licence MIT,
|
||||
© 2018 Daniel Miessler.
|
||||
|
||||
Usage : GameLauncher.Contracts.Accounts.PasswordRules (même contrôle dans le launcher et sur le serveur). La liste est
|
||||
normalisée au chargement (minuscules, substitutions courantes 0→o, 4→a, @→a…, chiffres et symboles de début et de fin
|
||||
retirés) : les variantes simples (« P@ssw0rd2024! ») sont refusées comme l'original.
|
||||
|
||||
Mise à jour : remplacer le fichier .gz par une version plus récente de la même liste (même format), puis relancer les
|
||||
tests (Contracts/PasswordRulesTests).
|
||||
Binary file not shown.
@@ -108,6 +108,78 @@ 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}";
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance du launcher
|
||||
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
|
||||
public const string Maintenance = "v1/maintenance";
|
||||
|
||||
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
|
||||
// Routes des JOUEURS (jetons « glpa_ ») : aucune n'est une route d'administration, et les routes d'administration
|
||||
// n'acceptent jamais un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
|
||||
|
||||
/// <summary>Défi joueur (création de compte, connexion, renouvellement) : anonyme, sans état sur le serveur.</summary>
|
||||
public const string AccountChallenge = "v1/account/challenge";
|
||||
|
||||
/// <summary>Création de compte (anonyme : preuve de travail + clé d'appareil).</summary>
|
||||
public const string AccountRegister = "v1/account/register";
|
||||
|
||||
/// <summary>Connexion par adresse e-mail et mot de passe (anonyme : preuve de travail + clé d'appareil).</summary>
|
||||
public const string AccountLogin = "v1/account/login";
|
||||
|
||||
/// <summary>Renouvellement de la session mémorisée (jeton de renouvellement + signature de la clé d'appareil).</summary>
|
||||
public const string AccountRefresh = "v1/account/refresh";
|
||||
|
||||
public const string AccountLogout = "v1/account/logout";
|
||||
|
||||
/// <summary>Compte connecté et ses appareils.</summary>
|
||||
public const string AccountMe = "v1/account/me";
|
||||
|
||||
/// <summary>Changement de mot de passe (session, ou jeton « changement exigé »).</summary>
|
||||
public const string AccountPassword = "v1/account/password";
|
||||
|
||||
public const string AccountDevicePattern = "v1/account/devices/{deviceId}";
|
||||
|
||||
/// <summary>Un appareil du compte (DELETE : le déconnecter). Identifiant : 32 caractères hexadécimaux.</summary>
|
||||
public static string AccountDevice(string deviceId) => $"v1/account/devices/{deviceId}";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
|
||||
@@ -15,6 +15,9 @@ public sealed record ServerInfo
|
||||
|
||||
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
|
||||
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Comptes joueurs (phase 8) : null si le serveur ne les propose pas (ou serveur plus ancien).</summary>
|
||||
public Accounts.PlayerAccountsInfo? Accounts { get; init; }
|
||||
}
|
||||
|
||||
public sealed record HealthStatus
|
||||
@@ -68,4 +71,52 @@ 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";
|
||||
|
||||
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
|
||||
|
||||
/// <summary>Adresse e-mail mal formée.</summary>
|
||||
public const string EmailInvalid = "email_invalid";
|
||||
|
||||
/// <summary>Adresse déjà utilisée par un autre compte (création de compte).</summary>
|
||||
public const string EmailTaken = "email_taken";
|
||||
|
||||
/// <summary>Mot de passe refusé par <see cref="Accounts.PasswordRules"/> (message : les raisons).</summary>
|
||||
public const string PasswordRejected = "password_rejected";
|
||||
|
||||
/// <summary>Adresse ou mot de passe incorrect (message volontairement identique dans les deux cas).</summary>
|
||||
public const string LoginFailed = "login_failed";
|
||||
|
||||
/// <summary>Trop d'essais : connexion bloquée temporairement (en-tête Retry-After).</summary>
|
||||
public const string LoginLocked = "login_locked";
|
||||
|
||||
/// <summary>Compte désactivé par un administrateur (révélé seulement après un mot de passe correct).</summary>
|
||||
public const string AccountDisabled = "account_disabled";
|
||||
|
||||
/// <summary>Compte suspendu après trop d'essais (connexion depuis un NOUVEL appareil refusée) : seul un administrateur
|
||||
/// peut lever la suspension ; les appareils déjà connectés continuent de fonctionner.</summary>
|
||||
public const string AccountSuspended = "account_suspended";
|
||||
|
||||
/// <summary>Un nouveau mot de passe est exigé avant toute autre action.</summary>
|
||||
public const string PasswordChangeRequired = "password_change_required";
|
||||
|
||||
/// <summary>Session mémorisée révoquée, expirée ou réutilisée (vol suspecté) : nouvelle connexion nécessaire.</summary>
|
||||
public const string SessionRevoked = "session_revoked";
|
||||
|
||||
/// <summary>Création de compte fermée, ou bloquée pour cette adresse IP / cet appareil.</summary>
|
||||
public const string RegistrationClosed = "registration_closed";
|
||||
|
||||
/// <summary>Conditions d'utilisation non acceptées ou dans une version dépassée.</summary>
|
||||
public const string TermsNotAccepted = "terms_not_accepted";
|
||||
|
||||
/// <summary>Preuve de possession de la clé d'appareil absente ou invalide.</summary>
|
||||
public const string DeviceProofInvalid = "device_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;
|
||||
}
|
||||
@@ -13,4 +13,9 @@
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Comptes joueurs : dictionnaire des mots de passe les plus utilisés (PasswordRules), voir Accounts\Resources\password-blocklist.SOURCE.txt. -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Accounts\Resources\password-blocklist.txt.gz" LogicalName="GameLauncher.Contracts.Accounts.password-blocklist.txt.gz" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (maintenance.json, commun à tous les jeux), activé ou désactivé depuis le menu
|
||||
/// d'administration. Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée
|
||||
/// « maintenance » dans le trousseau : les launchers des joueurs le vérifient comme le catalogue avant d'en tenir compte.
|
||||
/// Maintenance active : le launcher passe au rouge / orange, affiche le message au centre et bloque tout, sauf la
|
||||
/// désinstallation des jeux et le menu d'administration.
|
||||
/// Révision 0 = rien n'a jamais été publié (maintenance inactive).
|
||||
/// </summary>
|
||||
public sealed record MaintenanceDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-maintenance";
|
||||
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).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Vrai : maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Précision facultative affichée aux joueurs sous le texte principal (« Mise à jour des serveurs, retour vers 18 h »).
|
||||
/// Texte brut, sans lien, quelques lignes au plus (<see cref="MaintenanceRules"/>).
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>Début de la maintenance (à la seconde, UTC) ; null quand elle est désactivée. Couvert par la signature.</summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État du mode maintenance pour le menu d'administration (GET v1/maintenance).</summary>
|
||||
public sealed record MaintenanceState
|
||||
{
|
||||
/// <summary>Document en vigueur (révision 0, maintenance inactive, si rien n'a été publié).</summary>
|
||||
public MaintenanceDocument 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; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé la dernière publication, si le serveur la connaît.</summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication du mode maintenance (PUT v1/maintenance, requête signée, maintenance.manage).</summary>
|
||||
public sealed record MaintenancePublishRequest
|
||||
{
|
||||
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
/// <summary>Document signé, révision <see cref="BaseRevision"/> + 1.</summary>
|
||||
public MaintenanceDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles du mode maintenance, communes au menu (contrôle avant de signer), au serveur (refus avant publication) et au
|
||||
/// launcher des joueurs (refus d'un fichier hors limites, même signé). Le message est du texte brut, déjà nettoyé
|
||||
/// (<see cref="CleanMessage"/>) et SANS lien : rien ne doit pouvoir renvoyer le joueur vers un site depuis ce bandeau.
|
||||
/// </summary>
|
||||
public static class MaintenanceRules
|
||||
{
|
||||
/// <summary>Taille de maintenance.json (et de la requête de publication, sous la limite de 64 Ko de l'API).</summary>
|
||||
public const int MaxBytes = 8 * 1024;
|
||||
|
||||
public const int MaxMessageLength = 300;
|
||||
public const int MaxMessageLines = 6;
|
||||
|
||||
/// <summary>Texte principal affiché aux joueurs (non modifiable : toujours présent, même sans message).</summary>
|
||||
public const string Title = "Maintenance en cours";
|
||||
|
||||
public const string Subtitle = "Merci de revenir plus tard.";
|
||||
|
||||
/// <summary>Écart toléré entre le début annoncé et l'heure du serveur (horloges des PC).</summary>
|
||||
public static readonly TimeSpan MaxSinceSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
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>Rien de publié (révision 0) : maintenance inactive.</summary>
|
||||
public static MaintenanceDocument Defaults() => new() { Revision = 0, Enabled = false };
|
||||
|
||||
/// <summary>Lit maintenance.json (null : vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible ou trop volumineux.</exception>
|
||||
public static MaintenanceDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"fichier de maintenance trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<MaintenanceDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(MaintenanceDocument document) => JsonSerializer.Serialize(document, FileOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Condensé signé : format, version, révision, état, message et début (à la seconde). La date de publication et la
|
||||
/// signature n'en font pas partie.
|
||||
/// </summary>
|
||||
public static string Digest(MaintenanceDocument document)
|
||||
{
|
||||
var canonical = new CanonicalMaintenance(document.Format ?? "", document.Version, document.Revision, document.Enabled,
|
||||
string.IsNullOrEmpty(document.Message) ? null : document.Message,
|
||||
document.Since is { } since ? since.ToUnixTimeSeconds() : null);
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
|
||||
}
|
||||
|
||||
private sealed record CanonicalMaintenance(string Format, int Version, long Revision, bool Enabled, string? Message, long? Since);
|
||||
|
||||
/// <summary>
|
||||
/// Nettoie le message saisi par l'administrateur (fins de ligne, espaces, lignes vides ; mêmes refus que les demandes
|
||||
/// des joueurs : caractères invisibles, de mise en forme ou d'inversion du sens d'écriture), puis vérifie les bornes et
|
||||
/// l'absence de lien. Null si vide ; <paramref name="problem"/> : raison du refus.
|
||||
/// </summary>
|
||||
public static string? CleanMessage(string? value, out string? problem)
|
||||
{
|
||||
var text = FeedbackRules.CleanMultiline(value, MaxMessageLength, out problem);
|
||||
if (problem is not null) return null;
|
||||
if (text is null) return null;
|
||||
if (text.Count(c => c == '\n') + 1 > MaxMessageLines)
|
||||
{
|
||||
problem = $"{MaxMessageLines} lignes au plus.";
|
||||
return null;
|
||||
}
|
||||
if (FeedbackLinks.Contains(text))
|
||||
{
|
||||
problem = "aucun lien ni adresse de site n'est accepté (les joueurs ne doivent pas être renvoyés vers un site depuis ce message).";
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un document publié (révision 1 au moins). Les erreurs, en français, sont ajoutées à <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
/// <param name="now">Heure de référence (serveur) : le début ne peut pas être dans le futur ; null : non contrôlé (launcher).</param>
|
||||
public static bool Validate(MaintenanceDocument? document, ICollection<string> errors, DateTimeOffset? now = null)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (document is null)
|
||||
{
|
||||
errors.Add("Document de maintenance absent.");
|
||||
return false;
|
||||
}
|
||||
if (document.Format != MaintenanceDocument.FormatName || document.Version != MaintenanceDocument.CurrentVersion)
|
||||
errors.Add($"Format du mode maintenance non pris en charge ({document.Format} v{document.Version}).");
|
||||
if (document.Revision < 1) errors.Add("Révision invalide (1 au moins).");
|
||||
|
||||
if (document.Message is { } message)
|
||||
{
|
||||
var clean = CleanMessage(message, out var problem);
|
||||
if (problem is not null) errors.Add($"Message : {problem}");
|
||||
else if (!string.Equals(clean, message, StringComparison.Ordinal)) errors.Add("Message : texte non nettoyé (espaces, lignes vides).");
|
||||
}
|
||||
|
||||
if (document.Enabled)
|
||||
{
|
||||
if (document.Since is not { } since || since == default) errors.Add("Début de la maintenance absent.");
|
||||
else if (since.UtcTicks % TimeSpan.TicksPerSecond != 0)
|
||||
errors.Add("Début de la maintenance : à la seconde près.");
|
||||
else if (now is { } reference && since > reference + MaxSinceSkew)
|
||||
errors.Add("Début de la maintenance dans le futur : vérifiez l'heure de Windows.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (document.Since is not null) errors.Add("Maintenance désactivée : pas de date de début.");
|
||||
if (document.Message is not null) errors.Add("Maintenance désactivée : pas de message.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>Résumé pour l'audit et les journaux (« activée — message : … », « désactivée »).</summary>
|
||||
public static string Describe(MaintenanceDocument document) =>
|
||||
document.Enabled
|
||||
? "activée" + (document.Message is { } message ? $" — message : « {OneLine(message)} »" : " (sans message)")
|
||||
: "désactivée";
|
||||
|
||||
private static string OneLine(string text)
|
||||
{
|
||||
var line = text.Replace('\n', ' ');
|
||||
return line.Length <= 120 ? line : line[..119] + "…";
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public static partial class AdminGroups
|
||||
public static string? DefaultDescription(string groupId) => groupId switch
|
||||
{
|
||||
AdminRoles.Owner => "Tous les droits, y compris ceux des permissions créées plus tard. Groupe système : non modifiable.",
|
||||
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur et journal d'audit.",
|
||||
AdminRoles.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
|
||||
};
|
||||
|
||||
@@ -28,9 +28,22 @@ public static class AdminPermissions
|
||||
/// <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";
|
||||
|
||||
/// <summary>Mode maintenance du launcher : activation, désactivation et message affiché aux joueurs (publication signée).</summary>
|
||||
public const string MaintenanceManage = "maintenance.manage";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
|
||||
MaintenanceManage
|
||||
};
|
||||
|
||||
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
|
||||
@@ -45,6 +58,10 @@ public static class AdminPermissions
|
||||
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",
|
||||
MaintenanceManage => "Mode maintenance",
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -58,6 +75,15 @@ public static class AdminPermissions
|
||||
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.",
|
||||
MaintenanceManage => "Activer ou désactiver le mode maintenance du launcher (tout est bloqué chez les joueurs, sauf la " +
|
||||
"désinstallation des jeux) et modifier son message. Publication signée : la clé doit avoir " +
|
||||
"l'autorisation « mode maintenance » du trousseau.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,9 @@ public static class AdminRoles
|
||||
[Admin] = new[]
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
|
||||
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
|
||||
AdminPermissions.MaintenanceManage
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ public static class SignedMessages
|
||||
/// <summary>Origine normalisée d'une adresse : « https://hote[:port] », sans chemin ni barre finale.</summary>
|
||||
public static string NormalizeOrigin(Uri uri) => uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static byte[] Build(string header, params (string Name, string Value)[] fields)
|
||||
internal static byte[] Build(string header, params (string Name, string Value)[] fields)
|
||||
{
|
||||
var sb = new StringBuilder(header).Append('\n');
|
||||
foreach (var (name, value) in fields)
|
||||
|
||||
@@ -28,12 +28,26 @@ public static class ContentKinds
|
||||
{
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
public const string FeedbackForms = "feedback-forms";
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
/// <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>Identifiant signé du mode maintenance (commun à tous les jeux).</summary>
|
||||
public const string MaintenanceId = "maintenance";
|
||||
|
||||
/// <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,
|
||||
Maintenance => SigningScopes.Maintenance,
|
||||
_ => SigningScopes.Catalog
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Autorisations d'une clé du trousseau.</summary>
|
||||
@@ -45,7 +59,20 @@ 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";
|
||||
|
||||
/// <summary>Signer le mode maintenance (activation, message affiché aux joueurs) : permission maintenance.manage.</summary>
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms, Maintenance };
|
||||
|
||||
/// <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 +81,8 @@ 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);
|
||||
if (set.Contains(AdminPermissions.MaintenanceManage)) scopes.Add(Maintenance);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
@@ -61,6 +90,8 @@ public static class SigningScopes
|
||||
{
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
Forms => "formulaires des demandes",
|
||||
Maintenance => "mode maintenance",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
@@ -194,7 +225,13 @@ 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,
|
||||
ContentKinds.Maintenance => ContentKinds.MaintenanceId,
|
||||
_ => 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,12 @@ 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; }
|
||||
|
||||
/// <summary>Mode maintenance (null : publication non configurée, ou serveur antérieur à la 3.3.0).</summary>
|
||||
public MaintenanceSignatureState? Maintenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
@@ -64,3 +70,38 @@ 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; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du mode maintenance en vigueur (maintenance.json).</summary>
|
||||
public sealed record MaintenanceSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : jamais publié, maintenance inactive).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : jamais publié, 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 du mode maintenance est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, à côté du trousseau), lu par le launcher
|
||||
/// des joueurs :
|
||||
/// • au démarrage, AVANT la recherche des mises à jour des jeux (le launcher l'attend quelques secondes au plus) ;
|
||||
/// • puis toutes les <see cref="NormalInterval"/> (<see cref="ActiveInterval"/> pendant une maintenance, pour en voir la
|
||||
/// fin rapidement) et à la demande (publication depuis le menu d'administration de ce PC) ;
|
||||
/// • fichier borné en taille, contrôlé avec les règles du mode maintenance (même signé, un fichier hors limites est
|
||||
/// refusé) ; signature vérifiée avec le trousseau (autorisation « maintenance »), retour en arrière refusé (révision
|
||||
/// et date de signature), selon le mode « contentSigning » du launcher, comme le catalogue.
|
||||
/// Rien n'est conservé sur le disque (hormis la date de la dernière signature acceptée, dans l'état des signatures) :
|
||||
/// au démarrage, un serveur injoignable ne bloque jamais le launcher (le jeu installé reste jouable hors ligne). Pendant
|
||||
/// que le launcher tourne, une coupure ou un fichier refusé garde le dernier état vérifié ; un fichier absent (404)
|
||||
/// termine la maintenance.
|
||||
/// Ce n'est pas une barrière de sécurité (un launcher modifié peut l'ignorer) : c'est un affichage pour les joueurs.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceService
|
||||
{
|
||||
public static readonly TimeSpan NormalInterval = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan ActiveInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly object _stateLock = new();
|
||||
private byte[]? _lastAccepted;
|
||||
private bool _unreachableLogged;
|
||||
private MaintenanceDocument? _current;
|
||||
|
||||
/// <param name="url">Adresse du fichier publié (null : jamais de maintenance).</param>
|
||||
/// <param name="trust">Vérification des signatures (null : mode « off », document utilisé sans signature).</param>
|
||||
/// <param name="time">Horloge (tests).</param>
|
||||
public MaintenanceService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, ContentTrustService? trust,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_download = download;
|
||||
Url = url;
|
||||
_trust = trust;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Uri? Url { get; }
|
||||
|
||||
/// <summary>Dernier état accepté (null : rien de connu, ou rien de publié).</summary>
|
||||
public MaintenanceDocument? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock) return _current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maintenance en cours (dernier état vérifié).</summary>
|
||||
public bool IsActive => Current is { Enabled: true };
|
||||
|
||||
/// <summary>
|
||||
/// L'état affiché change (début, fin, nouveau message). Levé hors du fil de l'interface : l'appelant s'y ramène.
|
||||
/// Null : plus de maintenance (fichier absent).
|
||||
/// </summary>
|
||||
public event Action<MaintenanceDocument?>? Changed;
|
||||
|
||||
/// <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, "maintenance.json") : null;
|
||||
}
|
||||
|
||||
/// <summary>Relit le fichier publié. Jamais d'exception (sauf annulation demandée par l'appelant).</summary>
|
||||
/// <returns>Vrai : maintenance en cours (dernier état vérifié).</returns>
|
||||
public async Task<bool> RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Url is null) return false;
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
bytes = await _download(Url, MaintenanceRules.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
_unreachableLogged = false;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Rien de publié (ou fichier retiré à la main) : pas de maintenance.
|
||||
_unreachableLogged = false;
|
||||
_lastAccepted = null;
|
||||
Apply(null);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande : dernier état vérifié conservé, nouvel essai plus tard.
|
||||
if (!_unreachableLogged)
|
||||
Log.Warn($"Mode maintenance injoignable : {ex.Message}" + (_current is { Enabled: true } ? " (maintenance toujours affichée)." : "."));
|
||||
_unreachableLogged = true;
|
||||
return _current is { Enabled: true };
|
||||
}
|
||||
|
||||
// Même fichier que la dernière fois : déjà vérifié.
|
||||
if (_lastAccepted is not null && bytes.AsSpan().SequenceEqual(_lastAccepted)) return _current is { Enabled: true };
|
||||
|
||||
var document = await AcceptAsync(bytes, ct).ConfigureAwait(false);
|
||||
if (document is null) return _current is { Enabled: true };
|
||||
_lastAccepted = bytes;
|
||||
Apply(document);
|
||||
return document.Enabled;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Relecture périodique jusqu'à la fermeture du launcher (rythme plus rapide pendant une maintenance).</summary>
|
||||
public async Task RunAsync(CancellationToken lifetime)
|
||||
{
|
||||
if (Url is null) return;
|
||||
try
|
||||
{
|
||||
while (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(IsActive ? ActiveInterval : NormalInterval, _time, lifetime).ConfigureAwait(false);
|
||||
await RefreshAsync(lifetime).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
|
||||
{
|
||||
// fermeture du launcher
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Relecture du mode maintenance interrompue", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier lisible, dans les règles, pas plus ancien que l'état connu, à la signature acceptée ; sinon null.</summary>
|
||||
private async Task<MaintenanceDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
MaintenanceDocument? document;
|
||||
try
|
||||
{
|
||||
document = MaintenanceRules.Parse(bytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log.Error($"Mode maintenance illisible ({ex.Message}) : ignoré.");
|
||||
return null;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors))
|
||||
{
|
||||
Log.Error($"Mode maintenance refusé : {errors[0]}");
|
||||
return null;
|
||||
}
|
||||
if (_current is { } current && document!.Revision < current.Revision)
|
||||
{
|
||||
Log.Error($"Mode maintenance révision {document.Revision} refusé : plus ancien que la révision {current.Revision} déjà vue (retour en arrière).");
|
||||
return null;
|
||||
}
|
||||
if (_trust is not null && !await _trust.VerifyMaintenanceAsync(document!, ct).ConfigureAwait(false)) return null;
|
||||
return document;
|
||||
}
|
||||
|
||||
private void Apply(MaintenanceDocument? document)
|
||||
{
|
||||
MaintenanceDocument? previous;
|
||||
lock (_stateLock)
|
||||
{
|
||||
previous = _current;
|
||||
_current = document;
|
||||
}
|
||||
if (SameDisplay(previous, document)) return;
|
||||
Log.Info(document is { Enabled: true }
|
||||
? $"Mode maintenance ACTIVÉ (révision {document.Revision}) : tout est bloqué sauf la désinstallation et l'administration."
|
||||
: previous is { Enabled: true } ? "Mode maintenance terminé." : $"Mode maintenance : inactif (révision {document?.Revision ?? 0}).");
|
||||
try
|
||||
{
|
||||
Changed?.Invoke(document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Application du mode maintenance", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Même affichage pour le joueur (état, message, début) : rien à signaler.</summary>
|
||||
private static bool SameDisplay(MaintenanceDocument? a, MaintenanceDocument? b)
|
||||
{
|
||||
var activeA = a is { Enabled: true };
|
||||
var activeB = b is { Enabled: true };
|
||||
if (!activeA && !activeB) return true;
|
||||
return activeA == activeB && a!.Message == b!.Message && a.Since == b.Since;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed class LauncherConfig
|
||||
|
||||
public string GameName { get; set; } = "Mon Jeu";
|
||||
|
||||
public string Publisher { get; set; } = "Xbhost";
|
||||
public string Publisher { get; set; } = "LanisysGames";
|
||||
|
||||
public string Tagline { get; set; } = "";
|
||||
|
||||
|
||||
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
|
||||
/// </summary>
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
|
||||
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
|
||||
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
|
||||
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
|
||||
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
|
||||
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
|
||||
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
|
||||
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
|
||||
/// </summary>
|
||||
public string? PriorityVersion { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Version ParsedVersion => VersionUtil.Parse(Version);
|
||||
|
||||
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
|
||||
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
|
||||
/// </summary>
|
||||
public bool IsPriorityFor(Version currentVersion) =>
|
||||
TryGetPriorityVersion(out var priority) && priority > currentVersion;
|
||||
|
||||
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
|
||||
public bool TryGetPriorityVersion(out Version priority)
|
||||
{
|
||||
priority = new Version(0, 0, 0);
|
||||
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
|
||||
/// <list type="bullet">
|
||||
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
|
||||
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
|
||||
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
|
||||
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
|
||||
{
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
|
||||
if (markPriority) return VersionUtil.Display(published);
|
||||
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
|
||||
return null;
|
||||
return VersionUtil.Display(carried);
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Format != 1)
|
||||
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
|
||||
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
|
||||
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
|
||||
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
|
||||
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
|
||||
else if (priority > ParsedVersion)
|
||||
throw new UpdateSecurityException(
|
||||
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
|
||||
Notes ??= new List<string>();
|
||||
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
|
||||
Changelog = ChangelogLimits.Normalize(Changelog);
|
||||
|
||||
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
|
||||
public bool IsAvailable => Release is not null && DownloadUri is not null;
|
||||
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
|
||||
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
|
||||
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
|
||||
/// </summary>
|
||||
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -39,8 +41,23 @@ public sealed class ContentSecurityException : Exception
|
||||
public sealed class ContentTrustService
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Nouvel essai après un échec réseau (trousseau jamais obtenu pendant ce démarrage).</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Nouveau téléchargement, au plus à ce rythme, quand un contenu est signé par une clé absente du trousseau ou non
|
||||
/// autorisée pour ce contenu : clé ajoutée ou autorisation donnée (ex. « maintenance ») pendant que le launcher tourne.
|
||||
/// Plus court que la relecture du mode maintenance, pour qu'une activation juste après la republication du trousseau
|
||||
/// soit vue à la relecture suivante.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyChangeRetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Âge maximal du trousseau en mémoire : au-delà, il est retéléchargé à la vérification suivante (une clé retirée ou
|
||||
/// une autorisation enlevée est prise en compte sans redémarrer le launcher).
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyringMaxAge = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
@@ -80,12 +97,15 @@ public sealed class ContentTrustService
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
|
||||
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
|
||||
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
|
||||
/// vu (cache), sinon null. Téléchargé au démarrage, puis de nouveau :
|
||||
/// • après un échec réseau (<see cref="RetryDelay"/>) ;
|
||||
/// • quand un contenu est signé par une clé absente ou non autorisée pour ce contenu (<see cref="KeyChangeRetryDelay"/>) ;
|
||||
/// • quand il a plus de <see cref="KeyringMaxAge"/> (launcher ouvert longtemps).
|
||||
/// Chaque nouveau trousseau passe par les mêmes contrôles (signature racine, numéro jamais en baisse).
|
||||
/// </summary>
|
||||
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
|
||||
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool keyChanged, CancellationToken ct)
|
||||
{
|
||||
Task<KeyringDocument?> task;
|
||||
lock (_lock)
|
||||
@@ -93,7 +113,8 @@ public sealed class ContentTrustService
|
||||
var elapsed = _time.GetUtcNow() - _lastAttempt;
|
||||
if (_keyringTask is null
|
||||
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|
||||
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|
||||
|| (_keyringTask.IsCompleted && keyChanged && elapsed > KeyChangeRetryDelay)
|
||||
|| (_keyringTask.IsCompleted && elapsed > KeyringMaxAge))
|
||||
{
|
||||
_lastAttempt = _time.GetUtcNow();
|
||||
_keyringTask = FetchKeyringAsync();
|
||||
@@ -149,7 +170,7 @@ public sealed class ContentTrustService
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
|
||||
{
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : nouvel essai seulement aux conditions de GetKeyringAsync
|
||||
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
@@ -190,6 +211,57 @@ 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>
|
||||
/// Vérifie le mode maintenance téléchargé (maintenance.json) : clé du trousseau autorisée « maintenance », signature
|
||||
/// valide, pas plus ancien que le dernier accepté (un serveur compromis ne peut pas resservir une ancienne maintenance
|
||||
/// légitime). Faux : à ignorer (état précédent conservé).
|
||||
/// </summary>
|
||||
public async Task<bool> VerifyMaintenanceAsync(MaintenanceDocument maintenance, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
try
|
||||
{
|
||||
await VerifyAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(maintenance), maintenance.Signature,
|
||||
$"Mode maintenance (révision {maintenance.Revision}, {MaintenanceRules.Describe(maintenance)})", 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é.
|
||||
@@ -221,8 +293,9 @@ public sealed class ContentTrustService
|
||||
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
|
||||
{
|
||||
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
|
||||
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
|
||||
// Clé absente du trousseau, ou présente sans l'autorisation de ce contenu (clé ajoutée, ou autorisation donnée,
|
||||
// depuis le dernier téléchargement) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && KeyMayBeNewer(keyring, kind, signature))
|
||||
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
|
||||
|
||||
string problem;
|
||||
@@ -269,6 +342,14 @@ public sealed class ContentTrustService
|
||||
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
|
||||
}
|
||||
|
||||
/// <summary>La signature vient d'une clé que ce trousseau ne connaît pas, ou n'autorise pas pour ce contenu.</summary>
|
||||
private static bool KeyMayBeNewer(KeyringDocument keyring, string kind, ContentSignature? signature)
|
||||
{
|
||||
if (signature?.KeyId is not { } keyId) return false;
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
|
||||
return key?.Scopes is not { } scopes || !scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
private sealed class TrustState
|
||||
|
||||
@@ -56,7 +56,10 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
new("changelog.", "Notes de version"),
|
||||
new("release.", "Publications"),
|
||||
new("files.", "Fichiers du serveur"),
|
||||
new("audit.", "Consultations et exports du journal"),
|
||||
new("keyring.", "Signature (trousseau)"),
|
||||
new("feedback.", "Demandes des joueurs"),
|
||||
new("maintenance.", "Mode maintenance"),
|
||||
new("audit.", "Journal d'audit (consultations, exports, purges)"),
|
||||
new("security", "Refus de sécurité")
|
||||
};
|
||||
Results = new List<Choice>
|
||||
@@ -493,12 +496,28 @@ public static class AuditTexts
|
||||
"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",
|
||||
"feedback.form_create" => "Type de demande ajouté",
|
||||
"feedback.form_update" => "Type de demande modifié",
|
||||
"feedback.form_delete" => "Type de demande supprimé",
|
||||
"maintenance.enable" => "Mode maintenance activé",
|
||||
"maintenance.disable" => "Mode maintenance terminé",
|
||||
"maintenance.update" => "Message de la maintenance modifié",
|
||||
"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
|
||||
};
|
||||
|
||||
@@ -518,7 +537,7 @@ public static class AuditTexts
|
||||
{
|
||||
null => true,
|
||||
"security" => action is "request.signature" or "audit.suppressed",
|
||||
"audit." => action is "audit.view" or "audit.export",
|
||||
"audit." => action is "audit.view" or "audit.export" or "audit.purge",
|
||||
_ => action.StartsWith(category, StringComparison.Ordinal)
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Maintenance » : page d'activation et de désactivation du mode maintenance du launcher (commun à tous les
|
||||
/// jeux), avec un message facultatif pour les joueurs. Permission maintenance.manage ; publication signée (autorisation
|
||||
/// « maintenance » du trousseau, vérifiée par les launchers). L'état est aussi rappelé au tableau de bord.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "maintenance";
|
||||
|
||||
public string DisplayName => "Maintenance";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
MaintenancePanelViewModel.PanelId,
|
||||
"Maintenance",
|
||||
"Mode maintenance de tous les launchers : thème rouge / orange, message au centre, tout est bloqué chez les joueurs " +
|
||||
"sauf la désinstallation des jeux et ce menu.",
|
||||
"TrafficCone",
|
||||
AdminPermissions.MaintenanceManage,
|
||||
Order: 5,
|
||||
context => new MaintenancePanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Maintenance » (maintenance.manage) : état du mode maintenance de tous les launchers, activation (message
|
||||
/// facultatif, confirmation), modification du message pendant la maintenance, désactivation (confirmation).
|
||||
/// • Chaque publication est signée par la clé de ce PC (autorisation « maintenance » du trousseau) : avertissement si
|
||||
/// les launchers des joueurs ne l'accepteront pas (<see cref="AdminPanelViewModel.ConfirmPlayerSigningAsync"/>).
|
||||
/// • Message contrôlé ici avec les règles partagées (le serveur et les launchers les réappliquent) : texte brut, sans lien.
|
||||
/// • Après une publication, le launcher de ce PC relit aussitôt le mode maintenance (<see cref="IAdminContext.NotifyMaintenanceChanged"/>).
|
||||
/// </summary>
|
||||
public sealed class MaintenancePanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "maintenance";
|
||||
|
||||
private MaintenanceState? _state;
|
||||
private string _message = "";
|
||||
private string? _messageError;
|
||||
private string? _statusMessage;
|
||||
|
||||
public MaintenancePanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
|
||||
EnableCommand = new AsyncRelayCommand(ScrollingToTop(EnableAsync), () => !IsBusy && CanPublish && !IsActive);
|
||||
UpdateMessageCommand = new AsyncRelayCommand(ScrollingToTop(UpdateMessageAsync), () => !IsBusy && CanPublish && IsActive && IsMessageChanged);
|
||||
DisableCommand = new AsyncRelayCommand(ScrollingToTop(DisableAsync), () => !IsBusy && CanPublish && IsActive);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand EnableCommand { get; }
|
||||
public AsyncRelayCommand UpdateMessageCommand { get; }
|
||||
public AsyncRelayCommand DisableCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État publié
|
||||
|
||||
public bool IsLoaded => _state is not null;
|
||||
|
||||
/// <summary>Maintenance en cours (état publié).</summary>
|
||||
public bool IsActive => _state?.Document.Enabled == true;
|
||||
|
||||
/// <summary>Publication possible : état chargé et fichier public configuré sur le serveur.</summary>
|
||||
public bool CanPublish => _state is { PublishingEnabled: true };
|
||||
|
||||
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
|
||||
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
public string StateTitle => _state is null ? "Chargement…" : IsActive ? "Maintenance en cours" : "Launchers accessibles";
|
||||
|
||||
public string StateText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is not { } state) return "";
|
||||
var document = state.Document;
|
||||
var by = state.UpdatedBy is { } name ? $" par {name}" : "";
|
||||
if (document.Revision == 0) return "Le mode maintenance n'a jamais été activé.";
|
||||
if (document.Enabled)
|
||||
return (document.Since is { } since ? $"Depuis le {AccountTexts.Date(since)}" : "En cours") + by +
|
||||
(document.UpdatedAt is { } updated && document.Since is { } start && updated - start > TimeSpan.FromMinutes(1)
|
||||
? $" · message modifié le {AccountTexts.Date(updated)}"
|
||||
: "") + ".";
|
||||
return (document.UpdatedAt is { } at ? $"Dernière maintenance terminée le {AccountTexts.Date(at)}" : "Maintenance terminée") + by + ".";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Message publié (pendant la maintenance), sinon null.</summary>
|
||||
public string? PublishedMessage => IsActive ? _state!.Document.Message : null;
|
||||
|
||||
// ------------------------------------------------------------------ Saisie
|
||||
|
||||
/// <summary>Précision affichée aux joueurs sous « Maintenance en cours — merci de revenir plus tard » (facultative).</summary>
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _message, value ?? "")) return;
|
||||
MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
OnPropertyChanged(nameof(MessageCounter));
|
||||
OnPropertyChanged(nameof(PreviewMessage));
|
||||
OnPropertyChanged(nameof(IsMessageChanged));
|
||||
RefreshCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public string? MessageError
|
||||
{
|
||||
get => _messageError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _messageError, value)) OnPropertyChanged(nameof(HasMessageError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMessageError => _messageError is not null;
|
||||
|
||||
public string MessageCounter => $"{_message.Trim().Length} / {MaintenanceRules.MaxMessageLength}";
|
||||
|
||||
public int MaxMessageLength => MaintenanceRules.MaxMessageLength;
|
||||
|
||||
/// <summary>Le message saisi diffère de celui publié (bouton « Modifier le message »).</summary>
|
||||
public bool IsMessageChanged
|
||||
{
|
||||
get
|
||||
{
|
||||
var clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
return problem is null && !string.Equals(clean, PublishedMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Aperçu (textes affichés aux joueurs)
|
||||
|
||||
public string PreviewTitle => MaintenanceRules.Title;
|
||||
public string PreviewSubtitle => MaintenanceRules.Subtitle;
|
||||
|
||||
/// <summary>Message tel que les joueurs le verront (nettoyé), sinon null.</summary>
|
||||
public string? PreviewMessage => MaintenanceRules.CleanMessage(_message, out _);
|
||||
|
||||
// ------------------------------------------------------------------ Bandeau de résultat
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
protected override void OnBusyChanged() => RefreshCommands();
|
||||
|
||||
private void RefreshCommands()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
EnableCommand.RaiseCanExecuteChanged();
|
||||
UpdateMessageCommand.RaiseCanExecuteChanged();
|
||||
DisableCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
private async Task ReloadAsync() => Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: true);
|
||||
|
||||
private void Apply(MaintenanceState state, bool resetMessage)
|
||||
{
|
||||
_state = state;
|
||||
if (resetMessage) Message = state.Document.Enabled ? state.Document.Message ?? "" : _message;
|
||||
foreach (var name in new[]
|
||||
{
|
||||
nameof(IsLoaded), nameof(IsActive), nameof(CanPublish), nameof(ShowPublishingDisabled), nameof(PublicUrl),
|
||||
nameof(StateTitle), nameof(StateText), nameof(PublishedMessage), nameof(IsMessageChanged)
|
||||
})
|
||||
OnPropertyChanged(name);
|
||||
RefreshCommands();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
/// <summary>Message nettoyé, ou null avec l'erreur affichée (sous le champ).</summary>
|
||||
private bool TryCleanMessage(out string? clean)
|
||||
{
|
||||
clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
return problem is null;
|
||||
}
|
||||
|
||||
private async Task EnableAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!Context.Dialogs.Confirm("Activer le mode maintenance ?",
|
||||
"Tous les launchers des joueurs passeront en mode maintenance au plus tard dans 2 minutes (ceux qui démarrent : " +
|
||||
"aussitôt), pour tous les jeux :\n\n" +
|
||||
"• installation, mises à jour, vérification, réparation et lancement des jeux bloqués ;\n" +
|
||||
"• paramètres du launcher et des jeux, bouton « Aide » et site officiel indisponibles ;\n" +
|
||||
"• seule la désinstallation reste possible, et ce menu d'administration.\n\n" +
|
||||
"Les téléchargements déjà commencés se terminent et les parties en cours ne sont pas fermées. Les mises à jour du " +
|
||||
"launcher lui-même continuent.",
|
||||
"Activer la maintenance", danger: true))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Activer quand même")) return;
|
||||
await PublishAsync(enabled: true, message, since: null, state.Document.Revision,
|
||||
"Mode maintenance activé : les launchers des joueurs l'affichent au plus tard dans 2 minutes.");
|
||||
}
|
||||
|
||||
private async Task UpdateMessageAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Modifier quand même")) return;
|
||||
await PublishAsync(enabled: true, message, document.Since, document.Revision,
|
||||
"Message de la maintenance modifié : les launchers des joueurs l'affichent au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task DisableAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!Context.Dialogs.Confirm("Terminer la maintenance ?",
|
||||
"Les launchers des joueurs redeviendront utilisables au plus tard dans une minute (installation, mises à jour, " +
|
||||
"lancement des jeux) ; ils rechercheront aussitôt les mises à jour des jeux.",
|
||||
"Terminer la maintenance"))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Terminer quand même")) return;
|
||||
await PublishAsync(enabled: false, message: null, since: null, document.Revision,
|
||||
"Maintenance terminée : les launchers des joueurs redeviennent utilisables au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task PublishAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision, string success)
|
||||
{
|
||||
MaintenanceState? published = null;
|
||||
var ok = await RunAsync(async () =>
|
||||
published = await Context.Session.PublishMaintenanceAsync(enabled, message, since, baseRevision));
|
||||
if (!ok)
|
||||
{
|
||||
// Publication par quelqu'un d'autre entre-temps (409) : état relu, la saisie est gardée.
|
||||
if (ErrorMessage is not null) await ReloadKeepingMessageAsync();
|
||||
return;
|
||||
}
|
||||
Apply(published!, resetMessage: true);
|
||||
if (!enabled) Message = "";
|
||||
StatusMessage = success;
|
||||
Context.NotifyMaintenanceChanged(); // ce launcher bascule tout de suite
|
||||
}
|
||||
|
||||
private async Task ReloadKeepingMessageAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is ApiException or HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
// L'erreur de la publication reste affichée ; « Recharger » permet de réessayer.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, mode maintenance).
|
||||
/// 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>
|
||||
@@ -193,6 +194,29 @@ 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é."));
|
||||
if (state.Maintenance is { } maintenance)
|
||||
items.Add(maintenance.Unreadable
|
||||
? SignedContentItem.Failed("Mode maintenance",
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.")
|
||||
: new SignedContentItem(
|
||||
maintenance.Revision > 0
|
||||
? $"Mode maintenance (révision {maintenance.Revision}, {(maintenance.Enabled ? "en cours" : "inactif")})"
|
||||
: "Mode maintenance",
|
||||
ContentKinds.Maintenance, ContentKinds.MaintenanceId, maintenance.Digest, maintenance.Signature, maintenance.SignerName,
|
||||
state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Maintenance » (il est signé à chaque activation, modification ou fin).",
|
||||
"Jamais activé : rien à signer tant que rien n'est publié."));
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -319,12 +343,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;
|
||||
}
|
||||
@@ -342,6 +366,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; }
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ public sealed class AdminEnvironment
|
||||
/// </summary>
|
||||
public PlayerSigningCheck? PlayerSigning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Appelé après une publication du mode maintenance (page « Maintenance ») : le launcher de ce PC le relit aussitôt.
|
||||
/// Null : rien à faire (tests).
|
||||
/// </summary>
|
||||
public Action? MaintenanceChanged { get; init; }
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
|
||||
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
|
||||
|
||||
public void NotifyMaintenanceChanged() => _shell.Environment.MaintenanceChanged?.Invoke();
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Maintenance;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -31,6 +34,94 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
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);
|
||||
OpenMaintenanceCommand = new RelayCommand(() => Context.Navigate(MaintenancePanelViewModel.PanelId), () => CanOpenMaintenance);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>Page « Maintenance » présente (permission maintenance.manage).</summary>
|
||||
public bool CanOpenMaintenance => Context.Panels.Any(p => p.Id == MaintenancePanelViewModel.PanelId);
|
||||
|
||||
public RelayCommand OpenMaintenanceCommand { get; }
|
||||
|
||||
private string? _maintenanceText;
|
||||
|
||||
/// <summary>Bandeau « Maintenance en cours depuis … » (null : pas de maintenance, ou serveur antérieur à la 3.3.0).</summary>
|
||||
public string? MaintenanceText
|
||||
{
|
||||
get => _maintenanceText;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _maintenanceText, value)) OnPropertyChanged(nameof(IsMaintenanceActive));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMaintenanceActive => _maintenanceText is not null;
|
||||
|
||||
private async Task RefreshMaintenanceAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetMaintenanceAsync();
|
||||
MaintenanceText = state.Document is { Enabled: true } document
|
||||
? "Maintenance en cours" + (document.Since is { } since ? $" depuis le {AccountTexts.Date(since)}" : "") +
|
||||
(state.UpdatedBy is { } by ? $" (activée par {by})" : "") +
|
||||
" : les joueurs ne peuvent ni installer, ni mettre à jour, ni lancer les jeux."
|
||||
: null;
|
||||
}
|
||||
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
MaintenanceText = null; // serveur antérieur (404) ou fichier illisible : rien au tableau de bord
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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>
|
||||
@@ -130,6 +221,8 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
|
||||
ActiveInvitations = invitations.Count;
|
||||
}
|
||||
if (CanReadFeedback) await RefreshFeedbackAsync();
|
||||
await RefreshMaintenanceAsync();
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -36,6 +36,12 @@ public interface IAdminContext
|
||||
/// </summary>
|
||||
PlayerSigningCheck? PlayerSigning => null;
|
||||
|
||||
/// <summary>
|
||||
/// Le mode maintenance vient d'être publié depuis le menu : le launcher de ce PC le relit aussitôt (sans attendre sa
|
||||
/// relecture périodique). Sans effet par défaut (tests).
|
||||
/// </summary>
|
||||
void NotifyMaintenanceChanged() { }
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ public sealed class PlayerSigningCheck
|
||||
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
|
||||
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
|
||||
/// </summary>
|
||||
/// <param name="kind"><see cref="ContentKinds.Catalog"/> ou <see cref="ContentKinds.Manifest"/>.</param>
|
||||
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/>, <see cref="ContentKinds.FeedbackForms"/>
|
||||
/// ou <see cref="ContentKinds.Maintenance"/>.</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)
|
||||
{
|
||||
@@ -108,7 +109,13 @@ public sealed class PlayerSigningCheck
|
||||
{
|
||||
var catalog = kind == ContentKinds.Catalog;
|
||||
var consequence = Mode == ContentSigningMode.Require
|
||||
? catalog
|
||||
? 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."
|
||||
: kind == ContentKinds.Maintenance
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce changement : ils garderont l'état du mode " +
|
||||
"maintenance qu'ils connaissent (une maintenance ne s'afficherait pas, ou ne se terminerait qu'à leur redémarrage)."
|
||||
: 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 " +
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Catalog;
|
||||
using GameLauncher.Core.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
@@ -18,7 +19,9 @@ 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.Maintenance;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Platform;
|
||||
@@ -58,8 +61,18 @@ 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;
|
||||
|
||||
/// <summary>Mode maintenance publié depuis le menu d'administration (null : aucune adresse de trousseau ni de catalogue).</summary>
|
||||
private MaintenanceService? _maintenance;
|
||||
private Task<bool>? _maintenanceFetch;
|
||||
|
||||
/// <summary>Annulé à la fermeture du launcher (relecture périodique du mode maintenance).</summary>
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
|
||||
@@ -168,6 +181,11 @@ public partial class App : Application, ILauncherRestartHost
|
||||
_trust = CreateContentTrust(catalog);
|
||||
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
|
||||
|
||||
// Mode maintenance (publié depuis le menu d'administration) : lu dès maintenant, en parallèle du catalogue et de la
|
||||
// mise à jour du launcher, puis attendu quelques secondes au plus AVANT la recherche des mises à jour des jeux.
|
||||
var maintenanceStarted = DateTime.UtcNow;
|
||||
StartMaintenance(catalog, download: args.CaptureDirectory is null);
|
||||
|
||||
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
|
||||
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
|
||||
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
|
||||
@@ -218,7 +236,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
else if (normalStart && _updates.IsEnabled && (!_updates.AutoUpdate || _updates.SkippedUpdate is not null))
|
||||
_ = NotifyLauncherUpdateAsync();
|
||||
|
||||
// 5. Détection des installations + mises à jour des jeux
|
||||
// 5. Détection des installations + mises à jour des jeux (après le mode maintenance, qui peut les bloquer)
|
||||
if (args.CaptureDirectory is null) await ApplyStartupMaintenanceAsync(maintenanceStarted, wait: !args.Uninstall);
|
||||
try
|
||||
{
|
||||
if (args.CaptureDirectory is not null)
|
||||
@@ -355,13 +374,35 @@ public partial class App : Application, ILauncherRestartHost
|
||||
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
|
||||
launcher.AttachTransition(window);
|
||||
|
||||
// Mode maintenance : chaque changement (relecture périodique, publication depuis le menu de ce PC), puis l'état déjà connu.
|
||||
if (_maintenance is { } maintenance && !readOnly)
|
||||
{
|
||||
maintenance.Changed += state => Dispatcher.InvokeAsync(() => launcher.ApplyMaintenance(state));
|
||||
launcher.ApplyMaintenance(maintenance.Current);
|
||||
}
|
||||
|
||||
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A ou 5 clics sur le logo). Pas en mode --capture.
|
||||
if (!readOnly)
|
||||
{
|
||||
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
|
||||
// Publication du mode maintenance depuis le menu : ce launcher le relit aussitôt (sans attendre la relecture périodique).
|
||||
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
|
||||
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
|
||||
ThemeManager.BuiltInThemes.ToList(), _playerSigning,
|
||||
maintenanceChanged: () => { if (_maintenance is { } m) _ = m.RefreshAsync(_lifetime.Token); });
|
||||
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 =>
|
||||
{
|
||||
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
|
||||
feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
|
||||
};
|
||||
}
|
||||
|
||||
window.DataContext = launcher;
|
||||
@@ -370,6 +411,53 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return window;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>Attente maximale du mode maintenance au démarrage (au-delà : appliqué à son arrivée).</summary>
|
||||
private static readonly TimeSpan MaintenanceStartupBudget = TimeSpan.FromSeconds(4);
|
||||
|
||||
/// <summary>
|
||||
/// Lance la lecture du mode maintenance (maintenance.json, à côté du trousseau ou du catalogue). Rien n'est gardé sur le
|
||||
/// disque : hors ligne, le launcher démarre normalement (le jeu installé reste jouable).
|
||||
/// </summary>
|
||||
private void StartMaintenance(LauncherCatalog catalog, bool download)
|
||||
{
|
||||
if (!download || _http is null) return;
|
||||
try
|
||||
{
|
||||
var url = MaintenanceService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
|
||||
if (url is null) return;
|
||||
_maintenance = new MaintenanceService(_http.GetBytesAsync, url, _trust);
|
||||
_maintenanceFetch = _maintenance.RefreshAsync(_lifetime.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Mode maintenance", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avant la recherche des mises à jour des jeux : attend le mode maintenance (quelques secondes au plus), l'applique,
|
||||
/// puis lance sa relecture périodique. <paramref name="wait"/> faux (désinstallation depuis Windows) : pas d'attente.
|
||||
/// </summary>
|
||||
private async Task ApplyStartupMaintenanceAsync(DateTime started, bool wait)
|
||||
{
|
||||
if (_maintenance is not { } maintenance || _maintenanceFetch is not { } fetch) return;
|
||||
try
|
||||
{
|
||||
var remaining = MaintenanceStartupBudget - (DateTime.UtcNow - started);
|
||||
if (wait && !fetch.IsCompleted && remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
|
||||
if (wait && !fetch.IsCompleted)
|
||||
Log.Info("Mode maintenance : serveur lent, démarrage sans attendre (l'état sera appliqué à son arrivée).");
|
||||
_launcher?.ApplyMaintenance(maintenance.Current);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Mode maintenance", ex);
|
||||
}
|
||||
_ = maintenance.RunAsync(_lifetime.Token);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue des jeux
|
||||
|
||||
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
|
||||
@@ -474,6 +562,7 @@ 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)
|
||||
@@ -573,9 +662,11 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
new AdminModule(),
|
||||
new CatalogAdminModule(),
|
||||
new FeedbackAdminModule(),
|
||||
new ChangelogAdminModule(),
|
||||
new PublishAdminModule(),
|
||||
new FilesAdminModule(),
|
||||
new MaintenanceAdminModule(),
|
||||
new AccountsAdminModule(),
|
||||
new SigningAdminModule(),
|
||||
new AuditAdminModule()
|
||||
@@ -661,6 +752,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_lifetime.Cancel(); // relecture du mode maintenance
|
||||
_instanceChannel?.Dispose();
|
||||
_instanceChannel = null;
|
||||
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
|
||||
@@ -807,7 +899,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
/// <summary>
|
||||
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
|
||||
/// dossier protégé) : simple information dans le bandeau.
|
||||
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
|
||||
/// version prioritaire.
|
||||
/// </summary>
|
||||
private async Task NotifyLauncherUpdateAsync()
|
||||
{
|
||||
@@ -815,7 +908,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
|
||||
if (check.IsAvailable)
|
||||
if (check.IsPriority)
|
||||
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
|
||||
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
|
||||
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
|
||||
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Warning);
|
||||
else if (check.IsAvailable)
|
||||
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
|
||||
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Info);
|
||||
|
||||
@@ -47,7 +47,7 @@ public static class ScrollAssist
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Molette de la souris dans le menu d'administration : elle fait défiler ce qui est SOUS le curseur, jamais une
|
||||
/// 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).
|
||||
|
||||
@@ -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.5.0</Version>
|
||||
<Version>3.4.2</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>
|
||||
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
/// <summary>
|
||||
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
|
||||
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
|
||||
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
|
||||
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
|
||||
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
|
||||
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
|
||||
/// </summary>
|
||||
public async Task<bool> RunStartupCheckAsync()
|
||||
{
|
||||
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
|
||||
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
|
||||
if (_client is null || !IsSingleFileBuild) return false;
|
||||
|
||||
LauncherUpdateCheck check;
|
||||
try
|
||||
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
}
|
||||
|
||||
if (!check.IsAvailable) return false;
|
||||
if (!AutoUpdate && !check.IsPriority)
|
||||
{
|
||||
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
if (check.IsPriority)
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
|
||||
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
|
||||
|
||||
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
|
||||
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
|
||||
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
|
||||
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
return await InstallAsync(check, automatic: true);
|
||||
if (await InstallAsync(check, automatic: true)) return true;
|
||||
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
|
||||
if (check.IsPriority) SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Installation
|
||||
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
Log.Error("Mise à jour du launcher impossible", ex);
|
||||
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
|
||||
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
|
||||
if (automatic) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
|
||||
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
|
||||
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
|
||||
var retryAtNextStart = automatic && update.IsPriority;
|
||||
if (automatic && !retryAtNextStart) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
|
||||
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
|
||||
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
|
||||
: automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
}
|
||||
|
||||
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string? _apiUrlError;
|
||||
private readonly PlayerSigningCheck? _playerSigning;
|
||||
private readonly Action? _maintenanceChanged;
|
||||
private LauncherApiClient? _client;
|
||||
private AdminWindow? _window;
|
||||
|
||||
@@ -39,9 +40,10 @@ public sealed class AdminWindowService : IDisposable
|
||||
/// <param name="playerSigning">
|
||||
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
|
||||
/// </param>
|
||||
/// <param name="maintenanceChanged">Après une publication du mode maintenance : relecture immédiate par ce launcher.</param>
|
||||
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
|
||||
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
|
||||
PlayerSigningCheck? playerSigning = null)
|
||||
PlayerSigningCheck? playerSigning = null, Action? maintenanceChanged = null)
|
||||
{
|
||||
_owner = owner;
|
||||
_modules = modules;
|
||||
@@ -52,6 +54,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
|
||||
_apiUrlError = apiUrlError;
|
||||
_playerSigning = playerSigning;
|
||||
_maintenanceChanged = maintenanceChanged;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
@@ -93,7 +96,8 @@ public sealed class AdminWindowService : IDisposable
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion,
|
||||
RootKeys = LauncherUpdateService.LoadRootKeys(),
|
||||
PlayerSigning = _playerSigning
|
||||
PlayerSigning = _playerSigning,
|
||||
MaintenanceChanged = _maintenanceChanged
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Windows;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Feedback;
|
||||
using GameLauncher.Views;
|
||||
|
||||
namespace GameLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bouton « Aide » de la fenêtre principale : ouvre le formulaire de demande pour le jeu sélectionné (fenêtre modale,
|
||||
/// le jeu ne peut donc pas changer pendant la saisie). L'adresse de l'API est celle du menu d'administration
|
||||
/// (« apiUrl » du launcher.json INTÉGRÉ en Release) : sans elle, le bouton n'est pas affiché.
|
||||
/// Le launcher ne contacte l'API qu'au moment de l'envoi, jamais en arrière-plan ; les formulaires publiés (fichier
|
||||
/// statique signé, à côté du trousseau) ne sont lus qu'à l'ouverture de la fenêtre.
|
||||
/// </summary>
|
||||
public sealed class FeedbackWindowService
|
||||
{
|
||||
private readonly MainWindow _owner;
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string _launcherVersion;
|
||||
private readonly IFeedbackFormsSource? _forms;
|
||||
private LauncherApiClient? _client;
|
||||
private FeedbackWindow? _window;
|
||||
|
||||
/// <param name="forms">Formulaires publiés (null : formulaires d'origine).</param>
|
||||
public FeedbackWindowService(MainWindow owner, string? apiUrl, string launcherVersion, IFeedbackFormsSource? forms = null)
|
||||
{
|
||||
_owner = owner;
|
||||
_forms = forms;
|
||||
_apiUrl = apiUrl is not null && Uri.TryCreate(apiUrl, UriKind.Absolute, out var uri) ? uri : null;
|
||||
_launcherVersion = launcherVersion;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
|
||||
/// <param name="installedVersion">Version installée du jeu (null : non installé), pour les informations techniques.</param>
|
||||
public void Open(string gameId, string gameName, string? installedVersion)
|
||||
{
|
||||
if (_apiUrl is null) return;
|
||||
if (_window is not null)
|
||||
{
|
||||
_window.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Agent utilisateur générique (version du launcher seulement) : rien qui identifie le joueur ou son PC.
|
||||
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
|
||||
var viewModel = new FeedbackFormViewModel(new FeedbackTarget(gameId, gameName, installedVersion, _launcherVersion),
|
||||
new ApiFeedbackSender(_client), screenshots: new ScreenshotSource(() => _window), forms: _forms);
|
||||
var window = new FeedbackWindow(viewModel);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
_window = window;
|
||||
Log.Info($"[{gameId}] Ouverture de l'aide (demande).");
|
||||
_owner.SetDimmed(true);
|
||||
try
|
||||
{
|
||||
window.ShowDialog();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_owner.SetDimmed(false);
|
||||
_window = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_window = null;
|
||||
Log.Error("Ouverture de l'aide", ex);
|
||||
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Aide indisponible", ex.Message, DialogKind.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user