Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ae5cbe63 | ||
|
|
86593a43ee | ||
|
|
8a7902e5e8 | ||
|
|
50ef1025d8 | ||
|
|
bcae86f1a1 | ||
|
|
bcde2a0dac | ||
|
|
86fef875a6 | ||
|
|
1b397ec930 | ||
|
|
c89bd62aff | ||
|
|
f556848da4 | ||
|
|
1cf9e87e82 | ||
|
|
dc09c3ecef | ||
|
|
cbcc0b4f44 | ||
|
|
d0db02d7f3 | ||
|
|
63c2e37dad | ||
|
|
78780c20f1 | ||
|
|
3b95317d4c | ||
|
|
bb165c4be0 | ||
|
|
d2cb48b901 | ||
|
|
e06ddacf04 | ||
|
|
2e4b779be5 | ||
|
|
10558a6e5f | ||
|
|
54941a1009 | ||
|
|
bd8b76015f | ||
|
|
0d3d76a68e | ||
|
|
8862047e36 | ||
|
|
25c87d672e | ||
|
|
d687414872 | ||
|
|
fb71661f17 | ||
|
|
19ccd3bf9a | ||
|
|
370443f3d4 | ||
|
|
213cb9a533 | ||
|
|
dc740e0452 | ||
|
|
72e979afd6 | ||
|
|
1d5f85a77c | ||
|
|
629fb600e9 | ||
|
|
9455277c25 | ||
|
|
f1032df74a | ||
|
|
a341bc641a | ||
|
|
9b544cbc03 | ||
|
|
001ab82fda | ||
|
|
67f4c02efc | ||
|
|
8808fe2615 | ||
|
|
bd5b9d46ca | ||
|
|
688b726e24 | ||
|
|
929b27dbeb | ||
|
|
090d8c64ec | ||
|
|
93f3ba630c | ||
|
|
89775bd73b | ||
|
|
944e47dea7 | ||
|
|
c44c9fe3ef | ||
|
|
6d44b5680a | ||
|
|
a6b2f2d6bb | ||
|
|
e888b4037b | ||
|
|
417dd78df5 | ||
|
|
c8836a9baf | ||
|
|
e887a91d9b | ||
|
|
c1ad053861 | ||
|
|
5195398943 | ||
|
|
fd90c436e7 | ||
|
|
ca194c2bf8 | ||
|
|
4b7b7bb83c | ||
|
|
270e311fb4 | ||
|
|
80d0eb93c5 | ||
|
|
6d4219228e | ||
|
|
20991ddc61 | ||
|
|
fa01962d03 | ||
|
|
53d87f3cc6 | ||
|
|
dc18e01540 | ||
|
|
723da51c65 | ||
|
|
cf3e3f5b57 | ||
|
|
7c7e12f694 | ||
|
|
950d6acc4d | ||
|
|
39dd8300e4 | ||
|
|
9aa3876a0e |
No files matched your search
@@ -30,6 +30,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
README.md = README.md
|
||||
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
|
||||
tools\admin-key.bat = tools\admin-key.bat
|
||||
tools\sign-keyring.bat = tools\sign-keyring.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
@@ -40,8 +41,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
|
||||
server\deploy\installer-api.sh = server\deploy\installer-api.sh
|
||||
server\deploy\launcher-api-catalog.conf = server\deploy\launcher-api-catalog.conf
|
||||
server\deploy\launcher-api-games.conf = server\deploy\launcher-api-games.conf
|
||||
server\deploy\launcher-api.service = server\deploy\launcher-api.service
|
||||
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
|
||||
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.conf
|
||||
server\deploy\nginx-publications.conf = server\deploy\nginx-publications.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
|
||||
@@ -56,6 +61,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\Admin
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Tests", "tests\GameLauncher.Tests\GameLauncher.Tests.csproj", "{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src\GameLauncher.Modules\GameLauncher.Modules.csproj", "{9EBFA383-7928-46F6-8958-F4B38287E380}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -136,6 +145,22 @@ Global
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.ActiveCfg = Release|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.Build.0 = Release|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.Build.0 = Debug|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.ActiveCfg = Release|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.Build.0 = Release|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.Build.0 = Debug|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{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
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -151,6 +176,8 @@ Global
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F} = {266A4FF5-5752-46BA-803C-09E351720DB2}
|
||||
{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}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -32,11 +32,13 @@ 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 |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés (voir §8) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
|
||||
| `src/GameLauncher.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`) |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
@@ -47,14 +49,17 @@ src/GameLauncher/
|
||||
├─ launcher.json Configuration éditeur : un jeu, ou plusieurs (voir launcher.multi-jeux.example.json)
|
||||
├─ 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)
|
||||
│ 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
|
||||
│ └─ 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, interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification)
|
||||
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
|
||||
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
|
||||
```
|
||||
|
||||
## 3. Publier une version du jeu (ManifestBuilder)
|
||||
@@ -67,12 +72,25 @@ dotnet run --project tools\ManifestBuilder -c Release -- ^
|
||||
--exe MonJeu.exe ^
|
||||
--game-id MonJeu ^
|
||||
--notes notes.txt ^
|
||||
--remote-manifest https://launcher.example/monjeu/manifest.json ^
|
||||
--exclude "*.pdb;*.log"
|
||||
```
|
||||
|
||||
L'historique des notes est conservé d'une version à l'autre (30 versions) et affiché dans le
|
||||
launcher (panneau « Notes de version », bouton **Agrandir** pour les lire en grand).
|
||||
|
||||
`--remote-manifest` (recommandé, déjà dans `tools\publish-example.bat`) : l'historique est repris du
|
||||
manifest **en ligne**, et non du `manifest.json` local. Les notes corrigées depuis le menu
|
||||
d'administration (page « Notes de version », §8) ne sont donc pas écrasées à la publication
|
||||
suivante. Pas de manifest en ligne (première publication) : historique local. Serveur injoignable :
|
||||
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
|
||||
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
**Mise en ligne** : depuis le menu d'administration, page **Publier une version** (§8) : choisissez le
|
||||
`manifest.json` du dossier `--output`, puis « Publier ». Seuls les fichiers absents du serveur sont
|
||||
envoyés, l'envoi reprend après une coupure, et le manifest est mis en ligne en dernier. WinSCP reste
|
||||
possible (objects/ et l'archive d'abord, `manifest.json` en dernier), mais pas les deux en même temps.
|
||||
|
||||
### Format de `notes.txt`
|
||||
|
||||
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
|
||||
@@ -328,7 +346,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/)) |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` |
|
||||
| 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` |
|
||||
|
||||
@@ -371,7 +389,7 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
|
||||
|---|---|
|
||||
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
|
||||
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
|
||||
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
|
||||
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, ses `apiUrl` et `catalogUrl` sont ignorées (seules celles intégrées à l'exe comptent) |
|
||||
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
|
||||
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
|
||||
|
||||
@@ -467,9 +485,85 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
|
||||
## 8. Administration (Launcher API)
|
||||
|
||||
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
|
||||
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils ;
|
||||
le menu d'administration du launcher arrive en phase 2 (rien ne change pour les joueurs d'ici là).
|
||||
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
|
||||
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
|
||||
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).
|
||||
|
||||
* **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.
|
||||
* **Adresse du serveur** : `"apiUrl"` dans `src\GameLauncher\launcher.json` (racine du fichier,
|
||||
https). En Release, seule la valeur **intégrée à l'exe** est utilisée ; en Debug, celle du
|
||||
`launcher.json` utilisé (ex. `--config` avec `http://127.0.0.1:5080/api/` pour un serveur local).
|
||||
Sans `apiUrl`, le menu indique qu'il n'est relié à aucun serveur.
|
||||
* **Se connecter** : choisir sa clé (dossier `%USERPROFILE%\.launcher-keys`, ou « Choisir un autre
|
||||
fichier de clé… »), saisir sa phrase de passe. Verrouillage automatique après 10 min sans activité,
|
||||
quand Windows est verrouillé, ou avec `Ctrl+L` ; fermer la fenêtre efface la clé de la mémoire.
|
||||
* **« J'ai une invitation »** : crée la clé sur ce PC et l'enregistre avec le code reçu, puis
|
||||
affiche l'**empreinte** à dicter à la personne qui approuve. Sans code : clé seule (premier
|
||||
propriétaire, à déclarer sur le VPS). Deux rangements au choix :
|
||||
* **fichier chiffré** (phrase de passe, 12 caractères min.) : copiable et sauvegardable ;
|
||||
* **puce de sécurité (TPM 2.0) de ce PC** (code PIN, 6 caractères min.) : la clé est créée dans la
|
||||
puce et ne peut pas être copiée. Proposé seulement après vérification réelle de la puce. Elle
|
||||
disparaît avec la puce (carte mère, réinitialisation, Windows réinstallé) : gardez aussi une
|
||||
clé fichier de secours. Sur le disque : `admin.tpmkey` (référence, sans secret) et `admin.pub`.
|
||||
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
|
||||
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code.
|
||||
|
||||
* **Page « Jeux »** (permission `games.edit`) : nom affiché, accroche, description, liens (Discord,
|
||||
YouTube…), site, thème intégré, icône et fond, fréquence des recherches de mise à jour, jeu masqué,
|
||||
nom du launcher, jeu affiché au premier lancement, ordre des jeux. Champ vide = valeur de
|
||||
`launcher.json`. « Publier » contrôle les valeurs puis écrit `catalog.json` sur le serveur (requête
|
||||
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
|
||||
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
|
||||
le nom affiché ne change ni le raccourci, ni le registre, ni les dossiers.
|
||||
* **Page « Notes de version »** (permission `changelog.edit`) : corrige les notes des versions **déjà
|
||||
publiées** dans le `manifest.json` du serveur. Liste des versions, en-tête (titre, sous-titre,
|
||||
introduction), sections › éléments (modification ou commentaire, étiquette, sous-points, note),
|
||||
boutons ↑ ↓ pour l'ordre, **Aperçu** avec la présentation exacte du launcher. Mêmes limites que le
|
||||
launcher, vérifiées avant l'envoi. « Enregistrer » ne change que les notes (fichiers, archive,
|
||||
version et exécutable restent identiques) ; si le manifest a changé entre-temps (nouvelle version
|
||||
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
|
||||
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
|
||||
`server/deploy/LISEZMOI.md`, étape 3 ter.
|
||||
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
|
||||
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
|
||||
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
|
||||
si même numéro ou version inférieure), puis envoi : manifest, fichiers **absents du serveur**
|
||||
seulement (petits fichiers par lots, gros par morceaux), archive par morceaux de 4 Mo, chaque fichier
|
||||
vérifié (SHA-256) des deux côtés, et mise en ligne avec le manifest **en dernier** (historique des
|
||||
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
|
||||
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 quater.
|
||||
* **Page « Mes appareils »** (tout administrateur) : vos PC (clés), avec leur empreinte et leur dernière
|
||||
connexion. « Ajouter un appareil » crée une invitation « nouvel appareil » (code affiché une seule
|
||||
fois) ; sur le nouveau PC, « J'ai une invitation » ; de retour ici, l'appareil en attente s'approuve
|
||||
en saisissant l'empreinte affichée sur le nouveau PC. Personne d'autre ne peut approuver un appareil
|
||||
à votre nom. Révoquer un appareil perdu : propriétaires (sinon, demandez à un propriétaire).
|
||||
* **Page « Administrateurs »** (permission `admins.manage` : propriétaires) : inviter (nom, rôle → code
|
||||
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
|
||||
qu'il vous dicte, changer un rôle, révoquer un appareil ou un compte, supprimer une invitation.
|
||||
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
|
||||
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
|
||||
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
|
||||
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
|
||||
(`keyring.json`), signé **hors ligne** par la clé racine du launcher avec `tools\sign-keyring.bat` et publié
|
||||
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
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
de 200). Filtres par catégorie, résultat et texte (nom, IP, clé…), export CSV des entrées filtrées.
|
||||
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
|
||||
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
|
||||
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
|
||||
PNG/JPEG du même serveur, déposées dans `catalog/images/` (nouveau nom à chaque changement).
|
||||
Installation sur le VPS : `server/deploy/LISEZMOI.md`, étape 3 bis.
|
||||
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
|
||||
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
|
||||
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
|
||||
@@ -484,5 +578,6 @@ le menu d'administration du launcher arrive en phase 2 (rien ne change pour les
|
||||
|---|---|
|
||||
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
|
||||
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs ») ; 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/` |
|
||||
+332
-68
@@ -51,9 +51,9 @@ Conséquences :
|
||||
┌──────────────────────────────┐ HTTPS ┌─────────────────────────────────────────────┐
|
||||
│ GameLauncher.exe │──────────►│ nginx (TLS, limites, fichiers statiques) │
|
||||
│ ├─ Module « Administration »│ │ ├─ /exovivalzombie/… manifest, zip, objects│
|
||||
│ │ clé privée (Windows │ │ ├─ /launcher/… mise à jour launcher │
|
||||
│ │ Hello/TPM ou fichier │ │ ├─ /catalog/… infos des jeux (ph. 3)│
|
||||
│ │ chiffré) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
|
||||
│ │ clé privée : fichier │ │ ├─ /launcher/… mise à jour launcher │
|
||||
│ │ chiffré, ou puce TPM │ │ ├─ /catalog/… infos des jeux (ph. 3)│
|
||||
│ │ du PC (non exportable) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
|
||||
│ └─ GameLauncher.ApiClient │ │ ASP.NET Core, service systemd durci │
|
||||
└──────────────────────────────┘ │ données : /var/lib/launcher-api (700) │
|
||||
└─────────────────────────────────────────────┘
|
||||
@@ -64,9 +64,11 @@ Conséquences :
|
||||
|
||||
| Projet | Cible | Rôle |
|
||||
|---|---|---|
|
||||
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; module Administration (phase 2) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu (inchangée) : manifest, téléchargement, installation… |
|
||||
| `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 |
|
||||
| `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.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 |
|
||||
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
|
||||
@@ -74,7 +76,9 @@ Conséquences :
|
||||
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
|
||||
|
||||
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
|
||||
(+ `Core` à partir de la phase 3) ; `GameLauncher` → `Core`, `Contracts`, `ApiClient`.
|
||||
(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).
|
||||
|
||||
## 5. Modules
|
||||
@@ -96,35 +100,44 @@ Modules livrés en phase 1 : `System` (infos, santé), `Auth` (défi, connexion,
|
||||
(`AdminPermissions`), protège ses routes avec `.RequirePermission(...)` et ses modifications avec
|
||||
`.RequireSignedRequest()`.
|
||||
|
||||
### 5.2 Côté launcher (phase 2)
|
||||
### 5.2 Côté launcher (phase 2 ✅)
|
||||
|
||||
La logique d'un module (services, ViewModels) est une bibliothèque **net8.0 sans WPF**, testée sans
|
||||
interface ; le launcher WPF n'en fournit que l'**affichage** (un `DataTemplate` par type de page).
|
||||
|
||||
```csharp
|
||||
public interface ILauncherModule
|
||||
{
|
||||
string Id { get; } // "admin", "news", "friends"…
|
||||
string Id { get; } // "admin", puis "news", "friends"…
|
||||
string DisplayName { get; }
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
void AddResources(Uri resourceDictionary); // modèles WPF du module (DataTemplate)
|
||||
void AddAdminPanel(AdminPanelDescriptor panel); // page du menu d'administration
|
||||
void AddPage(PageDescriptor page); // section de la fenêtre principale (futur)
|
||||
void AddService<T>(T service) where T : class; // services partagés
|
||||
// Plus tard : AddPage (section de la fenêtre principale), services partagés…
|
||||
}
|
||||
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id, string Title, string IconKey, string RequiredPermission, int Order,
|
||||
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
string Id, string Title, string Description, string IconKey,
|
||||
string? RequiredPermission, // null : toute personne connectée
|
||||
int Order, Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
```
|
||||
|
||||
* Le **menu d'administration** est une fenêtre « coquille » : navigation à gauche (pages fournies
|
||||
par les modules, **filtrées selon les permissions** renvoyées par le serveur), en-tête avec
|
||||
l'administrateur connecté, durée de session restante et bouton « Verrouiller ».
|
||||
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), les jeux
|
||||
déclarés dans `launcher.json`, les boîtes de dialogue, le journal.
|
||||
* Les modules futurs ajoutent leurs pages joueurs (`AddPage`) **et** leurs pages d'administration
|
||||
(`AddAdminPanel`) : ils s'intègrent au menu admin sans le modifier.
|
||||
* `ModuleCatalog` rassemble les modules (déclarés dans `App.CreateModules()`), vérifie l'unicité des
|
||||
identifiants et le format des permissions, et donne les pages **autorisées par les permissions**
|
||||
renvoyées par le serveur (le serveur reste seul juge : masquer une page n'est qu'un confort).
|
||||
* Le **menu d'administration** est une fenêtre « coquille » : à gauche l'administrateur connecté, les
|
||||
pages des modules, le temps restant avant verrouillage et le bouton « Verrouiller » ; à droite la
|
||||
page choisie.
|
||||
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), le client de
|
||||
l'API, les jeux déclarés dans `launcher.json`, les boîtes de dialogue (`IModuleDialogs`), la
|
||||
navigation entre pages et le signalement d'une session perdue (retour à la connexion).
|
||||
`AdminPanelViewModel.RunAsync` gère « occupé », les messages d'erreur et la session expirée.
|
||||
* Ajouter une page à un module futur : une classe `AdminPanelViewModel` + son descripteur dans le
|
||||
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
|
||||
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
|
||||
|
||||
## 6. Authentification par clé publique/privée
|
||||
|
||||
@@ -135,11 +148,39 @@ public sealed record AdminPanelDescriptor(
|
||||
* `keyId` = 16 premiers caractères hexadécimaux du SHA-256 de la clé publique (SPKI).
|
||||
* **Empreinte** affichée aux humains : 5 groupes de 4 caractères (`AB12 CD34 EF56 7890 1A2B`, 80 bits),
|
||||
calculée avec une séparation de domaine : elle ne se déduit pas du `keyId`, visible dans les listes.
|
||||
* Stockage de la clé privée sur le PC de l'administrateur (phase 2) :
|
||||
* **Windows Hello / TPM** si disponible : clé non exportable, liée au PC, déverrouillée par le
|
||||
code PIN Windows (un PC = une clé ; un administrateur peut enregistrer plusieurs PC) ;
|
||||
* sinon **fichier chiffré** (PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la clé de
|
||||
signature du launcher) déverrouillé par une phrase de passe de 12 caractères minimum.
|
||||
* Stockage de la clé privée sur le PC de l'administrateur :
|
||||
* **fichier chiffré** (phase 2 ✅) : PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la
|
||||
clé de signature du launcher — déverrouillé par une phrase de passe de 12 caractères minimum.
|
||||
Dossier proposé : `%USERPROFILE%\.launcher-keys` (celui de `tools\admin-key.bat`). La clé de
|
||||
signature du launcher (`launcher-signing*.key`), rangée dans le même dossier, n'est jamais
|
||||
proposée ni acceptée. Une clé n'écrase jamais un fichier existant ;
|
||||
* **puce de sécurité TPM 2.0** (phase 2b ✅, au choix lors de « J'ai une invitation ») : la clé
|
||||
ECDSA P-256 est **générée dans la puce** par le fournisseur Windows « Microsoft Platform Crypto
|
||||
Provider » et n'en sort jamais (non exportable) ; la puce signe elle-même. Clé de l'utilisateur
|
||||
Windows courant, protégée par un **code PIN** (6 caractères minimum, saisi dans le launcher,
|
||||
vérifié par la puce, qui se bloque temporairement après plusieurs codes faux). Sur le disque,
|
||||
seul un fichier de référence `*.tpmkey` (nom de la clé dans la puce + clé publique, aucun
|
||||
secret) et le `.pub` habituel. À la connexion, la clé publique de la puce est comparée à celle
|
||||
de la référence. Le choix n'est **dégrisé qu'après une vérification réelle de la puce**
|
||||
(création, signature et suppression d'une clé de test). Rien ne change côté serveur : même
|
||||
format de clé publique P-256, mêmes signatures. Limites : la clé ne peut pas être sauvegardée
|
||||
et disparaît avec la puce (carte mère, réinitialisation du TPM, Windows réinstallé) — garder
|
||||
une clé « fichier » de secours ; le serveur ne peut pas prouver qu'une clé est dans une puce
|
||||
(pas d'attestation) : c'est une protection pour l'administrateur, pas une règle du serveur ;
|
||||
le blocage anti-essais est commun à toute la puce (les codes faux comptent aussi pour le code
|
||||
PIN Windows Hello) ; avec un profil Windows itinérant, la clé n'est utilisable que sur le PC
|
||||
qui l'a créée. Le fichier `.tpmkey` ne désigne que des clés nommées `GameLauncher.Admin.*`
|
||||
du fournisseur de la puce, et sa clé publique est comparée à celle de la puce avant tout
|
||||
essai du code PIN.
|
||||
Écran de connexion : **« Supprimer cette clé de la puce »** (clés TPM uniquement, après
|
||||
confirmation ; efface la clé de la puce, le `.tpmkey` et le `.pub` — à révoquer aussi sur le
|
||||
serveur ; les clés fichier se suppriment à la main). Après un code faux, le nombre d'essais
|
||||
restants est affiché ; si la puce est bloquée, un **décompte** indique quand elle acceptera de
|
||||
nouveau un code (état lu dans la puce par TPM2_GetCapability via le service TBS : compteur,
|
||||
maximum, intervalle de « guérison » ; estimation, PC allumé). Si cet état n'est pas lisible sur
|
||||
le PC, une explication générale est affichée et un nouvel essai reste possible.
|
||||
* Windows Hello (visage, empreinte) : écarté pour l'instant, son API ne crée que des clés RSA,
|
||||
ce qui demanderait de faire évoluer le serveur.
|
||||
|
||||
### 6.2 Connexion (défi-réponse)
|
||||
|
||||
@@ -174,6 +215,17 @@ empêchent de réutiliser une signature auprès d'un autre serveur.
|
||||
d'inactivité, 8 h au maximum**, 5 sessions par administrateur.
|
||||
* À chaque requête : session valide **et** administrateur actif **et** clé active (une révocation
|
||||
prend effet immédiatement, y compris depuis l'outil en ligne de commande).
|
||||
* **Côté launcher** (phase 2) : la clé déchiffrée n'existe en mémoire que pendant la session. Le menu
|
||||
se **verrouille** (session fermée, clé effacée, retour à la connexion) après **10 min sans activité**
|
||||
dans la fenêtre, à la fin des 8 h, quand la **session Windows est verrouillée** (Win+L, veille),
|
||||
sur `Ctrl+L` ou le bouton « Verrouiller », et si le serveur refuse la session (révocation). Tant
|
||||
que l'administrateur est actif, la session est prolongée sur le serveur toutes les 4 min. Fermer
|
||||
la fenêtre ou le launcher efface la clé, puis déconnecte la session. La phrase de passe n'est
|
||||
jamais conservée (champ effacé dès la tentative de connexion).
|
||||
* L'adresse de l'API (`apiUrl`) n'est lue, dans la version Release, que dans le `launcher.json`
|
||||
**intégré à l'exe** : un fichier posé à côté (ou `--config`) ne peut pas envoyer les
|
||||
administrateurs vers un autre serveur. Une valeur invalide est ignorée (le launcher des joueurs
|
||||
démarre toujours) et expliquée à l'ouverture du menu.
|
||||
|
||||
### 6.4 Requêtes signées
|
||||
|
||||
@@ -216,7 +268,7 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
|
||||
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
|
||||
| `admins.manage` | ✔ | | | Gestion des administrateurs (page « Administrateurs ») |
|
||||
| `audit.read` | ✔ | ✔ | | Journal d'audit |
|
||||
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
|
||||
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
|
||||
@@ -236,6 +288,8 @@ Les permissions sont des chaînes « module.action » : un module futur déclare
|
||||
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
|
||||
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
|
||||
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
|
||||
(Étapes 3 et 4 : « J'ai une invitation » ; étapes 1 et 5 : page « Administrateurs » du launcher,
|
||||
phase 6. `tools\admin-key.bat` reste possible en ligne de commande.)
|
||||
|
||||
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
|
||||
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
|
||||
@@ -252,8 +306,9 @@ sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas"
|
||||
## 9. Informations fixes et informations modifiables des jeux (phase 3)
|
||||
|
||||
Les jeux sont **toujours déclarés dans `launcher.json`** : le serveur ne peut ni ajouter ni retirer
|
||||
un jeu, seulement modifier l'affichage des jeux déclarés. Les champs actuels de `launcher.json` se
|
||||
répartissent ainsi :
|
||||
un jeu, seulement modifier l'affichage des jeux déclarés. Comme `apiUrl`, la liste des jeux que le
|
||||
menu d'administration peut modifier est lue, en Release, dans le `launcher.json` intégré à l'exe.
|
||||
Les champs de `launcher.json` se répartissent ainsi :
|
||||
|
||||
| Champ | Reste dans l'exe | Modifiable (serveur) | Raison |
|
||||
|---|:-:|:-:|---|
|
||||
@@ -276,47 +331,255 @@ validé et borné comme les notes de version, puis **mis en cache** (`%AppData%\
|
||||
Sans réseau : dernier cache, sinon valeurs de `launcher.json`. Le serveur écrit `catalog.json`
|
||||
seulement via l'API (permission `games.edit`, requête signée).
|
||||
|
||||
## 10. Notes de version (phase 4)
|
||||
### 9.1 Mise en œuvre (phase 3 ✅)
|
||||
|
||||
* Éditeur graphique dans le menu admin : arborescence version › sections › éléments, édition des
|
||||
champs du format enrichi, **aperçu en direct avec les mêmes modèles que le launcher**
|
||||
(`Themes/Changelog.xaml`) et les mêmes bornes (`ChangelogLimits`).
|
||||
**Format** (`Contracts/Catalog`) : `{ "format": "gamelauncher-catalog", "version": 1, "revision", "updatedAt",
|
||||
"launcher": { "launcherName", "defaultGame", "gameOrder" }, "games": { "<gameId>": { "gameName", "tagline",
|
||||
"description", "websiteUrl", "links": [{ "kind", "label", "url" }], "theme", "icon", "background",
|
||||
"updateCheckIntervalMinutes", "hidden" } } }`. Un champ absent = valeur de `launcher.json`.
|
||||
`CatalogRules` (partagé) borne tout : 48 Ko (sous la limite de 64 Ko des requêtes de l'API), 50 jeux,
|
||||
noms 80 caractères, accroche 200, description 2000 caractères / 40 lignes, 8 liens, adresses https
|
||||
(500 caractères, sans identifiants), images `.png`/`.jpg` du **même hôte que le catalogue**, thème =
|
||||
identifiant (`[a-z0-9-]`, jamais un chemin), intervalle 0 ou 5 à 1440. Le serveur **refuse** une
|
||||
valeur invalide (400) ; le launcher l'**ignore** (valeur de `launcher.json`).
|
||||
|
||||
**Serveur** (module `Catalog`, `Storage/CatalogStore`) :
|
||||
|
||||
* `GET v1/catalog` (administrateur connecté) : catalogue publié, publication activée ou non, adresse publique.
|
||||
* `PUT v1/catalog` (`games.edit` + requête signée) : `{ baseRevision, catalog }`. Révision différente
|
||||
de celle publiée → 409 `revision_conflict` (rien n'est écrasé) ; contrôle `CatalogRules` ; écriture
|
||||
de la référence privée (`/var/lib/launcher-api/catalog.json` + `.bak`), puis du **fichier public**
|
||||
(fichier temporaire du même dossier, droits 644 fixés explicitement malgré `UMask=0077`, renommage
|
||||
atomique) ; en cas d'échec du fichier public, la référence privée est remise en état (503) ;
|
||||
historique des 20 dernières versions ; audit `catalog.publish` (jeux modifiés).
|
||||
* Réglages `LauncherApi:Catalog:PublicFile` (vide = publication désactivée, 503 `not_configured`) et
|
||||
`PublicUrl` (défaut : `PublicOrigin` + `/catalog/catalog.json`). Au démarrage : chemin absolu `.json`,
|
||||
dossier existant, dossier public et données privées séparés (aucun dans l'autre, liens résolus).
|
||||
* VPS : dossier public (ex. `/srv/exovival/www/catalog`) autorisé en écriture par un complément
|
||||
systemd (`ReadWritePaths`), `catalog.json` servi par nginx sans cache (`server/deploy/LISEZMOI.md`, étape 3 bis).
|
||||
|
||||
**Launcher (joueur)** (`Core/Catalog`) :
|
||||
|
||||
* `catalogUrl` lue, en Release, **uniquement** dans le `launcher.json` intégré (comme `apiUrl`).
|
||||
* Démarrage : le cache est appliqué immédiatement ; le catalogue à jour est téléchargé en parallèle
|
||||
de la vérification de mise à jour du launcher et appliqué s'il arrive en 2,5 s (nouvelles images :
|
||||
1,5 s de plus) ; sinon il est mis en cache pour le démarrage suivant.
|
||||
* Téléchargements bornés (48 Ko, images 4 Mo), redirections suivies **uniquement** vers le même
|
||||
serveur (aucune requête vers un autre hôte), images contrôlées (signature PNG/JPEG), mises en cache
|
||||
sous l'empreinte de leur adresse (changer d'image = nouveau nom de fichier), fonds réduits à
|
||||
3840 px au chargement.
|
||||
* `CatalogMerger` : valeurs de `launcher.json` restaurées avant chaque application ; jeux inconnus
|
||||
ignorés ; thèmes intégrés uniquement ; images du cache uniquement ; jeu masqué toujours affiché s'il
|
||||
est installé ou demandé (raccourci) ; au moins un jeu affiché. Le nom du catalogue est un **nom
|
||||
affiché** : registre, raccourci, dossier d'installation proposé et dossier de données gardent le
|
||||
`gameName` de `launcher.json`.
|
||||
* Fiche du jeu : description et boutons de liens (icônes génériques : site, forum, vidéo…).
|
||||
|
||||
**Menu d'administration** : module `CatalogAdminModule` (permission `games.edit`), page « Jeux » :
|
||||
liste « Launcher » + jeux (ordre ↑ ↓), champs vides = valeur de `launcher.json` (rappelée sous le
|
||||
champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles que le serveur), saisie
|
||||
suspendue pendant l'envoi, conflit de révision signalé avec rechargement proposé, bandeau si la
|
||||
publication n'est pas configurée.
|
||||
|
||||
## 10. Notes de version (phase 4 ✅)
|
||||
|
||||
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
|
||||
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
|
||||
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
|
||||
**aperçu en direct avec les mêmes modèles que le launcher** (`Themes/Changelog.xaml`) et les mêmes
|
||||
bornes (`ChangelogLimits`). Une version à l'ancien format (liste à plat) est présentée en section(s)
|
||||
sans titre et enregistrée au format enrichi.
|
||||
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
|
||||
* Enregistrement : le launcher envoie le `manifest.json` modifié avec l'empreinte de la version
|
||||
qu'il a lue (concurrence optimiste) ; le serveur vérifie que **seules les notes ont changé**
|
||||
(fichiers, archive, version, exécutable identiques), valide, sauvegarde l'ancien manifest puis
|
||||
remplace atomiquement.
|
||||
* **Cohérence avec ManifestBuilder** : aujourd'hui il reprend l'historique des notes depuis le
|
||||
`manifest.json` *local*, ce qui écraserait les corrections faites en ligne. ManifestBuilder
|
||||
recevra une option `--remote-manifest <url>` (ajoutée au `.bat`) : l'historique est repris du
|
||||
serveur, seule la nouvelle version vient de `notes.txt`.
|
||||
* **Modèle partagé** : `ChangelogEntry`/`ChangelogSection`/`ChangelogItem` et `ChangelogLimits` sont
|
||||
dans `Contracts` (`GameLauncher.Contracts.Changelog`), utilisés par le launcher, ManifestBuilder, le
|
||||
menu d'administration et le serveur (qui ne référence pas `Core`). `ChangelogLimits.Check` signale
|
||||
les dépassements sans rien tronquer ; `Normalize` tronque (texte + « … » dans la longueur maximale).
|
||||
* **API** :
|
||||
* `GET v1/games/{gameId}/changelog` (administrateur connecté) : notes du manifest publié (liste à
|
||||
plat omise quand les sections existent) et **empreinte SHA-256** du manifest lu ;
|
||||
* `PUT v1/games/{gameId}/changelog/{version}` (`changelog.edit` + requête signée) : notes d'**une**
|
||||
version (60 Ko au plus, sous la limite de 64 Ko des requêtes) + empreinte de base. Identifiant et
|
||||
version : caractères sûrs uniquement (jamais utilisés dans un chemin).
|
||||
* **Serveur** (`Storage/ChangelogStore`) : manifest configuré par jeu (`LauncherApi:Games:<id>:ManifestFile`,
|
||||
chemin absolu, dossier existant, séparé des données privées ; `gameId` du manifest vérifié).
|
||||
Sous verrou : empreinte comparée (409 `revision_conflict` si le manifest a changé : nouvelle version
|
||||
publiée, autre administrateur), contrôle des bornes (400), entrée de la version remplacée dans
|
||||
l'arbre JSON (numéro, date et champs inconnus conservés, quelle que soit la casse ; liste à plat
|
||||
régénérée pour les anciens launchers), budget de tout l'historique vérifié, copie de l'ancien
|
||||
manifest dans les données privées (20 dernières), écriture d'un fichier temporaire caché du même
|
||||
dossier avec les droits du manifest existant, **empreinte revérifiée juste avant** le renommage
|
||||
atomique. Refus (503) si le manifest est un lien symbolique, ou si le dossier n'a pas le bit setgid
|
||||
alors que nginx lit le manifest par son groupe. Audit `changelog.edit`.
|
||||
* **Droits sur le VPS** : le service doit pouvoir écrire dans le dossier du jeu (complément systemd
|
||||
`ReadWritePaths`, `server/deploy/LISEZMOI.md` étape 3 ter). C'est un compromis : un service compromis
|
||||
pourrait alors modifier les fichiers du jeu servis aux joueurs (le manifest n'est pas encore signé,
|
||||
voir phase 7). L'autorisation est limitée au seul dossier du jeu ; sans `LauncherApi:Games`, rien
|
||||
n'est modifiable et le dossier reste en lecture seule pour le service.
|
||||
* **Cohérence avec ManifestBuilder** : option `--remote-manifest <url>` (ajoutée à
|
||||
`tools\publish-example.bat`) : l'historique est repris du manifest **en ligne**, seule la nouvelle
|
||||
version vient de `notes.txt`. 404 = première publication (historique local) ; toute autre erreur
|
||||
arrête la publication. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
## 11. Publication d'une version (phase 5)
|
||||
## 11. Publication d'une version (phase 5 ✅)
|
||||
|
||||
Depuis le menu admin › « Publier une version » : choix du dossier produit par le `.bat`, contrôle
|
||||
local (manifest valide, archive et objets présents avec les bonnes empreintes), puis envoi :
|
||||
Depuis le menu admin › « Publier une version » (module `PublishAdminModule`, permission
|
||||
`releases.publish`) : choix du `manifest.json` produit par le `.bat`, contrôle local (manifest valide,
|
||||
archive et objets présents avec la bonne taille), puis envoi :
|
||||
|
||||
1. `objects/` : seuls les objets **absents du serveur** (le serveur répond à une liste d'empreintes) ;
|
||||
2. l'archive ZIP, **par morceaux** (8 Mo, reprise possible, chaque morceau et le fichier final
|
||||
vérifiés par SHA-256) ;
|
||||
3. `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
|
||||
1. le manifest (par morceaux), contrôlé par le serveur ; il répond par la liste des objets **absents**
|
||||
de son dossier `objects/` ;
|
||||
2. ces objets seulement : petits fichiers **par lots** (une requête pour des centaines de fichiers),
|
||||
gros fichiers par morceaux ;
|
||||
3. l'archive ZIP **par morceaux** de 4 Mo (reprise à la position reçue) ;
|
||||
4. la mise en ligne : `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
|
||||
|
||||
Le serveur vérifie que chaque fichier référencé existe avec la bonne taille et la bonne empreinte
|
||||
avant d'accepter le manifest. L'historique des notes du serveur est conservé.
|
||||
**Contrôles communs** (`Contracts/Publishing/ManifestCheck`, launcher et serveur) : identifiant de jeu
|
||||
et version attendus ; archive = simple nom `.zip` à côté du manifest ; objets dans `objects/` uniquement ;
|
||||
chemins relatifs sûrs, sans doublon ; exécutable présent ; 10 000 fichiers, 128 Go d'archive au plus.
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
|
||||
**Envoi** (`Storage/PublicationStore`, module `Publishing`) : toutes les requêtes sont **signées** (y
|
||||
compris chaque morceau : la signature couvre l'empreinte du corps). Seules les routes d'envoi acceptent
|
||||
8 Mo (limite propre à la route, côté Kestrel et nginx) ; le reste de l'API garde 64 Ko. Dépôt dans un
|
||||
dossier **caché** du dossier du jeu (`.publish-<id>`, 0700, refusé par nginx) : la mise en ligne ne
|
||||
consiste qu'en renommages sur le même disque. Chaque morceau doit suivre exactement ce qui a été reçu
|
||||
(409 `offset_mismatch` sinon) ; l'empreinte SHA-256 est calculée au fil des morceaux (recalculée si le
|
||||
service a redémarré) et vérifiée avant d'accepter le fichier. Espace disque vérifié (507). Une
|
||||
publication par jeu ; celle d'un autre administrateur n'est remplaçable qu'après 30 min sans envoi ;
|
||||
abandon au bout de 48 h. Limite de requêtes : les routes d'envoi de morceaux ont leur propre limite
|
||||
(`Limits:PublicationUploadsPerMinute`, 1500/min par IP, politique `publication-upload`, à la place de la
|
||||
limite générale) ; côté nginx, zone `publication_par_ip` (25/s). Chaque refus 429 du serveur annonce
|
||||
son délai (`Retry-After`).
|
||||
|
||||
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
|
||||
distribuer un faux jeu. La phase 7 ajoute :
|
||||
**Mise en ligne** : tout est revérifié, puis contrôles **avant tout déplacement** (liens symboliques,
|
||||
droits, dossiers `objects/xx`) ; objets, archive (l'ancienne, de même nom, gardée de côté et remise en
|
||||
place si l'écriture du manifest échoue), puis manifest. Refus si les fichiers en ligne ont changé
|
||||
pendant l'envoi (empreinte du manifest **hors notes** : une correction de notes reste permise).
|
||||
Notes : celles de la version publiée, puis l'historique **du serveur** (corrections en ligne
|
||||
conservées), dans les bornes du launcher. Résultat gardé 24 h : une réponse perdue n'empêche pas de
|
||||
constater la mise en ligne. Audit : `release.start`, `release.publish`, `release.cancel`.
|
||||
Dossiers `objects/xx` manquants : créés **sans demander de bit spécial** (systemd refuse au service toute
|
||||
demande de setuid/setgid, `RestrictSUIDSGID=yes`) ; le groupe `www-data` vient du bit setgid de `objects/`,
|
||||
et les droits sont ceux du dossier du jeu, mais sans setgid (le fixer sous le masque du service le retire) :
|
||||
sans conséquence pour le launcher, qui y déplace des fichiers déjà du bon groupe, mais un fichier déposé
|
||||
avec WinSCP y prendrait le groupe `deploy`. D'où l'étape 3 quater : `objects/` et ses 256 sous-dossiers
|
||||
créés sur le VPS en `2750`.
|
||||
|
||||
* `keyring.json` : liste des clés publiques des administrateurs, **signée par la clé racine**
|
||||
(votre clé de signature du launcher, déjà intégrée à l'exe) ;
|
||||
* une **signature détachée** (`manifest.json.sig`, `catalog.json.sig`) produite par la clé de
|
||||
l'administrateur au moment de la publication ou de l'édition — les anciens launchers ignorent
|
||||
ces fichiers ;
|
||||
* côté joueur, vérification puis refus de tout contenu non signé par une clé du trousseau.
|
||||
**Launcher** (`Modules.Admin/Publishing/ReleaseUploader`) : les empreintes des fichiers sont vérifiées
|
||||
pendant leur lecture (un fichier modifié depuis le `.bat` est refusé) ; coupures (réseau, délai,
|
||||
429, serveur occupé, position décalée) : l'état est relu sur le serveur et l'envoi continue, après une
|
||||
pause affichée sur la page (429 : délai `Retry-After` du serveur, sinon 1 s doublée à chaque refus,
|
||||
30 s au plus) ; « Interrompre »
|
||||
garde ce qui a été reçu, « Publier » reprend. La mise en ligne elle-même ne s'interrompt pas. Pendant
|
||||
l'envoi, `IAdminContext.BeginLongOperation()` évite le verrouillage pour inactivité (verrouillage manuel,
|
||||
de Windows et durée maximale de session toujours appliqués).
|
||||
|
||||
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
|
||||
**VPS** : bloc nginx `server/deploy/nginx-publications.conf`, écriture dans le dossier du jeu
|
||||
(`games.conf`, étape 3 ter), dossier `objects` existant avec le bit setgid (`LISEZMOI.md`, étape 3 quater).
|
||||
|
||||
## 11 bis. Administrateurs et journal d'audit (phase 6 ✅)
|
||||
|
||||
Module `AccountsAdminModule` (pages « Mes appareils » et « Administrateurs ») et module
|
||||
`AuditAdminModule` (page « Journal d'audit »), sur les routes de la phase 1. La page ne propose que ce
|
||||
que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste seul juge).
|
||||
|
||||
* **Mes appareils** (tout administrateur connecté) : nouvelle route `GET v1/admins/me` (son compte, ses
|
||||
clés, ses invitations « nouvel appareil » en cours ; empreinte masquée pour une clé en attente, comme
|
||||
dans la liste complète). Ajout d'un PC : invitation `ForAdminId` = soi, puis approbation depuis ce PC
|
||||
avec l'empreinte lue sur le nouveau. Révoquer un de ses appareils reste réservé à `admins.manage`.
|
||||
Un administrateur peut supprimer une invitation « nouvel appareil » **créée par lui pour lui**
|
||||
(`DELETE v1/admins/invitations/{id}` ouverte à tout administrateur, droit revérifié dans la
|
||||
modification) ; les autres exigent `admins.manage`. `InvitationSummary.CreatedById` ajouté.
|
||||
* **Administrateurs** (`admins.manage`) : comptes en attente d'abord, puis actifs (vous en premier), les
|
||||
révoqués sur demande ; par compte : rôle (liste + « Appliquer », confirmation renforcée pour
|
||||
« Propriétaire »), révocation ; par appareil : approbation (empreinte saisie : format contrôlé localement,
|
||||
comparaison par le serveur en temps constant), révocation (sauf la clé de la session en cours). Nouvel
|
||||
appareil d'un autre compte actif : la page explique qu'il l'approuve lui-même ; compte sans appareil
|
||||
actif : restauration sur le VPS (`add-key`).
|
||||
* **Code d'invitation** : affiché une seule fois (copie dans le presse-papiers, « J'ai transmis le code »
|
||||
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
|
||||
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
|
||||
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
|
||||
texte ; limite des 5 000 entrées signalée) ; export CSV des entrées filtrées, toutes les cellules entre
|
||||
guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
|
||||
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
|
||||
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
|
||||
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
|
||||
tant qu'un code non transmis est affiché.
|
||||
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
|
||||
|
||||
## 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 :
|
||||
|
||||
**Ancre de confiance** : la ou les clés racine intégrées à l'exe (`Security\update-signing.pub`, celles des
|
||||
mises à jour du launcher). La clé privée racine reste **hors ligne** : le menu d'administration refuse toujours
|
||||
de l'ouvrir (nom de fichier réservé, et toute clé dont l'identifiant ou la clé publique est celle d'une racine
|
||||
intégrée, quel que soit le nom du fichier) ; elle ne sert qu'à `LauncherPublisher` (mises à jour du launcher,
|
||||
`sign-keyring`).
|
||||
|
||||
**Trousseau** (`keyring.json`, `Contracts/Signing/Keyring`) : `sequence` croissante, `issuedAt`, clés
|
||||
(identifiant, clé publique SPKI, nom, autorisations `releases` et/ou `catalog`), signature racine sur le SHA-256
|
||||
du trousseau canonique (message `GameLauncher/Keyring/v1`, clés et autorisations triées). Production : page
|
||||
**Signature** (propriétaires) › « Exporter la demande » (appareils ACTIFS des comptes actifs ; `releases` si le rôle
|
||||
a `releases.publish`, `catalog` s'il a `games.edit` ; noms débarrassés des caractères invisibles, de mise en forme
|
||||
ou d'inversion du sens d'écriture, refusés aussi par `Keyring.Validate`) → `tools\sign-keyring.bat` (compare la
|
||||
demande au trousseau signé précédent `keyring-N.json` du même dossier, vérifié avec `update-signing.pub`, et
|
||||
signale les **nouvelles clés** et autorisations élargies ; la demande vient du serveur et n'est donc pas digne de
|
||||
confiance : ces empreintes se vérifient hors du serveur, avec celle que la page **Mes appareils** calcule sur le
|
||||
PC de chacun — pas celle annoncée par le serveur ; confirmation, phrase de passe ; refuse une racine absente
|
||||
d'`update-signing.pub` ; écrit `keyring-N.json`) → « Publier le trousseau signé » (le launcher vérifie la racine et
|
||||
le numéro avant l'envoi ; l'export reste possible quand le trousseau publié est refusé par ce launcher). Les noms
|
||||
sont publics. Serveur : `PUT v1/keyring` (`admins.manage`,
|
||||
requête signée) contrôle la structure et un numéro strictement croissant, écrit le fichier public (par défaut à
|
||||
côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish` (clés ajoutées/retirées). La liste
|
||||
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).
|
||||
|
||||
**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
|
||||
chacun à partir de ce qu'il a lu (même code dans `Contracts`) :
|
||||
* manifest : jeu, version, exécutable, arguments, archive (adresse, taille, SHA-256), base des objets, fichiers
|
||||
(chemin, taille, SHA-256) triés — **pas** les notes ni la date : les notes restent corrigeables en ligne (texte
|
||||
borné, jamais exécuté) ; le launcher des joueurs calcule le même condensé depuis son modèle `GameManifest` ;
|
||||
* catalogue : tout sauf `revision`, `updatedAt` et `signature` (posés par le serveur), jeux triés par identifiant
|
||||
**tel que publié** (sans mise en minuscules) ; un catalogue dont deux jeux ne diffèrent que par la casse est
|
||||
refusé par les launchers (`HasDuplicateGames`), même signé.
|
||||
|
||||
Les membres absents ou `null` (trousseau, manifest) ne provoquent jamais d'exception : trousseau refusé, condensé
|
||||
calculé sur des valeurs vides.
|
||||
|
||||
**Production des signatures** (clé de la session ; date = heure du serveur) : à la mise en ligne d'une version
|
||||
(`ReleaseUploader`, juste avant le commit : la reprise des envois, fondée sur l'empreinte du manifest, n'est pas
|
||||
touchée ; corps `PublicationCommitRequest`), à chaque publication du catalogue (page Jeux, sur le catalogue
|
||||
contrôlé tel qu'il sera publié), et pour une version déjà en ligne (WinSCP, antérieure) : page « Publier une
|
||||
version » › « Signer la version en ligne » depuis son dossier (`POST v1/games/{id}/signature`, accepté seulement si
|
||||
le condensé en ligne est celui du dossier). Le serveur vérifie chaque signature (clé = celle de la session, date à
|
||||
± 10 min de son heure — une date future bloquerait les mises à jour suivantes chez les joueurs, signature valide)
|
||||
et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire toute `signature` d'un manifest reçu
|
||||
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
|
||||
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
|
||||
|
||||
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
|
||||
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
|
||||
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
|
||||
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)
|
||||
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
|
||||
sécurité », pas d'installation ni de mise à jour, le jeu installé reste jouable), catalogue ignoré (cache conservé).
|
||||
Le cache du catalogue contient le catalogue tel que publié (signature comprise) : il est revérifié à chaque
|
||||
démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou modifié sur le disque est ignoré en
|
||||
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
|
||||
pas les versions suivantes).
|
||||
|
||||
**Limites assumées** : images du catalogue non signées ; un launcher qui n'a jamais vu de trousseau accepte tout
|
||||
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
|
||||
(la page Signature le signale : re-signer d'abord).
|
||||
|
||||
## 13. Phases
|
||||
|
||||
@@ -324,15 +587,16 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|
||||
|---|---|---|
|
||||
| **0** | Architecture (ce document) | ✅ |
|
||||
| **1** | Contrats partagés ; Launcher API (modules, authentification par clé, sessions, requêtes signées, invitations/enrôlement/approbation/révocation, audit, limites, CLI) ; client API ; outil `AdminKeyTool` ; tests ; fichiers de déploiement (systemd, nginx) | ✅ |
|
||||
| **2** | Launcher : hôte de modules, entrée cachée (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), stockage de clé (Windows Hello/TPM, fichier chiffré), connexion, « J'ai une invitation », tableau de bord | à faire |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | à faire |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
|
||||
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
|
||||
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
|
||||
| **2** | Launcher : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
|
||||
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
|
||||
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
|
||||
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
|
||||
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable : tant que la phase 2 n'est pas
|
||||
livrée, rien ne change pour les joueurs.
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
|
||||
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
|
||||
|
||||
## 14. Choix écartés
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ public static class ApiModules
|
||||
new Modules.System.SystemModule(),
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule()
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule(),
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,4 +38,10 @@ public static class RateLimitPolicies
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
public const string Enrollment = "enrollment";
|
||||
|
||||
/// <summary>
|
||||
/// Morceaux envoyés pendant une publication (manifest, objets, archive) : limite propre, plus haute,
|
||||
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
|
||||
/// </summary>
|
||||
public const string PublicationUpload = "publication-upload";
|
||||
}
|
||||
@@ -39,6 +39,18 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public LimitSettings Limits { get; set; } = new();
|
||||
|
||||
public CatalogSettings Catalog { get; set; } = new();
|
||||
|
||||
public KeyringSettings Keyring { get; set; } = new();
|
||||
|
||||
public ContentSigningSettings ContentSigning { 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.
|
||||
/// </summary>
|
||||
public Dictionary<string, GameSettings> Games { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de validité d'un défi de connexion.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 60;
|
||||
|
||||
@@ -53,6 +65,58 @@ public sealed class LauncherApiOptions
|
||||
public int MaxTotal { get; set; } = 500;
|
||||
}
|
||||
|
||||
/// <summary>Catalogue des jeux (informations modifiables), publié en fichier statique servi par nginx.</summary>
|
||||
public sealed class CatalogSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Fichier public écrit par le service (chemin absolu, ex. « /srv/exovival/www/catalog/catalog.json »).
|
||||
/// Vide : publication désactivée (le catalogue reste consultable dans le menu d'administration).
|
||||
/// Le dossier doit exister, appartenir au compte du service, et être autorisé en écriture par
|
||||
/// systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string PublicFile { get; set; } = "";
|
||||
|
||||
/// <summary>Adresse publique du fichier (information). Vide : « PublicOrigin/catalog/catalog.json ».</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau (phase 7) : clés autorisées à signer le contenu, signé par la clé racine HORS LIGNE puis publié
|
||||
/// depuis la page « Signature » du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed class KeyringSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Fichier public (chemin absolu .json). Vide : « keyring.json » dans le dossier du catalogue (déjà autorisé
|
||||
/// en écriture et servi par nginx), si le catalogue est configuré ; sinon publication désactivée.
|
||||
/// </summary>
|
||||
public string PublicFile { get; set; } = "";
|
||||
|
||||
/// <summary>Adresse publique (information, « keyringUrl » de launcher.json). Vide : à côté du catalogue.</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Signature du contenu servi aux joueurs (phase 7).</summary>
|
||||
public sealed class ContentSigningSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Vrai : catalogue et versions ne sont publiés que signés (refus sinon). À activer une fois le trousseau
|
||||
/// publié et les launchers en mode « require » : évite de mettre en ligne un contenu que ces launchers refuseraient.
|
||||
/// </summary>
|
||||
public bool Require { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
|
||||
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
|
||||
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string ManifestFile { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class LimitSettings
|
||||
{
|
||||
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
|
||||
@@ -61,6 +125,13 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
|
||||
public int AuthPerMinute { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Morceaux envoyés pendant une publication, par minute et par adresse IP (à la place de
|
||||
/// <see cref="RequestsPerMinute"/> pour ces routes). 1500 morceaux de 4 Mo ≈ 100 Mo/s. Ces requêtes
|
||||
/// exigent une session et le droit de publier : le corps n'est lu qu'après ces contrôles.
|
||||
/// </summary>
|
||||
public int PublicationUploadsPerMinute { get; set; } = 1500;
|
||||
|
||||
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
|
||||
public int EnrollmentsPerHour { get; set; } = 10;
|
||||
|
||||
@@ -79,6 +150,28 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public IReadOnlyList<IPAddress> TrustedProxyAddresses { get; private set; } = Array.Empty<IPAddress>();
|
||||
|
||||
/// <summary>Fichier public du catalogue en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? CatalogPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du catalogue, calculée par <see cref="Validate"/>.</summary>
|
||||
public string CatalogPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Hôte de <see cref="CatalogPublicUrl"/> : seul hôte autorisé pour les images du catalogue (le
|
||||
/// launcher n'accepte que des images du même hôte que le catalogue).
|
||||
/// </summary>
|
||||
public string CatalogImageHost { get; private set; } = "";
|
||||
|
||||
/// <summary>Fichier public du trousseau en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? KeyringPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
|
||||
public string KeyringPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
@@ -105,17 +198,151 @@ public sealed class LauncherApiOptions
|
||||
}
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
ValidateCatalog();
|
||||
ValidateKeyring();
|
||||
ValidateGames();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
|
||||
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
|
||||
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
|
||||
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
|
||||
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1, "Limits");
|
||||
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1 &&
|
||||
Limits.PublicationUploadsPerMinute >= 1, "Limits");
|
||||
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
|
||||
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
|
||||
}
|
||||
|
||||
private void ValidateCatalog()
|
||||
{
|
||||
CatalogPublicFileFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Catalog.PublicFile))
|
||||
{
|
||||
var file = Catalog.PublicFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile doit être un chemin absolu vers un fichier .json.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
|
||||
CatalogPublicFileFullPath = full;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Catalog.PublicUrl))
|
||||
{
|
||||
CatalogPublicUrl = NormalizedOrigin + "/catalog/catalog.json";
|
||||
}
|
||||
else if (Uri.TryCreate(Catalog.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
|
||||
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
|
||||
{
|
||||
CatalogPublicUrl = url.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
|
||||
}
|
||||
|
||||
private void ValidateKeyring()
|
||||
{
|
||||
KeyringPublicFileFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Keyring.PublicFile))
|
||||
{
|
||||
var file = Keyring.PublicFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être un chemin absolu vers un fichier .json.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, "Keyring:PublicFile");
|
||||
KeyringPublicFileFullPath = full;
|
||||
}
|
||||
else if (CatalogPublicFileFullPath is { } catalog)
|
||||
{
|
||||
KeyringPublicFileFullPath = Path.Combine(Path.GetDirectoryName(catalog)!, "keyring.json");
|
||||
}
|
||||
if (KeyringPublicFileFullPath is { } keyring && CatalogPublicFileFullPath is { } catalogFile &&
|
||||
string.Equals(keyring, catalogFile, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être différent du fichier du catalogue.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Keyring.PublicUrl))
|
||||
{
|
||||
KeyringPublicUrl = new Uri(new Uri(CatalogPublicUrl), "keyring.json").AbsoluteUri;
|
||||
}
|
||||
else if (Uri.TryCreate(Keyring.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
|
||||
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
|
||||
{
|
||||
KeyringPublicUrl = url.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (gameId, game) in Games)
|
||||
{
|
||||
var name = $"Games:{gameId}:ManifestFile";
|
||||
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
|
||||
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
|
||||
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
|
||||
var file = game.ManifestFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, name);
|
||||
if (files.Values.Contains(full, StringComparer.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
|
||||
files[gameId] = full;
|
||||
}
|
||||
GameManifestFiles = files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
|
||||
/// dans l'autre (liens symboliques résolus).
|
||||
/// </summary>
|
||||
private void EnsureSeparatedFromData(string directory, string name)
|
||||
{
|
||||
var publicDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:{name} : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
|
||||
}
|
||||
|
||||
/// <summary>Chemin absolu, dernier élément résolu s'il s'agit d'un lien symbolique.</summary>
|
||||
private static string RealPath(string directory)
|
||||
{
|
||||
var full = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar);
|
||||
try
|
||||
{
|
||||
var target = new DirectoryInfo(full).ResolveLinkTarget(returnFinalTarget: true);
|
||||
return target is null ? full : Path.GetFullPath(target.FullName).TrimEnd(Path.DirectorySeparatorChar);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameOrInside(string path, string parent)
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
return string.Equals(path, parent, comparison) ||
|
||||
path.StartsWith(parent + Path.DirectorySeparatorChar, comparison);
|
||||
}
|
||||
|
||||
private static void Require(bool condition, string name)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException($"{Section}:{name} : valeur hors limites.");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.RateLimiting;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
@@ -5,6 +6,7 @@ using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
@@ -64,10 +66,20 @@ public static class ServerApp
|
||||
builder.Services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = (context, _) => new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
|
||||
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
|
||||
limiter.OnRejected = (context, _) =>
|
||||
{
|
||||
// Délai avant la prochaine fenêtre : le launcher reprend juste à temps au lieu d'attendre à l'aveugle.
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
return new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
|
||||
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
|
||||
};
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
IsPublicationUpload(context)
|
||||
? RateLimitPartition.GetNoLimiter("publication") // limite propre (politique PublicationUpload)
|
||||
: RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.PublicationUpload, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.PublicationUploadsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
|
||||
@@ -112,6 +124,10 @@ public static class ServerApp
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Route d'envoi de morceaux d'une publication (politique <see cref="RateLimitPolicies.PublicationUpload"/>).</summary>
|
||||
private static bool IsPublicationUpload(HttpContext context) =>
|
||||
context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == RateLimitPolicies.PublicationUpload;
|
||||
|
||||
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
|
||||
{
|
||||
PermitLimit = permits,
|
||||
|
||||
@@ -33,10 +33,12 @@ public sealed class AdminsModule : IApiModule
|
||||
|
||||
manage.MapGet(ApiRoutes.Admins, List);
|
||||
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
|
||||
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
|
||||
|
||||
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
|
||||
// Ouvertes à tout administrateur connecté : chacun peut voir son compte, ajouter et approuver SES
|
||||
// appareils, et supprimer les invitations « nouvel appareil » qu'il a créées pour lui-même ;
|
||||
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
|
||||
api.MapGet(ApiRoutes.MyAccount, MyAccount).RequireAdmin();
|
||||
api.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireAdmin().RequireSignedRequest();
|
||||
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
|
||||
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
|
||||
@@ -49,6 +51,24 @@ public sealed class AdminsModule : IApiModule
|
||||
private static IResult List(AdminStore store) =>
|
||||
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
|
||||
|
||||
/// <summary>
|
||||
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
|
||||
/// la liste complète : il la lit sur le nouvel appareil) et ses invitations « nouvel appareil » en cours.
|
||||
/// </summary>
|
||||
private static IResult MyAccount(HttpContext context, AdminStore store, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var db = store.Read();
|
||||
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
|
||||
return Results.Json(new MyAccount
|
||||
{
|
||||
Admin = ToSummary(admin),
|
||||
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(ToSummary).ToList()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult ListInvitations(AdminStore store, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
@@ -107,20 +127,24 @@ public sealed class AdminsModule : IApiModule
|
||||
return record;
|
||||
});
|
||||
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
|
||||
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
store.Update(db =>
|
||||
var deleted = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
// Sans admins.manage : seulement une invitation « nouvel appareil » créée par soi-même, pour soi-même.
|
||||
var invitation = db.Invitations.FirstOrDefault(i => i.Id == invitationId);
|
||||
var own = invitation is not null && invitation.ForAdminId == actor.Admin.Id && invitation.CreatedById == actor.Admin.Id;
|
||||
EnsureActor(db, actor, own ? AdminPermission.None : AdminPermission.Manage);
|
||||
if (invitation is null) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
db.Invitations.Remove(invitation);
|
||||
return invitation;
|
||||
});
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
@@ -285,6 +309,9 @@ public sealed class AdminsModule : IApiModule
|
||||
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
|
||||
}
|
||||
|
||||
private static string InvitationDetails(InvitationRecord invitation) =>
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}";
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
|
||||
string result = AuditResults.Ok)
|
||||
{
|
||||
@@ -306,6 +333,7 @@ public sealed class AdminsModule : IApiModule
|
||||
Status = k.Status,
|
||||
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
|
||||
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
|
||||
PublicKey = k.Status == AdminStatus.Active ? Convert.ToBase64String(k.PublicKeyBytes()) : null,
|
||||
AddedAt = k.AddedAt,
|
||||
LastUsedAt = k.LastUsedAt
|
||||
}).ToList()
|
||||
@@ -319,6 +347,7 @@ public sealed class AdminsModule : IApiModule
|
||||
ForAdminId = invitation.ForAdminId,
|
||||
CreatedAt = invitation.CreatedAt,
|
||||
CreatedBy = invitation.CreatedBy,
|
||||
CreatedById = invitation.CreatedById,
|
||||
ExpiresAt = invitation.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux (informations MODIFIABLES : nom affiché, accroche, présentation, liens, thème
|
||||
/// intégré, images, intervalle de mise à jour, ordre et visibilité). Les jeux restent déclarés dans le
|
||||
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
|
||||
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
|
||||
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// </summary>
|
||||
public sealed class CatalogModule : IApiModule
|
||||
{
|
||||
public string Name => "Catalog";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<CatalogStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
|
||||
Results.Json(State(store.Read(), store, options.Value), ApiJson.Options);
|
||||
|
||||
private static IResult Publish(CatalogUpdateRequest request, HttpContext context, CatalogStore store, AuditLog audit,
|
||||
TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<CatalogModule> logger)
|
||||
{
|
||||
if (request.Catalog is null) return ApiResults.BadRequest("Catalogue absent.");
|
||||
if (request.BaseRevision < 0) return ApiResults.BadRequest("Révision invalide.");
|
||||
|
||||
var errors = new List<string>();
|
||||
var catalog = CatalogRules.Normalize(request.Catalog with
|
||||
{
|
||||
Format = CatalogDocument.FormatName,
|
||||
Version = CatalogDocument.CurrentVersion,
|
||||
Revision = 0
|
||||
}, options.Value.CatalogImageHost, errors);
|
||||
if (errors.Count > 0)
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
// Signature (phase 7) : sur le catalogue tel qu'il sera publié (après contrôle), par la clé de cette session.
|
||||
var actor = context.Admin();
|
||||
if (request.Catalog.Signature is { } signature)
|
||||
{
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
ContentSigning.CatalogDigest(catalog), actor, time.GetUtcNow());
|
||||
catalog = catalog with { Signature = signature };
|
||||
}
|
||||
else if (options.Value.ContentSigning.Require)
|
||||
{
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige un catalogue signé (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
}
|
||||
|
||||
CatalogDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(catalog, request.BaseRevision, time.GetUtcNow());
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du catalogue");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}",
|
||||
Describe(previous, published) + (published.Signature is null ? " ; non signé" : $" ; signé (clé {published.Signature.KeyId})"));
|
||||
return Results.Json(State(published, store, options.Value), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static CatalogState State(CatalogDocument catalog, CatalogStore store, LauncherApiOptions options) => new()
|
||||
{
|
||||
Catalog = catalog,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = options.CatalogPublicUrl
|
||||
};
|
||||
|
||||
/// <summary>Résumé pour l'audit : jeux et réglages modifiés.</summary>
|
||||
private static string Describe(CatalogDocument before, CatalogDocument after)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
if (Serialize(before.Launcher) != Serialize(after.Launcher)) changed.Add("launcher");
|
||||
foreach (var id in before.Games.Keys.Union(after.Games.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
before.Games.TryGetValue(id, out var a);
|
||||
after.Games.TryGetValue(id, out var b);
|
||||
if (Serialize(a) != Serialize(b)) changed.Add(id);
|
||||
}
|
||||
return changed.Count == 0 ? "aucun changement" : "modifié : " + string.Join(", ", changed.Take(20));
|
||||
}
|
||||
|
||||
private static string Serialize(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version des jeux (éditeur du menu d'administration) : modification des versions EXISTANTES
|
||||
/// dans le manifest.json publié de chaque jeu (LauncherApi:Games). La création d'une version reste
|
||||
/// l'affaire de ManifestBuilder (et de la publication).
|
||||
/// • GET : tout administrateur connecté (le manifest est public de toute façon) ;
|
||||
/// • PUT (une version) : permission changelog.edit, requête signée, mêmes bornes que le launcher,
|
||||
/// empreinte du manifest attendue (409 si le manifest a changé entre-temps), audit.
|
||||
/// </summary>
|
||||
public sealed class ChangelogModule : IApiModule
|
||||
{
|
||||
public string Name => "Changelog";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<ChangelogStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.GameChangelogPattern, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.GameChangelogEntryPattern, Save).RequirePermission(AdminPermissions.ChangelogEdit).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(string gameId, ChangelogStore store, ILogger<ChangelogModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
try
|
||||
{
|
||||
return Results.Json(store.Read(gameId), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du manifest de {GameId}", gameId);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex is InvalidDataException
|
||||
? $"Le serveur n'a pas pu lire le manifest de « {gameId} » : {ex.Message}"
|
||||
: $"Le serveur n'a pas pu lire le manifest de « {gameId} » : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult Save(string gameId, string version, ChangelogUpdateRequest request, HttpContext context,
|
||||
ChangelogStore store, AuditLog audit, ILogger<ChangelogModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
if (!ChangelogLimits.IsEditableVersion(version)) return ApiResults.BadRequest("Numéro de version invalide.");
|
||||
if (request.Entry is null) return ApiResults.BadRequest("Notes de version absentes.");
|
||||
if (string.IsNullOrWhiteSpace(request.BaseSha256) || request.BaseSha256.Length != 64)
|
||||
return ApiResults.BadRequest("Empreinte du manifest absente ou invalide.");
|
||||
if (!string.Equals(request.Entry.Version, version, StringComparison.Ordinal))
|
||||
return ApiResults.BadRequest("La version des notes ne correspond pas à l'adresse.");
|
||||
|
||||
var errors = ChangelogLimits.Check(request.Entry);
|
||||
if (errors.Count > 0)
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
ChangelogState state;
|
||||
ChangelogEntry previous;
|
||||
try
|
||||
{
|
||||
(state, previous) = store.Save(gameId, version, request.Entry, request.BaseSha256);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Modification des notes de {GameId} {Version}", gameId, version);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex switch
|
||||
{
|
||||
InvalidDataException => $"Le serveur n'a pas pu lire le manifest : {ex.Message}",
|
||||
ManifestWriteRefusedException => $"Modification refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).",
|
||||
_ => "Le serveur n'a pas pu modifier le manifest : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter)."
|
||||
});
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
var after = state.Entries.FirstOrDefault(e => e.Version == version);
|
||||
audit.Write("changelog.edit", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{state.GameId} {version}",
|
||||
$"{Count(previous)} → {Count(after)} élément(s), manifest {state.ManifestSha256[..12]}");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static int Count(ChangelogEntry? entry) => entry is null ? 0 : ChangelogLimits.DisplayedItems(entry);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version depuis le menu d'administration (phase 5) : permission releases.publish,
|
||||
/// TOUTES les requêtes signées (y compris chaque morceau de fichier), audit du début, de la mise en
|
||||
/// ligne et de l'abandon. Les routes d'envoi acceptent des corps jusqu'à 8 Mo (les autres : 64 Ko).
|
||||
/// </summary>
|
||||
public sealed class PublishingModule : IApiModule
|
||||
{
|
||||
public string Name => "Publishing";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<PublicationStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
const string permission = AdminPermissions.ReleasesPublish;
|
||||
api.MapPost(ApiRoutes.PublicationStartPattern, Start).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.PublicationPattern, GetState).RequirePermission(permission);
|
||||
api.MapDelete(ApiRoutes.PublicationPattern, Cancel).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapPut(ApiRoutes.PublicationManifestPattern, Manifest).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPut(ApiRoutes.PublicationArchivePattern, Archive).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPut(ApiRoutes.PublicationObjectPattern, UploadObject).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPost(ApiRoutes.PublicationObjectsPattern, UploadObjects).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPost(ApiRoutes.PublicationCommitPattern, Commit).RequirePermission(permission).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Start(string gameId, PublicationStartRequest request, HttpContext context, PublicationStore store, AuditLog audit,
|
||||
ILogger<PublishingModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
var actor = context.Admin();
|
||||
return Guard(logger, () =>
|
||||
{
|
||||
var (state, replaced) = store.Start(gameId, request, actor.Admin.Id, actor.Admin.DisplayName);
|
||||
if (replaced is not null)
|
||||
Audit(audit, context, "release.cancel", replaced.GameId, replaced.Version, $"remplacée par la version {state.Version}");
|
||||
if (!state.Resumed)
|
||||
Audit(audit, context, "release.start", state.GameId, state.Version, $"version en ligne : {Display(state.PublishedVersion)}");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetState(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.GetState(id, context.Admin().Admin.Id), ApiJson.Options));
|
||||
|
||||
private static IResult Cancel(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
var record = store.Cancel(id, context.Admin().Admin.Id);
|
||||
Audit(audit, context, "release.cancel", record.GameId, record.Version, "abandonnée par l'administrateur");
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
private static IResult Manifest(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AppendManifest(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult Archive(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AppendArchive(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult UploadObject(string id, string sha256, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger)
|
||||
{
|
||||
if (!PublishingLimits.IsValidSha256(sha256)) return ApiResults.BadRequest("Empreinte d'objet invalide.");
|
||||
return Guard(logger, () =>
|
||||
Results.Json(store.AppendObject(id, context.Admin().Admin.Id, sha256, offset, context.CapturedBody()), ApiJson.Options));
|
||||
}
|
||||
|
||||
private static IResult UploadObjects(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AddObjects(id, context.Admin().Admin.Id, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
|
||||
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
// Corps facultatif : signature du contenu du manifest (phase 7). Sans corps : mise en ligne non signée.
|
||||
PublicationCommitRequest? request = null;
|
||||
var body = context.CapturedBody();
|
||||
if (body.Length > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<PublicationCommitRequest>(body, ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de mise en ligne illisible.");
|
||||
}
|
||||
}
|
||||
var signature = request?.Signature;
|
||||
if (signature is null && options.Value.ContentSigning.Require)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige une version signée (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
var actor = context.Admin();
|
||||
var (result, record) = store.Commit(id, actor.Admin.Id, signature,
|
||||
(gameId, digest) => ContentSignatures.RequireFromActor(signature!, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()));
|
||||
if (record is null) return Results.Json(result, ApiJson.Options); // déjà en ligne (réponse précédente perdue)
|
||||
Audit(audit, context, "release.publish", result.GameId, result.Version,
|
||||
$"version précédente : {Display(result.PreviousVersion)} ; {result.ObjectsAdded} fichier(s) ajouté(s)" +
|
||||
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}" +
|
||||
(signature is null ? " ; non signée" : $" ; signée (clé {signature.KeyId})"));
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
|
||||
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin (le détail va dans journalctl).</summary>
|
||||
private static IResult Guard(ILogger logger, Func<IResult> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
catch (ManifestWriteRefusedException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Publication refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Publication d'une version");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire dans le dossier du jeu : vérifiez ses droits et l'espace disque (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string gameId, string version, string details)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{gameId} {version}", details);
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
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 ;
|
||||
/// • 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).
|
||||
/// </summary>
|
||||
public sealed class SigningModule : IApiModule
|
||||
{
|
||||
public string Name => "Signing";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<KeyringStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Signing, State).RequirePermission(AdminPermissions.AdminsManage);
|
||||
api.MapPut(ApiRoutes.Keyring, PublishKeyring).RequirePermission(AdminPermissions.AdminsManage).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.ManifestSignaturePattern, GetManifest).RequirePermission(AdminPermissions.ReleasesPublish);
|
||||
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)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
var manifests = new List<ManifestSignatureState>();
|
||||
foreach (var (gameId, path) in settings.GameManifestFiles.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
manifests.Add(ManifestSignatures.Read(gameId, path, db));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or
|
||||
FormatException or InvalidOperationException)
|
||||
{
|
||||
// Manifest illisible (fichier modifié à la main…) : affiché « non signé », la page reste utilisable.
|
||||
logger.LogWarning(ex, "Signature du manifest de {GameId}", gameId);
|
||||
manifests.Add(new ManifestSignatureState { GameId = gameId });
|
||||
}
|
||||
}
|
||||
|
||||
CatalogSignatureState? catalog = null;
|
||||
if (catalogs.PublishingEnabled)
|
||||
{
|
||||
CatalogDocument? published;
|
||||
try
|
||||
{
|
||||
published = catalogs.ReadPublished();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature du catalogue");
|
||||
published = null;
|
||||
}
|
||||
catalog = published is null
|
||||
? new CatalogSignatureState()
|
||||
: new CatalogSignatureState
|
||||
{
|
||||
Revision = published.Revision,
|
||||
Digest = ContentSigning.CatalogDigest(published),
|
||||
Signature = published.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, published.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = keyrings.Read();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence illisible : la page propose d'en publier un nouveau (numéro choisi par le serveur au contrôle).
|
||||
logger.LogWarning(ex, "Trousseau de référence illisible");
|
||||
keyring = null;
|
||||
}
|
||||
|
||||
return Results.Json(new SigningState
|
||||
{
|
||||
Keyring = keyring,
|
||||
KeyringPublishingEnabled = keyrings.PublishingEnabled,
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult PublishKeyring(HttpContext context, KeyringStore keyrings, AuditLog audit, ILogger<SigningModule> logger)
|
||||
{
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = Keyring.Parse(context.CapturedBody());
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ApiResults.BadRequest($"Trousseau illisible : {ex.Message}");
|
||||
}
|
||||
if (keyring is null) return ApiResults.BadRequest("Trousseau absent.");
|
||||
|
||||
KeyringDocument? previous;
|
||||
try
|
||||
{
|
||||
previous = keyrings.Publish(keyring);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Publication du trousseau");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le trousseau public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence du serveur illisible : refus (le numéro du trousseau publié ne peut pas être contrôlé).
|
||||
logger.LogError(ex, "Trousseau de référence illisible");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le trousseau de référence du serveur est illisible : restaurez-le depuis l'historique (keyring-history/) avant de publier.");
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
var added = keyring.Keys.Select(k => k.KeyId).Except(previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).ToList();
|
||||
var removed = (previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).Except(keyring.Keys.Select(k => k.KeyId)).ToList();
|
||||
audit.Write("keyring.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"trousseau n° {keyring.Sequence}",
|
||||
$"{keyring.Keys.Count} clé(s), racine {keyring.Signature?.RootKeyId}" +
|
||||
(added.Count > 0 ? $" ; ajoutées : {string.Join(", ", added)}" : "") +
|
||||
(removed.Count > 0 ? $" ; retirées : {string.Join(", ", removed)}" : ""));
|
||||
return Results.Json(keyring, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult GetManifest(string gameId, AdminStore admins, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
return Results.Json(ManifestSignatures.Read(gameId, path, admins.Read()), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult SignManifest(string gameId, ManifestSignRequest request, HttpContext context, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
if (request.Signature is null || !PublishingSha(request.Digest)) return ApiResults.BadRequest("Signature ou condensé absent.");
|
||||
var actor = context.Admin();
|
||||
ManifestSignatureState state;
|
||||
try
|
||||
{
|
||||
state = ManifestSignatures.Sign(gameId, path, options.Value.DataDirectoryFullPath, request,
|
||||
digest => ContentSignatures.RequireFromActor(request.Signature, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()),
|
||||
admins.Read());
|
||||
}
|
||||
catch (ManifestWriteRefusedException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Signature refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Signature du manifest de {GameId}", gameId);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu réécrire le manifest : vérifiez les droits du dossier du jeu (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
audit.Write("release.sign", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{gameId} {state.Version}", $"version en ligne signée (contenu {state.Digest[..12]})");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static bool PublishingSha(string? value) => Contracts.Publishing.PublishingLimits.IsValidSha256(value);
|
||||
|
||||
private static string ManifestPath(string gameId, LauncherApiOptions options)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) throw ApiRejection.BadRequest("Identifiant de jeu invalide.");
|
||||
return options.GameManifestFiles.TryGetValue(gameId, out var path)
|
||||
? path
|
||||
: throw new ApiRejection(404, ApiErrorCodes.NotConfigured,
|
||||
"Jeu non configuré sur le serveur (LauncherApi:Games, voir server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle, par le serveur, des signatures de contenu envoyées par le menu d'administration (phase 7). Le
|
||||
/// serveur n'est pas l'ancre de confiance (ce sont les launchers des joueurs, avec le trousseau) : ce contrôle
|
||||
/// évite seulement de mettre en ligne une signature que les launchers refuseraient, et garantit que la clé qui
|
||||
/// signe est celle de la session (rien n'est signé au nom d'un autre).
|
||||
/// </summary>
|
||||
public static class ContentSignatures
|
||||
{
|
||||
/// <exception cref="ApiRejection">400 : signature d'une autre clé, date hors tolérance, ou signature invalide.</exception>
|
||||
public static void RequireFromActor(ContentSignature signature, string kind, string id, string digest,
|
||||
AuthenticatedAdmin actor, DateTimeOffset now)
|
||||
{
|
||||
if (!string.Equals(signature.KeyId, actor.Key.KeyId, StringComparison.Ordinal))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid, "La signature du contenu doit être faite par la clé de cette session.");
|
||||
if (signature.SignedAt < now - ContentSigning.MaxSignedAtSkew || signature.SignedAt > now + ContentSigning.MaxSignedAtSkew)
|
||||
throw new ApiRejection(400, ApiErrorCodes.ClockSkew,
|
||||
"Date de signature du contenu trop éloignée de l'heure du serveur : vérifiez l'heure de Windows, puis recommencez.");
|
||||
if (!ContentSigning.VerifyWithKey(kind, id, digest, signature, actor.Key.PublicKeyBytes()))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid,
|
||||
"Signature du contenu invalide : le contenu signé ne correspond pas à celui envoyé.");
|
||||
}
|
||||
|
||||
/// <summary>« Nicolas (PC fixe) » pour une clé connue du serveur, sinon null.</summary>
|
||||
public static string? SignerName(AdminDatabase db, ContentSignature? signature)
|
||||
{
|
||||
if (signature is null || db.FindKey(signature.KeyId) is not { } found) return null;
|
||||
return string.IsNullOrWhiteSpace(found.Key.Label) ? found.Admin.DisplayName : $"{found.Admin.DisplayName} ({found.Key.Label})";
|
||||
}
|
||||
|
||||
/// <summary>Signature présente dans un manifest publié (null : absente ou illisible).</summary>
|
||||
public static ContentSignature? ReadSignature(JsonObject manifest)
|
||||
{
|
||||
if (PublishedFiles.Property(manifest, "signature") is not JsonObject node) return null;
|
||||
try
|
||||
{
|
||||
return node.Deserialize<ContentSignature>(ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,43 @@ public static class SignedRequests
|
||||
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
|
||||
!HttpMethods.IsOptions(context.Request.Method))
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
var body = buffer.ToArray();
|
||||
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
|
||||
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
|
||||
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
|
||||
feature.MaxRequestBodySize = limit.MaxBytes;
|
||||
|
||||
byte[] body;
|
||||
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
|
||||
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
|
||||
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
|
||||
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
|
||||
{
|
||||
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
|
||||
body = new byte[length];
|
||||
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
|
||||
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
|
||||
throw new BadHttpRequestException("Corps plus long que annoncé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
body = buffer.ToArray();
|
||||
}
|
||||
context.Items[BodyKey] = body;
|
||||
context.Request.Body = new MemoryStream(body, writable: false);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
|
||||
public static byte[] CapturedBody(this HttpContext context) =>
|
||||
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
|
||||
|
||||
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
|
||||
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.WithMetadata(new RequestBodyLimit(maxBytes));
|
||||
|
||||
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
|
||||
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.AddEndpointFilter(async (invocation, next) =>
|
||||
@@ -94,3 +122,6 @@ public static class SignedRequests
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
|
||||
public sealed record RequestBodyLimit(long MaxBytes);
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux (informations modifiables).
|
||||
/// • Référence : catalog.json dans le dossier des données privées (+ historique des 20 dernières révisions).
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Catalog:PublicFile),
|
||||
/// écrite atomiquement (fichier temporaire du même dossier puis renommage), lisible par tous (0644).
|
||||
/// • Contrôle des modifications concurrentes : une publication précise la révision sur laquelle elle
|
||||
/// a été préparée ; si quelqu'un a publié entre-temps, elle est refusée (409).
|
||||
/// </summary>
|
||||
public sealed class CatalogStore
|
||||
{
|
||||
public const string FileName = "catalog.json";
|
||||
private const string HistoryFolder = "catalog-history";
|
||||
private const int HistoryLength = 20;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
|
||||
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 _writeLock = new();
|
||||
|
||||
public CatalogStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.CatalogPublicFileFullPath) { }
|
||||
|
||||
public CatalogStore(string dataDirectory, string? publicFile)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
/// <summary>Catalogue actuel (vide, révision 0, s'il n'a jamais été publié).</summary>
|
||||
public CatalogDocument Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return new CatalogDocument();
|
||||
var text = File.ReadAllText(_path);
|
||||
var document = JsonSerializer.Deserialize<CatalogDocument>(text, Json)
|
||||
?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
var errors = new List<string>();
|
||||
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
|
||||
}
|
||||
|
||||
/// <summary>Catalogue publié tel qu'écrit (sans normalisation : signature comprise), ou null s'il n'a jamais été publié.</summary>
|
||||
public CatalogDocument? ReadPublished()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
|
||||
/// fichier public, puis référence privée et historique.
|
||||
/// </summary>
|
||||
/// <returns>Le catalogue publié et le précédent.</returns>
|
||||
public (CatalogDocument Published, CatalogDocument Previous) Publish(CatalogDocument catalog, long baseRevision, DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile).");
|
||||
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var current = Read();
|
||||
if (current.Revision != baseRevision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le catalogue a été publié entre-temps (révision {current.Revision}) : rechargez-le, puis refaites vos modifications.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
|
||||
var published = catalog with
|
||||
{
|
||||
Format = CatalogDocument.FormatName,
|
||||
Version = CatalogDocument.CurrentVersion,
|
||||
Revision = current.Revision + 1,
|
||||
UpdatedAt = now
|
||||
};
|
||||
var json = JsonSerializer.Serialize(published, Json);
|
||||
if (Encoding.UTF8.GetByteCount(json) > CatalogRules.MaxFileBytes)
|
||||
throw ApiRejection.BadRequest($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko).");
|
||||
|
||||
// 1. Référence privée (+ .bak) : c'est elle qui porte la révision.
|
||||
var previousText = File.Exists(_path) ? File.ReadAllText(_path) : null;
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
|
||||
// 2. Fichier public. En cas d'échec, la référence privée est remise dans son état précédent :
|
||||
// les joueurs et le serveur restent sur la même révision (rien n'est publié à moitié).
|
||||
try
|
||||
{
|
||||
WritePublic(json);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
RestorePrivate(previousText);
|
||||
throw;
|
||||
}
|
||||
|
||||
// 3. Historique (aide uniquement).
|
||||
WriteHistory(published.Revision, json);
|
||||
return (published, current);
|
||||
}
|
||||
}
|
||||
|
||||
private void WritePublic(string json) => PublicFileWriter.Write(_publicFile!, json, "catalogue public");
|
||||
|
||||
private void RestorePrivate(string? previousText)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (previousText is null) File.Delete(_path);
|
||||
else SecureFiles.WriteAllTextAtomic(_path, previousText, keepBackup: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Très improbable (le dossier privé vient d'être écrit) : la révision suivante sera alors
|
||||
// refusée (409) jusqu'au rechargement de la page, sans rien écraser.
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"catalog-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "catalog-*.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,165 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version des jeux, dans le manifest.json PUBLIÉ de chaque jeu (LauncherApi:Games:<id>:ManifestFile).
|
||||
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
|
||||
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
|
||||
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
|
||||
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
|
||||
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
|
||||
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
|
||||
/// </summary>
|
||||
public sealed class ChangelogStore
|
||||
{
|
||||
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
|
||||
public const long MaxManifestBytes = PublishedFiles.MaxManifestBytes;
|
||||
|
||||
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
|
||||
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public ChangelogStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
|
||||
|
||||
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
/// <summary>Notes de version publiées du jeu.</summary>
|
||||
public ChangelogState Read(string gameId)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
var bytes = ReadManifest(path);
|
||||
var root = Parse(bytes, key);
|
||||
return State(key, root, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
|
||||
/// </summary>
|
||||
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
|
||||
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
lock (_writeLock)
|
||||
using (PublishedFiles.LockGame(_dataDirectory, key))
|
||||
{
|
||||
var bytes = ReadManifest(path);
|
||||
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw Conflict();
|
||||
|
||||
var root = Parse(bytes, key);
|
||||
var changelog = ChangelogArray(root)
|
||||
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
|
||||
var index = -1;
|
||||
for (var i = 0; i < changelog.Count; i++)
|
||||
if (changelog[i] is JsonObject candidate && string.Equals(PublishedFiles.StringValue(candidate, "version"), version, StringComparison.Ordinal))
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
if (index < 0)
|
||||
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
|
||||
|
||||
var old = (JsonObject)changelog[index]!;
|
||||
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
|
||||
|
||||
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
|
||||
// anciens launchers ; numéro et date de la version repris du manifest.
|
||||
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
|
||||
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
|
||||
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
|
||||
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
|
||||
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
|
||||
var replacement = old.DeepClone().AsObject();
|
||||
var edited = JsonSerializer.SerializeToNode(clean, PublishedFiles.ManifestJson)!.AsObject();
|
||||
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.DeepClone());
|
||||
changelog[index] = replacement;
|
||||
|
||||
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
|
||||
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
|
||||
if (total > ChangelogLimits.MaxTotalItems)
|
||||
throw ApiRejection.BadRequest(
|
||||
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
|
||||
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(_dataDirectory, key, bytes);
|
||||
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
|
||||
return (State(key, root, newBytes), previous);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw PublishedFiles.NotConfigured(gameId, "Modification des notes de version");
|
||||
}
|
||||
|
||||
private static byte[] ReadManifest(string path) =>
|
||||
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
|
||||
|
||||
internal static JsonObject Parse(byte[] bytes, string gameId)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
|
||||
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
|
||||
var manifestGameId = PublishedFiles.StringValue(root, "gameId");
|
||||
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
|
||||
{
|
||||
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
|
||||
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
|
||||
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
|
||||
return new ChangelogState
|
||||
{
|
||||
GameId = gameId,
|
||||
ManifestVersion = PublishedFiles.StringValue(root, "version") ?? "",
|
||||
ManifestSha256 = Sha256(bytes),
|
||||
Entries = entries
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
|
||||
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
|
||||
{
|
||||
try
|
||||
{
|
||||
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.ManifestJson) ?? new List<ChangelogEntry?>();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
|
||||
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
|
||||
|
||||
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
|
||||
|
||||
private static ApiRejection Conflict() => ApiRejection.Conflict(
|
||||
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau publié (phase 7).
|
||||
/// • Référence : keyring.json dans le dossier des données privées (+ historique).
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Keyring:PublicFile, par défaut à côté
|
||||
/// du catalogue), écrite atomiquement, lisible par tous.
|
||||
/// • Le serveur ne détient pas la clé racine et ne vérifie que la structure et le numéro (toujours croissant) :
|
||||
/// c'est le launcher qui vérifie la signature racine (avant l'envoi dans le menu d'administration, puis chez
|
||||
/// chaque joueur). Un trousseau falsifié serait refusé par tous les launchers.
|
||||
/// </summary>
|
||||
public sealed class KeyringStore
|
||||
{
|
||||
public const string FileName = "keyring.json";
|
||||
private const string HistoryFolder = "keyring-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 _writeLock = new();
|
||||
|
||||
public KeyringStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.KeyringPublicFileFullPath) { }
|
||||
|
||||
public KeyringStore(string dataDirectory, string? publicFile)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
/// <summary>Trousseau publié, ou null.</summary>
|
||||
public KeyringDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return Keyring.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>Publie un trousseau signé (structure contrôlée, numéro supérieur au trousseau actuel).</summary>
|
||||
/// <returns>Le trousseau précédent (null : premier).</returns>
|
||||
public KeyringDocument? Publish(KeyringDocument keyring)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou LauncherApi:Catalog:PublicFile).");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
if (keyring.Signature is null || string.IsNullOrEmpty(keyring.Signature.Value))
|
||||
throw ApiRejection.BadRequest("Trousseau non signé : signez-le d'abord avec la clé racine (tools\\sign-keyring.bat).");
|
||||
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var current = Read();
|
||||
if (current is not null && keyring.Sequence <= current.Sequence)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Trousseau n° {keyring.Sequence} refusé : le trousseau publié porte déjà le n° {current.Sequence} " +
|
||||
"(exportez une nouvelle demande, qui prend le numéro suivant).", ApiErrorCodes.RevisionConflict);
|
||||
var json = Keyring.Serialize(keyring);
|
||||
if (System.Text.Encoding.UTF8.GetByteCount(json) > Keyring.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Trousseau trop volumineux (au plus {Keyring.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte le numéro) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "trousseau public");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(keyring.Sequence, json);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long sequence, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"keyring-{sequence:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "keyring-*.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,68 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du manifest déjà en ligne d'un jeu (version publiée avant la phase 7, ou avec WinSCP) : le launcher
|
||||
/// envoie la signature du contenu de SON manifest.json (dossier de publication) ; le serveur ne l'ajoute que si la
|
||||
/// version en ligne a exactement ce contenu, puis réécrit le manifest atomiquement (mêmes règles que les notes).
|
||||
/// </summary>
|
||||
public static class ManifestSignatures
|
||||
{
|
||||
public static ManifestSignatureState Read(string gameId, string manifestPath, AdminDatabase admins)
|
||||
{
|
||||
if (PublishedFiles.ReadManifest(manifestPath) is not { } bytes) return new ManifestSignatureState { GameId = gameId };
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
var signature = ContentSignatures.ReadSignature(root);
|
||||
return new ManifestSignatureState
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = PublishedFiles.StringValue(root, "version") ?? "",
|
||||
Digest = Digest(bytes),
|
||||
Signature = signature,
|
||||
SignerName = ContentSignatures.SignerName(admins, signature)
|
||||
};
|
||||
}
|
||||
|
||||
/// <param name="verify">Contrôle de la signature sur le condensé de la version en ligne (lève une ApiRejection).</param>
|
||||
public static ManifestSignatureState Sign(string gameId, string manifestPath, string dataDirectory, ManifestSignRequest request,
|
||||
Action<string> verify, AdminDatabase admins)
|
||||
{
|
||||
using (PublishedFiles.LockGame(dataDirectory, gameId))
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath)
|
||||
?? throw ApiRejection.NotFound("Aucune version en ligne pour ce jeu.");
|
||||
var digest = Digest(bytes);
|
||||
if (!string.Equals(digest, request.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
throw ApiRejection.Conflict(
|
||||
"La version en ligne ne correspond pas au manifest.json choisi (fichiers, archive ou exécutable différents) : " +
|
||||
"choisissez le dossier de publication de la version en ligne.", ApiErrorCodes.RevisionConflict);
|
||||
verify(digest);
|
||||
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "signature", JsonSerializer.SerializeToNode(request.Signature, ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(dataDirectory, gameId, bytes);
|
||||
PublishedFiles.WriteManifest(manifestPath, newBytes, PublishedFiles.Sha256(bytes),
|
||||
() => ApiRejection.Conflict("Le manifest a été modifié pendant la signature : recommencez.", ApiErrorCodes.RevisionConflict));
|
||||
return Read(gameId, manifestPath, admins);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Digest(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ContentSigning.ManifestDigest(PublishedFiles.StripBom(bytes));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest en ligne illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichier PUBLIC servi par nginx (catalogue, trousseau) : écrit atomiquement (fichier temporaire caché du
|
||||
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
|
||||
/// </summary>
|
||||
internal static class PublicFileWriter
|
||||
{
|
||||
public const UnixFileMode Mode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
|
||||
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
|
||||
public static void Write(string path, string text, string what)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
|
||||
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, Mode);
|
||||
File.Move(temp, path, overwrite: true); // renommage atomique dans le même dossier
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fichier temporaire laissé : sans conséquence (nom unique)
|
||||
}
|
||||
throw new IOException($"Écriture du {what} impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version depuis le menu d'administration (phase 5).
|
||||
/// • Dépôt : dossier CACHÉ « .publish-<id> » dans le dossier du jeu (même disque : la mise en ligne
|
||||
/// finale n'est qu'une suite de renommages ; nginx refuse les chemins commençant par un point), droits
|
||||
/// 0700 ; description (version, plan) dans les données privées.
|
||||
/// • Envoi : manifest, puis objets absents du serveur (par lots, ou par morceaux pour les gros), puis
|
||||
/// archive par morceaux ; chaque fichier est reçu à la suite (position vérifiée : reprise possible
|
||||
/// après une coupure) puis vérifié (SHA-256) avant d'être accepté.
|
||||
/// • Mise en ligne (commit) : tout est revérifié AVANT de déplacer quoi que ce soit ; objets puis archive
|
||||
/// installés, manifest en DERNIER (comme avec WinSCP), historique des notes du serveur conservé ; si
|
||||
/// l'écriture du manifest échoue, l'archive remplacée est remise en place. Jamais si les fichiers en
|
||||
/// ligne ont changé pendant l'envoi (autre publication, WinSCP).
|
||||
/// • Une publication par jeu ; abandonnée au bout de 48 h sans envoi ; résultat conservé 24 h (une
|
||||
/// réponse perdue n'empêche pas de constater que la version est en ligne).
|
||||
/// </summary>
|
||||
public sealed class PublicationStore
|
||||
{
|
||||
private const string RecordsFolder = "publications";
|
||||
private const string StagingPrefix = ".publish-";
|
||||
private const string DoneSuffix = ".done";
|
||||
private const long FreeSpaceMargin = 256L * 1024 * 1024;
|
||||
private static readonly TimeSpan DoneLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan CleanUpEvery = TimeSpan.FromMinutes(10);
|
||||
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string _recordsDirectory;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _startLock = new();
|
||||
private readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, HashProgress> _hashes = new(StringComparer.Ordinal);
|
||||
private DateTimeOffset _lastCleanUp = DateTimeOffset.MinValue;
|
||||
|
||||
public PublicationStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath, time) { }
|
||||
|
||||
public PublicationStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory, TimeProvider time)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
_recordsDirectory = Path.Combine(dataDirectory, RecordsFolder);
|
||||
_time = time;
|
||||
}
|
||||
|
||||
// ================================================================== Début, état, abandon
|
||||
|
||||
/// <summary>
|
||||
/// Début d'une publication, ou reprise si le même manifest était en cours d'envoi. Une publication
|
||||
/// d'un autre administrateur, active depuis moins de 30 min, n'est pas remplacée (409).
|
||||
/// </summary>
|
||||
/// <returns>L'état, et la publication remplacée (audit) le cas échéant.</returns>
|
||||
public (PublicationState State, PublicationRecord? Replaced) Start(string gameId, PublicationStartRequest request, Guid adminId, string adminName)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
if (!ManifestCheck.IsValidVersion(request.Version)) throw ApiRejection.BadRequest("Numéro de version invalide.");
|
||||
if (!PublishingLimits.IsValidSha256(request.ManifestSha256)) throw ApiRejection.BadRequest("Empreinte du manifest invalide.");
|
||||
if (request.ManifestSize is <= 0 or > PublishingLimits.MaxManifestBytes) throw ApiRejection.BadRequest("Taille du manifest invalide.");
|
||||
|
||||
lock (_startLock)
|
||||
{
|
||||
CleanUp(force: true);
|
||||
PublicationRecord? replaced = null;
|
||||
foreach (var existing in Records().Where(r => string.Equals(r.GameId, key, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
{
|
||||
if (existing.AdminId == adminId && existing.Version == request.Version &&
|
||||
string.Equals(existing.ManifestSha256, request.ManifestSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Touch(existing.Id);
|
||||
return (Lock(existing.Id, () => State(Current(existing.Id), resumed: true)), null);
|
||||
}
|
||||
var idle = _time.GetUtcNow() - LastActivity(existing.Id);
|
||||
if (existing.AdminId != adminId && idle < PublishingLimits.TakeoverAfter)
|
||||
throw ApiRejection.Conflict(
|
||||
$"{existing.AdminName} publie la version {existing.Version} de ce jeu (dernier envoi il y a " +
|
||||
$"{Math.Max(1, (int)idle.TotalMinutes)} min). Réessayez plus tard.", ApiErrorCodes.PublicationInProgress);
|
||||
Lock(existing.Id, () =>
|
||||
{
|
||||
Delete(existing);
|
||||
return 0;
|
||||
});
|
||||
replaced = existing;
|
||||
}
|
||||
|
||||
var current = PublishedFiles.ReadManifest(manifestPath);
|
||||
var currentRoot = current is null ? null : ChangelogStore.Parse(current, key);
|
||||
var record = new PublicationRecord
|
||||
{
|
||||
Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
GameId = key,
|
||||
Version = request.Version,
|
||||
ManifestSha256 = request.ManifestSha256.ToLowerInvariant(),
|
||||
ManifestSize = request.ManifestSize,
|
||||
AdminId = adminId,
|
||||
AdminName = adminName,
|
||||
CreatedAt = _time.GetUtcNow(),
|
||||
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
|
||||
PublishedContentSha256 = OnlineContentHash(currentRoot)
|
||||
};
|
||||
CreateStaging(record);
|
||||
Save(record);
|
||||
return (Lock(record.Id, () => State(record, resumed: false)), replaced);
|
||||
}
|
||||
}
|
||||
|
||||
public PublicationState GetState(string id, Guid adminId)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
EnsurePlan(Current(id));
|
||||
return State(Current(id), resumed: false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Abandon : dépôt supprimé, rien n'est publié.</summary>
|
||||
public PublicationRecord Cancel(string id, Guid adminId)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
Delete(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================== Envoi
|
||||
|
||||
public ChunkResult AppendManifest(string id, Guid adminId, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var staging = Staging(record);
|
||||
var result = Append(Path.Combine(staging, "manifest.part"), Path.Combine(staging, "manifest.json"), offset, data,
|
||||
record.ManifestSize, record.ManifestSha256, "manifest");
|
||||
Touch(id);
|
||||
if (result.Complete) EnsurePlan(record);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public ChunkResult AppendArchive(string id, Guid adminId, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
if (!plan.ArchiveNeeded) return new ChunkResult { Received = plan.ArchiveSize, Complete = true };
|
||||
var staging = Staging(record);
|
||||
var result = Append(Path.Combine(staging, "archive.part"), Path.Combine(staging, "archive.zip"), offset, data,
|
||||
plan.ArchiveSize, plan.ArchiveSha256, "archive");
|
||||
Touch(id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Gros objet, par morceaux.</summary>
|
||||
public ChunkResult AppendObject(string id, Guid adminId, string sha256, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
var sha = sha256.ToLowerInvariant();
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
if (!plan.Pending.TryGetValue(sha, out var size))
|
||||
throw ApiRejection.BadRequest("Cet objet n'est pas attendu par cette publication.");
|
||||
var objects = StagingObjects(record);
|
||||
var result = Append(Path.Combine(objects, sha + ".part"), Path.Combine(objects, sha + ".bin"), offset, data, size, sha, "objet");
|
||||
Touch(id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Lot de petits objets (chacun vérifié) ; renvoie le nombre d'objets enregistrés.</summary>
|
||||
public ChunkResult AddObjects(string id, Guid adminId, byte[] body)
|
||||
{
|
||||
Find(id, adminId);
|
||||
List<(string Sha256, ReadOnlyMemory<byte> Data)> batch;
|
||||
try
|
||||
{
|
||||
batch = ObjectBatch.Read(body, PublishingLimits.MaxObjectsPerBatch);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
throw ApiRejection.BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
var objects = StagingObjects(record);
|
||||
var saved = 0;
|
||||
foreach (var (sha, data) in batch)
|
||||
{
|
||||
if (!plan.Pending.TryGetValue(sha, out var size))
|
||||
throw ApiRejection.BadRequest("Un objet du lot n'est pas attendu par cette publication.");
|
||||
if (data.Length != size) throw ApiRejection.BadRequest($"Objet {sha[..12]}… : taille différente de celle du manifest.");
|
||||
if (PublishedFiles.Sha256(data.Span) != sha)
|
||||
throw ApiRejection.BadRequest($"Objet {sha[..12]}… : contenu différent de son empreinte (fichier modifié ou abîmé).");
|
||||
var done = Path.Combine(objects, sha + ".bin");
|
||||
if (!File.Exists(done))
|
||||
{
|
||||
var temp = Path.Combine(objects, $"{sha}.{Guid.NewGuid():N}.tmp");
|
||||
PublishedFiles.WriteNew(temp, data.Span, PrivateFile);
|
||||
File.Move(temp, done, overwrite: true);
|
||||
}
|
||||
saved++;
|
||||
}
|
||||
Touch(id);
|
||||
return new ChunkResult { Received = saved, Complete = true };
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================== Mise en ligne
|
||||
|
||||
/// <summary>
|
||||
/// Met la version en ligne : objets, archive, puis manifest (historique des notes du serveur conservé).
|
||||
/// Déjà faite (réponse précédente perdue) : le même résultat est renvoyé.
|
||||
/// </summary>
|
||||
/// <returns>Le résultat, et l'enregistrement (null si la mise en ligne avait déjà eu lieu).</returns>
|
||||
/// <param name="signature">Signature du contenu du manifest par l'administrateur (phase 7), ou null.</param>
|
||||
/// <param name="verifySignature">Contrôle de la signature sur le condensé du manifest reçu (lève une ApiRejection).</param>
|
||||
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId, ContentSignature? signature = null,
|
||||
Action<string, string>? verifySignature = null)
|
||||
{
|
||||
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
|
||||
if (Load(id) is null && LoadDone(id) is { } done && done.AdminId == adminId) return (done.Result, null);
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
if (Load(id) is null && LoadDone(id) is { } finished && finished.AdminId == adminId) return (finished.Result, (PublicationRecord?)null);
|
||||
var record = Current(id);
|
||||
var (manifestPath, gameId) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var plan = RequirePlan(record);
|
||||
var state = State(record, resumed: false);
|
||||
if (state.PendingObjects.Count > 0 || !state.ArchiveComplete)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Envoi incomplet : {state.PendingObjects.Count} fichier(s) et {(state.ArchiveComplete ? "aucune archive" : "l'archive")} restent à envoyer.");
|
||||
|
||||
var staging = Staging(record);
|
||||
var manifestBytes = File.ReadAllBytes(Path.Combine(staging, "manifest.json"));
|
||||
var errors = new List<string>();
|
||||
var summary = ManifestCheck.Check(manifestBytes, gameId, record.Version, errors)
|
||||
?? throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
|
||||
using (PublishedFiles.LockGame(_dataDirectory, gameId))
|
||||
{
|
||||
var current = PublishedFiles.ReadManifest(manifestPath);
|
||||
var currentRoot = current is null ? null : ChangelogStore.Parse(current, gameId);
|
||||
var currentVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "";
|
||||
// Fichiers en ligne changés pendant l'envoi (autre publication, WinSCP) : jamais écrasés. Une
|
||||
// correction des notes (page Notes de version) ne change que « changelog » : elle est permise.
|
||||
if (OnlineContentHash(currentRoot) != record.PublishedContentSha256)
|
||||
throw ApiRejection.Conflict(
|
||||
$"La version en ligne a changé pendant l'envoi ({Display(record.PublishedVersion)} → {Display(currentVersion)}) : " +
|
||||
"vérifiez avant de publier la vôtre, puis recommencez.", ApiErrorCodes.RevisionConflict);
|
||||
|
||||
// Signature (phase 7) : sur le contenu reçu (fichiers, archive, exécutable…), que la fusion des notes ne touche pas.
|
||||
if (signature is not null)
|
||||
verifySignature?.Invoke(gameId, ContentSigning.ManifestDigest(PublishedFiles.StripBom(manifestBytes)));
|
||||
|
||||
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne) et la
|
||||
// signature de cette mise en ligne (une signature éventuellement présente dans le fichier reçu est retirée).
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
|
||||
PublishedFiles.SetProperty(root, "signature",
|
||||
signature is null ? null : JsonSerializer.SerializeToNode(signature, Contracts.ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
|
||||
// 0. Contrôles AVANT tout déplacement : droits, liens symboliques, dossiers objects/xx.
|
||||
if (new FileInfo(manifestPath).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
|
||||
var archiveTarget = Path.Combine(gameDirectory, summary.ArchiveName);
|
||||
if (new FileInfo(archiveTarget).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("l'archive publiée est un lien symbolique : la remplacer romprait le lien.");
|
||||
var mode = PublishedFiles.PublicMode(gameDirectory, current is null ? null : manifestPath);
|
||||
var objectsDirectory = Path.Combine(gameDirectory, "objects");
|
||||
PublishedFiles.EnsurePublicDirectory(objectsDirectory, gameDirectory, keepSetGid: true);
|
||||
foreach (var prefix in plan.Pending.Keys.Select(sha => sha[..2]).Distinct())
|
||||
PublishedFiles.EnsurePublicDirectory(Path.Combine(objectsDirectory, prefix), gameDirectory, keepSetGid: false);
|
||||
|
||||
// 1. Objets (contenu adressé par l'empreinte : un objet présent est le bon s'il a la bonne taille).
|
||||
var added = 0;
|
||||
foreach (var (sha, size) in plan.Pending)
|
||||
{
|
||||
var staged = Path.Combine(StagingObjects(record), sha + ".bin");
|
||||
var target = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha));
|
||||
if (ObjectPresent(target, size))
|
||||
{
|
||||
PublishedFiles.TryDelete(staged);
|
||||
continue;
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, mode);
|
||||
File.Move(staged, target, overwrite: true);
|
||||
added++;
|
||||
}
|
||||
foreach (var (sha, size) in summary.Objects)
|
||||
if (!ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le fichier {ManifestCheck.ObjectPath(sha)} a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
|
||||
|
||||
// 2. Archive : l'ancienne (même nom : republication) est gardée de côté jusqu'à l'écriture du manifest.
|
||||
var stagedArchive = Path.Combine(staging, "archive.zip");
|
||||
string? previousArchive = null;
|
||||
var archiveAdded = false;
|
||||
if (File.Exists(stagedArchive))
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(stagedArchive, mode);
|
||||
if (File.Exists(archiveTarget))
|
||||
{
|
||||
previousArchive = PublishedFiles.TempPath(archiveTarget);
|
||||
File.Move(archiveTarget, previousArchive);
|
||||
}
|
||||
File.Move(stagedArchive, archiveTarget);
|
||||
archiveAdded = true;
|
||||
}
|
||||
else if (!File.Exists(archiveTarget) || new FileInfo(archiveTarget).Length != summary.ArchiveSize)
|
||||
{
|
||||
throw ApiRejection.Conflict("L'archive a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
|
||||
}
|
||||
|
||||
// 3. Manifest en dernier : les launchers ne voient jamais une version incomplète.
|
||||
try
|
||||
{
|
||||
if (current is not null) PublishedFiles.Backup(_dataDirectory, gameId, current);
|
||||
PublishedFiles.WriteManifest(manifestPath, newBytes, current is null ? null : PublishedFiles.Sha256(current),
|
||||
() => ApiRejection.Conflict("Le manifest a été modifié pendant la mise en ligne : recommencez.", ApiErrorCodes.RevisionConflict));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Archive remise dans le dépôt (pas besoin de la renvoyer), l'ancienne remise en ligne.
|
||||
if (archiveAdded)
|
||||
{
|
||||
File.Move(archiveTarget, stagedArchive, overwrite: true);
|
||||
if (previousArchive is not null) File.Move(previousArchive, archiveTarget);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (previousArchive is not null) PublishedFiles.TryDelete(previousArchive);
|
||||
|
||||
var result = new PublicationResult
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = record.Version,
|
||||
PreviousVersion = currentVersion,
|
||||
ManifestSha256 = PublishedFiles.Sha256(newBytes),
|
||||
ObjectsAdded = added,
|
||||
ArchiveAdded = archiveAdded
|
||||
};
|
||||
SaveDone(new PublicationDone { Id = id, AdminId = record.AdminId, Result = result, At = _time.GetUtcNow() });
|
||||
Delete(record);
|
||||
return (result, (PublicationRecord?)record);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes : celles de la version publiée (du manifest reçu ; à défaut, celles déjà en ligne), puis
|
||||
/// l'historique du SERVEUR (qui contient les corrections faites en ligne), dans les bornes du launcher
|
||||
/// (30 versions ; les plus anciennes retirées si l'ensemble est trop long).
|
||||
/// Sans manifest en ligne : les notes du manifest reçu.
|
||||
/// </summary>
|
||||
private static JsonArray? MergeChangelog(JsonObject uploaded, JsonObject? current, string version)
|
||||
{
|
||||
var received = ChangelogStore.ChangelogArray(uploaded);
|
||||
if (current is null) return received?.DeepClone().AsArray();
|
||||
|
||||
static bool Is(JsonNode? node, string version) =>
|
||||
node is JsonObject entry && PublishedFiles.StringValue(entry, "version") == version;
|
||||
|
||||
var history = ChangelogStore.ChangelogArray(current) ?? new JsonArray();
|
||||
var merged = new JsonArray();
|
||||
var head = received?.FirstOrDefault(n => Is(n, version)) ?? history.FirstOrDefault(n => Is(n, version));
|
||||
if (head is not null) merged.Add(head.DeepClone());
|
||||
foreach (var entry in history)
|
||||
{
|
||||
if (merged.Count >= ChangelogLimits.MaxEntries) break;
|
||||
if (entry is JsonObject && !Is(entry, version)) merged.Add(entry.DeepClone());
|
||||
}
|
||||
// Budget de tout l'historique (contrôle de lecture en même temps : une entrée illisible empêche la publication).
|
||||
while (merged.Count > 1 &&
|
||||
ChangelogStore.Entries(merged).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems) > ChangelogLimits.MaxTotalItems)
|
||||
merged.RemoveAt(merged.Count - 1);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>Empreinte de la version en ligne hors notes et hors signature (une correction de notes ou une signature reste permise pendant l'envoi) ; vide si elle n'existe pas.</summary>
|
||||
private static string OnlineContentHash(JsonObject? root)
|
||||
{
|
||||
if (root is null) return "";
|
||||
var copy = root.DeepClone().AsObject();
|
||||
PublishedFiles.SetProperty(copy, "changelog", null);
|
||||
PublishedFiles.SetProperty(copy, "signature", null);
|
||||
return PublishedFiles.Sha256(System.Text.Encoding.UTF8.GetBytes(copy.ToJsonString()));
|
||||
}
|
||||
|
||||
// ================================================================== Plan (après réception du manifest)
|
||||
|
||||
/// <summary>Manifest complet : contrôle, puis liste des objets et de l'archive à envoyer.</summary>
|
||||
private void EnsurePlan(PublicationRecord record)
|
||||
{
|
||||
if (record.Plan is not null) return;
|
||||
var staging = Staging(record);
|
||||
var manifest = Path.Combine(staging, "manifest.json");
|
||||
if (!File.Exists(manifest)) return;
|
||||
|
||||
var (manifestPath, gameId) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var errors = new List<string>();
|
||||
var summary = ManifestCheck.Check(File.ReadAllBytes(manifest), gameId, record.Version, errors);
|
||||
if (summary is null)
|
||||
{
|
||||
Delete(record); // manifest refusé : la publication est abandonnée (à refaire avec un manifest corrigé)
|
||||
throw ApiRejection.BadRequest("Manifest refusé : " + string.Join(" ", errors.Take(5)) +
|
||||
(errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
}
|
||||
|
||||
var pending = summary.Objects
|
||||
.Where(o => !ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(o.Key)), o.Value))
|
||||
.ToDictionary(o => o.Key, o => o.Value, StringComparer.Ordinal);
|
||||
// Archive déjà en ligne (même nom, même taille, même empreinte DANS le manifest en ligne) : pas renvoyée.
|
||||
// Aucune lecture du fichier (plusieurs Go) pendant la requête.
|
||||
var archiveTarget = new FileInfo(Path.Combine(gameDirectory, summary.ArchiveName));
|
||||
var archiveNeeded = !(archiveTarget.Exists && archiveTarget.LinkTarget is null && archiveTarget.Length == summary.ArchiveSize &&
|
||||
PublishedArchive(manifestPath, gameId) is { } online &&
|
||||
string.Equals(online.Name, summary.ArchiveName, StringComparison.Ordinal) && online.Sha256 == summary.ArchiveSha256);
|
||||
|
||||
var needed = pending.Values.Sum() + (archiveNeeded ? summary.ArchiveSize : 0);
|
||||
var free = new DriveInfo(gameDirectory).AvailableFreeSpace;
|
||||
if (free < needed + FreeSpaceMargin)
|
||||
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage,
|
||||
$"Espace disque insuffisant sur le serveur : {needed / (1024 * 1024)} Mo à recevoir, {free / (1024 * 1024)} Mo libres.");
|
||||
|
||||
Save(record with
|
||||
{
|
||||
Plan = new PublicationPlan
|
||||
{
|
||||
ArchiveName = summary.ArchiveName,
|
||||
ArchiveSize = summary.ArchiveSize,
|
||||
ArchiveSha256 = summary.ArchiveSha256,
|
||||
ArchiveNeeded = archiveNeeded,
|
||||
Pending = pending,
|
||||
ObjectCount = summary.Objects.Count,
|
||||
ObjectsOnServer = summary.Objects.Count - pending.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Archive référencée par le manifest en ligne (nom, empreinte), sans la lire.</summary>
|
||||
private static (string Name, string Sha256)? PublishedArchive(string manifestPath, string gameId)
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath);
|
||||
if (bytes is null) return null;
|
||||
var root = ChangelogStore.Parse(bytes, gameId);
|
||||
if (PublishedFiles.Property(root, "archive") is not JsonObject archive) return null;
|
||||
var name = PublishedFiles.StringValue(archive, "url");
|
||||
var sha = PublishedFiles.StringValue(archive, "sha256");
|
||||
return name is null || sha is null ? null : (name, sha.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private PublicationPlan RequirePlan(PublicationRecord record)
|
||||
{
|
||||
EnsurePlan(record);
|
||||
return Current(record.Id).Plan ?? throw ApiRejection.Conflict("Envoyez d'abord le manifest.");
|
||||
}
|
||||
|
||||
private PublicationState State(PublicationRecord record, bool resumed)
|
||||
{
|
||||
var staging = Staging(record);
|
||||
var manifestDone = File.Exists(Path.Combine(staging, "manifest.json"));
|
||||
var plan = record.Plan;
|
||||
var pending = new List<string>();
|
||||
var partial = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
long archiveReceived = 0;
|
||||
var archiveComplete = false;
|
||||
if (plan is not null)
|
||||
{
|
||||
var (manifestPath, _) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var objects = Path.Combine(staging, "objects");
|
||||
foreach (var (sha, size) in plan.Pending)
|
||||
{
|
||||
if (File.Exists(Path.Combine(objects, sha + ".bin")) ||
|
||||
ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
|
||||
continue;
|
||||
pending.Add(sha);
|
||||
var part = new FileInfo(Path.Combine(objects, sha + ".part"));
|
||||
if (part.Exists && part.Length > 0) partial[sha] = part.Length;
|
||||
}
|
||||
archiveComplete = !plan.ArchiveNeeded || File.Exists(Path.Combine(staging, "archive.zip"));
|
||||
archiveReceived = archiveComplete ? plan.ArchiveSize : Length(Path.Combine(staging, "archive.part"));
|
||||
}
|
||||
|
||||
return new PublicationState
|
||||
{
|
||||
PublicationId = record.Id,
|
||||
GameId = record.GameId,
|
||||
Version = record.Version,
|
||||
PublishedVersion = record.PublishedVersion,
|
||||
Resumed = resumed,
|
||||
ManifestSize = record.ManifestSize,
|
||||
ManifestReceived = manifestDone ? record.ManifestSize : Length(Path.Combine(staging, "manifest.part")),
|
||||
ManifestAccepted = plan is not null,
|
||||
ObjectCount = plan?.ObjectCount ?? 0,
|
||||
ObjectsOnServer = plan?.ObjectsOnServer ?? 0,
|
||||
PendingObjects = pending,
|
||||
PartialObjects = partial,
|
||||
ArchiveName = plan?.ArchiveName,
|
||||
ArchiveSize = plan?.ArchiveSize ?? 0,
|
||||
ArchiveReceived = archiveReceived,
|
||||
ArchiveComplete = archiveComplete,
|
||||
ExpiresAt = LastActivity(record.Id) + PublishingLimits.IdleExpiry
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================== Réception d'un fichier
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute un morceau à la suite de ce qui a été reçu (position vérifiée), puis vérifie l'empreinte
|
||||
/// quand le fichier est complet (calculée au fil des morceaux ; recalculée après un redémarrage).
|
||||
/// </summary>
|
||||
private ChunkResult Append(string partPath, string donePath, long offset, byte[] data, long size, string sha256, string what)
|
||||
{
|
||||
if (File.Exists(donePath)) return new ChunkResult { Received = size, Complete = true };
|
||||
var received = Length(partPath);
|
||||
if (offset != received)
|
||||
throw ApiRejection.Conflict($"Position d'envoi {offset} inattendue ({what} : {received} octets déjà reçus).", ApiErrorCodes.OffsetMismatch);
|
||||
if (received + data.LongLength > size) throw ApiRejection.BadRequest($"Données en trop ({what} : {size} octets attendus).");
|
||||
if (data.Length == 0 && size > 0) throw ApiRejection.BadRequest("Morceau vide.");
|
||||
|
||||
var options = new FileStreamOptions { Mode = FileMode.Append, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
|
||||
using (var stream = new FileStream(partPath, options))
|
||||
{
|
||||
stream.Write(data);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
HashProgress? progress;
|
||||
if (received == 0)
|
||||
{
|
||||
RemoveHash(partPath);
|
||||
progress = _hashes[partPath] = new HashProgress();
|
||||
}
|
||||
else if (!_hashes.TryGetValue(partPath, out progress) || progress.Offset != received)
|
||||
{
|
||||
RemoveHash(partPath);
|
||||
progress = null;
|
||||
}
|
||||
progress?.Add(data);
|
||||
received += data.Length;
|
||||
if (received < size) return new ChunkResult { Received = received };
|
||||
|
||||
var actual = progress is not null && progress.Offset == size ? progress.Finish() : HashFile(partPath);
|
||||
RemoveHash(partPath);
|
||||
if (actual != sha256)
|
||||
{
|
||||
PublishedFiles.TryDelete(partPath);
|
||||
throw ApiRejection.BadRequest($"Empreinte différente pour {what} : fichier abîmé ou modifié pendant l'envoi ; il sera renvoyé.");
|
||||
}
|
||||
File.Move(partPath, donePath, overwrite: true);
|
||||
return new ChunkResult { Received = size, Complete = true };
|
||||
}
|
||||
|
||||
private void RemoveHash(string key)
|
||||
{
|
||||
if (_hashes.TryRemove(key, out var progress)) progress.Dispose();
|
||||
}
|
||||
|
||||
private sealed class HashProgress : IDisposable
|
||||
{
|
||||
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
public long Offset { get; private set; }
|
||||
|
||||
public void Add(byte[] data)
|
||||
{
|
||||
_hash.AppendData(data);
|
||||
Offset += data.Length;
|
||||
}
|
||||
|
||||
public string Finish() => Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
|
||||
|
||||
public void Dispose() => _hash.Dispose();
|
||||
}
|
||||
|
||||
// ================================================================== Enregistrements, dépôt
|
||||
|
||||
public sealed record PublicationRecord
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
public long ManifestSize { get; init; }
|
||||
public Guid AdminId { get; init; }
|
||||
public string AdminName { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Version en ligne au début (vide : aucune).</summary>
|
||||
public string PublishedVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest en ligne SANS les notes, au début : la mise en ligne est refusée si elle a changé.</summary>
|
||||
public string PublishedContentSha256 { get; init; } = "";
|
||||
|
||||
public PublicationPlan? Plan { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PublicationPlan
|
||||
{
|
||||
public string ArchiveName { get; init; } = "";
|
||||
public long ArchiveSize { get; init; }
|
||||
public string ArchiveSha256 { get; init; } = "";
|
||||
public bool ArchiveNeeded { get; init; }
|
||||
|
||||
/// <summary>Objets absents du serveur quand le manifest a été reçu (empreinte → taille).</summary>
|
||||
public Dictionary<string, long> Pending { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
public int ObjectCount { get; init; }
|
||||
public int ObjectsOnServer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication terminée : résultat gardé 24 h (réponse perdue, nouvel essai).</summary>
|
||||
public sealed record PublicationDone
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public Guid AdminId { get; init; }
|
||||
public PublicationResult Result { get; init; } = new();
|
||||
public DateTimeOffset At { get; init; }
|
||||
}
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw PublishedFiles.NotConfigured(gameId, "Publication");
|
||||
}
|
||||
|
||||
/// <summary>Publication de cet administrateur, encore valable (sinon 404/403).</summary>
|
||||
private PublicationRecord Find(string id, Guid adminId)
|
||||
{
|
||||
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
|
||||
CleanUp(force: false);
|
||||
var record = Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
|
||||
if (record.AdminId != adminId)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, $"Cette publication a été lancée par {record.AdminName}.");
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>Relue sous le verrou : abandonnée entre-temps → 404.</summary>
|
||||
private PublicationRecord Current(string id) =>
|
||||
Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
|
||||
|
||||
private string RecordPath(string id) => Path.Combine(_recordsDirectory, id + ".json");
|
||||
|
||||
private string DonePath(string id) => Path.Combine(_recordsDirectory, id + DoneSuffix + ".json");
|
||||
|
||||
private PublicationRecord? Load(string id) => Read<PublicationRecord>(RecordPath(id));
|
||||
|
||||
private PublicationDone? LoadDone(string id) => Read<PublicationDone>(DonePath(id));
|
||||
|
||||
private static T? Read<T>(string path) where T : class
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<PublicationRecord> Records()
|
||||
{
|
||||
if (!Directory.Exists(_recordsDirectory)) yield break;
|
||||
foreach (var file in Directory.GetFiles(_recordsDirectory, "*.json"))
|
||||
{
|
||||
var id = Path.GetFileNameWithoutExtension(file);
|
||||
if (PublishingLimits.IsValidPublicationId(id) && Load(id) is { } record) yield return record;
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(PublicationRecord record)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_recordsDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(RecordPath(record.Id), JsonSerializer.Serialize(record, ApiJson.Options), keepBackup: false);
|
||||
}
|
||||
|
||||
private void SaveDone(PublicationDone done)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_recordsDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(DonePath(done.Id), JsonSerializer.Serialize(done, ApiJson.Options), keepBackup: false);
|
||||
}
|
||||
|
||||
/// <summary>Dernier envoi : date de modification de l'enregistrement (mise à jour à chaque morceau).</summary>
|
||||
private DateTimeOffset LastActivity(string id)
|
||||
{
|
||||
var path = RecordPath(id);
|
||||
return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero) : DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private void Touch(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetLastWriteTimeUtc(RecordPath(id), _time.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// simple indication d'activité
|
||||
}
|
||||
}
|
||||
|
||||
private string Staging(PublicationRecord record) =>
|
||||
Path.Combine(Path.GetDirectoryName(Locate(record.GameId).Path)!, StagingPrefix + record.Id);
|
||||
|
||||
private string StagingObjects(PublicationRecord record)
|
||||
{
|
||||
var objects = Path.Combine(Staging(record), "objects");
|
||||
if (!Directory.Exists(objects)) CreatePrivateDirectory(objects);
|
||||
return objects;
|
||||
}
|
||||
|
||||
private void CreateStaging(PublicationRecord record)
|
||||
{
|
||||
var staging = Staging(record);
|
||||
if (new DirectoryInfo(Path.GetDirectoryName(staging)!).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le dossier du jeu est un lien symbolique.");
|
||||
CreatePrivateDirectory(staging);
|
||||
}
|
||||
|
||||
private static void CreatePrivateDirectory(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
|
||||
else Directory.CreateDirectory(path, PrivateDirectory);
|
||||
}
|
||||
|
||||
private void Delete(PublicationRecord record)
|
||||
{
|
||||
foreach (var key in _hashes.Keys.Where(k => k.Contains(StagingPrefix + record.Id, StringComparison.Ordinal)).ToList())
|
||||
RemoveHash(key);
|
||||
try
|
||||
{
|
||||
var staging = Staging(record);
|
||||
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
// dépôt laissé : supprimé au prochain nettoyage
|
||||
}
|
||||
PublishedFiles.TryDelete(RecordPath(record.Id));
|
||||
_locks.TryRemove(record.Id, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publications sans envoi depuis 48 h, résultats de plus de 24 h et dépôts orphelins : supprimés
|
||||
/// (au début d'une publication, et au plus toutes les 10 minutes lors des envois).
|
||||
/// </summary>
|
||||
private void CleanUp(bool force)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_locks)
|
||||
{
|
||||
if (!force && now - _lastCleanUp < CleanUpEvery) return;
|
||||
_lastCleanUp = now;
|
||||
}
|
||||
var known = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var record in Records().ToList())
|
||||
{
|
||||
if (now - LastActivity(record.Id) > PublishingLimits.IdleExpiry)
|
||||
Lock(record.Id, () =>
|
||||
{
|
||||
Delete(record);
|
||||
return 0;
|
||||
});
|
||||
else known.Add(record.Id);
|
||||
}
|
||||
if (Directory.Exists(_recordsDirectory))
|
||||
foreach (var done in Directory.GetFiles(_recordsDirectory, "*" + DoneSuffix + ".json"))
|
||||
if (now - new DateTimeOffset(File.GetLastWriteTimeUtc(done), TimeSpan.Zero) > DoneLifetime)
|
||||
PublishedFiles.TryDelete(done);
|
||||
foreach (var manifest in _manifests.Values)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(manifest)!;
|
||||
if (!Directory.Exists(directory)) continue;
|
||||
foreach (var staging in Directory.GetDirectories(directory, StagingPrefix + "*"))
|
||||
{
|
||||
var id = Path.GetFileName(staging)[StagingPrefix.Length..];
|
||||
if (known.Contains(id) || now - Directory.GetLastWriteTimeUtc(staging) <= PublishingLimits.IdleExpiry) continue;
|
||||
try
|
||||
{
|
||||
Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// nouvel essai au prochain nettoyage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private T Lock<T>(string id, Func<T> action)
|
||||
{
|
||||
lock (_locks.GetOrAdd(id, _ => new object())) return action();
|
||||
}
|
||||
|
||||
private static bool ObjectPresent(string path, long size)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return info.Exists && info.LinkTarget is null && info.Length == size;
|
||||
}
|
||||
|
||||
private static long Length(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return info.Exists ? info.Length : 0;
|
||||
}
|
||||
|
||||
private static string HashFile(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers PUBLICS d'un jeu (dossier servi par nginx : manifest.json, archive, objects/), communs aux
|
||||
/// notes de version (phase 4) et à la publication d'une version (phase 5) :
|
||||
/// • même verrou par jeu (une seule écriture du manifest à la fois, service et outils confondus) ;
|
||||
/// • même présentation JSON que ManifestBuilder ;
|
||||
/// • écriture atomique : fichier temporaire caché (refusé par nginx) dans le même dossier, puis renommage ;
|
||||
/// • droits : ceux du manifest existant (sinon 0640 avec le groupe du dossier setgid, ou 0644) ;
|
||||
/// refus si le dossier n'a pas le bit setgid alors que nginx lit les fichiers par leur groupe,
|
||||
/// ou si le manifest est un lien symbolique.
|
||||
/// </summary>
|
||||
internal static class PublishedFiles
|
||||
{
|
||||
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
|
||||
public static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private const string HistoryFolder = "manifest-history";
|
||||
private const int HistoryLength = 20;
|
||||
|
||||
private const UnixFileMode GroupReadable = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
|
||||
private const UnixFileMode AllReadable = GroupReadable | UnixFileMode.OtherRead;
|
||||
|
||||
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
|
||||
public static readonly JsonSerializerOptions ManifestJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
public static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
/// <summary>Verrou d'écriture du manifest d'un jeu (fichier dans les données privées).</summary>
|
||||
public static FileStream LockGame(string dataDirectory, string gameId) =>
|
||||
SecureFiles.AcquireLock(Path.Combine(dataDirectory, $"manifest-{gameId.ToLowerInvariant()}.lock"), LockTimeout);
|
||||
|
||||
public static string Sha256(ReadOnlySpan<byte> bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
public static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
|
||||
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
|
||||
|
||||
/// <summary>Manifest publié (null s'il n'existe pas encore).</summary>
|
||||
public static byte[]? ReadManifest(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) return null;
|
||||
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
public static JsonObject ParseObject(ReadOnlySpan<byte> bytes, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(bytes, documentOptions: ReadOptions) as JsonObject
|
||||
?? throw new InvalidDataException($"{what} : objet JSON attendu.");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or ArgumentException)
|
||||
{
|
||||
throw new InvalidDataException($"{what} illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Propriété d'un objet JSON, nom insensible à la casse (comme pour le launcher).</summary>
|
||||
public static JsonNode? Property(JsonObject node, string name)
|
||||
{
|
||||
foreach (var (key, value) in node)
|
||||
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? StringValue(JsonObject node, string name) =>
|
||||
Property(node, name) is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
|
||||
|
||||
/// <summary>Remplace une propriété (toutes casses confondues) ; null la retire.</summary>
|
||||
public static void SetProperty(JsonObject node, string name, JsonNode? value)
|
||||
{
|
||||
foreach (var existing in node.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
node.Remove(existing);
|
||||
if (value is not null) node[name] = value;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(JsonObject root)
|
||||
{
|
||||
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(root.ToJsonString(ManifestJson));
|
||||
if (bytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération ; 20 dernières).</summary>
|
||||
public static void Backup(string dataDirectory, string gameId, byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.Combine(dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
|
||||
SecureFiles.EnsureDirectory(Path.Combine(dataDirectory, HistoryFolder));
|
||||
SecureFiles.EnsureDirectory(directory);
|
||||
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// La copie est une aide : son échec n'empêche pas l'écriture.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Droits d'un nouveau fichier public du dossier : ceux du manifest existant, sinon lisible par le
|
||||
/// groupe du dossier (setgid) ou par tous. Refus si nginx risque de perdre l'accès.
|
||||
/// </summary>
|
||||
public static UnixFileMode PublicMode(string directory, string? referenceFile)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return AllReadable;
|
||||
var directoryMode = File.GetUnixFileMode(directory);
|
||||
var setgid = directoryMode.HasFlag(UnixFileMode.SetGroup);
|
||||
var mode = referenceFile is not null && File.Exists(referenceFile)
|
||||
? File.GetUnixFileMode(referenceFile)
|
||||
: setgid ? GroupReadable : AllReadable;
|
||||
// Le fichier créé appartient au compte du service, avec le groupe du dossier SEULEMENT si celui-ci
|
||||
// a le bit setgid : sinon nginx (groupe www-data) ne pourrait plus lire un fichier lisible par le groupe.
|
||||
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) && !setgid)
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : les fichiers écrits ne seraient plus lisibles par nginx.");
|
||||
// Fichier existant : ses droits sont repris tels quels (rw des comptes compris) ; nouveau fichier : lecture.
|
||||
return mode & (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sous-dossier public (objects, objects/ab) : droits du dossier du jeu, fixés explicitement (le masque
|
||||
/// du service, UMask=0077, retirerait l'accès de nginx). Refus s'il s'agit d'un lien symbolique.
|
||||
/// <paramref name="keepSetGid"/> : dossier qui en contiendra d'autres (objects) ; son bit setgid doit être
|
||||
/// conservé pour que les sous-dossiers créés ensuite aient le groupe lu par nginx. (Un dossier
|
||||
/// « feuille » garde de toute façon le groupe hérité à sa création, comme les fichiers qui y sont déplacés.)
|
||||
/// </summary>
|
||||
public static void EnsurePublicDirectory(string path, string gameDirectory, bool keepSetGid)
|
||||
{
|
||||
var info = new DirectoryInfo(path);
|
||||
if (info.LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException($"« {Path.GetFileName(path)} » est un lien symbolique dans le dossier du jeu.");
|
||||
if (info.Exists) return;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
return;
|
||||
}
|
||||
var gameMode = File.GetUnixFileMode(gameDirectory);
|
||||
var permissions = gameMode & PermissionBits;
|
||||
var needSetGid = keepSetGid && gameMode.HasFlag(UnixFileMode.SetGroup);
|
||||
// Jamais de bit spécial demandé : systemd l'interdit au service (RestrictSUIDSGID=yes, refus EPERM).
|
||||
// Dans un dossier setgid, le noyau transmet de lui-même le bit setgid et le groupe (www-data).
|
||||
Directory.CreateDirectory(path, permissions);
|
||||
if ((File.GetUnixFileMode(path) & PermissionBits) != permissions) // réduits par le masque du service (UMask=0077)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fixer les droits retire le setgid hérité : sans conséquence pour un dossier « feuille » (son
|
||||
// groupe reste celui hérité) ; « objects » doit le redemander, ce que systemd peut refuser.
|
||||
File.SetUnixFileMode(path, needSetGid ? permissions | UnixFileMode.SetGroup : permissions);
|
||||
}
|
||||
catch (UnauthorizedAccessException) when (needSetGid)
|
||||
{
|
||||
// traité ci-dessous
|
||||
}
|
||||
}
|
||||
// Sans setgid (ou si le compte du service n'appartient pas au groupe du dossier, Linux le retire), les
|
||||
// sous-dossiers créés ensuite n'auraient plus le groupe lu par nginx.
|
||||
if (needSetGid && File.GetUnixFileMode(path) != (permissions | UnixFileMode.SetGroup))
|
||||
{
|
||||
TryDeleteDirectory(path);
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier « objects » n'existe pas encore et le service ne peut pas le créer avec le bit setgid et l'accès de nginx : " +
|
||||
"créez-le sur le VPS (server/deploy/LISEZMOI.md, étape 3 quater).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Droits rwx des trois classes, sans setuid, setgid ni sticky.</summary>
|
||||
private const UnixFileMode PermissionBits =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé : vide, sans conséquence
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace (ou crée) le manifest publié. <paramref name="expectedSha256"/> : empreinte du manifest
|
||||
/// sur lequel l'écriture a été préparée (null : il ne devait pas exister) ; revérifiée juste avant le
|
||||
/// renommage, pour ne jamais écraser un envoi (WinSCP…) fait entre-temps.
|
||||
/// </summary>
|
||||
public static void WriteManifest(string path, byte[] bytes, string? expectedSha256, Func<ApiRejection> conflict)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
if (new FileInfo(path).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
|
||||
var mode = PublicMode(directory, path);
|
||||
var temp = TempPath(path);
|
||||
try
|
||||
{
|
||||
WriteNew(temp, bytes, mode);
|
||||
var current = ReadManifest(path);
|
||||
if (current is null ? expectedSha256 is not null : !string.Equals(Sha256(current), expectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw conflict();
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
TryDelete(temp);
|
||||
if (ex is ApiRejection or ManifestWriteRefusedException) throw;
|
||||
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier temporaire caché du même dossier (nginx refuse les noms commençant par un point).</summary>
|
||||
public static string TempPath(string path) =>
|
||||
Path.Combine(Path.GetDirectoryName(path)!, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
|
||||
public static void WriteNew(string path, ReadOnlySpan<byte> bytes, UnixFileMode mode)
|
||||
{
|
||||
using (var stream = new FileStream(path, new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, mode);
|
||||
}
|
||||
|
||||
public static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé en place : sans conséquence (nom unique, caché)
|
||||
}
|
||||
}
|
||||
|
||||
public static ApiRejection NotConfigured(string gameId, string what) => new(503, ApiErrorCodes.NotConfigured,
|
||||
$"{what} impossible pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
|
||||
}
|
||||
|
||||
/// <summary>Écriture refusée par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
|
||||
public sealed class ManifestWriteRefusedException : IOException
|
||||
{
|
||||
public ManifestWriteRefusedException(string message) : base(message) { }
|
||||
}
|
||||
@@ -24,8 +24,31 @@
|
||||
},
|
||||
"ChallengeLifetimeSeconds": 60,
|
||||
"InvitationLifetimeHours": 24,
|
||||
// Catalogue des jeux (phase 3) : fichier public écrit par le service, servi par nginx.
|
||||
// Vide : publication désactivée. À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « /srv/exovival/www/catalog/catalog.json ».
|
||||
"Catalog": {
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Trousseau (phase 7) : vide = « keyring.json » à côté du catalogue (même dossier, déjà autorisé en écriture).
|
||||
"Keyring": {
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Signature du contenu : true = catalogue et versions refusés s'ils ne sont pas signés (une fois les
|
||||
// launchers en mode « require », voir server/deploy/LISEZMOI.md, étape 3 quinquies).
|
||||
"ContentSigning": {
|
||||
"Require": false
|
||||
},
|
||||
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
|
||||
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
|
||||
"Games": {},
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
|
||||
"PublicationUploadsPerMinute": 1500,
|
||||
"AuthPerMinute": 20,
|
||||
"EnrollmentsPerHour": 10,
|
||||
"MaxActiveInvitations": 20,
|
||||
|
||||
+261
-13
@@ -65,6 +65,231 @@ Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), l
|
||||
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
|
||||
signatures seront refusées.
|
||||
|
||||
## 3 bis. Catalogue des jeux (VPS)
|
||||
|
||||
La page **Jeux** du menu d'administration publie `catalog.json` (nom affiché, accroche, description,
|
||||
liens, thème, images, ordre des jeux), que les launchers des joueurs lisent au démarrage à l'adresse
|
||||
`catalogUrl` de `launcher.json`. Le serveur l'écrit dans **un seul** dossier, servi par nginx, séparé
|
||||
de ses données privées. Exemple ci-dessous avec le site de « Serveur de téléchargement Exovival »
|
||||
(`/srv/exovival/www`) : adapte le chemin à la racine réelle de ton site.
|
||||
|
||||
1. Crée le dossier public (propriétaire `deploy`, groupe `www-data` pour nginx) :
|
||||
|
||||
```
|
||||
sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog /srv/exovival/www/catalog/images
|
||||
```
|
||||
2. Indique le fichier au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
|
||||
avec tes autres réglages) :
|
||||
|
||||
```
|
||||
{ "LauncherApi": { "Catalog": { "PublicFile": "/srv/exovival/www/catalog/catalog.json" } } }
|
||||
```
|
||||
|
||||
L'adresse publique est déduite de `PublicOrigin` (`https://launcher.lanisys.fr/catalog/catalog.json`) ;
|
||||
sinon, ajoute `"PublicUrl"`. Elle doit être **identique** à `catalogUrl` dans `launcher.json`.
|
||||
Le serveur refuse de démarrer si le dossier n'existe pas, ou si le dossier public et
|
||||
`/var/lib/launcher-api` sont l'un dans l'autre.
|
||||
3. Autorise le service à écrire dans ce dossier, et seulement lui (le reste du système reste en
|
||||
lecture seule) :
|
||||
|
||||
```
|
||||
sudo mkdir -p /etc/systemd/system/launcher-api.service.d
|
||||
sudo nano /etc/systemd/system/launcher-api.service.d/catalog.conf
|
||||
```
|
||||
|
||||
Colle le contenu de `server/deploy/launcher-api-catalog.conf` (ligne `ReadWritePaths=` adaptée),
|
||||
puis :
|
||||
|
||||
```
|
||||
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
```
|
||||
|
||||
Ce fichier est conservé lors des mises à jour du serveur.
|
||||
4. nginx : colle le bloc `location = /catalog/catalog.json { … }` de `server/deploy/nginx-catalog.conf`
|
||||
dans le bloc HTTPS du site, avant `location / {`, puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
5. Publie depuis la page **Jeux**, puis vérifie depuis ton PC :
|
||||
|
||||
```
|
||||
PS> curl.exe -sI https://launcher.lanisys.fr/catalog/catalog.json
|
||||
```
|
||||
|
||||
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
|
||||
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
|
||||
|
||||
**Images** (icône, fond) : envoie-les en SFTP (compte `deploy`) dans `www/catalog/images/`, en PNG
|
||||
ou JPEG de 4 Mo au plus, puis saisis leur adresse dans la page Jeux, par ex.
|
||||
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
|
||||
en cache : pour changer une image, envoie-la sous un **nouveau nom**. Les images d'un autre
|
||||
serveur sont refusées.
|
||||
|
||||
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
|
||||
|
||||
## 3 ter. Notes de version modifiables en ligne (VPS)
|
||||
|
||||
La page **Notes de version** du menu d'administration corrige les notes des versions déjà publiées,
|
||||
directement dans le `manifest.json` du jeu (rien d'autre n'y est modifié). Exemple ci-dessous avec le
|
||||
dossier du jeu de « Serveur de téléchargement Exovival » (`/srv/exovival/www/exovivalzombie`) : adapte
|
||||
le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
|
||||
|
||||
> **Compromis de sécurité** : le service pourra écrire dans le dossier du jeu (il remplace le manifest
|
||||
> par renommage dans ce dossier). Un service compromis pourrait donc modifier les fichiers du jeu de ce
|
||||
> dossier. L'autorisation se limite à ce seul dossier ; sans cette étape, rien n'est modifiable en ligne.
|
||||
|
||||
1. Vérifie que le dossier du jeu a le **bit setgid** (le `s` de `drwxr-s---`), pour que le manifest
|
||||
remplacé garde le groupe `www-data` lu par nginx :
|
||||
|
||||
```
|
||||
stat -c '%A %U:%G' /srv/exovival/www/exovivalzombie
|
||||
sudo chmod g+s /srv/exovival/www/exovivalzombie # seulement s'il manque
|
||||
```
|
||||
|
||||
Sans lui, le serveur refuse la modification plutôt que de rendre le manifest illisible.
|
||||
2. Indique le manifest au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
|
||||
avec tes autres réglages ; la clé est le `gameId` de `launcher.json`) :
|
||||
|
||||
```
|
||||
{ "LauncherApi": { "Games": { "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } } } }
|
||||
```
|
||||
3. Autorise le service à écrire dans ce dossier, et seulement lui :
|
||||
|
||||
```
|
||||
sudo nano /etc/systemd/system/launcher-api.service.d/games.conf
|
||||
```
|
||||
|
||||
Colle le contenu de `server/deploy/launcher-api-games.conf` (ligne `ReadWritePaths=` adaptée,
|
||||
une par jeu), puis :
|
||||
|
||||
```
|
||||
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
```
|
||||
|
||||
Ce fichier est conservé lors des mises à jour du serveur.
|
||||
4. Sur ton PC, ajoute `--remote-manifest <adresse du manifest>` à ton script de publication
|
||||
(déjà dans `tools\publish-example.bat`) : sans lui, la publication suivante écraserait les notes
|
||||
corrigées en ligne avec celles du `manifest.json` local.
|
||||
5. Teste : menu d'administration › **Notes de version**, corrige une faute, **Enregistrer**. L'ancien
|
||||
manifest est copié dans `/var/lib/launcher-api/manifest-history/`.
|
||||
|
||||
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
|
||||
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
|
||||
|
||||
## 3 quater. Publier une version depuis le launcher (VPS)
|
||||
|
||||
La page **Publier une version** du menu d'administration remplace l'envoi avec WinSCP : seuls les
|
||||
fichiers absents du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est mis
|
||||
en ligne en dernier. Prérequis : l'étape 3 ter (dossier du jeu déclaré dans `LauncherApi:Games`,
|
||||
complément `games.conf`, bit setgid).
|
||||
|
||||
1. nginx : ajoute la zone de limite des envois dans `/etc/nginx/conf.d/securite.conf`, à côté de
|
||||
`api_par_ip` (un envoi enchaîne plusieurs morceaux de 4 Mo par seconde ; avec la limite générale de
|
||||
l'API, il serait bridé et ferait des pauses) :
|
||||
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
|
||||
```
|
||||
|
||||
Puis colle le bloc `location ^~ /api/v1/publications/ { … }` de `server/deploy/nginx-publications.conf`
|
||||
dans le bloc HTTPS du site, à côté du bloc `/api/`, et recharge :
|
||||
|
||||
```
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Seules ces routes acceptent des envois de 8 Mo ; le reste de l'API reste limité à 64 Ko. Vérifie
|
||||
aussi que le site contient toujours la règle `location ~ /\. { deny all; }` : les fichiers en cours
|
||||
d'envoi sont rangés dans un dossier caché (`.publish-…`) du dossier du jeu.
|
||||
2. Crée le dossier `objects` du jeu **et ses 256 sous-dossiers** (`00` à `ff`), avec le groupe et le bit
|
||||
setgid du dossier du jeu (sans risque s'ils existent déjà : leurs droits sont simplement remis en état) :
|
||||
|
||||
```
|
||||
JEU=/srv/exovival/www/exovivalzombie
|
||||
sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects"
|
||||
for i in $(seq 0 255); do sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects/$(printf %02x $i)"; done
|
||||
```
|
||||
|
||||
Pourquoi : systemd interdit au service de demander le bit setgid (`RestrictSUIDSGID=yes`, gardé). Un
|
||||
sous-dossier qui manquerait serait créé par le service en `drwxr-x---` (groupe `www-data`, lisible par
|
||||
nginx) mais **sans setgid** : un fichier déposé ensuite dedans **avec WinSCP** prendrait le groupe
|
||||
`deploy` et ne serait plus lisible par nginx. Relance la boucle si `stat` montre un `drwxr-x---`.
|
||||
3. Espace disque : l'envoi est stocké sur le même disque que le jeu jusqu'à la mise en ligne. Prévois la
|
||||
taille de l'archive et des nouveaux fichiers (le serveur refuse l'envoi s'il manque de place).
|
||||
4. Teste : lance le .bat de publication (ManifestBuilder) sur ton PC, puis menu d'administration ›
|
||||
**Publier une version** › « Choisir le manifest.json… » (dossier `--output`) › **Publier**.
|
||||
|
||||
> **Déjà fait avant la correction des pauses ?** Ajoute la ligne `limit_req_zone … publication_par_ip …`
|
||||
> ci-dessus, remplace dans le bloc `/api/v1/publications/` du site `limit_req zone=api_par_ip burst=20 nodelay;`
|
||||
> par `limit_req zone=publication_par_ip burst=50 nodelay;`, recharge nginx, puis mets à jour le serveur
|
||||
> (étape 8) : il a désormais sa propre limite pour les envois (`LauncherApi:Limits:PublicationUploadsPerMinute`,
|
||||
> 1500 par minute par défaut, rien à ajouter dans `appsettings.Production.json`).
|
||||
|
||||
Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cliques de nouveau sur
|
||||
« Publier » avec le même dossier ; sans reprise, elle est supprimée au bout de 48 h. WinSCP reste
|
||||
possible, mais n'envoie pas une version avec les deux en même temps : le serveur refuse alors de
|
||||
mettre la sienne en ligne plutôt que d'écraser l'autre.
|
||||
|
||||
## 3 quinquies. Signer ce que reçoivent les joueurs (PC et VPS)
|
||||
|
||||
Sans signature, quelqu'un qui prendrait le contrôle du VPS pourrait distribuer un faux jeu. Avec la
|
||||
signature, chaque version mise en ligne (fichiers, archive, exécutable) et le catalogue portent la
|
||||
signature de l'administrateur qui les a publiés. Les launchers ne font confiance qu'aux clés du
|
||||
**trousseau**, lui-même signé **hors ligne** par la clé racine du launcher (`launcher-signing.key`, celle des
|
||||
mises à jour du launcher). Le VPS ne détient aucune de ces clés privées.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue : le trousseau est écrit dans le même dossier) et 3 quater.
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le trousseau
|
||||
est écrit à côté du catalogue (`/srv/exovival/www/catalog/keyring.json`).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/keyring.json { … }` de `server/deploy/nginx-catalog.conf` à côté
|
||||
de celui du catalogue, puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. **Trousseau** (PC) :
|
||||
1. menu d'administration › **Signature** › « Exporter la demande… » : enregistre `keyring-request-1.json`
|
||||
(les appareils actifs des administrateurs autorisés à publier) ;
|
||||
2. glisse ce fichier sur `tools\sign-keyring.bat` : la demande est comparée au trousseau signé précédent
|
||||
(`keyring-N.json` du même dossier) et chaque **NOUVELLE CLÉ** (ou autorisation élargie) est signalée.
|
||||
**Vérifie ces empreintes hors du serveur** : la demande vient du VPS, un serveur compromis pourrait y glisser
|
||||
sa propre clé. Fais lire à chaque personne l'empreinte de son appareil (page **Mes appareils**, calculée sur
|
||||
son PC) de vive voix ou par téléphone, et compare. Puis la phrase de passe de la clé racine → `keyring-N.json`
|
||||
(garde ce fichier dans le même dossier : il servira de référence la fois suivante) ;
|
||||
3. page **Signature** › « Publier le trousseau signé… » : le launcher vérifie la signature racine avant de l'envoyer.
|
||||
|
||||
Les noms des appareils (« Nicolas — PC fixe ») sont **publics** : le trousseau est lisible par tous.
|
||||
4. **Contenu déjà en ligne** :
|
||||
* version du jeu : page **Publier une version** › « Choisir le manifest.json… » du dossier de **la version
|
||||
en ligne** › « Signer la version en ligne » (le serveur vérifie que les fichiers sont identiques) ;
|
||||
* catalogue : page **Jeux** › « Publier » (il est signé à chaque publication).
|
||||
|
||||
La page **Signature** doit alors tout indiquer « valide pour les launchers ».
|
||||
5. **Launcher** : dans `src\GameLauncher\launcher.json`, décommente `"keyringUrl"` (adresse affichée par la page
|
||||
Signature) et `"contentSigning": "warn"`, puis publie le launcher (mise à jour signée habituelle). En « warn »,
|
||||
les problèmes sont seulement inscrits au journal du launcher (`%AppData%\<éditeur>\GameLauncher\logs\launcher.log`) : vérifie qu'il
|
||||
indique « Manifest de EZR … signé par … » et « Trousseau n° 1 vérifié ».
|
||||
6. **Protection complète** : passe à `"contentSigning": "require"` et publie le launcher : un contenu non signé,
|
||||
mal signé ou plus ancien que le dernier vu est alors refusé (le jeu installé reste jouable). Optionnel, sur le
|
||||
VPS : `"ContentSigning": { "Require": true }` dans la section `LauncherApi` d'`appsettings.Production.json`
|
||||
(puis `sudo systemctl restart launcher-api`) pour que le serveur refuse toute publication non signée.
|
||||
|
||||
Ensuite, les nouvelles versions et le catalogue sont signés automatiquement à leur publication. **À refaire
|
||||
(étape 3)** : à chaque nouvel administrateur ou nouvel appareil qui doit publier (avant sa première publication),
|
||||
et à chaque révocation (la page Signature signale le contenu signé par une clé qui va disparaître : re-signe-le
|
||||
d'abord). Une version envoyée avec WinSCP doit être signée ensuite (étape 4).
|
||||
|
||||
**Clé compromise** (appareil volé…) : révoque l'appareil, signe et publie un nouveau trousseau (étape 3), puis
|
||||
mets son numéro dans `"keyringMinSequence"` de `launcher.json` et publie le launcher. Sans cela, un launcher qui
|
||||
n'a encore jamais vu le nouveau trousseau (installation neuve, cache effacé) accepterait encore l'ancien,
|
||||
toujours signé par la clé racine, si un serveur compromis le lui présentait.
|
||||
|
||||
Limites :
|
||||
* les images du catalogue ne sont pas signées (un serveur compromis pourrait les remplacer, pas les liens ni les
|
||||
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.
|
||||
|
||||
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
|
||||
à la fois les mises à jour du launcher et le trousseau.
|
||||
|
||||
## 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 :
|
||||
@@ -76,7 +301,13 @@ Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères
|
||||
|
||||
Note l'**empreinte** affichée (`AB12 CD34 EF56 7890 1A2B`).
|
||||
|
||||
> Phase 2 : le launcher saura créer la clé lui-même (Windows Hello / TPM) et importer ce fichier.
|
||||
> Depuis la phase 2, le launcher sait aussi créer la clé : menu d'administration (Ctrl+Maj+Alt+A,
|
||||
> ou 5 clics rapides sur le logo) › « J'ai une invitation », **sans code** → clé seule, même dossier,
|
||||
> même format. Une clé créée par `admin-key.bat` est reconnue par le launcher, et inversement.
|
||||
> Le launcher peut aussi créer la clé **dans la puce de sécurité (TPM) du PC** (non copiable,
|
||||
> code PIN) : il écrit alors `admin.tpmkey` (référence, sans secret) et `admin.pub` ; c'est ce
|
||||
> `.pub` qu'on déclare ci-dessous. Une clé TPM ne fonctionne que dans le launcher, sur ce PC
|
||||
> (`admin-key.bat` n'utilise que des clés fichier) : garde une clé fichier de secours (§ 9, `add-key`).
|
||||
|
||||
## 5. Te déclarer propriétaire (PC puis VPS, une seule fois)
|
||||
|
||||
@@ -102,28 +333,35 @@ Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste d
|
||||
|
||||
## 7. Ajouter un administrateur
|
||||
|
||||
En attendant le menu d'administration du launcher (phases 2 et 6), avec `tools\admin-key.bat` :
|
||||
Tout se fait dans le launcher (menu d'administration), sans ligne de commande :
|
||||
|
||||
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
|
||||
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
|
||||
2. **Paul** : `admin-key.bat` (création de sa clé), puis
|
||||
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>` ; il te communique
|
||||
1. **Toi** : page **Administrateurs** › « Inviter un administrateur » (nom, rôle : propriétaire,
|
||||
administrateur ou éditeur, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable
|
||||
24 h, usage unique, affiché une seule fois).
|
||||
2. **Paul** : launcher › menu d'administration › « J'ai une invitation », code collé ; il te communique
|
||||
**par téléphone ou message** l'empreinte affichée.
|
||||
3. **Toi** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
|
||||
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
|
||||
3. **Toi** : page **Administrateurs** › « Actualiser » : le compte de Paul apparaît en premier, en
|
||||
attente ; saisis l'empreinte dictée sous son appareil, puis « Approuver ».
|
||||
|
||||
En ligne de commande, `tools\admin-key.bat` fait la même chose : `invite --api https://launcher.lanisys.fr/api/
|
||||
--name "Paul" --role editor`, puis `admins --api …` et `approve --api … --admin <id> --key-id <clé>
|
||||
--fingerprint "<empreinte>"`.
|
||||
|
||||
Le serveur n'affiche pas l'empreinte des clés en attente : l'approbation n'est possible qu'avec
|
||||
celle que Paul te donne. Si elle ne correspond pas, **n'insiste pas** : quelqu'un d'autre a pu
|
||||
utiliser le code. Révoque (`revoke-admin`) et recommence.
|
||||
utiliser le code. Révoque ce compte (page Administrateurs, ou `revoke-admin`) et recommence.
|
||||
|
||||
Nouveau PC pour un administrateur existant : **lui-même** lance `invite --api … --for <son id>`
|
||||
depuis un PC déjà enregistré, `enroll` sur le nouveau PC, puis `approve` depuis l'ancien. Personne
|
||||
d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
|
||||
Nouveau PC pour un administrateur existant : **lui-même**, depuis un PC déjà enregistré, page **Mes
|
||||
appareils** › « Ajouter un appareil » ; sur le nouveau PC, « J'ai une invitation » ; puis, de retour sur
|
||||
l'ancien, approbation de l'appareil en attente avec l'empreinte affichée par le nouveau (en ligne de
|
||||
commande : `invite --for <son id>`, `enroll`, `approve`). Personne d'autre ne peut approuver une clé à
|
||||
son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
|
||||
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
conservés ; les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
conservés, ainsi que les compléments systemd (`launcher-api.service.d/catalog.conf` et `games.conf`) ;
|
||||
les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
pas, le script remet la précédente en place.
|
||||
|
||||
## 9. Récupération (VPS)
|
||||
@@ -132,6 +370,7 @@ pas, le script remet la précédente en place.
|
||||
|---|---|
|
||||
| Voir les administrateurs, clés et empreintes | `sudo -u deploy /opt/launcher-api/GameLauncher.Server list` |
|
||||
| PC perdu ou volé | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-key --key <identifiant>` |
|
||||
| Clé TPM supprimée dans le launcher (« Supprimer cette clé de la puce ») | Même commande `revoke-key`, avec l'identifiant affiché par le launcher |
|
||||
| Retirer un administrateur | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-admin --id <identifiant>` |
|
||||
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
|
||||
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
|
||||
@@ -171,6 +410,15 @@ recréer les administrateurs.
|
||||
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
|
||||
├─ server-id identifiant aléatoire du serveur
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
├─ 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)
|
||||
└─ 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)
|
||||
└─ 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é)
|
||||
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# =============================================================================
|
||||
# Launcher API : autorisation d'écrire le catalogue des jeux (LISEZMOI.md, étape 3 bis).
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf
|
||||
#
|
||||
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
|
||||
# écrire dans UN SEUL dossier, celui du fichier public du catalogue (LauncherApi:Catalog:PublicFile).
|
||||
# Fichier séparé : il est conservé lors des mises à jour du serveur (installer-api.sh remplace
|
||||
# launcher-api.service, pas ce dossier).
|
||||
#
|
||||
# Adapter le chemin au dossier réellement servi par nginx (exemple de la documentation ci-dessous),
|
||||
# puis : sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
# =============================================================================
|
||||
[Service]
|
||||
ReadWritePaths=/srv/exovival/www/catalog
|
||||
@@ -0,0 +1,17 @@
|
||||
# =============================================================================
|
||||
# Launcher API : autorisation de modifier le manifest.json des jeux (LISEZMOI.md, étape 3 ter).
|
||||
# /etc/systemd/system/launcher-api.service.d/games.conf
|
||||
#
|
||||
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
|
||||
# écrire dans le dossier de chaque jeu configuré (LauncherApi:Games:<id>:ManifestFile), et
|
||||
# seulement là. Le remplacement du manifest se fait par renommage dans ce dossier : c'est le dossier
|
||||
# entier qui doit être autorisé (pas le seul fichier).
|
||||
# Compromis : un service compromis pourrait alors modifier les fichiers du jeu de ce dossier.
|
||||
# Sans ce complément (et sans LauncherApi:Games), les notes ne sont pas modifiables en ligne.
|
||||
#
|
||||
# Fichier séparé : conservé lors des mises à jour du serveur. Adapter le chemin au dossier du jeu
|
||||
# réellement servi par nginx (exemple de la documentation ci-dessous ; une ligne par jeu), puis :
|
||||
# sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
# =============================================================================
|
||||
[Service]
|
||||
ReadWritePaths=/srv/exovival/www/exovivalzombie
|
||||
@@ -6,6 +6,10 @@
|
||||
# terminal), enfermé par systemd : système en lecture seule, pas d'accès à /home, aucun privilège.
|
||||
# Ses binaires (/opt/launcher-api) appartiennent à root : il ne peut pas se modifier lui-même.
|
||||
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
|
||||
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
|
||||
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
|
||||
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
|
||||
#
|
||||
# Vérifier le durcissement : systemd-analyze security launcher-api
|
||||
# Journal : journalctl -u launcher-api -f
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Les images du catalogue (catalog/images/*.png|jpg) sont servies par « location / » ; les
|
||||
# fichiers temporaires de la Launcher API (.catalog.json.….tmp) par la règle des fichiers cachés
|
||||
# (refusés).
|
||||
# =============================================================================
|
||||
|
||||
# Catalogue : toujours relu sur le serveur (les launchers le lisent au démarrage).
|
||||
location = /catalog/catalog.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;
|
||||
}
|
||||
|
||||
# Trousseau (phase 7) : clés autorisées à signer versions et catalogue, signé par la clé racine du launcher.
|
||||
location = /catalog/keyring.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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# Publication d'une version depuis le launcher — deux ajouts (voir LISEZMOI.md, étape 3 quater) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf, à côté de la zone « api_par_ip » :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
|
||||
#
|
||||
# Un envoi enchaîne plusieurs morceaux de 4 Mo par seconde : la zone « api_par_ip » (5 par seconde)
|
||||
# briderait l'envoi vers 20 Mo/s et provoquerait des pauses. 25 par seconde ≈ 100 Mo/s ; ces routes
|
||||
# exigent une session d'administrateur et le droit de publier.
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival, à côté du bloc
|
||||
# « location ^~ /api/ { … } » (nginx-api.conf) : (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Seules ces routes acceptent de gros envois (morceaux de 4 Mo, 8 Mo au plus) ; toutes les autres
|
||||
# routes de l'API restent limitées à 64 Ko. Le préfixe le plus long l'emporte sur « /api/ ».
|
||||
# Les fichiers en cours d'envoi sont rangés dans un dossier caché (.publish-…) du dossier du jeu :
|
||||
# la règle « location ~ /\. { deny all; } » du site doit être présente (elle l'est dans le guide).
|
||||
# =============================================================================
|
||||
|
||||
location ^~ /api/v1/publications/ {
|
||||
limit_req zone=publication_par_ip burst=50 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 9m;
|
||||
|
||||
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_send_timeout 180s;
|
||||
proxy_read_timeout 180s;
|
||||
}
|
||||
@@ -36,8 +36,26 @@ public static class AdminKeyFile
|
||||
using (var writer = new StreamWriter(stream))
|
||||
writer.Write(pem);
|
||||
|
||||
// Clé publique : jamais écrasée non plus ; en cas d'échec, la clé privée qui vient d'être créée est retirée.
|
||||
var publicPath = Path.ChangeExtension(path, PublicKeyExtension);
|
||||
File.WriteAllText(publicPath, AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(publicPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// clé chiffrée laissée en place : sans danger
|
||||
}
|
||||
throw;
|
||||
}
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
@@ -32,12 +37,21 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
}
|
||||
|
||||
public string ServerId { get; }
|
||||
|
||||
/// <summary>Écart entre l'horloge du serveur et celle du PC (heure serveur = heure du PC + écart), mesuré à la connexion.</summary>
|
||||
public TimeSpan ClockOffset => _clockOffset;
|
||||
public string SessionId { get; }
|
||||
public AdminProfile Profile { get; private set; }
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
public DateTimeOffset IdleExpiresAt { get; private set; }
|
||||
public bool IsOpen => _token is not null;
|
||||
|
||||
/// <summary>Clé publique de CET appareil (SPKI), lue sur le PC : son empreinte ne dépend pas du serveur.</summary>
|
||||
public byte[] PublicKey => _signer.PublicKey;
|
||||
|
||||
/// <summary>Empreinte de la clé de cet appareil, calculée sur le PC (à dicter pour vérifier le trousseau).</summary>
|
||||
public string LocalFingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
|
||||
|
||||
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
// ------------------------------------------------------------------ Session
|
||||
@@ -76,6 +90,9 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
|
||||
public Task<MyAccount> GetMyAccountAsync(CancellationToken ct = default) => GetAsync<MyAccount>(ApiRoutes.MyAccount, ct);
|
||||
|
||||
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
|
||||
|
||||
@@ -104,6 +121,106 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
return GetAsync<AuditPage>(route, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
|
||||
public Task<CatalogState> GetCatalogAsync(CancellationToken ct = default) =>
|
||||
GetAsync<CatalogState>(ApiRoutes.Catalog, ct);
|
||||
|
||||
/// <summary>Publie le catalogue (409 « revision_conflict » si quelqu'un a publié depuis <paramref name="baseRevision"/>).</summary>
|
||||
public Task<CatalogState> PublishCatalogAsync(CatalogDocument catalog, long baseRevision, CancellationToken ct = default) =>
|
||||
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
|
||||
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
|
||||
|
||||
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
|
||||
public Task<ChangelogState> GetChangelogAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ChangelogState>(ApiRoutes.GameChangelog(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (requête signée). <paramref name="baseSha256"/> : empreinte
|
||||
/// du manifest sur lequel la modification a été préparée (409 revision_conflict s'il a changé).
|
||||
/// </summary>
|
||||
public Task<ChangelogState> SaveChangelogEntryAsync(string gameId, ChangelogEntry entry, string baseSha256, CancellationToken ct = default)
|
||||
{
|
||||
if (!ChangelogLimits.IsEditableVersion(entry.Version))
|
||||
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
|
||||
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
|
||||
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
|
||||
/// <summary>Délai d'une requête d'envoi de fichier (morceau de 4 Mo sur une connexion lente).</summary>
|
||||
public static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(3);
|
||||
|
||||
public Task<PublicationState> StartPublicationAsync(string gameId, PublicationStartRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<PublicationState>(HttpMethod.Post, ApiRoutes.PublicationStart(CheckGameId(gameId)), request, ct);
|
||||
|
||||
public Task<PublicationState> GetPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
GetAsync<PublicationState>(ApiRoutes.Publication(CheckPublicationId(id)), ct);
|
||||
|
||||
public Task CancelPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Publication(CheckPublicationId(id)), null, ct);
|
||||
|
||||
public Task<ChunkResult> UploadManifestAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationManifest(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadArchiveAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationArchive(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectAsync(string id, string sha256, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put,
|
||||
ApiRoutes.PublicationObject(CheckPublicationId(id), PublishingLimits.IsValidSha256(sha256) ? sha256.ToLowerInvariant()
|
||||
: throw new ArgumentException("Empreinte invalide.", nameof(sha256)), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectBatchAsync(string id, byte[] batch, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Post, ApiRoutes.PublicationObjects(CheckPublicationId(id)), batch,
|
||||
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
|
||||
|
||||
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
|
||||
/// <param name="signature">Signature du contenu du manifest (phase 7), ou null (version non signée).</param>
|
||||
public Task<PublicationResult> CommitPublicationAsync(string id, ContentSignature? signature = null, CancellationToken ct = default) =>
|
||||
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)),
|
||||
signature is null ? null : JsonSerializer.SerializeToUtf8Bytes(new PublicationCommitRequest { Signature = signature }, ApiJson.Options),
|
||||
signed: true, ct, timeout: UploadTimeout);
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
|
||||
/// <summary>
|
||||
/// Signe un contenu servi aux joueurs avec la clé de cette session (date : heure du serveur, à la seconde).
|
||||
/// La clé privée ne quitte jamais le PC (ni la puce TPM) : seul le condensé calculé ici est signé.
|
||||
/// </summary>
|
||||
public async Task<ContentSignature> SignContentAsync(string kind, string id, string digest, CancellationToken ct = default)
|
||||
{
|
||||
var signedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset);
|
||||
var message = ContentSigning.Message(kind, id, signedAt, _signer.KeyId, digest);
|
||||
var signature = await _signer.SignAsync(message, ct).ConfigureAwait(false);
|
||||
return new ContentSignature { KeyId = _signer.KeyId, SignedAt = signedAt, Value = Convert.ToBase64String(signature) };
|
||||
}
|
||||
|
||||
public Task<SigningState> GetSigningStateAsync(CancellationToken ct = default) => GetAsync<SigningState>(ApiRoutes.Signing, ct);
|
||||
|
||||
/// <summary>Publie un trousseau signé par la clé racine (le serveur contrôle la structure et le numéro).</summary>
|
||||
public Task<KeyringDocument> PublishKeyringAsync(KeyringDocument keyring, CancellationToken ct = default) =>
|
||||
SendSignedAsync<KeyringDocument>(HttpMethod.Put, ApiRoutes.Keyring, keyring, ct);
|
||||
|
||||
public Task<ManifestSignatureState> GetManifestSignatureAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ManifestSignatureState>(ApiRoutes.ManifestSignature(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>Signe la version en ligne (le serveur vérifie qu'elle a exactement le contenu signé).</summary>
|
||||
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
|
||||
|
||||
private static string CheckPublicationId(string id) =>
|
||||
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
|
||||
|
||||
private static string CheckGameId(string gameId) =>
|
||||
CatalogRules.IsValidGameId(gameId) ? gameId : throw new ArgumentException($"Identifiant de jeu invalide : « {gameId} ».", nameof(gameId));
|
||||
|
||||
// ------------------------------------------------------------------ Requêtes génériques (modules)
|
||||
|
||||
public Task<T> GetAsync<T>(string route, CancellationToken ct = default) =>
|
||||
@@ -113,7 +230,13 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct)
|
||||
/// <summary>Requête qui modifie avec un corps binaire (morceau de fichier), signée par la clé.</summary>
|
||||
public Task<T> SendSignedBytesAsync<T>(HttpMethod method, string route, byte[] body, string contentType, TimeSpan timeout,
|
||||
CancellationToken ct = default) =>
|
||||
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)
|
||||
{
|
||||
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
|
||||
|
||||
@@ -139,7 +262,7 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}, ct, contentType, timeout).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
|
||||
@@ -17,5 +17,8 @@ public sealed class ApiException : Exception
|
||||
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
|
||||
public string Code { get; }
|
||||
|
||||
/// <summary>Délai annoncé par le serveur avant un nouvel essai (en-tête Retry-After), s'il y en a un.</summary>
|
||||
public TimeSpan? RetryAfter { get; init; }
|
||||
|
||||
public bool IsUnauthorized => Status == 401;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace GameLauncher.ApiClient;
|
||||
/// <summary>
|
||||
/// Clé privée d'un administrateur, vue par le client : elle signe, elle ne sort jamais.
|
||||
/// Implémentations : <see cref="EcdsaAdminSigner"/> (clé en mémoire, déverrouillée depuis un fichier
|
||||
/// chiffré) ; Windows Hello / TPM dans le launcher (phase 2).
|
||||
/// chiffré) ; clé de la puce TPM dans le launcher (Platform\TpmKeyStore, phase 2b).
|
||||
/// </summary>
|
||||
public interface IAdminSigner
|
||||
{
|
||||
|
||||
@@ -41,7 +41,8 @@ public sealed class LauncherApiClient : IDisposable
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}, disposeHandler: handler is null)
|
||||
{
|
||||
Timeout = RequestTimeout
|
||||
// Délai propre à chaque requête (SendAsync) : 30 s par défaut, plus pour l'envoi de fichiers.
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
@@ -129,33 +130,50 @@ public sealed class LauncherApiClient : IDisposable
|
||||
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
|
||||
|
||||
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
|
||||
Action<HttpRequestMessage>? prepare, CancellationToken ct)
|
||||
Action<HttpRequestMessage>? prepare, CancellationToken ct,
|
||||
string contentType = "application/json", TimeSpan? timeout = null)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, Resolve(route));
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(body);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
request.Content.Headers.ContentType = contentType == "application/json"
|
||||
? new MediaTypeHeaderValue(contentType) { CharSet = "utf-8" }
|
||||
: new MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
prepare?.Invoke(request);
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(timeout ?? RequestTimeout);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var bytes = await ReadBoundedAsync(response, ct).ConfigureAwait(false);
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Connexion au serveur d'administration interrompue ({ex.Message}).", ex);
|
||||
}
|
||||
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
|
||||
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
|
||||
try
|
||||
@@ -191,25 +209,36 @@ public sealed class LauncherApiClient : IDisposable
|
||||
private static ApiException ToException(HttpResponseMessage response, byte[] body)
|
||||
{
|
||||
var status = (int)response.StatusCode;
|
||||
var retryAfter = RetryAfterOf(response);
|
||||
try
|
||||
{
|
||||
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
|
||||
return new ApiException(status, error.Code, error.Message);
|
||||
return new ApiException(status, error.Code, error.Message) { RetryAfter = retryAfter };
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// réponse d'erreur non JSON (nginx, proxy…)
|
||||
}
|
||||
return status switch
|
||||
var (code, message) = status switch
|
||||
{
|
||||
401 => new ApiException(status, ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
|
||||
403 => new ApiException(status, ApiErrorCodes.Forbidden, "Action non autorisée."),
|
||||
404 => new ApiException(status, ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
|
||||
413 => new ApiException(status, ApiErrorCodes.InvalidRequest, "Requête trop volumineuse."),
|
||||
429 => new ApiException(status, ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
|
||||
>= 500 => new ApiException(status, ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
|
||||
_ => new ApiException(status, ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
|
||||
401 => (ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
|
||||
403 => (ApiErrorCodes.Forbidden, "Action non autorisée."),
|
||||
404 => (ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
|
||||
413 => (ApiErrorCodes.InvalidRequest,
|
||||
"Requête trop volumineuse pour le serveur (envoi de fichiers : vérifiez nginx-publications.conf sur le VPS)."),
|
||||
429 => (ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
|
||||
>= 500 => (ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
|
||||
_ => (ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
|
||||
};
|
||||
return new ApiException(status, code, message) { RetryAfter = retryAfter };
|
||||
}
|
||||
|
||||
/// <summary>En-tête Retry-After (en secondes ou en date) ; absent ou incohérent : null.</summary>
|
||||
private static TimeSpan? RetryAfterOf(HttpResponseMessage response)
|
||||
{
|
||||
var header = response.Headers.RetryAfter;
|
||||
var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null);
|
||||
return delay is { } value && value >= TimeSpan.Zero ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
|
||||
|
||||
@@ -28,6 +28,9 @@ public static class ApiRoutes
|
||||
public const string Admins = "v1/admins";
|
||||
public const string Invitations = "v1/admins/invitations";
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
|
||||
public const string MyAccount = "v1/admins/me";
|
||||
|
||||
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
|
||||
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
|
||||
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
|
||||
@@ -36,4 +39,47 @@ public static class ApiRoutes
|
||||
|
||||
// ------------------------------------------------------------------ Audit
|
||||
public const string Audit = "v1/audit";
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
public const string Catalog = "v1/catalog";
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
|
||||
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
|
||||
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
|
||||
|
||||
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
|
||||
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
|
||||
|
||||
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
/// <summary>État des signatures (trousseau publié, manifests, catalogue) : propriétaires.</summary>
|
||||
public const string Signing = "v1/signing";
|
||||
|
||||
/// <summary>Publication d'un trousseau signé par la clé racine : propriétaires.</summary>
|
||||
public const string Keyring = "v1/keyring";
|
||||
|
||||
public const string ManifestSignaturePattern = "v1/games/{gameId}/signature";
|
||||
|
||||
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
public const string PublicationManifestPattern = "v1/publications/{id}/manifest";
|
||||
public const string PublicationArchivePattern = "v1/publications/{id}/archive";
|
||||
public const string PublicationObjectsPattern = "v1/publications/{id}/objects";
|
||||
public const string PublicationObjectPattern = "v1/publications/{id}/objects/{sha256}";
|
||||
public const string PublicationCommitPattern = "v1/publications/{id}/commit";
|
||||
|
||||
/// <summary>Préfixe des routes d'envoi (limite de taille propre, voir nginx-publications.conf).</summary>
|
||||
public const string PublicationsPrefix = "v1/publications/";
|
||||
|
||||
public static string PublicationStart(string gameId) => $"v1/games/{gameId}/publications";
|
||||
public static string Publication(string id) => $"v1/publications/{id}";
|
||||
public static string PublicationManifest(string id, long offset) => $"v1/publications/{id}/manifest?offset={offset}";
|
||||
public static string PublicationArchive(string id, long offset) => $"v1/publications/{id}/archive?offset={offset}";
|
||||
public static string PublicationObjects(string id) => $"v1/publications/{id}/objects";
|
||||
public static string PublicationObject(string id, string sha256, long offset) => $"v1/publications/{id}/objects/{sha256}?offset={offset}";
|
||||
public static string PublicationCommit(string id) => $"v1/publications/{id}/commit";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Informations MODIFIABLES des jeux (catalog.json, publié par le serveur et lu par les launchers).
|
||||
/// Les jeux restent déclarés dans launcher.json (intégré à l'exe) : une entrée pour un jeu non déclaré
|
||||
/// est ignorée par le launcher. Toute valeur absente (null) = valeur de launcher.json.
|
||||
/// Ce fichier est PUBLIC : il ne contient que de l'affichage (aucun nom d'administrateur).
|
||||
/// </summary>
|
||||
public sealed record CatalogDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-catalog";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Numéro de version du contenu, augmenté à chaque publication (contrôle des modifications concurrentes).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
public CatalogLauncher? Launcher { get; init; }
|
||||
|
||||
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
|
||||
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Signature de l'administrateur qui a publié (phase 7) : couvre tout sauf la révision et la date
|
||||
/// (<see cref="ContentSigning.CatalogDigest"/>). Ignorée par les launchers antérieurs.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
|
||||
public sealed record CatalogLauncher
|
||||
{
|
||||
public string? LauncherName { get; init; }
|
||||
|
||||
/// <summary>Jeu affiché au premier lancement.</summary>
|
||||
public string? DefaultGame { get; init; }
|
||||
|
||||
/// <summary>Ordre des jeux dans la liste (les jeux non cités suivent, dans l'ordre de launcher.json).</summary>
|
||||
public List<string>? GameOrder { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
|
||||
public sealed record CatalogGame
|
||||
{
|
||||
/// <summary>Nom AFFICHÉ (le nom de launcher.json reste celui du raccourci, du registre et des dossiers).</summary>
|
||||
public string? GameName { get; init; }
|
||||
|
||||
public string? Tagline { get; init; }
|
||||
|
||||
/// <summary>Texte de présentation (plusieurs lignes, texte brut).</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? WebsiteUrl { get; init; }
|
||||
|
||||
public List<CatalogLink>? Links { get; init; }
|
||||
|
||||
/// <summary>Thème INTÉGRÉ au launcher (« default », « exovival »…), jamais un fichier.</summary>
|
||||
public string? Theme { get; init; }
|
||||
|
||||
/// <summary>Image https (PNG ou JPEG) du même serveur que le catalogue.</summary>
|
||||
public string? Icon { get; init; }
|
||||
|
||||
/// <summary>Image de fond https (PNG ou JPEG) du même serveur que le catalogue.</summary>
|
||||
public string? Background { get; init; }
|
||||
|
||||
/// <summary>0 (désactivé) ou 5 à 1440 minutes.</summary>
|
||||
public int? UpdateCheckIntervalMinutes { get; init; }
|
||||
|
||||
/// <summary>Masqué de la liste (un jeu installé sur le PC reste toujours affiché).</summary>
|
||||
public bool? Hidden { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Lien affiché sous la présentation du jeu (Discord, YouTube…).</summary>
|
||||
public sealed record CatalogLink
|
||||
{
|
||||
/// <summary>Voir <see cref="CatalogLinkKinds"/> (choisit l'icône intégrée).</summary>
|
||||
public string Kind { get; init; } = CatalogLinkKinds.Website;
|
||||
|
||||
public string? Label { get; init; }
|
||||
|
||||
public string Url { get; init; } = "";
|
||||
}
|
||||
|
||||
public static class CatalogLinkKinds
|
||||
{
|
||||
public const string Website = "website";
|
||||
public const string Discord = "discord";
|
||||
public const string YouTube = "youtube";
|
||||
public const string X = "x";
|
||||
public const string Twitch = "twitch";
|
||||
public const string Steam = "steam";
|
||||
public const string Other = "other";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Website, Discord, YouTube, X, Twitch, Steam, Other };
|
||||
|
||||
/// <summary>Libellé par défaut d'un lien.</summary>
|
||||
public static string DefaultLabel(string kind) => kind switch
|
||||
{
|
||||
Website => "Site web",
|
||||
Discord => "Discord",
|
||||
YouTube => "YouTube",
|
||||
X => "X (Twitter)",
|
||||
Twitch => "Twitch",
|
||||
Steam => "Steam",
|
||||
_ => "Lien"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Publication du catalogue depuis le menu d'administration (requête signée, permission games.edit).</summary>
|
||||
public sealed record CatalogUpdateRequest
|
||||
{
|
||||
/// <summary>Révision sur laquelle les modifications ont été faites (refus si quelqu'un a publié entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
public CatalogDocument Catalog { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Catalogue tel que connu du serveur, pour le menu d'administration.</summary>
|
||||
public sealed record CatalogState
|
||||
{
|
||||
public CatalogDocument Catalog { get; init; } = new();
|
||||
|
||||
/// <summary>Faux : la publication n'est pas configurée sur le serveur (LauncherApi:Catalog:PublicFile).</summary>
|
||||
public bool PublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du catalogue (pour information).</summary>
|
||||
public string? PublicUrl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle et nettoyage du catalogue, communs au serveur (refus de toute valeur invalide) et au
|
||||
/// launcher des joueurs (valeur invalide ignorée : la valeur de launcher.json s'applique).
|
||||
/// Tout est borné : textes, nombre de jeux et de liens, adresses (https uniquement), images (même
|
||||
/// serveur que le catalogue, PNG ou JPEG), thème (identifiant d'un thème intégré, jamais un fichier).
|
||||
/// </summary>
|
||||
public static partial class CatalogRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Taille maximale de catalog.json. Inférieure à la taille maximale d'une requête de l'API (64 Ko :
|
||||
/// Limits:MaxRequestBodyBytes et client_max_body_size de nginx), qui n'est donc pas à augmenter.
|
||||
/// </summary>
|
||||
public const int MaxFileBytes = 48 * 1024;
|
||||
|
||||
/// <summary>Format du fichier publié (JSON indenté, écrit par le serveur).</summary>
|
||||
public static readonly JsonSerializerOptions PublishedJson = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
/// <summary>Taille (octets) du fichier publié pour ce catalogue.</summary>
|
||||
public static int PublishedSize(CatalogDocument catalog) =>
|
||||
Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(catalog, PublishedJson));
|
||||
public const int MaxGames = 50;
|
||||
public const int MaxGameNameLength = 80;
|
||||
public const int MaxLauncherNameLength = 80;
|
||||
public const int MaxTaglineLength = 200;
|
||||
public const int MaxDescriptionLength = 2000;
|
||||
public const int MaxDescriptionLines = 40;
|
||||
public const int MaxLinks = 8;
|
||||
public const int MaxLinkLabelLength = 40;
|
||||
public const int MaxUrlLength = 500;
|
||||
public const int MinUpdateInterval = 5;
|
||||
public const int MaxUpdateInterval = 1440;
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9_.\-]{1,64}\z")]
|
||||
private static partial Regex GameIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[a-z0-9][a-z0-9\-]{0,31}\z")]
|
||||
private static partial Regex ThemeRegex();
|
||||
|
||||
public static bool IsValidGameId(string? id) => id is not null && GameIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidTheme(string? theme) => theme is not null && ThemeRegex().IsMatch(theme);
|
||||
|
||||
/// <summary>
|
||||
/// Nettoie le catalogue. Chaque valeur invalide est retirée (null) et décrite dans <paramref name="errors"/> :
|
||||
/// le serveur refuse la publication s'il y en a, le launcher ignore simplement ces valeurs.
|
||||
/// </summary>
|
||||
/// <param name="imageHost">Hôte autorisé pour les images (celui du catalogue) ; null : https suffit.</param>
|
||||
public static CatalogDocument Normalize(CatalogDocument input, string? imageHost, List<string> errors)
|
||||
{
|
||||
if (input.Format != CatalogDocument.FormatName) errors.Add("Format de catalogue inconnu.");
|
||||
if (input.Version != CatalogDocument.CurrentVersion) errors.Add($"Version de catalogue non prise en charge ({input.Version}).");
|
||||
if (input.Revision < 0) errors.Add("Révision invalide.");
|
||||
|
||||
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (id, game) in input.Games ?? new Dictionary<string, CatalogGame>())
|
||||
{
|
||||
if (!IsValidGameId(id))
|
||||
{
|
||||
errors.Add($"Identifiant de jeu invalide : « {Truncate(id)} ».");
|
||||
continue;
|
||||
}
|
||||
if (games.ContainsKey(id))
|
||||
{
|
||||
errors.Add($"Jeu « {id} » présent plusieurs fois.");
|
||||
continue;
|
||||
}
|
||||
if (games.Count >= MaxGames)
|
||||
{
|
||||
errors.Add($"Trop de jeux (au plus {MaxGames}).");
|
||||
break;
|
||||
}
|
||||
if (game is null) continue;
|
||||
var cleaned = NormalizeGame(id, game, imageHost, errors);
|
||||
if (!IsEmpty(cleaned)) games[id] = cleaned;
|
||||
}
|
||||
|
||||
return new CatalogDocument
|
||||
{
|
||||
Revision = Math.Max(0, input.Revision),
|
||||
UpdatedAt = input.UpdatedAt,
|
||||
Launcher = NormalizeLauncher(input.Launcher, errors),
|
||||
Games = games
|
||||
};
|
||||
}
|
||||
|
||||
private static CatalogLauncher? NormalizeLauncher(CatalogLauncher? launcher, List<string> errors)
|
||||
{
|
||||
if (launcher is null) return null;
|
||||
var name = Line(launcher.LauncherName, MaxLauncherNameLength, "Nom du launcher", errors);
|
||||
string? defaultGame = null;
|
||||
if (!string.IsNullOrWhiteSpace(launcher.DefaultGame))
|
||||
{
|
||||
if (IsValidGameId(launcher.DefaultGame.Trim())) defaultGame = launcher.DefaultGame.Trim();
|
||||
else errors.Add("Jeu par défaut : identifiant invalide.");
|
||||
}
|
||||
|
||||
List<string>? order = null;
|
||||
if (launcher.GameOrder is { Count: > 0 })
|
||||
{
|
||||
order = new List<string>();
|
||||
foreach (var id in launcher.GameOrder)
|
||||
{
|
||||
if (!IsValidGameId(id))
|
||||
{
|
||||
errors.Add($"Ordre des jeux : identifiant invalide « {Truncate(id)} ».");
|
||||
continue;
|
||||
}
|
||||
if (order.Count >= MaxGames)
|
||||
{
|
||||
errors.Add($"Ordre des jeux : au plus {MaxGames} jeux.");
|
||||
break;
|
||||
}
|
||||
if (!order.Contains(id, StringComparer.OrdinalIgnoreCase)) order.Add(id);
|
||||
}
|
||||
if (order.Count == 0) order = null;
|
||||
}
|
||||
|
||||
return name is null && defaultGame is null && order is null
|
||||
? null
|
||||
: new CatalogLauncher { LauncherName = name, DefaultGame = defaultGame, GameOrder = order };
|
||||
}
|
||||
|
||||
private static CatalogGame NormalizeGame(string id, CatalogGame game, string? imageHost, List<string> errors)
|
||||
{
|
||||
var prefix = $"Jeu « {id} »";
|
||||
int? interval = null;
|
||||
if (game.UpdateCheckIntervalMinutes is { } minutes)
|
||||
{
|
||||
if (minutes == 0 || minutes is >= MinUpdateInterval and <= MaxUpdateInterval) interval = minutes;
|
||||
else errors.Add($"{prefix} : intervalle de recherche des mises à jour invalide (0, ou {MinUpdateInterval} à {MaxUpdateInterval} minutes).");
|
||||
}
|
||||
|
||||
string? theme = null;
|
||||
if (!string.IsNullOrWhiteSpace(game.Theme))
|
||||
{
|
||||
var value = game.Theme.Trim();
|
||||
if (IsValidTheme(value)) theme = value;
|
||||
else errors.Add($"{prefix} : thème invalide (identifiant d'un thème intégré au launcher).");
|
||||
}
|
||||
|
||||
return new CatalogGame
|
||||
{
|
||||
GameName = Line(game.GameName, MaxGameNameLength, $"{prefix} : nom", errors),
|
||||
Tagline = Line(game.Tagline, MaxTaglineLength, $"{prefix} : accroche", errors),
|
||||
Description = Text(game.Description, $"{prefix} : présentation", errors),
|
||||
WebsiteUrl = Url(game.WebsiteUrl, $"{prefix} : site web", errors),
|
||||
Links = Links(game.Links, prefix, errors),
|
||||
Theme = theme,
|
||||
Icon = Image(game.Icon, imageHost, $"{prefix} : icône", errors),
|
||||
Background = Image(game.Background, imageHost, $"{prefix} : image de fond", errors),
|
||||
UpdateCheckIntervalMinutes = interval,
|
||||
Hidden = game.Hidden == true ? true : null
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CatalogLink>? Links(List<CatalogLink>? links, string prefix, List<string> errors)
|
||||
{
|
||||
if (links is not { Count: > 0 }) return null;
|
||||
var result = new List<CatalogLink>();
|
||||
foreach (var link in links)
|
||||
{
|
||||
if (link is null) continue;
|
||||
if (result.Count >= MaxLinks)
|
||||
{
|
||||
errors.Add($"{prefix} : au plus {MaxLinks} liens.");
|
||||
break;
|
||||
}
|
||||
var kind = CatalogLinkKinds.All.Contains(link.Kind) ? link.Kind : null;
|
||||
if (kind is null)
|
||||
{
|
||||
errors.Add($"{prefix} : type de lien inconnu « {Truncate(link.Kind)} ».");
|
||||
continue;
|
||||
}
|
||||
var url = Url(link.Url, $"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)}", errors);
|
||||
if (url is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(link.Url)) errors.Add($"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)} sans adresse.");
|
||||
continue;
|
||||
}
|
||||
var label = Line(link.Label, MaxLinkLabelLength, $"{prefix} : libellé de lien", errors);
|
||||
result.Add(new CatalogLink { Kind = kind, Label = label, Url = url });
|
||||
}
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Valeurs
|
||||
|
||||
private static string? Line(string? value, int maxLength, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var cleaned = ApiText.CleanLine(value, maxLength);
|
||||
if (cleaned is null) errors.Add($"{field} : {maxLength} caractères au plus, sans caractère spécial invisible.");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// <summary>Texte de plusieurs lignes : fins de ligne normalisées, sans caractère de contrôle ou invisible.</summary>
|
||||
private static string? Text(string? value, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var lines = value.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||
var sb = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append('\n');
|
||||
var line = lines[i].TrimEnd();
|
||||
if (line.Length == 0) continue;
|
||||
// Indentation conservée (espaces de tête), le reste nettoyé comme une ligne simple.
|
||||
var indent = line.Length - line.TrimStart(' ').Length;
|
||||
var cleaned = ApiText.CleanLine(line, MaxDescriptionLength);
|
||||
if (cleaned is null)
|
||||
{
|
||||
errors.Add($"{field} : caractère spécial invisible ou de contrôle interdit.");
|
||||
return null;
|
||||
}
|
||||
sb.Append(' ', Math.Min(indent, 8)).Append(cleaned);
|
||||
}
|
||||
var text = sb.ToString().Trim('\n');
|
||||
if (text.Length > MaxDescriptionLength || text.Count(c => c == '\n') + 1 > MaxDescriptionLines)
|
||||
{
|
||||
errors.Add($"{field} : {MaxDescriptionLength} caractères et {MaxDescriptionLines} lignes au plus.");
|
||||
return null;
|
||||
}
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
|
||||
/// <summary>Adresse https absolue, sans identifiants, bornée.</summary>
|
||||
public static string? Url(string? value, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var text = value.Trim();
|
||||
if (text.Length > MaxUrlLength || !Uri.TryCreate(text, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrEmpty(uri.Host) ||
|
||||
text.Any(char.IsWhiteSpace) || text.Any(char.IsControl))
|
||||
{
|
||||
errors.Add($"{field} : adresse https:// complète attendue ({MaxUrlLength} caractères au plus).");
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
/// <summary>Image https PNG ou JPEG, sur l'hôte du catalogue.</summary>
|
||||
private static string? Image(string? value, string? imageHost, string field, List<string> errors)
|
||||
{
|
||||
var url = Url(value, field, errors);
|
||||
if (url is null) return null;
|
||||
var uri = new Uri(url);
|
||||
var path = uri.AbsolutePath;
|
||||
if (!(path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) || !string.IsNullOrEmpty(uri.Query))
|
||||
{
|
||||
errors.Add($"{field} : image .png ou .jpg attendue (sans paramètres).");
|
||||
return null;
|
||||
}
|
||||
if (imageHost is not null && !string.Equals(uri.Host, imageHost, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"{field} : l'image doit être sur le serveur du catalogue ({imageHost}).");
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private static bool IsEmpty(CatalogGame game) =>
|
||||
game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
|
||||
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null };
|
||||
|
||||
private static string Truncate(string? value) =>
|
||||
value is null ? "" : value.Length <= 40 ? value : value[..40] + "…";
|
||||
}
|
||||
+73
-4
@@ -1,11 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Core.Utils;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version du jeu (manifest.json → "changelog").
|
||||
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
|
||||
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
|
||||
///
|
||||
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
|
||||
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
|
||||
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
|
||||
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
|
||||
public List<string> Changes { get; set; } = new();
|
||||
|
||||
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
|
||||
|
||||
[JsonIgnore]
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
|
||||
@@ -142,6 +147,69 @@ public static class ChangelogLimits
|
||||
public const int MaxTitleLength = 200;
|
||||
public const int MaxTagLength = 24;
|
||||
public const int MaxTextLength = 2000;
|
||||
public const int MaxVersionLength = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
|
||||
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
|
||||
/// </summary>
|
||||
public const int MaxEditRequestBytes = 60 * 1024;
|
||||
|
||||
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
|
||||
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
|
||||
|
||||
/// <summary>
|
||||
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
|
||||
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
|
||||
/// </summary>
|
||||
public static List<string> Check(ChangelogEntry entry)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
void Length(string? value, int max, string what)
|
||||
{
|
||||
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
|
||||
}
|
||||
|
||||
Length(entry.Title, MaxTitleLength, "Titre");
|
||||
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
|
||||
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
|
||||
if (introduction.Count > MaxIntroductionParagraphs)
|
||||
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
|
||||
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
|
||||
|
||||
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
|
||||
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
|
||||
var items = 0;
|
||||
for (var s = 0; s < sections.Count; s++)
|
||||
{
|
||||
var section = sections[s];
|
||||
var where = $"Section {s + 1}";
|
||||
Length(section.Title, MaxTitleLength, $"{where} (titre)");
|
||||
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
|
||||
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
|
||||
items += sectionItems.Count;
|
||||
for (var i = 0; i < sectionItems.Count; i++)
|
||||
{
|
||||
var item = sectionItems[i];
|
||||
var at = $"{where}, élément {i + 1}";
|
||||
Length(item.Text, MaxTextLength, at);
|
||||
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
|
||||
Length(item.Note, MaxTextLength, $"{at} (note)");
|
||||
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
|
||||
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
|
||||
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
|
||||
}
|
||||
}
|
||||
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
|
||||
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
|
||||
errors.Add($"{MaxFlatChanges} modifications au plus.");
|
||||
return errors.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
|
||||
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
|
||||
|
||||
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
|
||||
{
|
||||
@@ -229,7 +297,8 @@ public static class ChangelogLimits
|
||||
|
||||
var text = sb.ToString().Trim();
|
||||
if (text.Length == 0) return null;
|
||||
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
|
||||
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
|
||||
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
|
||||
/// l'éditeur du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed record ChangelogState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
|
||||
public string ManifestVersion { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
|
||||
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// </summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
|
||||
public List<ChangelogEntry> Entries { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
|
||||
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
|
||||
/// </summary>
|
||||
public sealed record ChangelogUpdateRequest
|
||||
{
|
||||
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
|
||||
public string BaseSha256 { get; init; } = "";
|
||||
|
||||
public ChangelogEntry? Entry { get; init; }
|
||||
}
|
||||
@@ -12,6 +12,12 @@ public sealed record AdminKeySummary
|
||||
/// </summary>
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Clé publique (SPKI, Base64) des clés ACTIVES seulement (trousseau de la phase 7). Absente pour une clé en
|
||||
/// attente (on pourrait en déduire l'empreinte) ou révoquée.
|
||||
/// </summary>
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
}
|
||||
@@ -26,6 +32,13 @@ public sealed record AdminSummary
|
||||
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
|
||||
}
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté : ses appareils (clés) et les invitations « nouvel appareil » en cours.</summary>
|
||||
public sealed record MyAccount
|
||||
{
|
||||
public AdminSummary Admin { get; init; } = new();
|
||||
public IReadOnlyList<InvitationSummary> Invitations { get; init; } = Array.Empty<InvitationSummary>();
|
||||
}
|
||||
|
||||
public sealed record CreateInvitationRequest
|
||||
{
|
||||
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
@@ -46,6 +59,10 @@ public sealed record InvitationSummary
|
||||
public Guid? ForAdminId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Administrateur qui a créé l'invitation (null : auteur inconnu).</summary>
|
||||
public Guid? CreatedById { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -53,4 +53,19 @@ public static class ApiErrorCodes
|
||||
public const string SelfAction = "self_action";
|
||||
public const string Busy = "busy";
|
||||
public const string HttpsRequired = "https_required";
|
||||
|
||||
/// <summary>Quelqu'un a modifié la même donnée entre-temps (révision dépassée) : recharger avant de recommencer.</summary>
|
||||
public const string RevisionConflict = "revision_conflict";
|
||||
|
||||
/// <summary>Fonction non configurée sur le serveur.</summary>
|
||||
public const string NotConfigured = "not_configured";
|
||||
|
||||
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
|
||||
public const string OffsetMismatch = "offset_mismatch";
|
||||
|
||||
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
|
||||
public const string PublicationInProgress = "publication_in_progress";
|
||||
|
||||
/// <summary>Espace disque insuffisant sur le serveur.</summary>
|
||||
public const string InsufficientStorage = "insufficient_storage";
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>Ce que contient un manifest.json contrôlé.</summary>
|
||||
/// <param name="Objects">Objets à servir (empreinte en minuscules → taille), une fois chacun.</param>
|
||||
public sealed record ManifestSummary(
|
||||
string GameId,
|
||||
string Version,
|
||||
string Executable,
|
||||
string ArchiveName,
|
||||
long ArchiveSize,
|
||||
string ArchiveSha256,
|
||||
IReadOnlyDictionary<string, long> Objects,
|
||||
int FileCount,
|
||||
long TotalSize,
|
||||
string? NotesTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un manifest.json produit par ManifestBuilder, AVANT de le publier : par le launcher
|
||||
/// (dossier choisi) et par le serveur (manifest reçu). Mêmes règles des deux côtés ; le serveur ne fait
|
||||
/// confiance à rien de ce qu'il reçoit.
|
||||
/// • identifiant de jeu et version attendus ;
|
||||
/// • archive : simple nom de fichier .zip (jamais un chemin), taille et empreinte valides ;
|
||||
/// • objets dans « objects/ » (seul dossier écrit par la publication) ;
|
||||
/// • chemins des fichiers relatifs et sûrs (ni « .. », ni chemin absolu, ni « : »), sans doublon ;
|
||||
/// • exécutable présent parmi les fichiers ; tailles et nombre de fichiers bornés.
|
||||
/// </summary>
|
||||
public static partial class ManifestCheck
|
||||
{
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9._+\-]{1,64}\z")]
|
||||
private static partial Regex VersionRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}\.zip\z", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ArchiveNameRegex();
|
||||
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
public static bool IsValidVersion(string? version) => version is not null && VersionRegex().IsMatch(version);
|
||||
|
||||
/// <summary>Contrôle ; null (et <paramref name="errors"/> rempli) si le manifest n'est pas publiable.</summary>
|
||||
public static ManifestSummary? Check(ReadOnlySpan<byte> json, string expectedGameId, string? expectedVersion, List<string> errors)
|
||||
{
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
if (json.Length > PublishingLimits.MaxManifestBytes)
|
||||
{
|
||||
errors.Add("Manifest trop volumineux.");
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(json.ToArray(), ReadOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"Manifest illisible : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add("Manifest illisible : objet JSON attendu.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Get(root, "formatVersion") is { } format && (format.ValueKind != JsonValueKind.Number || format.GetDouble() != 1))
|
||||
errors.Add("Format de manifest non pris en charge (formatVersion 1 attendu).");
|
||||
|
||||
var gameId = Text(root, "gameId");
|
||||
if (!string.Equals(gameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add($"Ce manifest est celui du jeu « {gameId} », pas de « {expectedGameId} ».");
|
||||
|
||||
var version = Text(root, "version");
|
||||
if (!IsValidVersion(version))
|
||||
errors.Add($"Version « {version} » invalide (lettres, chiffres, « . », « _ », « + », « - »).");
|
||||
else if (expectedVersion is not null && !string.Equals(version, expectedVersion, StringComparison.Ordinal))
|
||||
errors.Add($"Le manifest est celui de la version {version}, pas {expectedVersion}.");
|
||||
|
||||
var objectsBase = Text(root, "objectsBaseUrl");
|
||||
if (!string.IsNullOrEmpty(objectsBase) && objectsBase is not ("objects/" or "objects"))
|
||||
errors.Add($"« objectsBaseUrl » doit être « objects/ » (manifest produit par ManifestBuilder), pas « {objectsBase} ».");
|
||||
|
||||
// Archive (installation initiale)
|
||||
string archiveName = "", archiveSha = "";
|
||||
long archiveSize = 0;
|
||||
if (Get(root, "archive") is { ValueKind: JsonValueKind.Object } archive)
|
||||
{
|
||||
archiveName = Text(archive, "url") ?? "";
|
||||
archiveSha = (Text(archive, "sha256") ?? "").ToLowerInvariant();
|
||||
archiveSize = Number(archive, "size");
|
||||
if (!ArchiveNameRegex().IsMatch(archiveName))
|
||||
errors.Add($"Archive « {archiveName} » : simple nom de fichier .zip attendu (à côté du manifest).");
|
||||
if (!PublishingLimits.IsValidSha256(archiveSha)) errors.Add("Archive : empreinte SHA-256 invalide.");
|
||||
if (archiveSize <= 0 || archiveSize > PublishingLimits.MaxArchiveBytes) errors.Add("Archive : taille invalide.");
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Archive absente du manifest.");
|
||||
}
|
||||
|
||||
// Fichiers du jeu
|
||||
var objects = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalSize = 0;
|
||||
var fileCount = 0;
|
||||
if (Get(root, "files") is { ValueKind: JsonValueKind.Array } files)
|
||||
{
|
||||
fileCount = files.GetArrayLength();
|
||||
if (fileCount == 0) errors.Add("Aucun fichier dans le manifest.");
|
||||
if (fileCount > PublishingLimits.MaxFiles) errors.Add($"Trop de fichiers ({fileCount} ; {PublishingLimits.MaxFiles} au plus).");
|
||||
else
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var file in files.EnumerateArray())
|
||||
{
|
||||
index++;
|
||||
if (errors.Count > 20) break;
|
||||
if (file.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add($"Fichier n° {index} invalide.");
|
||||
continue;
|
||||
}
|
||||
var path = Text(file, "path") ?? "";
|
||||
var sha = (Text(file, "sha256") ?? "").ToLowerInvariant();
|
||||
var size = Number(file, "size");
|
||||
if (!IsSafeRelativePath(path)) errors.Add($"Chemin non autorisé : « {path} ».");
|
||||
else if (!paths.Add(path.Replace('\\', '/'))) errors.Add($"Fichier en double : « {path} ».");
|
||||
if (!PublishingLimits.IsValidSha256(sha)) errors.Add($"« {path} » : empreinte SHA-256 invalide.");
|
||||
else if (size < 0 || size > PublishingLimits.MaxObjectBytes) errors.Add($"« {path} » : taille invalide.");
|
||||
else if (objects.TryGetValue(sha, out var known) && known != size)
|
||||
errors.Add($"« {path} » : même empreinte qu'un autre fichier mais taille différente.");
|
||||
else
|
||||
{
|
||||
objects[sha] = size;
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Liste des fichiers absente du manifest.");
|
||||
}
|
||||
|
||||
var executable = (Text(root, "executable") ?? "").Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(executable)) errors.Add("Exécutable absent du manifest.");
|
||||
else if (!paths.Contains(executable)) errors.Add($"L'exécutable « {executable} » ne fait pas partie des fichiers.");
|
||||
|
||||
if (Get(root, "changelog") is { } changelog && changelog.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null))
|
||||
errors.Add("Notes de version (« changelog ») : liste attendue.");
|
||||
|
||||
if (errors.Count > 0) return null;
|
||||
return new ManifestSummary(gameId!, version!, executable, archiveName, archiveSize, archiveSha, objects, fileCount, totalSize,
|
||||
NotesTitle(root, version!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chemin relatif sûr (mêmes règles que le launcher pour l'installation).</summary>
|
||||
public static bool IsSafeRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path.Length > 1024 || path.Any(char.IsControl)) return false;
|
||||
var p = path.Replace('\\', '/');
|
||||
if (p.StartsWith('/') || p.Contains(':')) return false;
|
||||
return p.Split('/').All(s => s.Length > 0 && s != "." && s != "..");
|
||||
}
|
||||
|
||||
/// <summary>Chemin d'un objet sous le dossier du manifest : « objects/ab/abcdef….bin ».</summary>
|
||||
public static string ObjectPath(string sha256)
|
||||
{
|
||||
var h = sha256.ToLowerInvariant();
|
||||
return $"objects/{h[..2]}/{h}.bin";
|
||||
}
|
||||
|
||||
private static string? NotesTitle(JsonElement root, string version)
|
||||
{
|
||||
if (Get(root, "changelog") is not { ValueKind: JsonValueKind.Array } changelog) return null;
|
||||
foreach (var entry in changelog.EnumerateArray())
|
||||
if (entry.ValueKind == JsonValueKind.Object && Text(entry, "version") == version)
|
||||
return Text(entry, "title");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement? Get(JsonElement element, string name)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Text(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null;
|
||||
|
||||
private static long Number(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : -1;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Bornes de la publication d'une version depuis le menu d'administration. Les requêtes d'envoi
|
||||
/// (morceaux de fichiers) sont les seules à dépasser 64 Ko : elles ont leur propre limite, côté
|
||||
/// serveur et côté nginx (server/deploy/nginx-publications.conf).
|
||||
/// </summary>
|
||||
public static partial class PublishingLimits
|
||||
{
|
||||
/// <summary>Taille d'un morceau envoyé par le launcher.</summary>
|
||||
public const int ChunkBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Taille maximale d'un corps de requête d'envoi accepté par le serveur (morceau ou lot d'objets).</summary>
|
||||
public const long MaxUploadRequestBytes = 8L * 1024 * 1024 + 64 * 1024;
|
||||
|
||||
/// <summary>Objets de cette taille au plus : envoyés par lots ; au-delà, en morceaux.</summary>
|
||||
public const int BatchObjectMaxBytes = 2 * 1024 * 1024;
|
||||
|
||||
public const int MaxObjectsPerBatch = 500;
|
||||
public const int MaxFiles = 10_000;
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
public const long MaxObjectBytes = 32L * 1024 * 1024 * 1024;
|
||||
public const long MaxArchiveBytes = 128L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>Publication abandonnée (aucun envoi) au-delà de cette durée : supprimée par le serveur.</summary>
|
||||
public static readonly TimeSpan IdleExpiry = TimeSpan.FromHours(48);
|
||||
|
||||
/// <summary>Publication d'un autre administrateur sans envoi depuis cette durée : peut être remplacée.</summary>
|
||||
public static readonly TimeSpan TakeoverAfter = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>Type du corps d'un lot d'objets (voir <see cref="ObjectBatch"/>).</summary>
|
||||
public const string ObjectBatchContentType = "application/vnd.gamelauncher.objects";
|
||||
|
||||
public const string BinaryContentType = "application/octet-stream";
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
|
||||
private static partial Regex PublicationIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-fA-F]{64}\z")]
|
||||
private static partial Regex Sha256Regex();
|
||||
|
||||
public static bool IsValidPublicationId(string? id) => id is not null && PublicationIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidSha256(string? value) => value is not null && Sha256Regex().IsMatch(value);
|
||||
}
|
||||
|
||||
/// <summary>Début (ou reprise) de la publication d'une version : le manifest est envoyé ensuite.</summary>
|
||||
public sealed record PublicationStartRequest
|
||||
{
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest.json à publier : même manifest = reprise d'un envoi interrompu.</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État d'une publication en cours (ce qui reste à envoyer).</summary>
|
||||
public sealed record PublicationState
|
||||
{
|
||||
public string PublicationId { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne au début de la publication (vide : aucune).</summary>
|
||||
public string PublishedVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Vrai si un envoi interrompu de ce même manifest est repris.</summary>
|
||||
public bool Resumed { get; init; }
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
public long ManifestReceived { get; init; }
|
||||
|
||||
/// <summary>Manifest reçu et contrôlé : la liste des objets à envoyer est connue.</summary>
|
||||
public bool ManifestAccepted { get; init; }
|
||||
|
||||
/// <summary>Fichiers (objets) du manifest ; ceux déjà présents sur le serveur ne sont pas renvoyés.</summary>
|
||||
public int ObjectCount { get; init; }
|
||||
public int ObjectsOnServer { get; init; }
|
||||
|
||||
/// <summary>Empreintes des objets restant à envoyer.</summary>
|
||||
public List<string> PendingObjects { get; init; } = new();
|
||||
|
||||
/// <summary>Objets envoyés en partie (empreinte → octets reçus) : l'envoi reprend à cet endroit.</summary>
|
||||
public Dictionary<string, long> PartialObjects { get; init; } = new();
|
||||
|
||||
public string? ArchiveName { get; init; }
|
||||
public long ArchiveSize { get; init; }
|
||||
public long ArchiveReceived { get; init; }
|
||||
public bool ArchiveComplete { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réponse à l'envoi d'un morceau (ou d'un lot d'objets).</summary>
|
||||
public sealed record ChunkResult
|
||||
{
|
||||
/// <summary>Octets reçus pour ce fichier (ou nombre d'objets enregistrés pour un lot).</summary>
|
||||
public long Received { get; init; }
|
||||
|
||||
/// <summary>Fichier complet et vérifié (SHA-256).</summary>
|
||||
public bool Complete { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Version publiée.</summary>
|
||||
public sealed record PublicationResult
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string PreviousVersion { get; init; } = "";
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
public int ObjectsAdded { get; init; }
|
||||
public bool ArchiveAdded { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lot d'objets dans un seul corps de requête (petits fichiers : une requête pour des centaines de
|
||||
/// fichiers) : pour chaque objet, empreinte SHA-256 (32 octets bruts), taille (8 octets, gros-boutiste)
|
||||
/// puis contenu.
|
||||
/// </summary>
|
||||
public static class ObjectBatch
|
||||
{
|
||||
public const int HeaderBytes = 32 + 8;
|
||||
|
||||
public static byte[] Write(IReadOnlyList<(string Sha256, byte[] Data)> objects)
|
||||
{
|
||||
var total = objects.Sum(o => (long)HeaderBytes + o.Data.Length);
|
||||
var buffer = new byte[total];
|
||||
var position = 0;
|
||||
foreach (var (sha, data) in objects)
|
||||
{
|
||||
Convert.FromHexString(sha).CopyTo(buffer, position);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(buffer.AsSpan(position + 32, 8), data.Length);
|
||||
data.CopyTo(buffer, position + HeaderBytes);
|
||||
position += HeaderBytes + data.Length;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>Lecture bornée ; <see cref="InvalidDataException"/> si le lot est mal formé.</summary>
|
||||
public static List<(string Sha256, ReadOnlyMemory<byte> Data)> Read(ReadOnlyMemory<byte> body, int maxObjects)
|
||||
{
|
||||
var result = new List<(string, ReadOnlyMemory<byte>)>();
|
||||
var position = 0;
|
||||
while (position < body.Length)
|
||||
{
|
||||
if (result.Count >= maxObjects) throw new InvalidDataException($"Lot trop grand ({maxObjects} objets au plus).");
|
||||
if (body.Length - position < HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
var span = body.Span;
|
||||
var sha = Convert.ToHexString(span.Slice(position, 32)).ToLowerInvariant();
|
||||
var length = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(span.Slice(position + 32, 8));
|
||||
if (length < 0 || length > body.Length - position - HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
result.Add((sha, body.Slice(position + HeaderBytes, (int)length)));
|
||||
position += HeaderBytes + (int)length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,28 @@ public static class SignedMessages
|
||||
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
|
||||
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
|
||||
|
||||
/// <summary>
|
||||
/// Signature d'un contenu servi aux joueurs (manifest d'un jeu, catalogue) par la clé d'un administrateur
|
||||
/// (phase 7). En-tête distinct : une signature de contenu ne peut pas servir de signature de requête,
|
||||
/// et inversement.
|
||||
/// </summary>
|
||||
public const string ContentHeader = "GameLauncher/Content/v1";
|
||||
|
||||
/// <summary>Signature du trousseau (clés autorisées à signer le contenu) par la clé racine du launcher.</summary>
|
||||
public const string KeyringHeader = "GameLauncher/Keyring/v1";
|
||||
|
||||
/// <param name="kind">« manifest » ou « catalog ».</param>
|
||||
/// <param name="id">Identifiant du jeu (minuscules) ou « catalog ».</param>
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du contenu canonique.</param>
|
||||
public static byte[] Content(string kind, string id, long signedAt, string keyId, string digest) =>
|
||||
Build(ContentHeader,
|
||||
("kind", kind), ("id", id), ("signed-at", signedAt.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
("key", keyId), ("digest", digest));
|
||||
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du trousseau canonique (sans sa signature).</param>
|
||||
public static byte[] Keyring(string rootKeyId, string digest) =>
|
||||
Build(KeyringHeader, ("root", rootKeyId), ("digest", digest));
|
||||
|
||||
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
|
||||
public static string HashInvitationCode(string normalizedCode) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature d'un contenu servi aux joueurs (propriété « signature » du manifest.json d'un jeu ou du
|
||||
/// catalog.json), produite par la clé d'un administrateur. Les anciens launchers ignorent cette propriété.
|
||||
/// </summary>
|
||||
public sealed record ContentSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé qui a signé (16 caractères hexadécimaux).</summary>
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Date de la signature (heure du serveur, à la seconde) : sert aussi à refuser un contenu plus ancien que le dernier vu.</summary>
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256 / SHA-256 (64 octets, IEEE P1363) en Base64.</summary>
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Types de contenu signés et autorisations (« scopes ») correspondantes dans le trousseau.</summary>
|
||||
public static class ContentKinds
|
||||
{
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
|
||||
public const string CatalogId = "catalog";
|
||||
|
||||
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
|
||||
public static string ScopeOf(string kind) => kind == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
|
||||
}
|
||||
|
||||
/// <summary>Autorisations d'une clé du trousseau.</summary>
|
||||
public static class SigningScopes
|
||||
{
|
||||
/// <summary>Signer le manifest d'un jeu (ce qui est installé et exécuté) : permission releases.publish.</summary>
|
||||
public const string Releases = "releases";
|
||||
|
||||
/// <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>Autorisations qu'apportent les permissions d'un administrateur.</summary>
|
||||
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
|
||||
{
|
||||
var set = permissions.ToHashSet(StringComparer.Ordinal);
|
||||
var scopes = new List<string>();
|
||||
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
|
||||
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public static string DisplayName(string scope) => scope switch
|
||||
{
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partie d'un manifest couverte par la signature : tout ce qui décide de ce qui est installé et exécuté
|
||||
/// (jeu, version, exécutable, arguments, archive, fichiers et leurs empreintes). Les notes de version et la
|
||||
/// date ne le sont pas : elles restent modifiables en ligne (texte borné, jamais exécuté).
|
||||
/// </summary>
|
||||
public sealed record ManifestContent
|
||||
{
|
||||
public int FormatVersion { get; init; } = 1;
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string Executable { get; init; } = "";
|
||||
public string? LaunchArguments { get; init; }
|
||||
public ManifestContentArchive Archive { get; init; } = new();
|
||||
public string? ObjectsBaseUrl { get; init; }
|
||||
public List<ManifestContentFile> Files { get; init; } = new();
|
||||
|
||||
/// <summary>Lit un manifest.json (mêmes noms de propriétés que le launcher, casse indifférente).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static ManifestContent Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.StartsWith(Utf8Bom)) json = json[Utf8Bom.Length..];
|
||||
return JsonSerializer.Deserialize<ManifestContent>(json, ReadOptions) ?? throw new JsonException("manifest vide");
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 32
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ManifestContentArchive
|
||||
{
|
||||
public string Url { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ManifestContentFile
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une vérification de signature de contenu.</summary>
|
||||
public sealed record ContentCheck(bool Valid, string Reason, KeyringKey? Key)
|
||||
{
|
||||
public static ContentCheck Fail(string reason) => new(false, reason, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Condensés canoniques et signatures du contenu. Le même code est utilisé par le menu d'administration
|
||||
/// (qui signe), le serveur (qui contrôle avant d'écrire) et le launcher des joueurs (qui vérifie) : chacun
|
||||
/// calcule le condensé à partir de ce qu'il a lui-même lu.
|
||||
/// </summary>
|
||||
public static class ContentSigning
|
||||
{
|
||||
/// <summary>Écart toléré par le serveur entre la date de signature et son horloge.</summary>
|
||||
public static readonly TimeSpan MaxSignedAtSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest (fichiers triés, empreintes en minuscules ; valeurs absentes = vides).</summary>
|
||||
public static string ManifestDigest(ManifestContent manifest)
|
||||
{
|
||||
var archive = manifest.Archive ?? new ManifestContentArchive();
|
||||
var canonical = new ManifestContent
|
||||
{
|
||||
FormatVersion = manifest.FormatVersion,
|
||||
GameId = (manifest.GameId ?? "").ToLowerInvariant(),
|
||||
Version = manifest.Version ?? "",
|
||||
Executable = (manifest.Executable ?? "").Replace('\\', '/'),
|
||||
LaunchArguments = string.IsNullOrEmpty(manifest.LaunchArguments) ? null : manifest.LaunchArguments,
|
||||
Archive = new ManifestContentArchive
|
||||
{
|
||||
Url = archive.Url ?? "",
|
||||
Size = archive.Size,
|
||||
Sha256 = (archive.Sha256 ?? "").ToLowerInvariant()
|
||||
},
|
||||
ObjectsBaseUrl = string.IsNullOrWhiteSpace(manifest.ObjectsBaseUrl) ? "objects/" : manifest.ObjectsBaseUrl,
|
||||
Files = (manifest.Files ?? new List<ManifestContentFile>())
|
||||
.Where(f => f is not null)
|
||||
.Select(f => new ManifestContentFile { Path = f.Path ?? "", Size = f.Size, Sha256 = (f.Sha256 ?? "").ToLowerInvariant() })
|
||||
.OrderBy(f => f.Path, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Sha256, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest.json (octets bruts).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static string ManifestDigest(ReadOnlySpan<byte> manifestJson) => ManifestDigest(ManifestContent.Parse(manifestJson));
|
||||
|
||||
/// <summary>
|
||||
/// Condensé du contenu signé d'un catalogue : tout sauf la révision, la date et la signature (posées par le
|
||||
/// serveur) ; jeux triés par identifiant, tels qu'écrits (sans changer la casse : deux identifiants qui ne
|
||||
/// diffèrent que par la casse donneraient deux entrées distinctes, voir <see cref="HasDuplicateGames"/>).
|
||||
/// </summary>
|
||||
public static string CatalogDigest(CatalogDocument catalog)
|
||||
{
|
||||
var games = new SortedDictionary<string, CatalogGame>(StringComparer.Ordinal);
|
||||
foreach (var (id, game) in catalog.Games ?? new Dictionary<string, CatalogGame>())
|
||||
games[id] = game;
|
||||
var canonical = new CanonicalCatalog(catalog.Format, catalog.Version, catalog.Launcher, games);
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifiants de jeux en double à la casse près : le launcher n'en garderait qu'un, différent de celui
|
||||
/// couvert par la signature. Un tel catalogue est refusé par la vérification.
|
||||
/// </summary>
|
||||
public static bool HasDuplicateGames(CatalogDocument catalog) =>
|
||||
(catalog.Games?.Keys ?? Enumerable.Empty<string>()).GroupBy(k => k, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
|
||||
|
||||
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();
|
||||
|
||||
/// <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());
|
||||
|
||||
/// <summary>Message à signer par la clé de l'administrateur.</summary>
|
||||
public static byte[] Message(string kind, string id, DateTimeOffset signedAt, string keyId, string digest) =>
|
||||
SignedMessages.Content(kind, SignedId(kind, id), signedAt.ToUnixTimeSeconds(), keyId, digest);
|
||||
|
||||
/// <summary>Vérifie une signature avec une clé publique donnée (le serveur : la clé de l'administrateur qui envoie).</summary>
|
||||
public static bool VerifyWithKey(string kind, string id, string digest, ContentSignature signature, ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId) || signature.SignedAt == default) return false;
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value) return false;
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(publicKey), signature.KeyId, StringComparison.Ordinal)) return false;
|
||||
return AdminKeys.Verify(publicKey, Message(kind, id, signature.SignedAt, signature.KeyId, digest), value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie une signature avec le trousseau (le launcher des joueurs) : clé présente, autorisée pour ce type
|
||||
/// de contenu, signature valide sur le condensé calculé localement.
|
||||
/// </summary>
|
||||
public static ContentCheck Verify(string kind, string id, string digest, ContentSignature? signature, KeyringDocument keyring)
|
||||
{
|
||||
if (signature is null) return ContentCheck.Fail("contenu non signé");
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId)) return ContentCheck.Fail("signature mal formée");
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.Ordinal));
|
||||
if (key is null) return ContentCheck.Fail($"signé par une clé absente du trousseau ({signature.KeyId})");
|
||||
if (key.Scopes is null || !key.Scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal))
|
||||
return ContentCheck.Fail($"la clé {signature.KeyId} ({key.Name}) n'est pas autorisée à signer ce contenu");
|
||||
byte[] publicKey;
|
||||
try
|
||||
{
|
||||
publicKey = Convert.FromBase64String(key.PublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return ContentCheck.Fail("clé du trousseau illisible");
|
||||
}
|
||||
return VerifyWithKey(kind, id, digest, signature, publicKey)
|
||||
? new ContentCheck(true, $"signé par {key.Name} le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC", key)
|
||||
: ContentCheck.Fail("SIGNATURE INVALIDE (contenu modifié après signature, ou signature d'un autre contenu)");
|
||||
}
|
||||
|
||||
internal static string Sha256(ReadOnlySpan<byte> data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau (keyring.json) : clés d'administrateurs autorisées à signer le contenu servi aux joueurs, signé
|
||||
/// par la clé RACINE (celle des mises à jour du launcher, dont la clé publique est intégrée à l'exe). La clé
|
||||
/// racine reste hors ligne (LauncherPublisher, « sign-keyring.bat ») : un serveur compromis ne peut ni
|
||||
/// ajouter une clé, ni modifier le trousseau sans que les launchers le refusent.
|
||||
/// </summary>
|
||||
public sealed record KeyringDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-keyring";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Numéro croissant : un launcher refuse un trousseau plus ancien que le dernier qu'il a vu.</summary>
|
||||
public long Sequence { get; init; }
|
||||
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
public List<KeyringKey> Keys { get; init; } = new();
|
||||
|
||||
/// <summary>Signature de la clé racine (absente dans une demande de trousseau, avant signature).</summary>
|
||||
public KeyringSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
public sealed record KeyringKey
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Clé publique ECDSA P-256 (SPKI, Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Administrateur et appareil (affichage, journal du launcher).</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Ce que la clé peut signer (<see cref="SigningScopes"/>).</summary>
|
||||
public List<string> Scopes { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record KeyringSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé racine (16 caractères hexadécimaux, comme pour les mises à jour du launcher).</summary>
|
||||
public string RootKeyId { get; init; } = "";
|
||||
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Contrôles, condensé et signature du trousseau (même code partout).</summary>
|
||||
public static class Keyring
|
||||
{
|
||||
/// <summary>Taille maximale de keyring.json.</summary>
|
||||
public const int MaxBytes = 60 * 1024; // envoyé à l'API : sous la limite de 64 Ko des requêtes
|
||||
|
||||
public const int MaxKeys = 100;
|
||||
public const int MaxNameLength = 120;
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Lit un trousseau (null : JSON vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static KeyringDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"trousseau trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<KeyringDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(KeyringDocument keyring) =>
|
||||
JsonSerializer.Serialize(keyring, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true });
|
||||
|
||||
/// <summary>Condensé du trousseau sans sa signature (clés triées, autorisations triées).</summary>
|
||||
public static string Digest(KeyringDocument keyring)
|
||||
{
|
||||
var canonical = keyring with
|
||||
{
|
||||
Signature = null,
|
||||
IssuedAt = ContentSigning.Truncate(keyring.IssuedAt),
|
||||
Keys = (keyring.Keys ?? new List<KeyringKey>())
|
||||
.Select(k => k with { Scopes = (k.Scopes ?? new List<string>()).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal).ToList() })
|
||||
.OrderBy(k => k.KeyId, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Contrôle de structure (sans la signature racine). Les erreurs sont ajoutées à <paramref name="errors"/>.</summary>
|
||||
public static bool Validate(KeyringDocument keyring, List<string> errors)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (keyring.Format != KeyringDocument.FormatName || keyring.Version != KeyringDocument.CurrentVersion)
|
||||
errors.Add($"Format de trousseau non pris en charge ({keyring.Format} v{keyring.Version}).");
|
||||
if (keyring.Sequence < 1) errors.Add("Numéro du trousseau invalide (1 au moins).");
|
||||
if (keyring.IssuedAt == default) errors.Add("Date du trousseau absente.");
|
||||
if (keyring.Keys is null || keyring.Keys.Any(k => k is null || k.Scopes is null || k.PublicKey is null || k.Name is null))
|
||||
{
|
||||
errors.Add("Trousseau incomplet (clés, clé publique, nom ou autorisations absents).");
|
||||
return false;
|
||||
}
|
||||
if (keyring.Keys.Count > MaxKeys) errors.Add($"Trop de clés dans le trousseau (au plus {MaxKeys}).");
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var key in keyring.Keys)
|
||||
{
|
||||
var label = AdminKeys.IsValidKeyId(key.KeyId) ? key.KeyId : "(identifiant invalide)";
|
||||
if (!AdminKeys.IsValidKeyId(key.KeyId)) errors.Add("Clé du trousseau : identifiant invalide.");
|
||||
else if (!seen.Add(key.KeyId)) errors.Add($"Clé {label} en double.");
|
||||
try
|
||||
{
|
||||
var spki = AdminKeys.ParsePublicKey(key.PublicKey);
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(spki), key.KeyId, StringComparison.Ordinal))
|
||||
errors.Add($"Clé {label} : l'identifiant ne correspond pas à la clé publique.");
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
errors.Add($"Clé {label} : clé publique illisible ou d'un autre type que ECDSA P-256.");
|
||||
}
|
||||
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.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom affichable sans tromperie : ni caractères de contrôle, ni caractères de mise en forme invisibles
|
||||
/// (inversion du sens d'écriture…), qui pourraient maquiller un nom dans la console ou les fenêtres.
|
||||
/// </summary>
|
||||
public static bool IsDisplaySafe(string name) =>
|
||||
!name.Any(c => char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
|
||||
or System.Globalization.UnicodeCategory.LineSeparator
|
||||
or System.Globalization.UnicodeCategory.ParagraphSeparator);
|
||||
|
||||
/// <summary>Signe le trousseau avec la clé racine (LauncherPublisher).</summary>
|
||||
public static KeyringDocument Sign(KeyringDocument keyring, ECDsa rootKey)
|
||||
{
|
||||
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
|
||||
var unsigned = keyring with { Signature = null, IssuedAt = ContentSigning.Truncate(keyring.IssuedAt) };
|
||||
var signature = AdminKeys.Sign(rootKey, SignedMessages.Keyring(rootKeyId, Digest(unsigned)));
|
||||
return unsigned with { Signature = new KeyringSignature { RootKeyId = rootKeyId, Value = Convert.ToBase64String(signature) } };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la signature racine avec les clés publiques intégrées au launcher (celles de update-signing.pub).
|
||||
/// </summary>
|
||||
/// <param name="rootKeys">Identifiant et clé publique SPKI de chaque clé racine de confiance.</param>
|
||||
public static bool VerifyRoot(KeyringDocument keyring, IEnumerable<(string KeyId, byte[] PublicKey)> rootKeys, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (keyring.Signature is not { } signature)
|
||||
{
|
||||
error = "trousseau non signé par la clé racine";
|
||||
return false;
|
||||
}
|
||||
var root = rootKeys.FirstOrDefault(k => string.Equals(k.KeyId, signature.RootKeyId, StringComparison.OrdinalIgnoreCase));
|
||||
if (root.PublicKey is null)
|
||||
{
|
||||
error = $"trousseau signé par une clé racine inconnue de ce launcher ({signature.RootKeyId})";
|
||||
return false;
|
||||
}
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value ||
|
||||
!AdminKeys.Verify(root.PublicKey, SignedMessages.Keyring(signature.RootKeyId.ToLowerInvariant(), Digest(keyring)), value))
|
||||
{
|
||||
error = "SIGNATURE DU TROUSSEAU INVALIDE";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>Mise en ligne d'une version : signature du manifest par l'administrateur qui publie.</summary>
|
||||
public sealed record PublicationCommitRequest
|
||||
{
|
||||
/// <summary>Signature du contenu du manifest envoyé (<see cref="ContentSigning.ManifestDigest(ManifestContent)"/>).</summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du manifest en ligne d'un jeu (avec le condensé calculé par le serveur).</summary>
|
||||
public sealed record ManifestSignatureState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Condensé du contenu signé de la version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé, si le serveur la connaît.</summary>
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature de la version en ligne (déjà publiée, par exemple avec WinSCP) à partir du manifest.json du dossier
|
||||
/// de publication : le serveur ne l'accepte que si le contenu en ligne est identique.
|
||||
/// </summary>
|
||||
public sealed record ManifestSignRequest
|
||||
{
|
||||
/// <summary>Condensé calculé par le launcher à partir du manifest.json local.</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Signatures du contenu, pour la page « Signature » (propriétaires).</summary>
|
||||
public sealed record SigningState
|
||||
{
|
||||
/// <summary>Trousseau publié (null : aucun).</summary>
|
||||
public KeyringDocument? Keyring { get; init; }
|
||||
|
||||
/// <summary>Faux : publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou catalogue absent).</summary>
|
||||
public bool KeyringPublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du trousseau (à mettre dans « keyringUrl » de launcher.json).</summary>
|
||||
public string? KeyringPublicUrl { get; init; }
|
||||
|
||||
/// <summary>Vrai : le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require).</summary>
|
||||
public bool RequireSignatures { get; init; }
|
||||
|
||||
public List<ManifestSignatureState> Manifests { get; init; } = new();
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
{
|
||||
public long Revision { get; init; }
|
||||
public string Digest { get; init; } = "";
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Applique le catalogue aux jeux déclarés dans launcher.json — affichage uniquement :
|
||||
/// • jeux inconnus de launcher.json : ignorés (le serveur ne peut ni ajouter ni retirer un jeu) ;
|
||||
/// • nom AFFICHÉ seulement (raccourci, registre et dossier des données gardent le nom de launcher.json) ;
|
||||
/// • thème : seulement un thème intégré au launcher ;
|
||||
/// • images : seulement des fichiers du cache local (téléchargés et contrôlés par <see cref="CatalogService"/>) ;
|
||||
/// • jeu masqué : toujours affiché s'il est installé sur le PC ou demandé (raccourci, désinstallation),
|
||||
/// et au moins un jeu reste affiché.
|
||||
/// Peut être appliqué plusieurs fois (cache au démarrage, puis version à jour) : les valeurs de
|
||||
/// launcher.json sont rétablies avant chaque application.
|
||||
/// </summary>
|
||||
public static class CatalogMerger
|
||||
{
|
||||
/// <summary>Valeurs de launcher.json avant toute application du catalogue.</summary>
|
||||
private sealed record Baseline(string Tagline, string? WebsiteUrl, string Theme, string? Icon, string? Background, int UpdateCheckIntervalMinutes);
|
||||
|
||||
private static readonly ConditionalWeakTable<LauncherConfig, Baseline> Baselines = new();
|
||||
|
||||
/// <param name="builtInThemes">Thèmes intégrés au launcher (les autres valeurs sont ignorées).</param>
|
||||
/// <param name="localImage">Fichier local d'une image du catalogue (null : pas encore en cache → image par défaut).</param>
|
||||
/// <param name="mustStayVisible">Jeu installé sur ce PC, ou demandé en ligne de commande : jamais masqué.</param>
|
||||
public static void Apply(LauncherCatalog launcher, CatalogDocument catalog, IReadOnlyCollection<string> builtInThemes,
|
||||
Func<string?, string?> localImage, Func<LauncherConfig, bool> mustStayVisible)
|
||||
{
|
||||
var declared = launcher.DeclaredGames;
|
||||
foreach (var game in declared)
|
||||
{
|
||||
Restore(game);
|
||||
if (!catalog.Games.TryGetValue(game.GameId, out var entry)) continue;
|
||||
|
||||
if (entry.GameName is { } name) game.DisplayName = name;
|
||||
if (entry.Tagline is { } tagline) game.Tagline = tagline;
|
||||
if (entry.Description is { } description) game.Description = description;
|
||||
if (entry.WebsiteUrl is { } website) game.WebsiteUrl = website;
|
||||
if (entry.Links is { Count: > 0 } links)
|
||||
game.Links = links.Select(l => new GameLink(l.Kind, l.Label ?? CatalogLinkKinds.DefaultLabel(l.Kind), l.Url)).ToList();
|
||||
if (entry.Theme is { } theme)
|
||||
{
|
||||
if (builtInThemes.Contains(theme, StringComparer.OrdinalIgnoreCase)) game.Theme = theme;
|
||||
else Log.Warn($"Catalogue : thème « {theme} » inconnu de cette version du launcher ({game.GameId}), ignoré.");
|
||||
}
|
||||
if (localImage(entry.Icon) is { } icon) game.Icon = icon;
|
||||
if (localImage(entry.Background) is { } background) game.Background = background;
|
||||
if (entry.UpdateCheckIntervalMinutes is { } interval) game.UpdateCheckIntervalMinutes = interval;
|
||||
}
|
||||
|
||||
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
|
||||
var ordered = new List<LauncherConfig>();
|
||||
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
|
||||
if (declared.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
|
||||
ordered.Add(game);
|
||||
ordered.AddRange(declared.Where(g => !ordered.Contains(g)));
|
||||
|
||||
// Visibilité.
|
||||
var visible = ordered.Where(g => !(catalog.Games.TryGetValue(g.GameId, out var entry) && entry.Hidden == true) || mustStayVisible(g))
|
||||
.ToList();
|
||||
if (visible.Count == 0) visible.Add(ordered[0]);
|
||||
|
||||
var launcherName = catalog.Launcher?.LauncherName ?? launcher.DeclaredLauncherName;
|
||||
var defaultGame = catalog.Launcher?.DefaultGame is { } requested && visible.Any(g => string.Equals(g.GameId, requested, StringComparison.OrdinalIgnoreCase))
|
||||
? requested
|
||||
: launcher.DeclaredDefaultGameId;
|
||||
launcher.ApplyDisplay(launcherName, defaultGame, visible);
|
||||
}
|
||||
|
||||
/// <summary>Rétablit les valeurs de launcher.json (enregistrées à la première application).</summary>
|
||||
private static void Restore(LauncherConfig game)
|
||||
{
|
||||
var baseline = Baselines.GetValue(game, g => new Baseline(g.Tagline, g.WebsiteUrl, g.Theme, g.Icon, g.Background, g.UpdateCheckIntervalMinutes));
|
||||
game.DisplayName = null;
|
||||
game.Description = null;
|
||||
game.Links = Array.Empty<GameLink>();
|
||||
game.Tagline = baseline.Tagline;
|
||||
game.WebsiteUrl = baseline.WebsiteUrl;
|
||||
game.Theme = baseline.Theme;
|
||||
game.Icon = baseline.Icon;
|
||||
game.Background = baseline.Background;
|
||||
game.UpdateCheckIntervalMinutes = baseline.UpdateCheckIntervalMinutes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux côté joueur : catalog.json (statique, publié depuis le menu d'administration)
|
||||
/// téléchargé, contrôlé et borné (<see cref="CatalogRules"/> : une valeur invalide est ignorée), puis
|
||||
/// mis en cache dans le dossier des données du launcher. Sans réseau : dernier cache, sinon les
|
||||
/// valeurs de launcher.json. Images (icône, fond) : PNG ou JPEG du serveur du catalogue, 4 Mo au plus,
|
||||
/// téléchargées une fois puis conservées en cache.
|
||||
/// </summary>
|
||||
public sealed class CatalogService
|
||||
{
|
||||
public const long MaxImageBytes = 4 * 1024 * 1024;
|
||||
private const string FolderName = "catalog";
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri _catalogUrl;
|
||||
|
||||
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ContentTrustService? _trust;
|
||||
|
||||
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl, ContentTrustService? trust = null)
|
||||
: this(http.GetBytesAsync, dataDirectory, catalogUrl, trust) { }
|
||||
|
||||
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
|
||||
/// <param name="trust">Vérification de la signature (phase 7) : en mode « require », un catalogue refusé est ignoré.</param>
|
||||
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl,
|
||||
ContentTrustService? trust = null)
|
||||
{
|
||||
_download = download;
|
||||
_catalogUrl = catalogUrl;
|
||||
_trust = trust;
|
||||
Directory = Path.Combine(dataDirectory, FolderName);
|
||||
CachePath = Path.Combine(Directory, "catalog.json");
|
||||
ImagesDirectory = Path.Combine(Directory, "images");
|
||||
}
|
||||
|
||||
public string Directory { get; }
|
||||
public string CachePath { get; }
|
||||
public string ImagesDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue du cache (null : absent, illisible ou, en mode « require », non signé). Le cache contient le
|
||||
/// catalogue tel que publié (signature comprise) : il est revérifié puis contrôlé à chaque lecture.
|
||||
/// </summary>
|
||||
public CatalogDocument? LoadCached()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
|
||||
if (ParseRaw(File.ReadAllBytes(CachePath)) is not { } raw) return null;
|
||||
if (_trust is not null && !_trust.VerifyCachedCatalog(raw)) return null;
|
||||
return CatalogRules.Normalize(raw, _catalogUrl.Host, new List<string>());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
|
||||
{
|
||||
Log.Warn($"Catalogue en cache illisible : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge le catalogue, le contrôle et le met en cache. Null si le serveur ne répond pas ou si
|
||||
/// le fichier est inutilisable (le cache est alors conservé tel quel).
|
||||
/// </summary>
|
||||
public async Task<CatalogDocument?> FetchAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
|
||||
if (ParseRaw(bytes) is not { } raw) return null;
|
||||
// Signature vérifiée sur le catalogue tel que publié, avant tout contrôle des valeurs.
|
||||
if (_trust is not null && !await _trust.VerifyCatalogAsync(raw, ct).ConfigureAwait(false)) return null;
|
||||
var errors = new List<string>();
|
||||
var catalog = CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
|
||||
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
|
||||
|
||||
// Cache : le catalogue tel que publié (la signature reste vérifiable hors ligne).
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(raw, ApiJson.Options));
|
||||
return catalog;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
|
||||
InvalidDataException or JsonException or OperationCanceledException)
|
||||
{
|
||||
Log.Warn($"Catalogue des jeux indisponible ({_catalogUrl.Host}) : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Catalogue tel que publié (null : format non pris en charge).</summary>
|
||||
private static CatalogDocument? ParseRaw(byte[] bytes)
|
||||
{
|
||||
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
|
||||
?? throw new InvalidDataException("catalogue vide");
|
||||
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
|
||||
{
|
||||
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images
|
||||
|
||||
/// <summary>Image déjà en cache (null sinon) : utilisable immédiatement, même hors ligne.</summary>
|
||||
public string? CachedImage(string? url)
|
||||
{
|
||||
if (ImagePath(url) is not { } path) return null;
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image en cache, sinon téléchargée (PNG ou JPEG contrôlé). Null si indisponible. Appels simultanés
|
||||
/// pour la même image (démarrage, puis arrière-plan) : un seul téléchargement, partagé.
|
||||
/// </summary>
|
||||
public Task<string?> GetImageAsync(string? url)
|
||||
{
|
||||
if (ImagePath(url) is not { } path) return Task.FromResult<string?>(null);
|
||||
if (File.Exists(path)) return Task.FromResult<string?>(path);
|
||||
var download = _imageDownloads.GetOrAdd(path, _ => new Lazy<Task<string?>>(() => DownloadImageAsync(url!, path)));
|
||||
return download.Value;
|
||||
}
|
||||
|
||||
private async Task<string?> DownloadImageAsync(string url, string path)
|
||||
{
|
||||
var temp = $"{path}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
var bytes = await _download(new Uri(url), MaxImageBytes, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!IsPngOrJpeg(bytes)) throw new InvalidDataException("ce n'est pas une image PNG ou JPEG");
|
||||
System.IO.Directory.CreateDirectory(ImagesDirectory);
|
||||
await File.WriteAllBytesAsync(temp, bytes).ConfigureAwait(false);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
return path;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
|
||||
InvalidDataException or OperationCanceledException)
|
||||
{
|
||||
FileUtil.TryDeleteFile(temp);
|
||||
Log.Warn($"Image du catalogue indisponible ({url}) : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_imageDownloads.TryRemove(path, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Supprime les images du cache qui ne sont plus utilisées par le catalogue.</summary>
|
||||
public void PruneImages(CatalogDocument catalog)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.Directory.Exists(ImagesDirectory)) return;
|
||||
var used = catalog.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
|
||||
.Select(ImagePath).Where(p => p is not null).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var file in System.IO.Directory.GetFiles(ImagesDirectory))
|
||||
{
|
||||
if (used.Contains(file)) continue;
|
||||
// Fichier temporaire : peut-être un téléchargement en cours (supprimé s'il est ancien).
|
||||
if (file.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) &&
|
||||
DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < TimeSpan.FromHours(1))
|
||||
continue;
|
||||
FileUtil.TryDeleteFile(file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Nettoyage des images du catalogue : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier du cache pour une image (nom = empreinte de l'adresse) ; null si l'adresse est refusée.</summary>
|
||||
private string? ImagePath(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Host, _catalogUrl.Host, StringComparison.OrdinalIgnoreCase) ||
|
||||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
|
||||
return null;
|
||||
var extension = uri.AbsolutePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? ".png" : ".jpg";
|
||||
var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32].ToLowerInvariant();
|
||||
return Path.Combine(ImagesDirectory, name + extension);
|
||||
}
|
||||
|
||||
internal static bool IsPngOrJpeg(ReadOnlySpan<byte> data) =>
|
||||
data.StartsWith(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }) ||
|
||||
data.StartsWith(new byte[] { 0xFF, 0xD8, 0xFF });
|
||||
}
|
||||
@@ -13,4 +13,9 @@
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Catalogue des jeux (catalog.json) : format et règles partagés avec la Launcher API. -->
|
||||
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
@@ -39,9 +41,28 @@ public sealed class GameManifest
|
||||
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signature de l'administrateur qui a mis la version en ligne (phase 7), vérifiée avec le trousseau
|
||||
/// (<see cref="Trust.ContentTrustService"/>). Absente des manifests produits par ManifestBuilder.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long TotalSize => Files.Sum(f => f.Size);
|
||||
|
||||
/// <summary>Partie couverte par la signature (tout ce qui décide de ce qui est installé et exécuté).</summary>
|
||||
public ManifestContent ToSignedContent() => new()
|
||||
{
|
||||
FormatVersion = FormatVersion,
|
||||
GameId = GameId,
|
||||
Version = Version,
|
||||
Executable = Executable,
|
||||
LaunchArguments = LaunchArguments,
|
||||
Archive = new ManifestContentArchive { Url = Archive.Url, Size = Archive.Size, Sha256 = Archive.Sha256 },
|
||||
ObjectsBaseUrl = ObjectsBaseUrl,
|
||||
Files = Files.Select(f => new ManifestContentFile { Path = f.Path, Size = f.Size, Sha256 = f.Sha256 }).ToList()
|
||||
};
|
||||
|
||||
/// <summary>URL d'où le manifest a été téléchargé (sert à résoudre les URL relatives).</summary>
|
||||
[JsonIgnore]
|
||||
public Uri? SourceUri { get; set; }
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
@@ -34,21 +34,34 @@ public sealed class LauncherCatalog
|
||||
Publisher = publisher;
|
||||
DefaultGameId = defaultGameId;
|
||||
Games = games;
|
||||
DeclaredGames = games;
|
||||
DeclaredLauncherName = launcherName;
|
||||
DeclaredDefaultGameId = defaultGameId;
|
||||
IsMultiGameFormat = isMultiGameFormat;
|
||||
}
|
||||
|
||||
/// <summary>Identifiant technique du launcher (dossier de données, instance unique).</summary>
|
||||
public string LauncherId { get; }
|
||||
|
||||
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés.</summary>
|
||||
public string LauncherName { get; }
|
||||
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés (modifiable par le catalogue).</summary>
|
||||
public string LauncherName { get; private set; }
|
||||
|
||||
public string Publisher { get; }
|
||||
|
||||
/// <summary>Jeu sélectionné au premier lancement (sinon le premier de la liste).</summary>
|
||||
public string? DefaultGameId { get; }
|
||||
public string? DefaultGameId { get; private set; }
|
||||
|
||||
public IReadOnlyList<LauncherConfig> Games { get; }
|
||||
/// <summary>Jeux affichés, dans l'ordre (le catalogue peut changer l'ordre et masquer des jeux non installés).</summary>
|
||||
public IReadOnlyList<LauncherConfig> Games { get; private set; }
|
||||
|
||||
/// <summary>Tous les jeux déclarés dans launcher.json, y compris ceux masqués par le catalogue.</summary>
|
||||
public IReadOnlyList<LauncherConfig> DeclaredGames { get; }
|
||||
|
||||
/// <summary>Nom du launcher selon launcher.json (avant le catalogue).</summary>
|
||||
public string DeclaredLauncherName { get; }
|
||||
|
||||
/// <summary>Jeu par défaut selon launcher.json (avant le catalogue).</summary>
|
||||
public string? DeclaredDefaultGameId { get; }
|
||||
|
||||
/// <summary>Vrai si launcher.json utilise le tableau "games" (même avec un seul jeu).</summary>
|
||||
public bool IsMultiGameFormat { get; }
|
||||
@@ -62,6 +75,65 @@ public sealed class LauncherCatalog
|
||||
/// </summary>
|
||||
public string? LauncherUpdateUrl { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse de la Launcher API (menu d'administration), ex. « https://launcher.lanisys.fr/api/ ».
|
||||
/// Facultatif : sans cette valeur, le menu d'administration est désactivé. Le launcher publié ne
|
||||
/// lit cette valeur que dans le launcher.json INTÉGRÉ à l'exe (voir App) : un fichier posé à côté
|
||||
/// ne peut pas rediriger les administrateurs vers un autre serveur.
|
||||
/// Une valeur invalide ne bloque jamais le launcher des joueurs : elle est ignorée
|
||||
/// (<see cref="ApiUrl"/> null) et expliquée dans <see cref="ApiUrlError"/>.
|
||||
/// </summary>
|
||||
public string? ApiUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « apiUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? ApiUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du catalogue des jeux (catalog.json : informations modifiables publiées depuis le menu
|
||||
/// d'administration), ex. « https://launcher.lanisys.fr/catalog/catalog.json ». Facultatif. Comme
|
||||
/// <see cref="ApiUrl"/>, le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public string? CatalogUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? CatalogUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du trousseau (keyring.json : clés autorisées à signer le contenu, signé par la clé racine du
|
||||
/// launcher), ex. « https://launcher.lanisys.fr/catalog/keyring.json ». Facultatif. Comme <see cref="ApiUrl"/>,
|
||||
/// le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public string? KeyringUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « keyringUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? KeyringUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « contentSigning » : vérification des signatures du manifest et du catalogue. « warn » (défaut si
|
||||
/// keyringUrl est renseignée) : un contenu non signé ou mal signé est signalé dans le journal mais utilisé ;
|
||||
/// « require » : il est refusé (aucune installation ni mise à jour, catalogue ignoré) ; « off » : rien n'est vérifié.
|
||||
/// Comme keyringUrl, lu uniquement dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public Trust.ContentSigningMode ContentSigning { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « contentSigning » a été ignorée ou est incohérente, sinon null.</summary>
|
||||
public string? ContentSigningError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « keyringMinSequence » : numéro minimal du trousseau accepté (0 par défaut). Un trousseau plus ancien, même
|
||||
/// signé par la clé racine, est refusé : à relever dans le launcher publié après le retrait d'une clé compromise,
|
||||
/// pour qu'une installation neuve n'accepte pas un ancien trousseau qui la contenait encore. Intégré à l'exe.
|
||||
/// </summary>
|
||||
public long KeyringMinSequence { get; private set; }
|
||||
|
||||
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
|
||||
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
|
||||
{
|
||||
LauncherName = launcherName;
|
||||
DefaultGameId = defaultGameId;
|
||||
Games = games;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom du dossier des données du launcher et de sa clé de registre (« dataFolder » dans
|
||||
/// launcher.json, « GameLauncher » par défaut). Indépendant de <see cref="LauncherId"/>, qui
|
||||
@@ -202,10 +274,11 @@ public sealed class LauncherCatalog
|
||||
? $@"Local\{Publisher}.{Games[0].GameId}.Launcher"
|
||||
: $@"Local\{Publisher}.{LauncherId}";
|
||||
|
||||
/// <summary>Jeu déclaré dans launcher.json (même s'il est masqué par le catalogue).</summary>
|
||||
public LauncherConfig? Find(string? gameId) =>
|
||||
string.IsNullOrWhiteSpace(gameId)
|
||||
? null
|
||||
: Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
|
||||
: DeclaredGames.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static LauncherCatalog Load(string path)
|
||||
{
|
||||
@@ -240,8 +313,12 @@ public sealed class LauncherCatalog
|
||||
new[] { single }, isMultiGameFormat: false)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var singleApiUrlError),
|
||||
ApiUrlError = singleApiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
|
||||
CatalogUrlError = singleCatalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
if (gamesNode is not JsonArray array || array.Count == 0)
|
||||
@@ -291,8 +368,42 @@ public sealed class LauncherCatalog
|
||||
return new LauncherCatalog(launcherId, launcherName, publisher, defaultGame, games, isMultiGameFormat: true)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var apiUrlError),
|
||||
ApiUrlError = apiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
|
||||
CatalogUrlError = catalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
/// <summary>« keyringUrl », « contentSigning » et « keyringMinSequence » (phase 7).</summary>
|
||||
private LauncherCatalog WithContentSigning(JsonObject root)
|
||||
{
|
||||
KeyringUrl = ReadTrustedUrl(root, "keyringUrl", "https://launcher.exemple.fr/catalog/keyring.json", out var keyringError);
|
||||
KeyringUrlError = keyringError;
|
||||
var text = Get(root, "contentSigning") is JsonValue value && value.TryGetValue<string>(out var s) ? s.Trim() : null;
|
||||
var declared = Get(root, "contentSigning") is not null;
|
||||
(ContentSigning, ContentSigningError) = text?.ToLowerInvariant() switch
|
||||
{
|
||||
"off" => (Trust.ContentSigningMode.Off, null),
|
||||
"warn" => (Trust.ContentSigningMode.Warn, null),
|
||||
"require" => (Trust.ContentSigningMode.Require, null),
|
||||
_ when !declared => (KeyringUrl is null ? Trust.ContentSigningMode.Off : Trust.ContentSigningMode.Warn, null),
|
||||
// Valeur invalide : prudence (vérification sans blocage), signalée dans le journal.
|
||||
_ => (Trust.ContentSigningMode.Warn,
|
||||
"launcher.json : contentSigning ignorée (valeurs possibles : \"off\", \"warn\", \"require\") : vérification sans blocage (« warn »).")
|
||||
};
|
||||
if (Get(root, "keyringMinSequence") is { } minNode)
|
||||
{
|
||||
if (minNode is JsonValue minValue && minValue.TryGetValue<long>(out var min) && min >= 0)
|
||||
KeyringMinSequence = min;
|
||||
else
|
||||
ContentSigningError ??= "launcher.json : keyringMinSequence ignorée (nombre entier positif attendu).";
|
||||
}
|
||||
if (ContentSigning == Trust.ContentSigningMode.Require && KeyringUrl is null)
|
||||
ContentSigningError = "launcher.json : contentSigning « require » sans keyringUrl valide : aucun manifest ni catalogue ne pourra être " +
|
||||
"vérifié, donc aucune installation ni mise à jour ne sera possible.";
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
@@ -315,6 +426,42 @@ public sealed class LauncherCatalog
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>« apiUrl » : adresse https absolue, sans paramètres (http accepté seulement en local, pour les tests).</summary>
|
||||
/// <summary>Adresse https absolue, sans paramètres ni identifiants (http autorisé seulement en local) ; null si absente ou invalide.</summary>
|
||||
private static string? ReadTrustedUrl(JsonObject root, string name, string example, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var node = Get(root, name);
|
||||
if (node is null) return null;
|
||||
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
|
||||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
|
||||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
error = $"launcher.json : {name} ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"{example}\".";
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
private static string? ReadApiUrl(JsonObject root, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var node = Get(root, "apiUrl");
|
||||
if (node is null) return null;
|
||||
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
|
||||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
|
||||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
error = "launcher.json : apiUrl ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"https://launcher.exemple.fr/api/\".";
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri.EndsWith('/') ? uri.AbsoluteUri : uri.AbsoluteUri + "/";
|
||||
}
|
||||
|
||||
/// <summary>Lecture d'une propriété sans tenir compte de la casse (gameId / GameId…).</summary>
|
||||
private static JsonNode? Get(JsonObject obj, string name)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
@@ -47,6 +48,27 @@ public sealed class LauncherConfig
|
||||
/// <summary>Image de fond remplaçant celle du thème (image, chemin relatif au launcher). Facultatif.</summary>
|
||||
public string? Background { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (affichage modifiable)
|
||||
|
||||
/// <summary>
|
||||
/// Nom AFFICHÉ, donné par le catalogue. <see cref="GameName"/> reste le nom de launcher.json : celui
|
||||
/// du raccourci du Bureau, de « Applications installées » et du dossier des données du jeu.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>Nom à afficher : celui du catalogue, sinon celui de launcher.json.</summary>
|
||||
[JsonIgnore]
|
||||
public string ShownName => string.IsNullOrWhiteSpace(DisplayName) ? GameName : DisplayName;
|
||||
|
||||
/// <summary>Présentation du jeu (catalogue, texte brut de plusieurs lignes).</summary>
|
||||
[JsonIgnore]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Liens affichés sous la présentation (catalogue).</summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<GameLink> Links { get; set; } = Array.Empty<GameLink>();
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du jeu Unity : « Société\Produit » des Player Settings de Unity
|
||||
/// (ex. "LanisysGames\\Exovival Zombie Reborn"). Sert à ouvrir, depuis les paramètres du jeu,
|
||||
@@ -127,3 +149,7 @@ public sealed class LauncherConfig
|
||||
part.Trim() == part && part.IndexOfAny(invalid) < 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lien affiché sous la présentation d'un jeu (Discord, YouTube…), adresse https contrôlée.</summary>
|
||||
/// <param name="Kind">Type de lien (icône intégrée) : website, discord, youtube, x, twitch, steam, other.</param>
|
||||
public sealed record GameLink(string Kind, string Label, string Url);
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Core.SelfUpdate;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Historique des notes de version lors d'une publication (ManifestBuilder). Avec --remote-manifest,
|
||||
/// l'historique est repris du manifest EN LIGNE : les corrections faites depuis le menu
|
||||
/// d'administration ne sont pas écrasées par le manifest.json local (resté dans son état d'origine).
|
||||
/// </summary>
|
||||
public static class ChangelogHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest publié (null s'il n'existe pas encore : première publication). Toute autre erreur
|
||||
/// (réseau, fichier illisible) est signalée : publier sans elle perdrait les corrections en ligne.
|
||||
/// </summary>
|
||||
public static async Task<GameManifest?> FetchPublishedAsync(HttpDownloader http, Uri url, CancellationToken ct = default)
|
||||
{
|
||||
if (url.Scheme != Uri.UriSchemeHttps && !(url.Scheme == Uri.UriSchemeHttp && url.IsLoopback))
|
||||
throw new ArgumentException($"Adresse du manifest en ligne : https:// attendu ({url}).");
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = await http.GetStringAsync(url, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)
|
||||
?? throw new InvalidDataException("manifest vide");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"manifest illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes de la version publiée en tête, puis l'historique (sans cette version si elle est republiée
|
||||
/// avec de nouvelles notes), bornés comme par le launcher. Sans nouvelles notes (pas de --notes),
|
||||
/// les notes existantes de la version, éventuellement corrigées en ligne, sont conservées.
|
||||
/// </summary>
|
||||
public static List<ChangelogEntry> Build(IEnumerable<ChangelogEntry?>? history, ChangelogEntry entry)
|
||||
{
|
||||
var list = (history ?? Enumerable.Empty<ChangelogEntry?>()).OfType<ChangelogEntry>().ToList();
|
||||
if (!entry.IsEmpty)
|
||||
{
|
||||
list.RemoveAll(c => string.Equals(c.Version, entry.Version, StringComparison.Ordinal));
|
||||
list.Insert(0, entry);
|
||||
}
|
||||
return ChangelogLimits.Normalize(list);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -11,8 +11,15 @@ namespace GameLauncher.Core.Services;
|
||||
public sealed class HttpDownloader : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(30);
|
||||
private const int MaxSameHostRedirects = 3;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
/// <summary>
|
||||
/// Client SANS redirection automatique (catalogue, images) : une redirection n'est suivie que vers le
|
||||
/// même serveur, après vérification, et aucune requête ne part vers un autre serveur.
|
||||
/// </summary>
|
||||
private readonly HttpClient _strictHttp;
|
||||
|
||||
public HttpDownloader(string userAgent)
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
@@ -25,6 +32,15 @@ public sealed class HttpDownloader : IDisposable
|
||||
};
|
||||
_http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
|
||||
_strictHttp = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(15),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
_strictHttp.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
/// <summary>Taille maximale par défaut d'un document texte (manifest du jeu).</summary>
|
||||
@@ -79,6 +95,64 @@ public sealed class HttpDownloader : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge un petit fichier en mémoire (catalogue, image) sans passer par les caches, en
|
||||
/// refusant toute redirection vers un autre serveur ou vers une adresse non https.
|
||||
/// Au-delà de <paramref name="maxBytes"/> octets, le téléchargement est interrompu.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GetBytesAsync(Uri uri, long maxBytes, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
var current = uri;
|
||||
HttpResponseMessage response;
|
||||
for (var redirects = 0; ; redirects++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true };
|
||||
request.Headers.Pragma.ParseAdd("no-cache");
|
||||
response = await _strictHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
var status = (int)response.StatusCode;
|
||||
if (status is < 300 or > 399 || status == 304) break;
|
||||
|
||||
// Redirection : suivie seulement vers le même serveur (hôte, schéma, port), quelques fois au plus.
|
||||
var location = response.Headers.Location;
|
||||
response.Dispose();
|
||||
var next = location is null ? null : location.IsAbsoluteUri ? location : new Uri(current, location);
|
||||
if (next is null || redirects >= MaxSameHostRedirects ||
|
||||
!string.Equals(next.Host, uri.Host, StringComparison.OrdinalIgnoreCase) ||
|
||||
next.Scheme != uri.Scheme || next.Port != uri.Port)
|
||||
throw new InvalidDataException($"Redirection refusée ({uri.Host} → {next?.Host ?? "?"}).");
|
||||
current = next;
|
||||
}
|
||||
|
||||
using var _ = response;
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength > maxBytes)
|
||||
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[81920];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, timeout.Token).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > maxBytes)
|
||||
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Le serveur ne répond pas ({uri.Host}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge <paramref name="uri"/> vers <paramref name="destination"/>.
|
||||
/// Si <paramref name="allowResume"/> est vrai et qu'un fichier partiel existe, le
|
||||
@@ -155,5 +229,9 @@ public sealed class HttpDownloader : IDisposable
|
||||
throw new IOException($"Téléchargement incomplet ({Format.Bytes(position)} sur {Format.Bytes(expectedSize)}).");
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
_strictHttp.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
@@ -8,11 +10,16 @@ public sealed class ManifestClient
|
||||
{
|
||||
private readonly HttpDownloader _http;
|
||||
private readonly Uri _manifestUri;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly string? _gameId;
|
||||
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri)
|
||||
/// <param name="trust">Vérification des signatures (phase 7), avec l'identifiant du jeu de launcher.json.</param>
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri, ContentTrustService? trust = null, string? gameId = null)
|
||||
{
|
||||
_http = http;
|
||||
_manifestUri = manifestUri;
|
||||
_trust = trust;
|
||||
_gameId = gameId;
|
||||
}
|
||||
|
||||
public async Task<GameManifest> FetchAsync(CancellationToken ct = default)
|
||||
@@ -31,6 +38,8 @@ public sealed class ManifestClient
|
||||
|
||||
manifest.SourceUri = _manifestUri;
|
||||
manifest.Validate();
|
||||
// Signature (phase 7) : refus en mode « require » (ContentSecurityException), sinon journal seulement.
|
||||
if (_trust is not null && _gameId is not null) await _trust.VerifyManifestAsync(manifest, _gameId, ct).ConfigureAwait(false);
|
||||
// Notes de version bornées et nettoyées : elles sont affichées telles quelles dans l'interface.
|
||||
manifest.Changelog = ChangelogLimits.Normalize(manifest.Changelog);
|
||||
Log.Info($"Manifest récupéré : version {manifest.Version}, {manifest.Files.Count} fichiers, {Format.Bytes(manifest.TotalSize)}.");
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Trust;
|
||||
|
||||
/// <summary>Vérification des signatures du contenu (« contentSigning » de launcher.json).</summary>
|
||||
public enum ContentSigningMode
|
||||
{
|
||||
/// <summary>Rien n'est vérifié (comportement antérieur à la phase 7).</summary>
|
||||
Off,
|
||||
|
||||
/// <summary>Signatures vérifiées ; un problème est inscrit au journal, le contenu est utilisé quand même (mise en place).</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Un contenu non signé, mal signé ou plus ancien que le dernier vu est refusé.</summary>
|
||||
Require
|
||||
}
|
||||
|
||||
/// <summary>Contenu refusé (mode « require ») : signature absente ou invalide, trousseau indisponible, retour en arrière.</summary>
|
||||
public sealed class ContentSecurityException : Exception
|
||||
{
|
||||
public ContentSecurityException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confiance dans le contenu servi par le serveur (phase 7) :
|
||||
/// • trousseau (keyring.json) téléchargé puis vérifié avec la ou les clés RACINE intégrées à l'exe (celles des
|
||||
/// mises à jour du launcher), conservé en cache ; un trousseau plus ancien que le dernier vu est refusé ;
|
||||
/// • manifest et catalogue : signature d'une clé du trousseau autorisée pour ce contenu, sur le condensé calculé
|
||||
/// ici à partir de ce qui a été téléchargé ; un contenu signé plus tôt que le dernier accepté est refusé (un
|
||||
/// serveur compromis ne peut pas resservir une ancienne version vulnérable) ;
|
||||
/// • mode « warn » : problèmes inscrits au journal sans blocage ; « require » : contenu refusé.
|
||||
/// État conservé dans le dossier des données du launcher (trust\content-trust.json).
|
||||
/// </summary>
|
||||
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);
|
||||
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri? _keyringUrl;
|
||||
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _lock = new();
|
||||
private Task<KeyringDocument?>? _keyringTask;
|
||||
private bool _keyringFetched;
|
||||
private DateTimeOffset _lastAttempt;
|
||||
private TrustState? _state;
|
||||
|
||||
/// <param name="rootKeys">Clés racine de confiance (update-signing.pub intégré à l'exe).</param>
|
||||
/// <param name="minKeyringSequence">
|
||||
/// Numéro minimal du trousseau (« keyringMinSequence » de launcher.json) : un trousseau plus ancien, même signé par
|
||||
/// la racine, est refusé. À relever après le retrait d'une clé compromise, pour les installations neuves.
|
||||
/// </param>
|
||||
/// <param name="time">Horloge (tests).</param>
|
||||
public ContentTrustService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri? keyringUrl,
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys, ContentSigningMode mode, long minKeyringSequence = 0,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_download = download;
|
||||
_time = time ?? TimeProvider.System;
|
||||
_keyringUrl = keyringUrl;
|
||||
_rootKeys = rootKeys;
|
||||
Mode = mode;
|
||||
MinKeyringSequence = Math.Max(0, minKeyringSequence);
|
||||
StatePath = Path.Combine(dataDirectory, "trust", "content-trust.json");
|
||||
}
|
||||
|
||||
public ContentSigningMode Mode { get; }
|
||||
public long MinKeyringSequence { get; }
|
||||
public string StatePath { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Trousseau
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
|
||||
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
|
||||
{
|
||||
Task<KeyringDocument?> task;
|
||||
lock (_lock)
|
||||
{
|
||||
var elapsed = _time.GetUtcNow() - _lastAttempt;
|
||||
if (_keyringTask is null
|
||||
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|
||||
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|
||||
{
|
||||
_lastAttempt = _time.GetUtcNow();
|
||||
_keyringTask = FetchKeyringAsync();
|
||||
}
|
||||
task = _keyringTask;
|
||||
}
|
||||
return await task.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Dernier trousseau vérifié en cache (sans réseau).</summary>
|
||||
public KeyringDocument? CachedKeyring => LoadState().Keyring;
|
||||
|
||||
private async Task<KeyringDocument?> FetchKeyringAsync()
|
||||
{
|
||||
var cached = LoadState().Keyring;
|
||||
if (_keyringUrl is null || Mode == ContentSigningMode.Off) return cached;
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(FetchTimeout);
|
||||
var bytes = await _download(_keyringUrl, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0]);
|
||||
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error);
|
||||
if (keyring.Sequence < MinKeyringSequence)
|
||||
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_keyringFetched = true;
|
||||
if (cached is not null)
|
||||
{
|
||||
if (keyring.Sequence < cached.Sequence)
|
||||
{
|
||||
Log.Error($"Trousseau n° {keyring.Sequence} refusé : plus ancien que le n° {cached.Sequence} déjà vu (retour en arrière).");
|
||||
return cached;
|
||||
}
|
||||
if (keyring.Sequence == cached.Sequence && Keyring.Digest(keyring) != Keyring.Digest(cached))
|
||||
{
|
||||
Log.Error($"Trousseau n° {keyring.Sequence} refusé : différent de celui déjà vu sous le même numéro.");
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
var state = LoadState();
|
||||
if (cached is null || keyring.Sequence > cached.Sequence)
|
||||
{
|
||||
state.Keyring = keyring;
|
||||
SaveState(state);
|
||||
Log.Info($"Trousseau n° {keyring.Sequence} vérifié ({keyring.Keys.Count} clé(s)).");
|
||||
}
|
||||
return keyring;
|
||||
}
|
||||
}
|
||||
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
|
||||
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande… : jamais d'exception vers l'appelant, nouvel essai plus tard.
|
||||
Log.Warn($"Trousseau injoignable : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu est utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Contenu
|
||||
|
||||
/// <summary>Vérifie le manifest d'un jeu (lève <see cref="ContentSecurityException"/> en mode « require »).</summary>
|
||||
public Task VerifyManifestAsync(GameManifest manifest, string gameId, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return Task.CompletedTask;
|
||||
return VerifyAsync(ContentKinds.Manifest, gameId, ContentSigning.ManifestDigest(manifest.ToSignedContent()), manifest.Signature,
|
||||
$"Manifest de {gameId} (version {manifest.Version})", ct);
|
||||
}
|
||||
|
||||
/// <summary>Vérifie le catalogue tel que téléchargé (avant tout contrôle des valeurs). Faux : à ignorer.</summary>
|
||||
public async Task<bool> VerifyCatalogAsync(CatalogDocument catalog, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Catalogue (révision {catalog.Revision})";
|
||||
try
|
||||
{
|
||||
if (ContentSigning.HasDuplicateGames(catalog))
|
||||
return Report(what, "jeux en double (identifiants ne différant que par la casse)");
|
||||
await VerifyAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, what, ct)
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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é.
|
||||
/// L'antériorité n'est pas contrôlée ici (le cache est ce qui a déjà été accepté).
|
||||
/// </summary>
|
||||
public bool VerifyCachedCatalog(CatalogDocument catalog)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Catalogue en cache (révision {catalog.Revision})";
|
||||
if (ContentSigning.HasDuplicateGames(catalog)) return Report(what, "jeux en double (identifiants ne différant que par la casse)");
|
||||
var keyring = LoadState().Keyring;
|
||||
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
|
||||
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, keyring);
|
||||
return check.Valid || Report(what, check.Reason);
|
||||
}
|
||||
|
||||
/// <summary>Problème : refus en mode « require » (faux), avertissement en mode « warn » (vrai).</summary>
|
||||
private bool Report(string what, string problem)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Require)
|
||||
{
|
||||
Log.Error($"{what} : {problem}. Contenu refusé (contentSigning « require »).");
|
||||
return false;
|
||||
}
|
||||
Log.Warn($"{what} : {problem}. Contenu utilisé quand même (contentSigning « warn »).");
|
||||
return true;
|
||||
}
|
||||
|
||||
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)))
|
||||
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
|
||||
|
||||
string problem;
|
||||
if (keyring is null)
|
||||
{
|
||||
problem = _keyringUrl is null ? "aucun trousseau configuré (keyringUrl)" : "trousseau indisponible (jamais téléchargé)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var check = ContentSigning.Verify(kind, id, digest, signature, keyring);
|
||||
if (!check.Valid)
|
||||
{
|
||||
problem = check.Reason;
|
||||
}
|
||||
else
|
||||
{
|
||||
var slot = $"{kind}:{ContentSigning.SignedId(kind, id)}";
|
||||
lock (_lock)
|
||||
{
|
||||
var state = LoadState();
|
||||
if (state.LastSigned.TryGetValue(slot, out var last) && signature!.SignedAt < last)
|
||||
{
|
||||
problem = $"signé le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC, plus ancien que le contenu déjà vu " +
|
||||
$"(signé le {last:yyyy-MM-dd HH:mm} UTC) : retour en arrière refusé";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Date enregistrée plafonnée : une signature datée loin dans le futur (horloge déréglée) ne doit
|
||||
// pas faire refuser toutes les publications suivantes.
|
||||
var ceiling = ContentSigning.Truncate(_time.GetUtcNow() + MaxFutureSignedAt);
|
||||
var recorded = signature!.SignedAt > ceiling ? ceiling : signature.SignedAt;
|
||||
if (recorded > last)
|
||||
{
|
||||
state.LastSigned[slot] = recorded;
|
||||
SaveState(state);
|
||||
}
|
||||
Log.Info($"{what} : {check.Reason}.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
private sealed class TrustState
|
||||
{
|
||||
public KeyringDocument? Keyring { get; set; }
|
||||
public Dictionary<string, DateTimeOffset> LastSigned { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>État en cache (le trousseau est revérifié à la lecture : un fichier modifié à la main est ignoré).</summary>
|
||||
private TrustState LoadState()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state is not null) return _state;
|
||||
var state = new TrustState();
|
||||
try
|
||||
{
|
||||
if (File.Exists(StatePath) && new FileInfo(StatePath).Length <= Keyring.MaxBytes * 2)
|
||||
{
|
||||
var read = JsonSerializer.Deserialize<TrustState>(File.ReadAllText(StatePath), ApiJson.Options);
|
||||
if (read is not null)
|
||||
{
|
||||
state.LastSigned = new Dictionary<string, DateTimeOffset>(read.LastSigned ?? new(), StringComparer.Ordinal);
|
||||
var errors = new List<string>();
|
||||
if (read.Keyring is { } keyring && Keyring.Validate(keyring, errors) && Keyring.VerifyRoot(keyring, _rootKeys, out _)
|
||||
&& keyring.Sequence >= MinKeyringSequence)
|
||||
state.Keyring = keyring;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"État de vérification des signatures illisible ({ex.Message}) : recréé.");
|
||||
}
|
||||
return _state = state;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveState(TrustState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!);
|
||||
FileUtil.WriteAllTextAtomic(StatePath, JsonSerializer.Serialize(state, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true }));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"État de vérification des signatures non enregistré : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>Textes communs des pages « Administrateurs » et « Mes appareils ».</summary>
|
||||
public static class AccountTexts
|
||||
{
|
||||
/// <summary>« 01/11/2026 à 13:40 » (heure du PC).</summary>
|
||||
public static string Date(DateTimeOffset value) =>
|
||||
value.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
public static string Status(string status) => status switch
|
||||
{
|
||||
AdminStatus.Active => "Actif",
|
||||
AdminStatus.Pending => "En attente d'approbation",
|
||||
AdminStatus.Revoked => "Révoqué",
|
||||
_ => status
|
||||
};
|
||||
|
||||
/// <summary>Ce que permet chaque rôle (liste des rôles proposés).</summary>
|
||||
public static string RoleHint(string role) => role switch
|
||||
{
|
||||
AdminRoles.Owner => "tous les droits, dont la gestion des administrateurs",
|
||||
AdminRoles.Admin => "jeux, notes de version, publication et journal d'audit",
|
||||
AdminRoles.Editor => "informations des jeux et notes de version",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public static string KeyLabel(string? label) => string.IsNullOrWhiteSpace(label) ? "Appareil sans nom" : label;
|
||||
|
||||
/// <summary>Son propre appareil en attente : l'empreinte se lit sur le nouveau PC.</summary>
|
||||
public const string OwnPendingText = "Empreinte affichée sur le nouveau PC : saisissez-la ci-dessous pour l'approuver.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actions communes aux pages de comptes (approuver ou révoquer un appareil, supprimer une invitation,
|
||||
/// afficher un code d'invitation) : confirmation, appel signé, puis rechargement de la page.
|
||||
/// </summary>
|
||||
public abstract class AccountsPanelBase : AdminPanelViewModel
|
||||
{
|
||||
private InvitationCodeViewModel? _createdInvitation;
|
||||
private string? _statusMessage;
|
||||
|
||||
protected AccountsPanelBase(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
|
||||
/// <summary>Faux pendant un échange avec le serveur (boutons des listes désactivés).</summary>
|
||||
public bool CanEdit => !IsBusy;
|
||||
|
||||
/// <summary>Résultat de la dernière action (« Appareil approuvé. »…).</summary>
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
/// <summary>Code d'une invitation tout juste créée : affiché une seule fois, jusqu'à « J'ai transmis le code ».</summary>
|
||||
public InvitationCodeViewModel? CreatedInvitation
|
||||
{
|
||||
get => _createdInvitation;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _createdInvitation, value)) return;
|
||||
OnPropertyChanged(nameof(HasCreatedInvitation));
|
||||
OnCommandsChanged(); // pas de nouvelle invitation tant qu'un code non transmis est affiché
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasCreatedInvitation => _createdInvitation is not null;
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
OnCommandsChanged();
|
||||
}
|
||||
|
||||
/// <summary>Réévalue les commandes propres à la page.</summary>
|
||||
protected virtual void OnCommandsChanged()
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
/// <summary>Relit les données de la page sur le serveur.</summary>
|
||||
protected abstract Task ReloadAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Modification signée, puis rechargement SÉPARÉ : si seul le rechargement échoue, la modification
|
||||
/// (faite sur le serveur) est quand même annoncée, avec l'erreur du rechargement.
|
||||
/// </summary>
|
||||
protected async Task<bool> ApplyAsync(Func<Task> change, string success)
|
||||
{
|
||||
StatusMessage = null;
|
||||
if (!await RunAsync(change)) return false;
|
||||
StatusMessage = success;
|
||||
await RunAsync(ReloadAsync);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void ShowInvitation(InvitationCreated created, string title, string instructions)
|
||||
{
|
||||
StatusMessage = null;
|
||||
CreatedInvitation = new InvitationCodeViewModel(created.Code, title, instructions, created.Invitation.ExpiresAt,
|
||||
Context.Dialogs, () => CreatedInvitation = null);
|
||||
}
|
||||
|
||||
internal async Task ApproveAsync(AdminKeyItem key)
|
||||
{
|
||||
StatusMessage = null;
|
||||
var typed = AdminKeys.NormalizeFingerprint(key.FingerprintInput);
|
||||
if (typed is null)
|
||||
{
|
||||
ErrorMessage = "Empreinte incomplète : saisissez les 20 caractères (5 groupes de 4) affichés sur le nouvel appareil.";
|
||||
return;
|
||||
}
|
||||
if (!Context.Dialogs.Confirm($"Approuver l'appareil « {key.Label} » ?",
|
||||
$"{key.AdminName} pourra se connecter depuis cet appareil.\n\nN'approuvez que si l'empreinte vous a été " +
|
||||
"communiquée par la personne elle-même (téléphone, message), et non recopiée depuis un message reçu d'un inconnu.",
|
||||
"Approuver"))
|
||||
return;
|
||||
await ApplyAsync(() => Context.Session.ApproveKeyAsync(key.AdminId, key.KeyId, typed),
|
||||
$"Appareil « {key.Label} » de {key.AdminName} approuvé.");
|
||||
}
|
||||
|
||||
internal async Task RevokeKeyAsync(AdminKeyItem key)
|
||||
{
|
||||
if (!Context.Dialogs.Confirm($"Révoquer l'appareil « {key.Label} » ?",
|
||||
$"{key.AdminName} ne pourra plus se connecter depuis cet appareil ; les sessions qui l'utilisent sont fermées " +
|
||||
"immédiatement. Une clé révoquée ne peut pas être réactivée.", "Révoquer", danger: true))
|
||||
return;
|
||||
await ApplyAsync(() => Context.Session.RevokeKeyAsync(key.AdminId, key.KeyId),
|
||||
$"Appareil « {key.Label} » de {key.AdminName} révoqué.");
|
||||
}
|
||||
|
||||
internal async Task DeleteInvitationAsync(InvitationItem invitation)
|
||||
{
|
||||
if (!Context.Dialogs.Confirm("Supprimer l'invitation ?",
|
||||
$"{invitation.Title} : le code ne pourra plus être utilisé.", "Supprimer", danger: true))
|
||||
return;
|
||||
await ApplyAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Context.Session.DeleteInvitationAsync(invitation.Id);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
// déjà utilisée ou expirée entre-temps
|
||||
}
|
||||
}, "Invitation supprimée.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Appareil (clé) d'un administrateur.</summary>
|
||||
public sealed class AdminKeyItem : ObservableObject
|
||||
{
|
||||
private string _fingerprintInput = "";
|
||||
private readonly string? _fingerprintWarning;
|
||||
|
||||
internal AdminKeyItem(AccountsPanelBase page, Guid adminId, string adminName, AdminKeySummary key, bool isCurrentSession,
|
||||
bool canApprove, string? approveHint, bool canRevoke, string pendingText)
|
||||
{
|
||||
// Cet appareil : empreinte calculée sur le PC (celle à dicter lors de la vérification du trousseau), pas celle
|
||||
// annoncée par le serveur ; une différence est signalée.
|
||||
var local = isCurrentSession ? page.Context.Session.LocalFingerprint : null;
|
||||
if (local is not null && key.Fingerprint is not null &&
|
||||
!string.Equals(AdminKeys.NormalizeFingerprint(local), AdminKeys.NormalizeFingerprint(key.Fingerprint), StringComparison.Ordinal))
|
||||
_fingerprintWarning = $" · ATTENTION : le serveur annonce une autre empreinte ({key.Fingerprint})";
|
||||
else if (local is not null)
|
||||
_fingerprintWarning = " · empreinte calculée sur ce PC";
|
||||
FingerprintText = local ?? key.Fingerprint ?? pendingText;
|
||||
AdminId = adminId;
|
||||
AdminName = adminName;
|
||||
KeyId = key.KeyId;
|
||||
Label = AccountTexts.KeyLabel(key.Label);
|
||||
Status = key.Status;
|
||||
IsCurrentSession = isCurrentSession;
|
||||
Fingerprint = local ?? key.Fingerprint;
|
||||
AddedText = $"Ajouté le {AccountTexts.Date(key.AddedAt)}";
|
||||
LastUsedText = key.LastUsedAt is { } used ? $"Dernière connexion le {AccountTexts.Date(used)}" : "Jamais utilisé";
|
||||
CanApprove = canApprove && IsPending;
|
||||
ApproveHint = IsPending ? approveHint : null;
|
||||
CanRevoke = canRevoke && !IsRevoked && !isCurrentSession;
|
||||
ApproveCommand = new AsyncRelayCommand(() => page.ApproveAsync(this), () => CanApprove);
|
||||
RevokeCommand = new AsyncRelayCommand(() => page.RevokeKeyAsync(this), () => CanRevoke);
|
||||
}
|
||||
|
||||
public Guid AdminId { get; }
|
||||
public string AdminName { get; }
|
||||
public string KeyId { get; }
|
||||
public string Label { get; }
|
||||
public string Status { get; }
|
||||
public string StatusText => IsCurrentSession ? "Cet appareil (session en cours)" : AccountTexts.Status(Status);
|
||||
public bool IsPending => Status == AdminStatus.Pending;
|
||||
public bool IsActive => Status == AdminStatus.Active;
|
||||
public bool IsRevoked => Status == AdminStatus.Revoked;
|
||||
public bool IsCurrentSession { get; }
|
||||
|
||||
/// <summary>Empreinte (clés actives ou révoquées) ; masquée par le serveur pour une clé en attente.</summary>
|
||||
public string? Fingerprint { get; }
|
||||
|
||||
/// <summary>Empreinte, ou (clé en attente) où la trouver : sur le nouvel appareil.</summary>
|
||||
public string FingerprintText { get; }
|
||||
public string AddedText { get; }
|
||||
public string LastUsedText { get; }
|
||||
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}{_fingerprintWarning}";
|
||||
|
||||
public bool CanApprove { get; }
|
||||
|
||||
/// <summary>Pourquoi ce n'est pas vous qui approuvez (nouvel appareil d'un autre administrateur…).</summary>
|
||||
public string? ApproveHint { get; }
|
||||
|
||||
public bool HasApproveHint => !string.IsNullOrEmpty(ApproveHint);
|
||||
public bool CanRevoke { get; }
|
||||
|
||||
/// <summary>Empreinte dictée par la personne (« AB12 CD34 EF56 7890 1A2B »).</summary>
|
||||
public string FingerprintInput
|
||||
{
|
||||
get => _fingerprintInput;
|
||||
set => SetProperty(ref _fingerprintInput, value ?? "");
|
||||
}
|
||||
|
||||
public AsyncRelayCommand ApproveCommand { get; }
|
||||
public AsyncRelayCommand RevokeCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Invitation en cours (code pas encore utilisé).</summary>
|
||||
public sealed class InvitationItem
|
||||
{
|
||||
internal InvitationItem(AccountsPanelBase page, InvitationSummary invitation, bool canDelete)
|
||||
{
|
||||
Id = invitation.Id;
|
||||
IsDevice = invitation.ForAdminId is not null;
|
||||
Title = IsDevice
|
||||
? $"Nouvel appareil de {invitation.DisplayName}"
|
||||
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role)}";
|
||||
DetailText = $"Créée par {invitation.CreatedBy ?? "le serveur"} le {AccountTexts.Date(invitation.CreatedAt)} · " +
|
||||
$"expire le {AccountTexts.Date(invitation.ExpiresAt)}";
|
||||
CanDelete = canDelete;
|
||||
DeleteCommand = new AsyncRelayCommand(() => page.DeleteInvitationAsync(this), () => CanDelete);
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
public bool IsDevice { get; }
|
||||
public string Title { get; }
|
||||
public string DetailText { get; }
|
||||
public bool CanDelete { get; }
|
||||
public AsyncRelayCommand DeleteCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code d'une invitation tout juste créée. Le serveur n'en garde que l'empreinte : il n'est affiché
|
||||
/// qu'ici, une seule fois, et oublié dès que l'administrateur indique l'avoir transmis.
|
||||
/// </summary>
|
||||
public sealed class InvitationCodeViewModel : ObservableObject
|
||||
{
|
||||
private readonly IModuleDialogs _dialogs;
|
||||
private bool _copied;
|
||||
|
||||
internal InvitationCodeViewModel(string code, string title, string instructions, DateTimeOffset expiresAt,
|
||||
IModuleDialogs dialogs, Action dismiss)
|
||||
{
|
||||
Code = code;
|
||||
Title = title;
|
||||
Instructions = instructions;
|
||||
ExpiresText = $"Valable une seule fois, jusqu'au {AccountTexts.Date(expiresAt)}. Il ne sera plus affiché.";
|
||||
_dialogs = dialogs;
|
||||
CopyCommand = new RelayCommand(Copy);
|
||||
DismissCommand = new RelayCommand(dismiss);
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
public string Title { get; }
|
||||
public string Instructions { get; }
|
||||
public string ExpiresText { get; }
|
||||
|
||||
public bool Copied
|
||||
{
|
||||
get => _copied;
|
||||
private set => SetProperty(ref _copied, value);
|
||||
}
|
||||
|
||||
public RelayCommand CopyCommand { get; }
|
||||
|
||||
/// <summary>« J'ai transmis le code » : le code disparaît de l'écran.</summary>
|
||||
public RelayCommand DismissCommand { get; }
|
||||
|
||||
private void Copy()
|
||||
{
|
||||
_dialogs.CopyToClipboard(Code);
|
||||
Copied = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Comptes » : page « Mes appareils » (tout administrateur) et page « Administrateurs »
|
||||
/// (gestion des administrateurs : propriétaires).
|
||||
/// </summary>
|
||||
public sealed class AccountsAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "accounts";
|
||||
|
||||
public string DisplayName => "Comptes";
|
||||
|
||||
public void Register(IModuleRegistry registry)
|
||||
{
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
MyDevicesPanelViewModel.PanelId,
|
||||
"Mes appareils",
|
||||
"Les PC depuis lesquels vous pouvez vous connecter : ajoutez-en un (invitation « nouvel appareil »), puis approuvez-le " +
|
||||
"ici avec l'empreinte qu'il affiche.",
|
||||
"KeyVariant",
|
||||
RequiredPermission: null,
|
||||
Order: 40,
|
||||
context => new MyDevicesPanelViewModel(context)));
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
AdminsPanelViewModel.PanelId,
|
||||
"Administrateurs",
|
||||
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son rôle ou révoquez-le.",
|
||||
"AccountGroup",
|
||||
AdminPermissions.AdminsManage,
|
||||
Order: 50,
|
||||
context => new AdminsPanelViewModel(context)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Administrateurs » (admins.manage) : comptes et appareils, invitations, approbation par
|
||||
/// empreinte, rôles, révocation. Le serveur reste seul juge : cette page ne propose que ce qu'il
|
||||
/// accepte (pas de changement de son propre rôle, pas d'appareil approuvé au nom d'un autre
|
||||
/// administrateur actif…) et affiche ses refus tels quels.
|
||||
/// </summary>
|
||||
public sealed class AdminsPanelViewModel : AccountsPanelBase
|
||||
{
|
||||
public const string PanelId = "admins.manage";
|
||||
|
||||
private List<AdminSummary> _summaries = new();
|
||||
private IReadOnlyList<AdminAccountItem> _admins = Array.Empty<AdminAccountItem>();
|
||||
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
|
||||
private bool _showRevoked;
|
||||
private string _newName = "";
|
||||
private Choice _newRole;
|
||||
|
||||
public AdminsPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RoleChoices = AdminRoles.All.Select(r => new Choice(r, AdminTexts.Role(r))).ToList();
|
||||
_newRole = RoleChoices.First(c => c.Value == AdminRoles.Editor);
|
||||
InviteCommand = new AsyncRelayCommand(InviteAsync, () => !IsBusy && !HasCreatedInvitation && !string.IsNullOrWhiteSpace(_newName));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> RoleChoices { get; }
|
||||
|
||||
public IReadOnlyList<AdminAccountItem> Admins
|
||||
{
|
||||
get => _admins;
|
||||
private set => SetProperty(ref _admins, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<InvitationItem> Invitations
|
||||
{
|
||||
get => _invitations;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasInvitations => _invitations.Count > 0;
|
||||
|
||||
/// <summary>Afficher aussi les comptes révoqués (historique).</summary>
|
||||
public bool ShowRevoked
|
||||
{
|
||||
get => _showRevoked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _showRevoked, value)) BuildAdmins();
|
||||
}
|
||||
}
|
||||
|
||||
public string RevokedText
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = _summaries.Count(a => a.Status == AdminStatus.Revoked);
|
||||
return count == 0 ? "Aucun compte révoqué" : $"Afficher les comptes révoqués ({count})";
|
||||
}
|
||||
}
|
||||
|
||||
public string SummaryText
|
||||
{
|
||||
get
|
||||
{
|
||||
var active = _summaries.Count(a => a.Status == AdminStatus.Active);
|
||||
var pending = _summaries.Where(a => a.Status != AdminStatus.Revoked).SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
|
||||
return $"{active} compte(s) actif(s)" + (pending == 0 ? "" : $" · {pending} appareil(s) en attente d'approbation");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Nouvelle invitation
|
||||
|
||||
public string NewName
|
||||
{
|
||||
get => _newName;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _newName, value ?? "")) InviteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public Choice NewRole
|
||||
{
|
||||
get => _newRole;
|
||||
set
|
||||
{
|
||||
if (value is not null && SetProperty(ref _newRole, value)) OnPropertyChanged(nameof(NewRoleHint));
|
||||
}
|
||||
}
|
||||
|
||||
public string NewRoleHint => $"{AdminTexts.Role(_newRole.Value)} : {AccountTexts.RoleHint(_newRole.Value ?? "")}.";
|
||||
|
||||
public AsyncRelayCommand InviteCommand { get; }
|
||||
|
||||
protected override void OnCommandsChanged() => InviteCommand.RaiseCanExecuteChanged();
|
||||
|
||||
private async Task InviteAsync()
|
||||
{
|
||||
var name = _newName.Trim();
|
||||
var role = _newRole.Value!;
|
||||
if (role == AdminRoles.Owner &&
|
||||
!Context.Dialogs.Confirm("Inviter un propriétaire ?",
|
||||
$"{name} aura tous les droits, dont celui de gérer les administrateurs (y compris de révoquer votre compte).",
|
||||
"Inviter", danger: true))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
InvitationCreated? created = null;
|
||||
if (!await RunAsync(async () =>
|
||||
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = name, Role = role })))
|
||||
return;
|
||||
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
|
||||
NewName = "";
|
||||
ShowInvitation(created!, $"Invitation de {created!.Invitation.DisplayName} ({AdminTexts.Role(created.Invitation.Role)})",
|
||||
$"Transmettez ce code à {created.Invitation.DisplayName}. Dans le launcher : menu d'administration › « J'ai une invitation ». " +
|
||||
"Le launcher lui affichera ensuite une empreinte : faites-la-vous dicter (téléphone, message), puis approuvez son appareil " +
|
||||
"ci-dessous en la saisissant.");
|
||||
await RunAsync(ReloadAsync);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
|
||||
internal async Task ChangeRoleAsync(AdminAccountItem admin)
|
||||
{
|
||||
var role = admin.SelectedRole.Value!;
|
||||
if (role == admin.Role) return;
|
||||
var message = $"{admin.DisplayName} : {AdminTexts.Role(admin.Role)} → {AdminTexts.Role(role)}. Le changement s'applique " +
|
||||
"immédiatement, y compris à ses sessions ouvertes.";
|
||||
if (role == AdminRoles.Owner) message += "\n\nUn propriétaire peut gérer tous les administrateurs, y compris révoquer votre compte.";
|
||||
if (!Context.Dialogs.Confirm("Changer le rôle ?", message, "Changer le rôle", danger: role == AdminRoles.Owner))
|
||||
{
|
||||
admin.ResetRole();
|
||||
return;
|
||||
}
|
||||
if (!await ApplyAsync(() => Context.Session.ChangeRoleAsync(admin.Id, role),
|
||||
$"{admin.DisplayName} est maintenant {AdminTexts.Role(role).ToLowerInvariant()}."))
|
||||
admin.ResetRole();
|
||||
}
|
||||
|
||||
internal async Task RevokeAdminAsync(AdminAccountItem admin)
|
||||
{
|
||||
if (!Context.Dialogs.Confirm($"Révoquer le compte de {admin.DisplayName} ?",
|
||||
"Ses sessions sont fermées et tous ses appareils révoqués immédiatement ; ses invitations « nouvel appareil » sont " +
|
||||
"supprimées. Un compte révoqué ne peut pas être réactivé : il faudrait l'inviter de nouveau.",
|
||||
"Révoquer le compte", danger: true))
|
||||
return;
|
||||
await ApplyAsync(() => Context.Session.RevokeAdminAsync(admin.Id), $"Compte de {admin.DisplayName} révoqué.");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
protected override async Task ReloadAsync()
|
||||
{
|
||||
var admins = await Context.Session.ListAdminsAsync();
|
||||
var invitations = await Context.Session.ListInvitationsAsync();
|
||||
_summaries = admins;
|
||||
BuildAdmins();
|
||||
Invitations = invitations.Select(i => new InvitationItem(this, i, canDelete: true)).ToList();
|
||||
OnPropertyChanged(nameof(RevokedText));
|
||||
OnPropertyChanged(nameof(SummaryText));
|
||||
}
|
||||
|
||||
private void BuildAdmins()
|
||||
{
|
||||
var me = Context.Session.Profile;
|
||||
// À traiter d'abord : comptes en attente, puis comptes actifs (vous en premier), puis révoqués.
|
||||
Admins = _summaries
|
||||
.Where(a => _showRevoked || a.Status != AdminStatus.Revoked)
|
||||
.OrderBy(a => a.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
|
||||
.ThenBy(a => a.Id == me.Id ? 0 : 1)
|
||||
.ThenBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.Select(a => new AdminAccountItem(this, a, me.Id, me.KeyId, RoleChoices))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Compte d'un administrateur et ses appareils.</summary>
|
||||
public sealed class AdminAccountItem : ObservableObject
|
||||
{
|
||||
private readonly IReadOnlyList<Choice> _roles;
|
||||
private Choice _selectedRole;
|
||||
|
||||
internal AdminAccountItem(AdminsPanelViewModel page, AdminSummary admin, Guid myId, string myKeyId, IReadOnlyList<Choice> roles)
|
||||
{
|
||||
_roles = roles;
|
||||
Id = admin.Id;
|
||||
DisplayName = admin.DisplayName;
|
||||
Initials = AdminTexts.Initials(admin.DisplayName);
|
||||
Role = admin.Role;
|
||||
Status = admin.Status;
|
||||
IsYou = admin.Id == myId;
|
||||
CreatedText = $"Invité le {AccountTexts.Date(admin.CreatedAt)}";
|
||||
_selectedRole = roles.FirstOrDefault(r => r.Value == admin.Role) ?? roles[^1];
|
||||
|
||||
var hasActiveKey = admin.Keys.Any(k => k.Status == AdminStatus.Active);
|
||||
// Premier appareil (compte en attente) : approuvé par un propriétaire. Nouvel appareil d'un compte
|
||||
// actif : par son titulaire seulement, depuis un de ses appareils actifs (règle du serveur).
|
||||
string? hint = null;
|
||||
var canApprove = !IsRevoked && (Status == AdminStatus.Pending || IsYou);
|
||||
if (!canApprove && Status == AdminStatus.Active)
|
||||
hint = hasActiveKey
|
||||
? $"Nouvel appareil : {DisplayName} l'approuve lui-même depuis un de ses appareils actifs (page « Mes appareils »)."
|
||||
: $"{DisplayName} n'a plus aucun appareil actif : restaurez son accès sur le VPS (« add-key »), ou révoquez ce compte et invitez-le de nouveau.";
|
||||
Keys = admin.Keys
|
||||
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
|
||||
.ThenByDescending(k => k.AddedAt)
|
||||
.Select(k => new AdminKeyItem(page, admin.Id, admin.DisplayName, k, IsYou && k.KeyId == myKeyId,
|
||||
canApprove, hint, canRevoke: !IsRevoked,
|
||||
IsYou ? AccountTexts.OwnPendingText : $"Empreinte affichée sur le nouvel appareil : faites-la-vous dicter par {admin.DisplayName}."))
|
||||
.ToList();
|
||||
|
||||
ChangeRoleCommand = new AsyncRelayCommand(() => page.ChangeRoleAsync(this), () => CanChangeRole && RoleChanged);
|
||||
RevokeCommand = new AsyncRelayCommand(() => page.RevokeAdminAsync(this), () => CanRevoke);
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
public string DisplayName { get; }
|
||||
public string Initials { get; }
|
||||
public string Role { get; }
|
||||
public string RoleText => AdminTexts.Role(Role);
|
||||
public string Status { get; }
|
||||
public string StatusText => AccountTexts.Status(Status);
|
||||
public bool IsPending => Status == AdminStatus.Pending;
|
||||
public bool IsRevoked => Status == AdminStatus.Revoked;
|
||||
public bool IsYou { get; }
|
||||
public string CreatedText { get; }
|
||||
public IReadOnlyList<AdminKeyItem> Keys { get; }
|
||||
|
||||
/// <summary>On ne change ni son propre rôle ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
|
||||
public bool CanChangeRole => !IsYou && !IsRevoked;
|
||||
|
||||
public bool CanRevoke => !IsYou && !IsRevoked;
|
||||
|
||||
public IReadOnlyList<Choice> RoleChoices => _roles;
|
||||
|
||||
public Choice SelectedRole
|
||||
{
|
||||
get => _selectedRole;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _selectedRole, value)) return;
|
||||
OnPropertyChanged(nameof(RoleChanged));
|
||||
ChangeRoleCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rôle choisi différent du rôle actuel : bouton « Appliquer » affiché.</summary>
|
||||
public bool RoleChanged => _selectedRole.Value != Role;
|
||||
|
||||
public AsyncRelayCommand ChangeRoleCommand { get; }
|
||||
public AsyncRelayCommand RevokeCommand { get; }
|
||||
|
||||
internal void ResetRole() => SelectedRole = _roles.FirstOrDefault(r => r.Value == Role) ?? _selectedRole;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Mes appareils » (tout administrateur connecté) : ses clés, l'ajout d'un nouveau PC (invitation
|
||||
/// « nouvel appareil », puis approbation ici avec l'empreinte affichée sur le nouveau PC), ses invitations.
|
||||
/// Personne d'autre ne peut approuver un appareil à son nom (règle du serveur).
|
||||
/// </summary>
|
||||
public sealed class MyDevicesPanelViewModel : AccountsPanelBase
|
||||
{
|
||||
public const string PanelId = "admins.devices";
|
||||
|
||||
private IReadOnlyList<AdminKeyItem> _devices = Array.Empty<AdminKeyItem>();
|
||||
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
|
||||
|
||||
public MyDevicesPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
AddDeviceCommand = new AsyncRelayCommand(AddDeviceAsync, () => !IsBusy && !HasCreatedInvitation);
|
||||
}
|
||||
|
||||
public string DisplayName => Context.Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
|
||||
|
||||
/// <summary>Révoquer un de ses appareils exige la gestion des administrateurs (sinon : un propriétaire s'en charge).</summary>
|
||||
public bool CanRevokeDevices => Context.Session.Has(AdminPermissions.AdminsManage);
|
||||
|
||||
public IReadOnlyList<AdminKeyItem> Devices
|
||||
{
|
||||
get => _devices;
|
||||
private set => SetProperty(ref _devices, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<InvitationItem> Invitations
|
||||
{
|
||||
get => _invitations;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasInvitations => _invitations.Count > 0;
|
||||
|
||||
public AsyncRelayCommand AddDeviceCommand { get; }
|
||||
|
||||
protected override void OnCommandsChanged() => AddDeviceCommand.RaiseCanExecuteChanged();
|
||||
|
||||
private async Task AddDeviceAsync()
|
||||
{
|
||||
StatusMessage = null;
|
||||
InvitationCreated? created = null;
|
||||
if (!await RunAsync(async () =>
|
||||
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = Context.Session.Profile.Id })))
|
||||
return;
|
||||
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
|
||||
ShowInvitation(created!, "Invitation pour votre nouvel appareil",
|
||||
"Sur le nouveau PC : launcher › menu d'administration › « J'ai une invitation », puis collez ce code. Le nouveau PC " +
|
||||
"affiche ensuite une empreinte : revenez ici, saisissez-la sous l'appareil en attente et approuvez-le.");
|
||||
await RunAsync(ReloadAsync);
|
||||
}
|
||||
|
||||
protected override async Task ReloadAsync()
|
||||
{
|
||||
var account = await Context.Session.GetMyAccountAsync();
|
||||
var me = Context.Session.Profile;
|
||||
Devices = account.Admin.Keys
|
||||
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
|
||||
.ThenByDescending(k => k.AddedAt)
|
||||
.Select(k => new AdminKeyItem(this, account.Admin.Id, account.Admin.DisplayName, k, k.KeyId == me.KeyId,
|
||||
canApprove: true, approveHint: null, canRevoke: CanRevokeDevices, AccountTexts.OwnPendingText))
|
||||
.ToList();
|
||||
Invitations = account.Invitations
|
||||
.Select(i => new InvitationItem(this, i, canDelete: i.CreatedById == me.Id || CanRevokeDevices))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Administration » : la fenêtre d'administration elle-même (connexion, invitation,
|
||||
/// verrouillage) et sa page d'accueil. Les autres pages (Jeux, Notes de version, Publication,
|
||||
/// Administrateurs, Audit) seront déclarées par leurs propres modules.
|
||||
/// </summary>
|
||||
public sealed class AdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "admin";
|
||||
|
||||
public string DisplayName => "Administration";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
DashboardPanelViewModel.PanelId,
|
||||
"Tableau de bord",
|
||||
"Votre compte, la session en cours et l'état du serveur.",
|
||||
"ViewDashboard",
|
||||
RequiredPermission: null,
|
||||
Order: 0,
|
||||
context => new DashboardPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Audit;
|
||||
|
||||
/// <summary>Module « Audit » : page « Journal d'audit » (permission audit.read : propriétaires, administrateurs).</summary>
|
||||
public sealed class AuditAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "audit";
|
||||
|
||||
public string DisplayName => "Audit";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
AuditPanelViewModel.PanelId,
|
||||
"Journal d'audit",
|
||||
"Connexions, publications, modifications et refus enregistrés par le serveur, les plus récents d'abord.",
|
||||
"History",
|
||||
AdminPermissions.AuditRead,
|
||||
Order: 60,
|
||||
context => new AuditPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Journal d'audit » (audit.read) : actions enregistrées par le serveur, les plus récentes
|
||||
/// d'abord, chargées par pages de 200 ; filtres (texte, catégorie, résultat) appliqués aux entrées
|
||||
/// chargées ; export CSV de ce qui est affiché.
|
||||
/// </summary>
|
||||
public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "audit.read";
|
||||
|
||||
/// <summary>Entrées par page (maximum accepté par le serveur).</summary>
|
||||
public const int PageSize = 200;
|
||||
|
||||
/// <summary>Au-delà, « Charger plus » s'arrête (mémoire du launcher) : utiliser journalctl ou les fichiers du VPS.</summary>
|
||||
public const int MaxLoaded = 5000;
|
||||
|
||||
/// <summary>Entrées affichées au plus (la page n'est pas virtualisée) ; l'export reprend toutes celles filtrées.</summary>
|
||||
public const int MaxDisplayed = 500;
|
||||
|
||||
private readonly List<AuditEntry> _loaded = new();
|
||||
private IReadOnlyList<AuditItem> _filtered = Array.Empty<AuditItem>();
|
||||
private string? _nextCursor;
|
||||
private IReadOnlyList<AuditItem> _entries = Array.Empty<AuditItem>();
|
||||
private string _searchText = "";
|
||||
private Choice _category;
|
||||
private Choice _result;
|
||||
private string? _lastRefresh;
|
||||
private bool _hasLoaded;
|
||||
|
||||
public AuditPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
Categories = new List<Choice>
|
||||
{
|
||||
new(null, "Toutes les actions"),
|
||||
new("auth.", "Connexions"),
|
||||
new("admins.", "Administrateurs"),
|
||||
new("cli.", "Outil du VPS (ligne de commande)"),
|
||||
new("catalog.", "Informations des jeux"),
|
||||
new("changelog.", "Notes de version"),
|
||||
new("release.", "Publications"),
|
||||
new("security", "Refus de sécurité")
|
||||
};
|
||||
Results = new List<Choice>
|
||||
{
|
||||
new(null, "Tous les résultats"),
|
||||
new(AuditResults.Ok, "Réussis"),
|
||||
new(AuditResults.Denied, "Refusés")
|
||||
};
|
||||
_category = Categories[0];
|
||||
_result = Results[0];
|
||||
RefreshCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: true)), () => !IsBusy);
|
||||
LoadMoreCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: false)), () => !IsBusy && CanLoadMore);
|
||||
ClearFiltersCommand = new RelayCommand(ClearFilters, () => HasFilters);
|
||||
ExportCommand = new AsyncRelayCommand(ExportAsync, () => !IsBusy && _entries.Count > 0);
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> Categories { get; }
|
||||
public IReadOnlyList<Choice> Results { get; }
|
||||
|
||||
public IReadOnlyList<AuditItem> Entries
|
||||
{
|
||||
get => _entries;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _entries, value)) return;
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(EmptyText));
|
||||
OnPropertyChanged(nameof(TruncatedText));
|
||||
OnPropertyChanged(nameof(IsTruncated));
|
||||
ExportCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasEntries => _entries.Count > 0;
|
||||
|
||||
public bool IsTruncated => _filtered.Count > _entries.Count;
|
||||
|
||||
public string TruncatedText => IsTruncated
|
||||
? $"Seules les {MaxDisplayed} premières entrées sont affichées : affinez la recherche, ou exportez les {_filtered.Count} entrées filtrées."
|
||||
: "";
|
||||
|
||||
/// <summary>Message quand rien n'est affiché (vide tant que rien n'a été chargé : l'erreur éventuelle suffit).</summary>
|
||||
public string EmptyText => !_hasLoaded || HasEntries ? ""
|
||||
: _loaded.Count == 0 ? "Le journal est vide."
|
||||
: "Aucune entrée chargée ne correspond aux filtres" + (CanLoadMore ? " : chargez des entrées plus anciennes." : ".");
|
||||
|
||||
public string SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _searchText, value ?? "")) ApplyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
public Choice Category
|
||||
{
|
||||
get => _category;
|
||||
set
|
||||
{
|
||||
if (value is not null && SetProperty(ref _category, value)) ApplyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
public Choice Result
|
||||
{
|
||||
get => _result;
|
||||
set
|
||||
{
|
||||
if (value is not null && SetProperty(ref _result, value)) ApplyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null;
|
||||
|
||||
/// <summary>« 37 entrées affichées sur 200 chargées (depuis le 12/10/2026 à 08:14) ».</summary>
|
||||
public string SummaryText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_loaded.Count == 0) return "";
|
||||
var oldest = _loaded[^1].Timestamp.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
|
||||
var shown = HasFilters ? $"{_filtered.Count} entrée(s) sur {_loaded.Count} chargée(s)" : $"{_loaded.Count} entrée(s) chargée(s)";
|
||||
return $"{shown}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
|
||||
: _loaded.Count >= MaxLoaded ? $". Limite de {MaxLoaded} entrées atteinte : les plus anciennes restent dans les fichiers du journal sur le VPS."
|
||||
: ".");
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanLoadMore => _nextCursor is not null && _loaded.Count < MaxLoaded;
|
||||
|
||||
public string? LastRefresh
|
||||
{
|
||||
get => _lastRefresh;
|
||||
private set => SetProperty(ref _lastRefresh, value);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand LoadMoreCommand { get; }
|
||||
public RelayCommand ClearFiltersCommand { get; }
|
||||
/// <summary>Exporte les entrées filtrées (toutes, pas seulement celles affichées).</summary>
|
||||
public AsyncRelayCommand ExportCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
LoadMoreCommand.RaiseCanExecuteChanged();
|
||||
ExportCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Chargé au premier affichage ; ensuite « Actualiser » (garde les pages déjà chargées et les filtres d'ici là).</summary>
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync(reset: true)) : Task.CompletedTask;
|
||||
|
||||
private async Task LoadAsync(bool reset)
|
||||
{
|
||||
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor);
|
||||
if (reset) _loaded.Clear();
|
||||
_loaded.AddRange(page.Entries);
|
||||
_nextCursor = page.NextCursor;
|
||||
_hasLoaded = true;
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
OnPropertyChanged(nameof(CanLoadMore));
|
||||
LoadMoreCommand.RaiseCanExecuteChanged();
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private void ClearFilters()
|
||||
{
|
||||
_searchText = "";
|
||||
_category = Categories[0];
|
||||
_result = Results[0];
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
OnPropertyChanged(nameof(Category));
|
||||
OnPropertyChanged(nameof(Result));
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
var text = _searchText.Trim();
|
||||
_filtered = _loaded
|
||||
.Where(e => AuditTexts.InCategory(e.Action, _category.Value))
|
||||
.Where(e => _result.Value is null || e.Result == _result.Value)
|
||||
.Select(e => new AuditItem(e))
|
||||
.Where(item => text.Length == 0 || item.Matches(text))
|
||||
.ToList();
|
||||
Entries = _filtered.Count > MaxDisplayed ? _filtered.Take(MaxDisplayed).ToList() : _filtered;
|
||||
OnPropertyChanged(nameof(HasFilters));
|
||||
OnPropertyChanged(nameof(SummaryText));
|
||||
OnPropertyChanged(nameof(EmptyText));
|
||||
ClearFiltersCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Export
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
var path = Context.Dialogs.PickSaveFile("Exporter le journal d'audit", "Fichier CSV (*.csv)|*.csv",
|
||||
$"journal-audit-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
|
||||
if (path is null) return;
|
||||
var entries = _filtered;
|
||||
if (await RunAsync(() => File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true))))
|
||||
Context.Dialogs.ShowMessage("Journal exporté", $"{entries.Count} entrée(s) enregistrée(s) dans {Path.GetFileName(path)}.",
|
||||
ModuleMessageKind.Success);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Entrée du journal telle qu'affichée.</summary>
|
||||
public sealed class AuditItem
|
||||
{
|
||||
internal AuditItem(AuditEntry entry)
|
||||
{
|
||||
Entry = entry;
|
||||
TimeText = entry.Timestamp.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
ActionText = AuditTexts.Action(entry.Action);
|
||||
ResultText = AuditTexts.Result(entry.Result);
|
||||
ActorText = entry.ActorName ?? (entry.KeyId is null ? "Inconnu" : "Clé inconnue");
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
|
||||
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
|
||||
DetailText = string.Join(" — ", parts);
|
||||
var origin = new List<string>();
|
||||
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
|
||||
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
|
||||
OriginText = string.Join(" · ", origin);
|
||||
}
|
||||
|
||||
public AuditEntry Entry { get; }
|
||||
public string TimeText { get; }
|
||||
public string ActionText { get; }
|
||||
|
||||
/// <summary>Code technique (« admins.key_approve »), en infobulle.</summary>
|
||||
public string ActionCode => Entry.Action;
|
||||
|
||||
public string Result => Entry.Result;
|
||||
public string ResultText { get; }
|
||||
public bool IsOk => Entry.Result == AuditResults.Ok;
|
||||
public bool IsDenied => Entry.Result == AuditResults.Denied;
|
||||
public bool IsFailed => Entry.Result == AuditResults.Failed;
|
||||
public string ActorText { get; }
|
||||
public string DetailText { get; }
|
||||
public bool HasDetail => DetailText.Length > 0;
|
||||
public string OriginText { get; }
|
||||
|
||||
/// <summary>Recherche dans tout ce qui est affiché (sans tenir compte de la casse).</summary>
|
||||
internal bool Matches(string text) =>
|
||||
new[] { TimeText, ActionText, Entry.Action, ResultText, ActorText, DetailText, OriginText }
|
||||
.Any(value => value.Contains(text, StringComparison.CurrentCultureIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Libellés du journal (français) et export CSV.</summary>
|
||||
public static class AuditTexts
|
||||
{
|
||||
public static string Action(string action) => action switch
|
||||
{
|
||||
"auth.login" => "Connexion",
|
||||
"auth.logout" => "Déconnexion",
|
||||
"admins.enroll" => "Appareil enregistré avec une invitation",
|
||||
"admins.invite" => "Invitation créée",
|
||||
"admins.invite_deleted" => "Invitation supprimée",
|
||||
"admins.key_approve" => "Appareil approuvé",
|
||||
"admins.key_revoke" => "Appareil révoqué",
|
||||
"admins.role" => "Rôle modifié",
|
||||
"admins.revoke" => "Compte révoqué",
|
||||
"cli.add_owner" => "Propriétaire ajouté sur le VPS",
|
||||
"cli.add_key" => "Clé ajoutée sur le VPS",
|
||||
"cli.revoke_admin" => "Compte révoqué sur le VPS",
|
||||
"cli.revoke_key" => "Clé révoquée sur le VPS",
|
||||
"catalog.publish" => "Informations des jeux publiées",
|
||||
"changelog.edit" => "Notes de version modifiées",
|
||||
"release.start" => "Publication d'une version commencée",
|
||||
"release.publish" => "Version mise en ligne",
|
||||
"release.cancel" => "Publication abandonnée",
|
||||
"request.signature" => "Requête signée refusée",
|
||||
"audit.suppressed" => "Tentatives refusées regroupées",
|
||||
_ => action
|
||||
};
|
||||
|
||||
public static string Result(string result) => result switch
|
||||
{
|
||||
AuditResults.Ok => "Réussi",
|
||||
AuditResults.Denied => "Refusé",
|
||||
AuditResults.Failed => "Échec",
|
||||
_ => result
|
||||
};
|
||||
|
||||
/// <summary>Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées.</summary>
|
||||
public static bool InCategory(string action, string? category) => category switch
|
||||
{
|
||||
null => true,
|
||||
"security" => action is "request.signature" or "audit.suppressed",
|
||||
_ => action.StartsWith(category, StringComparison.Ordinal)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// CSV (séparateur « ; », pour Excel en français). Les valeurs viennent en partie de requêtes anonymes ou
|
||||
/// de saisies (nom du client, de l'appareil…) : une cellule qui commencerait par = + - @ serait interprétée
|
||||
/// comme une formule par le tableur ; elle est donc précédée d'une apostrophe, et toutes les cellules sont
|
||||
/// entre guillemets (un tableur qui sépare par des virgules ne peut pas couper une valeur en deux).
|
||||
/// </summary>
|
||||
public static string ToCsv(IEnumerable<AuditItem> items)
|
||||
{
|
||||
var csv = new StringBuilder();
|
||||
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
|
||||
foreach (var item in items)
|
||||
{
|
||||
var e = item.Entry;
|
||||
csv.AppendLine(string.Join(';', new[]
|
||||
{
|
||||
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
item.ActionText, e.Action, item.ResultText, e.ActorName, e.Ip, e.KeyId, e.Target, e.Details
|
||||
}.Select(Cell)));
|
||||
}
|
||||
return csv.ToString();
|
||||
}
|
||||
|
||||
private static string Cell(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
value = value.Replace("\r", " ").Replace("\n", " ");
|
||||
var first = value.TrimStart(' ');
|
||||
if (value[0] == '\t' || (first.Length > 0 && first[0] is '=' or '+' or '-' or '@')) value = "'" + value;
|
||||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Catalogue des jeux » : page « Jeux » du menu d'administration (informations modifiables
|
||||
/// des jeux déclarés dans launcher.json, publiées dans catalog.json). Permission games.edit.
|
||||
/// </summary>
|
||||
public sealed class CatalogAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "catalog";
|
||||
|
||||
public string DisplayName => "Catalogue des jeux";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
GamesPanelViewModel.PanelId,
|
||||
"Jeux",
|
||||
"Ce que le launcher affiche pour chaque jeu : nom, accroche, présentation, liens, apparence, ordre. " +
|
||||
"Les jeux eux-mêmes restent déclarés dans launcher.json.",
|
||||
"Gamepad",
|
||||
AdminPermissions.GamesEdit,
|
||||
Order: 10,
|
||||
context => new GamesPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Catalog;
|
||||
|
||||
/// <summary>Choix d'une liste (null : valeur par défaut de launcher.json).</summary>
|
||||
public sealed record Choice(string? Value, string Label)
|
||||
{
|
||||
/// <summary>Texte affiché par les listes déroulantes.</summary>
|
||||
public override string ToString() => Label;
|
||||
}
|
||||
|
||||
/// <summary>Partie modifiable de la page « Jeux » (réglages du launcher ou un jeu).</summary>
|
||||
public abstract class CatalogEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
|
||||
protected CatalogEditor(Action changed) => _changed = changed;
|
||||
|
||||
public abstract string Title { get; }
|
||||
public abstract string Subtitle { get; }
|
||||
public abstract string IconKey { get; }
|
||||
|
||||
/// <summary>Modifié depuis la dernière publication (pastille dans la liste).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
internal set => SetProperty(ref _isDirty, value);
|
||||
}
|
||||
private bool _isDirty;
|
||||
|
||||
protected bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (!SetProperty(ref field, value, name)) return false;
|
||||
_changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void Changed() => _changed();
|
||||
|
||||
protected static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
}
|
||||
|
||||
/// <summary>Réglages du launcher : nom, jeu affiché au premier lancement.</summary>
|
||||
public sealed class LauncherEditor : CatalogEditor
|
||||
{
|
||||
private string _launcherName = "";
|
||||
private Choice _defaultGame;
|
||||
|
||||
public LauncherEditor(string declaredName, string? declaredDefault, IReadOnlyList<LauncherConfig> games, Action changed) : base(changed)
|
||||
{
|
||||
DeclaredName = declaredName;
|
||||
GameChoices = new[] { new Choice(null, declaredDefault is null ? "Par défaut (premier jeu)" : $"Par défaut ({declaredDefault})") }
|
||||
.Concat(games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})"))).ToList();
|
||||
_defaultGame = GameChoices[0];
|
||||
}
|
||||
|
||||
public override string Title => "Launcher";
|
||||
public override string Subtitle => "Nom, jeu affiché au démarrage, ordre des jeux";
|
||||
public override string IconKey => "Tune";
|
||||
|
||||
public string DeclaredName { get; }
|
||||
public string DefaultNameHint => $"Vide : « {DeclaredName} » (launcher.json).";
|
||||
|
||||
public string LauncherName
|
||||
{
|
||||
get => _launcherName;
|
||||
set => Set(ref _launcherName, value ?? "");
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
|
||||
public Choice DefaultGame
|
||||
{
|
||||
get => _defaultGame;
|
||||
set
|
||||
{
|
||||
if (value is not null) Set(ref _defaultGame, value); // null : liste de l'écran en cours de remplacement
|
||||
}
|
||||
}
|
||||
|
||||
internal void Load(CatalogLauncher? launcher)
|
||||
{
|
||||
_launcherName = launcher?.LauncherName ?? "";
|
||||
_defaultGame = GameChoices.FirstOrDefault(c => c.Value is not null &&
|
||||
string.Equals(c.Value, launcher?.DefaultGame, StringComparison.OrdinalIgnoreCase)) ?? GameChoices[0];
|
||||
OnPropertyChanged(nameof(LauncherName));
|
||||
OnPropertyChanged(nameof(DefaultGame));
|
||||
}
|
||||
|
||||
internal CatalogLauncher? ToCatalog(IReadOnlyList<string> order, IReadOnlyList<string> declaredOrder)
|
||||
{
|
||||
var name = Clean(LauncherName);
|
||||
var gameOrder = order.SequenceEqual(declaredOrder, StringComparer.OrdinalIgnoreCase) ? null : order.ToList();
|
||||
return name is null && DefaultGame.Value is null && gameOrder is null
|
||||
? null
|
||||
: new CatalogLauncher { LauncherName = name, DefaultGame = DefaultGame.Value, GameOrder = gameOrder };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Un jeu déclaré dans launcher.json, et ses informations modifiables.</summary>
|
||||
public sealed class GameEditor : CatalogEditor
|
||||
{
|
||||
private string _gameName = "", _tagline = "", _description = "", _websiteUrl = "", _icon = "", _background = "", _interval = "";
|
||||
private Choice _theme;
|
||||
private bool _hidden;
|
||||
|
||||
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed) : base(changed)
|
||||
{
|
||||
Declared = declared;
|
||||
_builtInThemeChoices = new[] { new Choice(null, $"Par défaut ({ThemeOf(declared)})") }
|
||||
.Concat(themes.Select(t => new Choice(t, t))).ToList();
|
||||
ThemeChoices = _builtInThemeChoices;
|
||||
_theme = ThemeChoices[0];
|
||||
LinkKinds = CatalogLinkKinds.All.Select(k => new Choice(k, CatalogLinkKinds.DefaultLabel(k))).ToList();
|
||||
AddLinkCommand = new RelayCommand(AddLink, () => Links.Count < CatalogRules.MaxLinks);
|
||||
Links.CollectionChanged += (_, _) =>
|
||||
{
|
||||
AddLinkCommand.RaiseCanExecuteChanged();
|
||||
Changed();
|
||||
};
|
||||
}
|
||||
|
||||
public LauncherConfig Declared { get; }
|
||||
public string GameId => Declared.GameId;
|
||||
|
||||
public override string Title => string.IsNullOrWhiteSpace(GameName) ? Declared.GameName : GameName.Trim();
|
||||
public override string Subtitle => Hidden ? $"{GameId} · masqué" : GameId;
|
||||
public override string IconKey => Hidden ? "EyeOffOutline" : "Gamepad";
|
||||
|
||||
// ------------------------------------------------------------------ Valeurs (vide : launcher.json)
|
||||
|
||||
public string GameName
|
||||
{
|
||||
get => _gameName;
|
||||
set
|
||||
{
|
||||
if (Set(ref _gameName, value ?? "")) OnPropertyChanged(nameof(Title));
|
||||
}
|
||||
}
|
||||
|
||||
public string Tagline { get => _tagline; set => Set(ref _tagline, value ?? ""); }
|
||||
public string Description { get => _description; set => Set(ref _description, value ?? ""); }
|
||||
public string WebsiteUrl { get => _websiteUrl; set => Set(ref _websiteUrl, value ?? ""); }
|
||||
public string Icon { get => _icon; set => Set(ref _icon, value ?? ""); }
|
||||
public string Background { get => _background; set => Set(ref _background, value ?? ""); }
|
||||
|
||||
/// <summary>Minutes entre deux recherches de mise à jour (vide : launcher.json ; 0 : désactivé).</summary>
|
||||
public string UpdateInterval { get => _interval; set => Set(ref _interval, value ?? ""); }
|
||||
|
||||
private readonly IReadOnlyList<Choice> _builtInThemeChoices;
|
||||
|
||||
/// <summary>Thèmes intégrés (+ celui du catalogue publié s'il est inconnu de ce launcher, pour ne pas l'effacer en silence).</summary>
|
||||
public IReadOnlyList<Choice> ThemeChoices { get; private set; }
|
||||
|
||||
public Choice Theme
|
||||
{
|
||||
get => _theme;
|
||||
set
|
||||
{
|
||||
// null : liste des thèmes remplacée à l'écran (rechargement), pas un choix de l'administrateur.
|
||||
if (value is not null) Set(ref _theme, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Hidden
|
||||
{
|
||||
get => _hidden;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _hidden, value)) return;
|
||||
OnPropertyChanged(nameof(Subtitle));
|
||||
OnPropertyChanged(nameof(IconKey));
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<LinkEditor> Links { get; } = new();
|
||||
public IReadOnlyList<Choice> LinkKinds { get; }
|
||||
public RelayCommand AddLinkCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Valeurs de launcher.json (rappel)
|
||||
|
||||
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci et « Applications installées » gardent ce nom.";
|
||||
public string TaglineHint => string.IsNullOrWhiteSpace(Declared.Tagline) ? "Vide : aucune accroche." : $"Vide : « {Declared.Tagline} ».";
|
||||
public string WebsiteHint => string.IsNullOrWhiteSpace(Declared.WebsiteUrl) ? "Adresse https. Vide : aucun site." : $"Adresse https. Vide : {Declared.WebsiteUrl}";
|
||||
public string IntervalHint => $"0 : désactivé, ou {CatalogRules.MinUpdateInterval} à {CatalogRules.MaxUpdateInterval} minutes. Vide : {Declared.UpdateCheckIntervalMinutes} (launcher.json).";
|
||||
public string ImageHint => "Image .png ou .jpg (4 Mo au plus) déposée sur le serveur du catalogue, ex. https://…/catalog/images/fond.jpg. " +
|
||||
"Les launchers gardent l'image en cache : pour la changer, déposez-la sous un NOUVEAU nom. Vide : image de launcher.json.";
|
||||
|
||||
private static string ThemeOf(LauncherConfig config) => string.IsNullOrWhiteSpace(config.Theme) ? "default" : config.Theme;
|
||||
|
||||
private void AddLink()
|
||||
{
|
||||
if (Links.Count >= CatalogRules.MaxLinks) return;
|
||||
Links.Add(new LinkEditor(LinkKinds, CatalogLinkKinds.Website, "", "", Changed, RemoveLink));
|
||||
}
|
||||
|
||||
private void RemoveLink(LinkEditor link) => Links.Remove(link);
|
||||
|
||||
internal void Load(CatalogGame? game)
|
||||
{
|
||||
_gameName = game?.GameName ?? "";
|
||||
_tagline = game?.Tagline ?? "";
|
||||
_description = game?.Description ?? "";
|
||||
_websiteUrl = game?.WebsiteUrl ?? "";
|
||||
_icon = game?.Icon ?? "";
|
||||
_background = game?.Background ?? "";
|
||||
_interval = game?.UpdateCheckIntervalMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||
_hidden = game?.Hidden == true;
|
||||
ThemeChoices = _builtInThemeChoices;
|
||||
_theme = ThemeChoices.FirstOrDefault(c => c.Value is not null && string.Equals(c.Value, game?.Theme, StringComparison.OrdinalIgnoreCase))
|
||||
?? ThemeChoices[0];
|
||||
if (_theme.Value is null && game?.Theme is { } unknown)
|
||||
{
|
||||
// Publié par une autre version du launcher : conservé (les launchers qui ne le connaissent pas l'ignorent).
|
||||
_theme = new Choice(unknown, $"{unknown} (inconnu de ce launcher)");
|
||||
ThemeChoices = _builtInThemeChoices.Append(_theme).ToList();
|
||||
}
|
||||
Links.Clear();
|
||||
foreach (var link in game?.Links ?? new List<CatalogLink>())
|
||||
Links.Add(new LinkEditor(LinkKinds, link.Kind, link.Label ?? "", link.Url, Changed, RemoveLink));
|
||||
foreach (var name in new[] { nameof(GameName), nameof(Tagline), nameof(Description), nameof(WebsiteUrl), nameof(Icon), nameof(Background),
|
||||
nameof(UpdateInterval), nameof(Hidden), nameof(ThemeChoices), nameof(Theme), nameof(Title), nameof(Subtitle), nameof(IconKey) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
/// <summary>Valeurs saisies (contrôlées ensuite par <see cref="CatalogRules"/>) ; null si tout est par défaut.</summary>
|
||||
internal CatalogGame? ToCatalog(List<string> errors)
|
||||
{
|
||||
int? interval = null;
|
||||
if (!string.IsNullOrWhiteSpace(UpdateInterval))
|
||||
{
|
||||
if (int.TryParse(UpdateInterval.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var minutes)) interval = minutes;
|
||||
else errors.Add($"Jeu « {GameId} » : intervalle de mise à jour : nombre de minutes attendu.");
|
||||
}
|
||||
var links = Links.Where(l => !l.IsEmpty)
|
||||
.Select(l => new CatalogLink { Kind = l.Kind.Value ?? CatalogLinkKinds.Other, Label = Clean(l.Label), Url = l.Url.Trim() })
|
||||
.ToList();
|
||||
var game = new CatalogGame
|
||||
{
|
||||
GameName = Clean(GameName),
|
||||
Tagline = Clean(Tagline),
|
||||
Description = Clean(Description),
|
||||
WebsiteUrl = Clean(WebsiteUrl),
|
||||
Links = links.Count == 0 ? null : links,
|
||||
Theme = Theme.Value,
|
||||
Icon = Clean(Icon),
|
||||
Background = Clean(Background),
|
||||
UpdateCheckIntervalMinutes = interval,
|
||||
Hidden = Hidden ? true : null
|
||||
};
|
||||
return game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
|
||||
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null }
|
||||
? null
|
||||
: game;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lien d'un jeu (Discord, YouTube…).</summary>
|
||||
public sealed class LinkEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private Choice _kind;
|
||||
private string _label, _url;
|
||||
|
||||
public LinkEditor(IReadOnlyList<Choice> kinds, string kind, string label, string url, Action changed, Action<LinkEditor> remove)
|
||||
{
|
||||
Kinds = kinds;
|
||||
_kind = kinds.FirstOrDefault(k => k.Value == kind) ?? kinds[^1];
|
||||
_label = label;
|
||||
_url = url;
|
||||
_changed = changed;
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> Kinds { get; }
|
||||
|
||||
public Choice Kind
|
||||
{
|
||||
get => _kind;
|
||||
set
|
||||
{
|
||||
if (value is not null && SetProperty(ref _kind, value)) _changed(); // null : liste de l'écran en cours de remplacement
|
||||
}
|
||||
}
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _label, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Url
|
||||
{
|
||||
get => _url;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _url, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal bool IsEmpty => string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Label);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Jeux » : informations modifiables des jeux déclarés dans launcher.json, publiées dans le
|
||||
/// catalogue (catalog.json) que les launchers des joueurs lisent au démarrage.
|
||||
/// • une liste : « Launcher » (nom, jeu par défaut) puis les jeux, dans l'ordre d'affichage (↑ ↓) ;
|
||||
/// • champ vide = valeur de launcher.json (rappelée sous le champ) ;
|
||||
/// • « Publier » : contrôle complet (mêmes règles que le serveur), requête signée, révision attendue
|
||||
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé).
|
||||
/// </summary>
|
||||
public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "catalog.games";
|
||||
|
||||
private CatalogState? _state;
|
||||
private CatalogEditor? _selected;
|
||||
private string? _statusMessage;
|
||||
private bool _loading;
|
||||
private string _publishedSnapshot = "";
|
||||
private bool _hasChanges;
|
||||
private bool _hasConflict;
|
||||
|
||||
public GamesPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
Launcher = new LauncherEditor(context.LauncherName, context.DefaultGameId, context.Games, OnEdited);
|
||||
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
|
||||
RebuildItems(Launcher);
|
||||
|
||||
PublishCommand = new AsyncRelayCommand(PublishAsync, () => !IsBusy && HasChanges && PublishingEnabled);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy);
|
||||
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
|
||||
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
|
||||
}
|
||||
|
||||
public LauncherEditor Launcher { get; }
|
||||
|
||||
/// <summary>Jeux dans l'ordre d'affichage du launcher.</summary>
|
||||
public ObservableCollection<GameEditor> Games { get; } = new();
|
||||
|
||||
/// <summary>Liste affichée : « Launcher », puis les jeux.</summary>
|
||||
public ObservableCollection<CatalogEditor> Items { get; } = new();
|
||||
|
||||
public CatalogEditor? Selected
|
||||
{
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selected, value)) return;
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _state?.PublishingEnabled == true;
|
||||
public bool ShowPublishingDisabled => _state is not null && !_state.PublishingEnabled;
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
/// <summary>« Révision 12, publiée le 31/10/2026 à 08:42 » / « Jamais publié ».</summary>
|
||||
public string RevisionText => _state?.Catalog is not { } catalog ? "" :
|
||||
catalog.Revision == 0 ? "Jamais publié : le launcher utilise les valeurs de launcher.json."
|
||||
: $"Révision {catalog.Revision}" + (catalog.UpdatedAt is { } at ? $", publiée le {at.ToLocalTime():dd/MM/yyyy à HH:mm}" : "") + ".";
|
||||
|
||||
public bool HasChanges
|
||||
{
|
||||
get => _hasChanges;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _hasChanges, value)) return;
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Quelqu'un a publié entre-temps : il faut recharger.</summary>
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set => SetProperty(ref _hasConflict, value);
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public AsyncRelayCommand PublishCommand { get; }
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
|
||||
/// <summary>Faux pendant un chargement ou une publication : la saisie attend la réponse du serveur.</summary>
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
public Task LoadAsync() => RunAsync(async () =>
|
||||
{
|
||||
var state = await Context.Session.GetCatalogAsync();
|
||||
Apply(state);
|
||||
StatusMessage = null;
|
||||
});
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les modifications non publiées seront perdues : la page affichera le catalogue actuellement publié.",
|
||||
"Abandonner et recharger"))
|
||||
return;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Affiche un catalogue (chargé ou publié) et en fait la référence « sans modification ».</summary>
|
||||
private void Apply(CatalogState state)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_state = state;
|
||||
var catalog = state.Catalog;
|
||||
Launcher.Load(catalog.Launcher);
|
||||
foreach (var game in Games) game.Load(catalog.Games.TryGetValue(game.GameId, out var entry) ? entry : null);
|
||||
|
||||
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
|
||||
var ordered = new List<GameEditor>();
|
||||
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
|
||||
if (Games.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
|
||||
ordered.Add(game);
|
||||
ordered.AddRange(Context.Games.Select(d => Games.First(g => g.Declared == d)).Where(g => !ordered.Contains(g)));
|
||||
var selected = Selected;
|
||||
Games.Clear();
|
||||
foreach (var game in ordered) Games.Add(game);
|
||||
RebuildItems(selected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
_publishedSnapshot = Snapshot(BuildCatalog(new List<string>()));
|
||||
HasConflict = false;
|
||||
RefreshDirty();
|
||||
foreach (var name in new[] { nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(PublicUrl), nameof(RevisionText) })
|
||||
OnPropertyChanged(name);
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication
|
||||
|
||||
private Task PublishAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
var errors = new List<string>();
|
||||
var draft = BuildCatalog(errors);
|
||||
var imageHost = Uri.TryCreate(state.PublicUrl, UriKind.Absolute, out var url) ? url.Host : null;
|
||||
var normalized = CatalogRules.Normalize(draft, imageHost, errors);
|
||||
if (errors.Count == 0 && CatalogRules.PublishedSize(normalized) > CatalogRules.MaxFileBytes)
|
||||
errors.Add($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko) : raccourcissez les descriptions.");
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Signature (phase 7) du catalogue tel qu'il sera publié (contrôlé avec les mêmes règles que le serveur).
|
||||
var signature = await Context.Session.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
ContentSigning.CatalogDigest(normalized));
|
||||
var published = await Context.Session.PublishCatalogAsync(normalized with { Signature = signature }, state.Catalog.Revision);
|
||||
Apply(published);
|
||||
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
|
||||
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
Context.Dialogs.ShowMessage("Publié entre-temps",
|
||||
"Quelqu'un a publié le catalogue pendant vos modifications. Rechargez la page pour partir de sa version " +
|
||||
"(vos modifications non publiées seront perdues : notez-les si besoin).", ModuleMessageKind.Warning);
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Catalogue saisi (ordre affiché, champs non vides).</summary>
|
||||
private CatalogDocument BuildCatalog(List<string> errors)
|
||||
{
|
||||
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var game in Games)
|
||||
if (game.ToCatalog(errors) is { } entry) games[game.GameId] = entry;
|
||||
return new CatalogDocument
|
||||
{
|
||||
Launcher = Launcher.ToCatalog(Games.Select(g => g.GameId).ToList(), Context.Games.Select(g => g.GameId).ToList()),
|
||||
Games = games
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
private void OnEdited()
|
||||
{
|
||||
if (_loading) return;
|
||||
RefreshDirty();
|
||||
}
|
||||
|
||||
private void RefreshDirty()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var current = BuildCatalog(errors);
|
||||
HasChanges = Snapshot(current) != _publishedSnapshot || errors.Count > 0;
|
||||
|
||||
var published = string.IsNullOrEmpty(_publishedSnapshot) ? null : JsonSerializer.Deserialize<CatalogDocument>(_publishedSnapshot, ApiJson.Options);
|
||||
Launcher.IsDirty = Snapshot(current.Launcher) != Snapshot(published?.Launcher);
|
||||
foreach (var game in Games)
|
||||
{
|
||||
current.Games.TryGetValue(game.GameId, out var now);
|
||||
CatalogGame? before = null;
|
||||
published?.Games.TryGetValue(game.GameId, out before);
|
||||
game.IsDirty = Snapshot(now) != Snapshot(before);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanMove(int delta) =>
|
||||
Selected is GameEditor game && Games.IndexOf(game) is var index and >= 0 && index + delta >= 0 && index + delta < Games.Count;
|
||||
|
||||
private void Move(int delta)
|
||||
{
|
||||
if (Selected is not GameEditor game || !CanMove(delta)) return;
|
||||
var index = Games.IndexOf(game);
|
||||
Games.Move(index, index + delta);
|
||||
RebuildItems(game);
|
||||
OnEdited();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstruit la liste affichée puis resélectionne <paramref name="select"/> : la liste de l'écran
|
||||
/// vide sa sélection quand ses éléments sont retirés, la notification la rétablit.
|
||||
/// </summary>
|
||||
private void RebuildItems(CatalogEditor? select)
|
||||
{
|
||||
Items.Clear();
|
||||
Items.Add(Launcher);
|
||||
foreach (var game in Games) Items.Add(game);
|
||||
_selected = select is not null && Items.Contains(select) ? select : Items.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(Selected));
|
||||
MoveUpCommand?.RaiseCanExecuteChanged();
|
||||
MoveDownCommand?.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static string Snapshot(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Notes de version » : page du menu d'administration pour corriger les notes des versions
|
||||
/// déjà publiées (manifest.json du serveur). Permission changelog.edit.
|
||||
/// </summary>
|
||||
public sealed class ChangelogAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "changelog";
|
||||
|
||||
public string DisplayName => "Notes de version";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
ChangelogPanelViewModel.PanelId,
|
||||
"Notes de version",
|
||||
"Corrigez les notes des versions déjà publiées : texte, sections, étiquettes, sous-points. " +
|
||||
"Une nouvelle version et ses notes se publient toujours avec ManifestBuilder (notes.txt).",
|
||||
"FileDocument",
|
||||
AdminPermissions.ChangelogEdit,
|
||||
Order: 20,
|
||||
context => new ChangelogPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version, en cours de modification : en-tête (titre, sous-titre, introduction), puis
|
||||
/// sections › éléments. <see cref="Preview"/> est l'entrée telle que le launcher l'affichera (mêmes
|
||||
/// bornes, mêmes modèles d'affichage), recalculée à chaque modification.
|
||||
/// </summary>
|
||||
public sealed class VersionEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private readonly Func<string, string, bool> _confirm;
|
||||
private string _title = "", _subtitle = "", _introduction = "";
|
||||
private string _snapshot = "";
|
||||
private bool _isDirty, _showPreview, _loading;
|
||||
private ChangelogEntry _preview;
|
||||
private IReadOnlyList<string> _limitErrors = Array.Empty<string>();
|
||||
|
||||
/// <param name="confirm">Confirmation (titre, message) avant de retirer une section qui contient des éléments.</param>
|
||||
/// <param name="changed">Prévient la page (bouton « Enregistrer », pastilles).</param>
|
||||
public VersionEditor(ChangelogEntry published, Func<string, string, bool> confirm, Action changed)
|
||||
{
|
||||
Version = published.Version;
|
||||
Date = published.Date;
|
||||
_confirm = confirm;
|
||||
_changed = changed;
|
||||
_preview = new ChangelogEntry { Version = Version, Date = Date };
|
||||
Sections.CollectionChanged += OnSectionsChanged;
|
||||
AddSectionCommand = new RelayCommand(AddSection, () => Sections.Count < ChangelogLimits.MaxSectionsPerEntry);
|
||||
Load(published);
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
public DateTimeOffset Date { get; }
|
||||
public string DateText => Date == default ? "" : _preview.DateText;
|
||||
|
||||
// ------------------------------------------------------------------ En-tête
|
||||
|
||||
public string Title { get => _title; set => Set(ref _title, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-titre : une ou plusieurs lignes.</summary>
|
||||
public string Subtitle { get => _subtitle; set => Set(ref _subtitle, value ?? ""); }
|
||||
|
||||
/// <summary>Paragraphes d'introduction, séparés par une ligne vide.</summary>
|
||||
public string Introduction { get => _introduction; set => Set(ref _introduction, value ?? ""); }
|
||||
|
||||
public ObservableCollection<SectionEditor> Sections { get; } = new();
|
||||
public RelayCommand AddSectionCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
/// <summary>Modifié depuis le chargement (ou le dernier enregistrement).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
private set => SetProperty(ref _isDirty, value);
|
||||
}
|
||||
|
||||
/// <summary>Affichage : modification (faux) ou aperçu tel que le verront les joueurs (vrai).</summary>
|
||||
public bool ShowPreview
|
||||
{
|
||||
get => _showPreview;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showPreview, value)) return;
|
||||
OnPropertyChanged(nameof(IsEditMode));
|
||||
OnPropertyChanged(nameof(IsPreviewMode));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEditMode { get => !_showPreview; set { if (value) ShowPreview = false; } }
|
||||
public bool IsPreviewMode { get => _showPreview; set { if (value) ShowPreview = true; } }
|
||||
|
||||
/// <summary>Notes telles que le launcher les affichera.</summary>
|
||||
public ChangelogEntry Preview
|
||||
{
|
||||
get => _preview;
|
||||
private set => SetProperty(ref _preview, value);
|
||||
}
|
||||
|
||||
/// <summary>Limites dépassées (rien n'est tronqué : à corriger avant d'enregistrer).</summary>
|
||||
public IReadOnlyList<string> LimitErrors
|
||||
{
|
||||
get => _limitErrors;
|
||||
private set
|
||||
{
|
||||
_limitErrors = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasLimitErrors));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasLimitErrors => _limitErrors.Count > 0;
|
||||
|
||||
/// <summary>« 3 sections · 14 éléments ».</summary>
|
||||
public string CountText
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = Sections.Sum(s => s.Items.Count);
|
||||
return $"{Sections.Count} section{(Sections.Count > 1 ? "s" : "")} · {items} élément{(items > 1 ? "s" : "")} " +
|
||||
$"(au plus {ChangelogLimits.MaxItemsPerEntry})";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chargement, conversion
|
||||
|
||||
/// <summary>Affiche des notes publiées et en fait la référence « sans modification ».</summary>
|
||||
public void Load(ChangelogEntry entry)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_title = entry.Title ?? "";
|
||||
_subtitle = entry.Subtitle ?? "";
|
||||
_introduction = string.Join("\n\n", entry.Introduction ?? new List<string>());
|
||||
Sections.Clear();
|
||||
// Ancien format (liste à plat) : présenté en section(s) sans titre, enregistré au format enrichi.
|
||||
var sections = entry.Sections.Count > 0
|
||||
? entry.Sections
|
||||
: (entry.Changes ?? new List<string>())
|
||||
.Chunk(ChangelogLimits.MaxItemsPerSection)
|
||||
.Select(chunk => new ChangelogSection { Items = chunk.Select(c => new ChangelogItem { Text = c }).ToList() })
|
||||
.ToList();
|
||||
foreach (var section in sections) Sections.Add(new SectionEditor(section, this));
|
||||
foreach (var name in new[] { nameof(Title), nameof(Subtitle), nameof(Introduction) }) OnPropertyChanged(name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
_snapshot = Snapshot(Normalized(ToEntry()));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>Notes saisies (non tronquées) : c'est ce qui est envoyé au serveur.</summary>
|
||||
public ChangelogEntry ToEntry()
|
||||
{
|
||||
var entry = new ChangelogEntry
|
||||
{
|
||||
Version = Version,
|
||||
Date = Date,
|
||||
Title = Clean(Title),
|
||||
Subtitle = Clean(Subtitle.Replace("\r\n", "\n")),
|
||||
Introduction = Paragraphs(Introduction),
|
||||
Sections = Sections.Select(s => s.ToSection()).ToList()
|
||||
};
|
||||
// Liste à plat des anciens launchers : régénérée par le serveur à l'enregistrement (requête allégée).
|
||||
if (entry.Sections.Count == 0) entry.Changes = new List<string>();
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
internal void Changed()
|
||||
{
|
||||
if (_loading) return;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
var entry = ToEntry();
|
||||
var preview = Normalized(entry);
|
||||
var errors = ChangelogLimits.Check(entry);
|
||||
if (preview is null) errors.Insert(0, "Les notes ne peuvent pas être vides : gardez au moins un titre ou un élément.");
|
||||
LimitErrors = errors;
|
||||
Preview = preview ?? new ChangelogEntry { Version = Version, Date = Date };
|
||||
IsDirty = Snapshot(preview) != _snapshot;
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddSectionCommand.RaiseCanExecuteChanged();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (!SetProperty(ref field, value, name)) return false;
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddSection()
|
||||
{
|
||||
if (Sections.Count >= ChangelogLimits.MaxSectionsPerEntry) return;
|
||||
var section = new SectionEditor(new ChangelogSection(), this);
|
||||
section.AddChangeCommand.Execute(null);
|
||||
Sections.Add(section);
|
||||
}
|
||||
|
||||
internal void MoveSection(SectionEditor section, int delta) => ListMoves.Move(Sections, section, delta);
|
||||
|
||||
internal void RemoveSection(SectionEditor section)
|
||||
{
|
||||
if (section.Items.Any(i => !i.IsEmpty) &&
|
||||
!_confirm("Retirer la section ?", $"La section « {(string.IsNullOrWhiteSpace(section.Title) ? "sans titre" : section.Title.Trim())} » " +
|
||||
$"et ses {section.Items.Count} élément(s) seront retirés de ces notes (rien n'est enregistré avant « Enregistrer »)."))
|
||||
return;
|
||||
Sections.Remove(section);
|
||||
}
|
||||
|
||||
private void OnSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
foreach (var section in Sections) section.RefreshMoves();
|
||||
Changed();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static ChangelogEntry? Normalized(ChangelogEntry entry) => ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault();
|
||||
|
||||
private static string Snapshot(ChangelogEntry? entry) => entry is null ? "" : JsonSerializer.Serialize(entry, ApiJson.Options);
|
||||
|
||||
internal static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
|
||||
/// <summary>Paragraphes séparés par une (ou plusieurs) ligne(s) vide(s) ; retours à la ligne simples gardés en espaces.</summary>
|
||||
private static List<string> Paragraphs(string text) =>
|
||||
System.Text.RegularExpressions.Regex.Split(text.Replace("\r\n", "\n"), @"\n[ \t]*\n")
|
||||
.Select(p => Clean(p.Replace('\n', ' ')))
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
/// <summary>Une ligne par élément (sous-points).</summary>
|
||||
internal static List<string> Lines(string text) =>
|
||||
text.Replace("\r\n", "\n").Split('\n').Select(Clean).OfType<string>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Section des notes (titre facultatif) et ses éléments.</summary>
|
||||
public sealed class SectionEditor : ObservableObject
|
||||
{
|
||||
private readonly VersionEditor _version;
|
||||
private string _title;
|
||||
|
||||
public SectionEditor(ChangelogSection section, VersionEditor version)
|
||||
{
|
||||
_version = version;
|
||||
_title = section.Title ?? "";
|
||||
AddChangeCommand = new RelayCommand(() => Add(comment: false), CanAdd);
|
||||
AddCommentCommand = new RelayCommand(() => Add(comment: true), CanAdd);
|
||||
foreach (var item in section.Items ?? new List<ChangelogItem>()) Items.Add(new ItemEditor(item, this));
|
||||
Items.CollectionChanged += (_, _) =>
|
||||
{
|
||||
foreach (var item in Items) item.RefreshMoves();
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddChangeCommand.RaiseCanExecuteChanged();
|
||||
AddCommentCommand.RaiseCanExecuteChanged();
|
||||
_version.Changed();
|
||||
};
|
||||
MoveUpCommand = new RelayCommand(() => _version.MoveSection(this, -1), () => _version.Sections.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _version.MoveSection(this, +1),
|
||||
() => _version.Sections.IndexOf(this) is var i && i >= 0 && i < _version.Sections.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _version.RemoveSection(this));
|
||||
}
|
||||
|
||||
/// <summary>Titre de la section (facultatif), ex. « PROGRESSION ».</summary>
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _title, value ?? "")) _version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ItemEditor> Items { get; } = new();
|
||||
|
||||
public string CountText => $"{Items.Count} élément{(Items.Count > 1 ? "s" : "")}";
|
||||
|
||||
public RelayCommand AddChangeCommand { get; }
|
||||
public RelayCommand AddCommentCommand { get; }
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal VersionEditor Version => _version;
|
||||
|
||||
internal ChangelogSection ToSection() => new()
|
||||
{
|
||||
Title = VersionEditor.Clean(Title),
|
||||
Items = Items.Select(i => i.ToItem()).ToList()
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void MoveItem(ItemEditor item, int delta) => ListMoves.Move(Items, item, delta);
|
||||
|
||||
internal void RemoveItem(ItemEditor item) => Items.Remove(item);
|
||||
|
||||
private bool CanAdd() => Items.Count < ChangelogLimits.MaxItemsPerSection;
|
||||
|
||||
private void Add(bool comment)
|
||||
{
|
||||
if (!CanAdd()) return;
|
||||
Items.Add(new ItemEditor(new ChangelogItem { Type = comment ? ChangelogItem.CommentType : null }, this));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Élément d'une section : une modification (étiquette, sous-points, note) ou un commentaire.</summary>
|
||||
public sealed class ItemEditor : ObservableObject
|
||||
{
|
||||
public static readonly IReadOnlyList<Choice> Kinds = new[]
|
||||
{
|
||||
new Choice(ChangelogItem.ChangeType, "Modification"),
|
||||
new Choice(ChangelogItem.CommentType, "Commentaire")
|
||||
};
|
||||
|
||||
private readonly SectionEditor _section;
|
||||
private Choice _kind;
|
||||
private string _text, _tag, _details, _note;
|
||||
|
||||
public ItemEditor(ChangelogItem item, SectionEditor section)
|
||||
{
|
||||
_section = section;
|
||||
_kind = item.IsComment ? Kinds[1] : Kinds[0];
|
||||
_text = item.Text ?? "";
|
||||
_tag = item.Tag ?? "";
|
||||
_details = string.Join("\n", item.Details ?? new List<string>());
|
||||
_note = item.Note ?? "";
|
||||
MoveUpCommand = new RelayCommand(() => _section.MoveItem(this, -1), () => _section.Items.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _section.MoveItem(this, +1),
|
||||
() => _section.Items.IndexOf(this) is var i && i >= 0 && i < _section.Items.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _section.RemoveItem(this));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> KindChoices => Kinds;
|
||||
|
||||
public Choice Kind
|
||||
{
|
||||
get => _kind;
|
||||
set
|
||||
{
|
||||
// null : liste de l'écran en cours de remplacement, pas un choix de l'administrateur.
|
||||
if (value is null || !SetProperty(ref _kind, value)) return;
|
||||
OnPropertyChanged(nameof(IsComment));
|
||||
OnPropertyChanged(nameof(IsChange));
|
||||
_section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsComment => _kind.Value == ChangelogItem.CommentType;
|
||||
public bool IsChange => !IsComment;
|
||||
|
||||
public string Text { get => _text; set => Set(ref _text, value ?? ""); }
|
||||
|
||||
/// <summary>Étiquette en pastille, ex. « NOUVEAU » (modifications uniquement).</summary>
|
||||
public string Tag { get => _tag; set => Set(ref _tag, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-points : un par ligne (modifications uniquement).</summary>
|
||||
public string Details { get => _details; set => Set(ref _details, value ?? ""); }
|
||||
|
||||
/// <summary>Texte de conclusion après les sous-points (modifications uniquement).</summary>
|
||||
public string Note { get => _note; set => Set(ref _note, value ?? ""); }
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal bool IsEmpty => string.IsNullOrWhiteSpace(Text) && string.IsNullOrWhiteSpace(Details);
|
||||
|
||||
internal ChangelogItem ToItem() => IsComment
|
||||
? new ChangelogItem { Type = ChangelogItem.CommentType, Text = Text.Trim() }
|
||||
: new ChangelogItem
|
||||
{
|
||||
Text = Text.Trim(),
|
||||
Tag = VersionEditor.Clean(Tag),
|
||||
Details = VersionEditor.Lines(Details),
|
||||
Note = VersionEditor.Clean(Note)
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void Set(ref string field, string value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (SetProperty(ref field, value, name)) _section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ListMoves
|
||||
{
|
||||
public static void Move<T>(ObservableCollection<T> list, T item, int delta)
|
||||
{
|
||||
var index = list.IndexOf(item);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= list.Count) return;
|
||||
list.Move(index, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
|
||||
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
|
||||
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
|
||||
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
|
||||
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
|
||||
/// • « Enregistrer » : une requête signée par version modifiée, avec l'empreinte du manifest lu
|
||||
/// (si le manifest a changé entre-temps, rien n'est écrasé et le rechargement est proposé).
|
||||
/// </summary>
|
||||
public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "changelog.notes";
|
||||
|
||||
private ChangelogState? _state;
|
||||
private Choice? _selectedGame;
|
||||
private VersionEditor? _selectedVersion;
|
||||
private bool _hasChanges, _hasConflict;
|
||||
private string? _statusMessage;
|
||||
private int _skippedVersions;
|
||||
|
||||
public ChangelogPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame)) return;
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
|
||||
{
|
||||
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
|
||||
return;
|
||||
}
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
|
||||
_ = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Versions
|
||||
|
||||
public ObservableCollection<VersionEditor> Versions { get; } = new();
|
||||
|
||||
public VersionEditor? SelectedVersion
|
||||
{
|
||||
get => _selectedVersion;
|
||||
set
|
||||
{
|
||||
// null : la liste de l'écran se vide lors d'un rechargement ; la sélection est rétablie ensuite.
|
||||
if (value is not null) SetProperty(ref _selectedVersion, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasVersions => Versions.Count > 0;
|
||||
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
|
||||
|
||||
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
|
||||
public string ManifestText => _state is null ? "" :
|
||||
$"Version publiée : {_state.ManifestVersion} · {Versions.Count} version{(Versions.Count > 1 ? "s" : "")} avec des notes" +
|
||||
(_skippedVersions > 0 ? $" ({_skippedVersions} non modifiable{(_skippedVersions > 1 ? "s" : "")} en ligne : numéro avec caractères spéciaux ou en double)" : "");
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
public bool HasChanges
|
||||
{
|
||||
get => _hasChanges;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasChanges, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Le manifest a changé sur le serveur (version publiée, autre administrateur) : il faut recharger.</summary>
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
/// <summary>Faux pendant un chargement ou un enregistrement.</summary>
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
public AsyncRelayCommand SaveCommand { get; }
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
public Task LoadAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
Apply(state);
|
||||
StatusMessage = null;
|
||||
});
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes non enregistrées seront perdues : la page affichera celles du manifest publié.", "Abandonner et recharger"))
|
||||
return;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private void Apply(ChangelogState state)
|
||||
{
|
||||
var selected = _selectedVersion?.Version;
|
||||
_state = state;
|
||||
HasConflict = false;
|
||||
Versions.Clear();
|
||||
// Une version présente deux fois n'est modifiable qu'une fois (le serveur modifie la première).
|
||||
var editable = state.Entries.Where(e => ChangelogLimits.IsEditableVersion(e.Version))
|
||||
.DistinctBy(e => e.Version, StringComparer.Ordinal).ToList();
|
||||
_skippedVersions = state.Entries.Count - editable.Count;
|
||||
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
|
||||
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Enregistrement
|
||||
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_state is null || _selectedGame?.Value is not { } gameId) return;
|
||||
var dirty = Versions.Where(v => v.IsDirty).ToList();
|
||||
|
||||
// Tout est vérifié avant le premier envoi : limites, taille de chaque requête.
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var entry = version.ToEntry();
|
||||
var errors = version.LimitErrors; // limites + notes vides
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage($"Notes de la version {version.Version} à corriger",
|
||||
string.Join("\n", errors.Take(12)), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
var size = JsonSerializer.SerializeToUtf8Bytes(new ChangelogUpdateRequest { BaseSha256 = _state.ManifestSha256, Entry = entry }, ApiJson.Options).Length;
|
||||
if (size > ChangelogLimits.MaxEditRequestBytes)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage("Notes trop longues pour l'édition en ligne",
|
||||
$"Les notes de la version {version.Version} font {size / 1024} Ko (au plus {ChangelogLimits.MaxEditRequestBytes / 1024} Ko par version). " +
|
||||
"Raccourcissez-les, ou modifiez notes.txt puis republiez la version.", ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var saved = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var state = await Context.Session.SaveChangelogEntryAsync(gameId, version.ToEntry(), _state.ManifestSha256);
|
||||
_state = state;
|
||||
if (state.Entries.FirstOrDefault(e => e.Version == version.Version) is { } published) version.Load(published);
|
||||
saved.Add(version.Version);
|
||||
}
|
||||
StatusMessage = $"Notes enregistrées ({string.Join(", ", saved)}). Les launchers les afficheront à leur prochaine recherche de mise à jour.";
|
||||
Log.Info($"Administration : notes de version modifiées ({gameId} {string.Join(", ", saved)}).");
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
if (saved.Count > 0) StatusMessage = $"Notes enregistrées : {string.Join(", ", saved)}.";
|
||||
Context.Dialogs.ShowMessage("Manifest modifié entre-temps",
|
||||
"Le manifest du jeu a changé sur le serveur (nouvelle version publiée ou autre administrateur). Rechargez la page " +
|
||||
"pour partir de la version publiée (les modifications non enregistrées seront perdues : notez-les si besoin).",
|
||||
ModuleMessageKind.Warning);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
_state = null;
|
||||
HasConflict = false;
|
||||
StatusMessage = null;
|
||||
Versions.Clear();
|
||||
_selectedVersion = null;
|
||||
_skippedVersions = 0;
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void RefreshChanges() => HasChanges = Versions.Any(v => v.IsDirty);
|
||||
|
||||
private bool Confirm(string title, string message) => Context.Dialogs.Confirm(title, message, "Retirer", danger: true);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Module « Administration » du launcher (sans WPF) : clés de l'administrateur, connexion,
|
||||
invitation, verrouillage de session, tableau de bord. Les écrans correspondants sont dans le
|
||||
launcher (Views\Admin, Themes\Admin.xaml). Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules.Admin</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules.Admin</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Publication » : page du menu d'administration pour mettre en ligne une version produite par
|
||||
/// le .bat (ManifestBuilder), sans WinSCP. Permission releases.publish (propriétaires, administrateurs).
|
||||
/// </summary>
|
||||
public sealed class PublishAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "publishing";
|
||||
|
||||
public string DisplayName => "Publication";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
PublishPanelViewModel.PanelId,
|
||||
"Publier une version",
|
||||
"Mettez en ligne la version préparée par le .bat de publication (ManifestBuilder) : seuls les fichiers absents " +
|
||||
"du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est publié en dernier.",
|
||||
"CloudUpload",
|
||||
AdminPermissions.ReleasesPublish,
|
||||
Order: 30,
|
||||
context => new PublishPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Publier une version » : choix du manifest.json produit par le .bat (ManifestBuilder), contrôle
|
||||
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
|
||||
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
|
||||
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
|
||||
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète.
|
||||
/// </summary>
|
||||
public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "releases.publish";
|
||||
|
||||
private Choice? _selectedGame;
|
||||
private LocalRelease? _release;
|
||||
private string? _manifestPath, _publishedVersion, _statusMessage, _progressText, _speedText;
|
||||
private string _publishedText = "";
|
||||
private ManifestSignatureState? _online;
|
||||
private IReadOnlyList<string> _warnings = Array.Empty<string>();
|
||||
private bool _isPublishing, _committing;
|
||||
private double _progressValue;
|
||||
private PublicationState? _interrupted;
|
||||
private CancellationTokenSource? _cancellation;
|
||||
|
||||
public PublishPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit);
|
||||
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _manifestPath is not null);
|
||||
PublishCommand = new AsyncRelayCommand(PublishAsync, () => CanEdit && _release is not null);
|
||||
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
|
||||
SignOnlineCommand = new AsyncRelayCommand(SignOnlineAsync, () => CanEdit && CanSignOnline);
|
||||
AbandonCommand = new AsyncRelayCommand(AbandonAsync, () => CanEdit && _interrupted is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu, dossier
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame) || _isPublishing) return;
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
SetRelease(null);
|
||||
_ = RefreshPublishedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public string? ManifestPath
|
||||
{
|
||||
get => _manifestPath;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _manifestPath, value)) RecheckCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>« Version en ligne : 1.1 » (ou la raison pour laquelle elle est inconnue).</summary>
|
||||
public string PublishedText
|
||||
{
|
||||
get => _publishedText;
|
||||
private set => SetProperty(ref _publishedText, value);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Version à publier
|
||||
|
||||
public bool HasRelease => _release is not null;
|
||||
public string VersionText => _release is null ? "" : $"Version {_release.Summary.Version}";
|
||||
public string? NotesTitle => _release?.Summary.NotesTitle;
|
||||
public bool HasNotesTitle => !string.IsNullOrWhiteSpace(NotesTitle);
|
||||
|
||||
public string FilesText => _release is null ? "" :
|
||||
$"{_release.Summary.FileCount} fichier(s), {Format.Bytes(_release.Summary.TotalSize)} · exécutable « {_release.Summary.Executable} »";
|
||||
|
||||
public string ArchiveText => _release is null ? "" : $"{_release.Summary.ArchiveName} ({Format.Bytes(_release.Summary.ArchiveSize)})";
|
||||
|
||||
/// <summary>Points d'attention : republication, retour à une version précédente…</summary>
|
||||
public IReadOnlyList<string> Warnings
|
||||
{
|
||||
get => _warnings;
|
||||
private set
|
||||
{
|
||||
_warnings = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasWarnings => _warnings.Count > 0;
|
||||
|
||||
// ------------------------------------------------------------------ Envoi
|
||||
|
||||
public bool IsPublishing
|
||||
{
|
||||
get => _isPublishing;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isPublishing, value)) return;
|
||||
OnPropertyChanged(nameof(HasInterrupted));
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
OnPropertyChanged(nameof(ShowBusyBar));
|
||||
RefreshCommands();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Faux pendant un contrôle ou un envoi.</summary>
|
||||
public bool CanEdit => !IsBusy && !_isPublishing;
|
||||
|
||||
/// <summary>Barre d'attente du contrôle du dossier (l'envoi a sa propre barre de progression).</summary>
|
||||
public bool ShowBusyBar => IsBusy && !_isPublishing;
|
||||
|
||||
public double ProgressValue
|
||||
{
|
||||
get => _progressValue;
|
||||
private set => SetProperty(ref _progressValue, value);
|
||||
}
|
||||
|
||||
public string? ProgressText
|
||||
{
|
||||
get => _progressText;
|
||||
private set => SetProperty(ref _progressText, value);
|
||||
}
|
||||
|
||||
public string? SpeedText
|
||||
{
|
||||
get => _speedText;
|
||||
private set => SetProperty(ref _speedText, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Envoi interrompu (annulé, coupure, erreur) : il reprendra à « Publier », ou peut être abandonné (fichiers
|
||||
/// reçus supprimés). Pas pendant l'envoi lui-même : la publication est alors ouverte, pas interrompue.
|
||||
/// </summary>
|
||||
public bool HasInterrupted => _interrupted is not null && !_isPublishing;
|
||||
|
||||
public string InterruptedText => _interrupted is null ? "" :
|
||||
$"L'envoi de la version {_interrupted.Version} a été interrompu. « Publier » le reprend là où il s'est arrêté ; " +
|
||||
"« Abandonner » supprime ce qui a déjà été reçu par le serveur.";
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public AsyncRelayCommand ChooseCommand { get; }
|
||||
public AsyncRelayCommand RecheckCommand { get; }
|
||||
public AsyncRelayCommand PublishCommand { get; }
|
||||
public RelayCommand CancelCommand { get; }
|
||||
public AsyncRelayCommand AbandonCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Signature de la version en ligne (phase 7)
|
||||
|
||||
/// <summary>« Version en ligne signée par … » / « non signée ».</summary>
|
||||
public string OnlineSignatureText => _online is not { Version.Length: > 0 } online ? ""
|
||||
: online.Signature is { } signature
|
||||
? $"Version en ligne {online.Version} signée par {online.SignerName ?? $"la clé {signature.KeyId}"} le {Accounts.AccountTexts.Date(signature.SignedAt)}."
|
||||
: $"Version en ligne {online.Version} non signée : les launchers en mode « require » la refusent.";
|
||||
|
||||
public bool HasOnlineSignatureText => OnlineSignatureText.Length > 0;
|
||||
public bool IsOnlineSigned => _online?.Signature is not null;
|
||||
|
||||
/// <summary>Le dossier choisi a exactement le contenu de la version en ligne : elle peut être signée avec lui.</summary>
|
||||
public bool CanSignOnline => !_isPublishing && _release is { } release && _online is { Digest.Length: > 0 } online &&
|
||||
string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Même numéro de version que celle en ligne, mais contenu différent : impossible de la signer avec ce dossier.</summary>
|
||||
public string? OnlineMismatchText => _release is { } release && _online is { Digest.Length: > 0 } online &&
|
||||
release.Summary.Version == online.Version &&
|
||||
!string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase)
|
||||
? "La version en ligne porte ce numéro mais son contenu diffère de ce dossier : elle ne peut pas être signée avec lui."
|
||||
: null;
|
||||
|
||||
public AsyncRelayCommand SignOnlineCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Signe la version déjà en ligne (publiée avant la signature, ou avec WinSCP) à partir du dossier choisi : le
|
||||
/// serveur n'accepte que si les fichiers en ligne sont exactement ceux de ce manifest.
|
||||
/// </summary>
|
||||
private async Task SignOnlineAsync()
|
||||
{
|
||||
if (_release is not { } release || _selectedGame?.Value is not { } gameId || _online is not { } online) return;
|
||||
if (!Context.Dialogs.Confirm($"Signer la version en ligne {online.Version} ?",
|
||||
"Vous attestez que la version en ligne est bien celle de ce dossier de publication (le serveur vérifie que les fichiers, " +
|
||||
"l'archive et l'exécutable sont identiques). Les launchers des joueurs l'accepteront ensuite.", "Signer"))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
if (await RunAsync(async () =>
|
||||
{
|
||||
var signature = await Context.Session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest);
|
||||
_online = await Context.Session.SignOnlineManifestAsync(gameId,
|
||||
new ManifestSignRequest { Digest = release.ContentDigest, Signature = signature });
|
||||
}))
|
||||
StatusMessage = $"Version en ligne {online.Version} signée.";
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
private async Task RefreshSignatureAsync(string gameId)
|
||||
{
|
||||
try
|
||||
{
|
||||
_online = await Context.Session.GetManifestSignatureAsync(gameId);
|
||||
}
|
||||
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
_online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
|
||||
}
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
private void NotifySignature()
|
||||
{
|
||||
foreach (var name in new[] { nameof(OnlineSignatureText), nameof(HasOnlineSignatureText), nameof(IsOnlineSigned),
|
||||
nameof(CanSignOnline), nameof(OnlineMismatchText) })
|
||||
OnPropertyChanged(name);
|
||||
SignOnlineCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
OnPropertyChanged(nameof(ShowBusyBar));
|
||||
RefreshCommands();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshPublishedAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
private async Task ChooseAsync()
|
||||
{
|
||||
var path = Context.Dialogs.PickOpenFile("Manifest de la version à publier (dossier produit par le .bat)",
|
||||
"Manifest de publication (manifest.json)|manifest.json", _manifestPath is null ? null : Path.GetDirectoryName(_manifestPath));
|
||||
if (path is not null) await InspectAsync(path);
|
||||
}
|
||||
|
||||
/// <summary>Contrôle du dossier choisi (hors du fil de l'interface : lecture de nombreux fichiers).</summary>
|
||||
public Task InspectAsync(string manifestPath) => RunAsync(async () =>
|
||||
{
|
||||
ManifestPath = manifestPath;
|
||||
StatusMessage = null;
|
||||
SetRelease(null);
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var release = await Task.Run(() => ReleaseUploader.Inspect(manifestPath, gameId));
|
||||
await RefreshPublishedCoreAsync(gameId);
|
||||
SetRelease(release);
|
||||
});
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
if (_release is not { } release || _selectedGame?.Value is not { } gameId) return;
|
||||
var online = _publishedVersion switch
|
||||
{
|
||||
null => "version en ligne inconnue",
|
||||
"" => "aucune version en ligne",
|
||||
_ => $"version en ligne : {_publishedVersion}"
|
||||
};
|
||||
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
|
||||
$"Les joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
|
||||
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
|
||||
return;
|
||||
|
||||
StatusMessage = null;
|
||||
_committing = false;
|
||||
IsPublishing = true;
|
||||
// Créés ici, sur le fil de l'interface : l'envoi les rappelle sur ce fil (boutons, liaisons).
|
||||
var progress = new Progress<UploadProgress>(OnProgress);
|
||||
var started = new Progress<PublicationState>(OnStarted);
|
||||
_cancellation = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
using var _ = Context.BeginLongOperation();
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ReleaseUploader.PublishAsync(Context.Session, gameId, release, progress, started, _cancellation.Token);
|
||||
SetInterrupted(null);
|
||||
_publishedVersion = result.Version;
|
||||
PublishedText = $"Version en ligne : {result.Version}";
|
||||
StatusMessage = $"Version {result.Version} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
|
||||
(result.ArchiveAdded ? " et l'archive" : "") +
|
||||
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
|
||||
UpdateWarnings();
|
||||
Log.Info($"Administration : version {result.Version} de {gameId} publiée.");
|
||||
await RefreshSignatureAsync(gameId);
|
||||
}
|
||||
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
|
||||
{
|
||||
StatusMessage = null;
|
||||
ProgressText = "Envoi interrompu.";
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cancellation.Dispose();
|
||||
_cancellation = null;
|
||||
_committing = false;
|
||||
IsPublishing = false;
|
||||
SpeedText = null;
|
||||
}
|
||||
}
|
||||
|
||||
private Task AbandonAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_interrupted is not { } publication) return;
|
||||
if (!Context.Dialogs.Confirm("Abandonner la publication ?",
|
||||
$"Les fichiers de la version {publication.Version} déjà reçus par le serveur seront supprimés. Rien n'a été mis en ligne.",
|
||||
"Abandonner", danger: true))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await Context.Session.CancelPublicationAsync(publication.PublicationId);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
// déjà terminée ou expirée
|
||||
}
|
||||
SetInterrupted(null);
|
||||
ProgressText = null;
|
||||
ProgressValue = 0;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private Task RefreshPublishedAsync() => _selectedGame?.Value is { } gameId ? RefreshPublishedCoreAsync(gameId) : Task.CompletedTask;
|
||||
|
||||
/// <summary>Version en ligne (manifest du serveur) ; une erreur ici n'empêche pas de continuer.</summary>
|
||||
private async Task RefreshPublishedCoreAsync(string gameId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
_publishedVersion = state.ManifestVersion;
|
||||
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
|
||||
await RefreshSignatureAsync(gameId);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
_publishedVersion = "";
|
||||
PublishedText = "Aucune version en ligne pour l'instant.";
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
|
||||
{
|
||||
_publishedVersion = null;
|
||||
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
|
||||
}
|
||||
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
_publishedVersion = null;
|
||||
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
|
||||
}
|
||||
UpdateWarnings();
|
||||
}
|
||||
|
||||
private void SetRelease(LocalRelease? release)
|
||||
{
|
||||
_release = release;
|
||||
foreach (var name in new[] { nameof(HasRelease), nameof(VersionText), nameof(NotesTitle), nameof(HasNotesTitle), nameof(FilesText), nameof(ArchiveText) })
|
||||
OnPropertyChanged(name);
|
||||
UpdateWarnings();
|
||||
RefreshCommands();
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
private void UpdateWarnings()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (_release is { } release && !string.IsNullOrEmpty(_publishedVersion))
|
||||
{
|
||||
if (release.Summary.Version == _publishedVersion)
|
||||
warnings.Add("Cette version est déjà en ligne : elle sera republiée (fichiers, archive et notes de cette version remplacés).");
|
||||
else if (VersionUtil.TryParse(release.Summary.Version, out var local) && VersionUtil.TryParse(_publishedVersion, out var online) &&
|
||||
local < online)
|
||||
warnings.Add($"Version inférieure à celle en ligne ({_publishedVersion}) : les joueurs reviendront à la version {release.Summary.Version}.");
|
||||
}
|
||||
if (_release is { Summary.NotesTitle: null })
|
||||
warnings.Add("Aucune note pour cette version dans le manifest (celles déjà en ligne pour ce numéro sont conservées).");
|
||||
Warnings = warnings;
|
||||
}
|
||||
|
||||
private void OnStarted(PublicationState state)
|
||||
{
|
||||
SetInterrupted(state);
|
||||
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
|
||||
}
|
||||
|
||||
private void SetInterrupted(PublicationState? state)
|
||||
{
|
||||
_interrupted = state;
|
||||
OnPropertyChanged(nameof(HasInterrupted));
|
||||
OnPropertyChanged(nameof(InterruptedText));
|
||||
AbandonCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void OnProgress(UploadProgress progress)
|
||||
{
|
||||
if (progress.Step == ReleaseUploader.CommitStep && !_committing)
|
||||
{
|
||||
_committing = true; // la mise en ligne ne s'interrompt pas
|
||||
CancelCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
ProgressValue = progress.Total > 0 ? Math.Clamp((double)progress.Done / progress.Total, 0, 1) : 0;
|
||||
ProgressText = progress.Total > 0
|
||||
? $"{progress.Step} — {Format.Bytes(progress.Done)} / {Format.Bytes(progress.Total)}"
|
||||
: progress.Step;
|
||||
SpeedText = progress.BytesPerSecond > 0 ? Format.Speed(progress.BytesPerSecond) : null;
|
||||
}
|
||||
|
||||
private void RefreshCommands()
|
||||
{
|
||||
ChooseCommand.RaiseCanExecuteChanged();
|
||||
RecheckCommand.RaiseCanExecuteChanged();
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
CancelCommand.RaiseCanExecuteChanged();
|
||||
AbandonCommand.RaiseCanExecuteChanged();
|
||||
SignOnlineCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Publishing;
|
||||
|
||||
/// <summary>Version prête à publier : dossier produit par ManifestBuilder (le .bat), contrôlé.</summary>
|
||||
public sealed record LocalRelease(string Folder, string ManifestPath, byte[] ManifestBytes, string ManifestSha256, ManifestSummary Summary)
|
||||
{
|
||||
/// <summary>Condensé du contenu signé (phase 7) : fichiers, archive, exécutable… (notes exclues).</summary>
|
||||
public string ContentDigest { get; } = ContentSigning.ManifestDigest(ManifestBytes);
|
||||
|
||||
public string ArchivePath => Path.Combine(Folder, Summary.ArchiveName);
|
||||
|
||||
public string ObjectFile(string sha256) =>
|
||||
Path.Combine(Folder, ManifestCheck.ObjectPath(sha256).Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
|
||||
/// <summary>Avancement de l'envoi.</summary>
|
||||
public sealed record UploadProgress(string Step, long Done, long Total, double BytesPerSecond);
|
||||
|
||||
/// <summary>Contenu du dossier de publication incorrect (message affiché tel quel).</summary>
|
||||
public sealed class ReleaseCheckException : InvalidOperationException
|
||||
{
|
||||
public ReleaseCheckException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version : contrôle du dossier local, puis envoi au serveur (manifest, objets absents
|
||||
/// du serveur, archive) et mise en ligne. Reprise automatique : après une coupure (réseau, serveur
|
||||
/// occupé, trop de requêtes), l'état est relu sur le serveur et l'envoi continue là où il s'est arrêté ;
|
||||
/// relancé plus tard avec le même manifest, l'envoi reprend aussi.
|
||||
/// </summary>
|
||||
public static class ReleaseUploader
|
||||
{
|
||||
private const int MaxAttempts = 8;
|
||||
|
||||
/// <summary>Signature réutilisée pour un nouvel essai de mise en ligne pendant ce délai au plus (serveur : ± 10 min).</summary>
|
||||
private static readonly TimeSpan SignatureReuseLimit = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Dernière étape (mise en ligne sur le serveur) : elle ne s'interrompt pas.</summary>
|
||||
public const string CommitStep = "Mise en ligne";
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle du dossier : manifest valide (mêmes règles que le serveur), du bon jeu, archive et fichiers
|
||||
/// présents avec la bonne taille (empreintes vérifiées pendant l'envoi).
|
||||
/// </summary>
|
||||
public static LocalRelease Inspect(string manifestPath, string gameId)
|
||||
{
|
||||
var info = new FileInfo(manifestPath);
|
||||
if (!info.Exists) throw new ReleaseCheckException($"Manifest introuvable : {manifestPath}");
|
||||
if (info.Length > PublishingLimits.MaxManifestBytes) throw new ReleaseCheckException("Manifest trop volumineux.");
|
||||
var bytes = File.ReadAllBytes(info.FullName);
|
||||
var errors = new List<string>();
|
||||
var summary = ManifestCheck.Check(bytes, gameId, expectedVersion: null, errors)
|
||||
?? throw new ReleaseCheckException("Ce manifest ne peut pas être publié :\n" + string.Join("\n", errors.Take(8)));
|
||||
|
||||
var release = new LocalRelease(info.DirectoryName!, info.FullName, bytes, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), summary);
|
||||
var archive = new FileInfo(release.ArchivePath);
|
||||
if (!archive.Exists) errors.Add($"Archive absente : {summary.ArchiveName}");
|
||||
else if (archive.Length != summary.ArchiveSize) errors.Add($"Archive {summary.ArchiveName} : taille différente de celle du manifest.");
|
||||
var missing = 0;
|
||||
foreach (var (sha, size) in summary.Objects)
|
||||
{
|
||||
var file = new FileInfo(release.ObjectFile(sha));
|
||||
if (file.Exists && file.Length == size) continue;
|
||||
if (++missing <= 5) errors.Add($"{ManifestCheck.ObjectPath(sha)} : {(file.Exists ? "taille différente" : "absent")}.");
|
||||
}
|
||||
if (missing > 5) errors.Add($"… et {missing - 5} autre(s) fichier(s) de objects/.");
|
||||
if (errors.Count > 0)
|
||||
throw new ReleaseCheckException("Dossier de publication incomplet (relancez le .bat de publication) :\n" + string.Join("\n", errors));
|
||||
return release;
|
||||
}
|
||||
|
||||
/// <summary>Envoie ce qui manque puis met la version en ligne.</summary>
|
||||
/// <param name="progress">Avancement ; un <see cref="Progress{T}"/> créé sur le fil de l'interface y est rappelé.</param>
|
||||
/// <param name="started">Publication ouverte sur le serveur (pour l'abandonner si besoin). Comme <paramref name="progress"/>,
|
||||
/// passer un <see cref="Progress{T}"/> : l'envoi se poursuit hors du fil de l'interface (ConfigureAwait(false)).</param>
|
||||
public static async Task<PublicationResult> PublishAsync(AdminSession session, string gameId, LocalRelease release,
|
||||
IProgress<UploadProgress>? progress, IProgress<PublicationState>? started, CancellationToken ct)
|
||||
{
|
||||
var run = new Run(session, release, progress);
|
||||
var state = await Retry(() => session.StartPublicationAsync(gameId, new PublicationStartRequest
|
||||
{
|
||||
Version = release.Summary.Version,
|
||||
ManifestSha256 = release.ManifestSha256,
|
||||
ManifestSize = release.ManifestBytes.LongLength
|
||||
}, ct), ct).ConfigureAwait(false);
|
||||
started?.Report(state);
|
||||
|
||||
var committing = false;
|
||||
ContentSignature? signature = null;
|
||||
var signedLocally = DateTime.MinValue;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var before = run.Done;
|
||||
try
|
||||
{
|
||||
if (!committing)
|
||||
{
|
||||
if (attempt > 1) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
|
||||
await run.ManifestAsync(state, ct).ConfigureAwait(false);
|
||||
if (!state.ManifestAccepted) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
|
||||
run.Plan(state);
|
||||
await run.ObjectsAsync(state, ct).ConfigureAwait(false);
|
||||
await run.ArchiveAsync(state, ct).ConfigureAwait(false);
|
||||
run.Report(CommitStep);
|
||||
committing = true;
|
||||
}
|
||||
// Mise en ligne : ni interrompue (elle se termine sur le serveur de toute façon), ni perdue
|
||||
// (réponse perdue : le serveur renvoie le même résultat au nouvel essai).
|
||||
// Signature du contenu (phase 7) juste avant la mise en ligne : le serveur la contrôle (clé de cette
|
||||
// session, date proche de son heure), puis l'intègre au manifest publié.
|
||||
// Nouvel essai après plusieurs minutes : signature refaite (le serveur refuse une date trop éloignée).
|
||||
if (signature is null || DateTime.UtcNow - signedLocally > SignatureReuseLimit)
|
||||
{
|
||||
signature = await session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest).ConfigureAwait(false);
|
||||
signedLocally = DateTime.UtcNow;
|
||||
}
|
||||
return await session.CommitPublicationAsync(state.PublicationId, signature, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
|
||||
{
|
||||
if (run.Done > before) attempt = 1; // l'envoi avance : les essais repartent de zéro
|
||||
var delay = Backoff(attempt, ex);
|
||||
if (delay > TimeSpan.Zero) run.Waiting(PauseReason(ex), delay);
|
||||
await Task.Delay(delay, committing ? CancellationToken.None : ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Envoi
|
||||
|
||||
private sealed class Run
|
||||
{
|
||||
private readonly AdminSession _session;
|
||||
private readonly LocalRelease _release;
|
||||
private readonly IProgress<UploadProgress>? _progress;
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
private long _total, _done, _sentThisRun;
|
||||
|
||||
public long Done => _done;
|
||||
private string _step = "Préparation";
|
||||
|
||||
public Run(AdminSession session, LocalRelease release, IProgress<UploadProgress>? progress)
|
||||
{
|
||||
_session = session;
|
||||
_release = release;
|
||||
_progress = progress;
|
||||
}
|
||||
|
||||
public async Task ManifestAsync(PublicationState state, CancellationToken ct)
|
||||
{
|
||||
if (state.ManifestReceived >= state.ManifestSize) return;
|
||||
if (_total == 0) _total = state.ManifestSize - state.ManifestReceived;
|
||||
_step = "Envoi du manifest";
|
||||
var bytes = _release.ManifestBytes;
|
||||
for (var offset = state.ManifestReceived; offset < bytes.LongLength;)
|
||||
{
|
||||
var length = (int)Math.Min(PublishingLimits.ChunkBytes, bytes.LongLength - offset);
|
||||
await _session.UploadManifestAsync(state.PublicationId, offset, bytes.AsSpan((int)offset, length).ToArray(), ct).ConfigureAwait(false);
|
||||
offset += length;
|
||||
Sent(length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total à envoyer, connu une fois le manifest accepté (calculé une seule fois).</summary>
|
||||
public void Plan(PublicationState state)
|
||||
{
|
||||
if (_total > state.ManifestSize || !state.ManifestAccepted) return;
|
||||
var objects = state.PendingObjects.Sum(sha => Size(sha) - state.PartialObjects.GetValueOrDefault(sha));
|
||||
var archive = state.ArchiveComplete ? 0 : state.ArchiveSize - state.ArchiveReceived;
|
||||
_total = _done + objects + archive;
|
||||
Report(_step);
|
||||
}
|
||||
|
||||
public async Task ObjectsAsync(PublicationState state, CancellationToken ct)
|
||||
{
|
||||
var pending = state.PendingObjects.Select(sha => sha.ToLowerInvariant()).ToList();
|
||||
if (pending.Count == 0) return;
|
||||
var count = 0;
|
||||
void Step() => _step = $"Envoi des fichiers du jeu ({count}/{pending.Count})";
|
||||
|
||||
// Petits fichiers : par lots (une requête pour des centaines de fichiers).
|
||||
var batch = new List<(string Sha, byte[] Data)>();
|
||||
long batchBytes = 0;
|
||||
async Task FlushAsync()
|
||||
{
|
||||
if (batch.Count == 0) return;
|
||||
await _session.UploadObjectBatchAsync(state.PublicationId, ObjectBatch.Write(batch), ct).ConfigureAwait(false);
|
||||
count += batch.Count;
|
||||
Step();
|
||||
Sent(batchBytes);
|
||||
batch.Clear();
|
||||
batchBytes = 0;
|
||||
}
|
||||
|
||||
foreach (var sha in pending.Where(s => Size(s) <= PublishingLimits.BatchObjectMaxBytes))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var data = await File.ReadAllBytesAsync(_release.ObjectFile(sha), ct).ConfigureAwait(false);
|
||||
if (Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant() != sha) throw Changed(ManifestCheck.ObjectPath(sha));
|
||||
batch.Add((sha, data));
|
||||
batchBytes += data.Length;
|
||||
if (batchBytes >= PublishingLimits.ChunkBytes || batch.Count >= PublishingLimits.MaxObjectsPerBatch) await FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
await FlushAsync().ConfigureAwait(false);
|
||||
|
||||
// Gros fichiers : par morceaux, reprise à la position déjà reçue.
|
||||
foreach (var sha in pending.Where(s => Size(s) > PublishingLimits.BatchObjectMaxBytes))
|
||||
{
|
||||
Step();
|
||||
await FileAsync(_release.ObjectFile(sha), sha, state.PartialObjects.GetValueOrDefault(sha), ManifestCheck.ObjectPath(sha),
|
||||
(offset, data) => _session.UploadObjectAsync(state.PublicationId, sha, offset, data, ct), ct).ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ArchiveAsync(PublicationState state, CancellationToken ct)
|
||||
{
|
||||
if (state.ArchiveComplete) return;
|
||||
_step = $"Envoi de l'archive {_release.Summary.ArchiveName}";
|
||||
await FileAsync(_release.ArchivePath, _release.Summary.ArchiveSha256, state.ArchiveReceived, _release.Summary.ArchiveName,
|
||||
(offset, data) => _session.UploadArchiveAsync(state.PublicationId, offset, data, ct), ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Envoi d'un fichier à partir de <paramref name="from"/> ; le fichier est relu en entier pour vérifier
|
||||
/// son empreinte (un fichier modifié depuis la génération est refusé avant la mise en ligne).
|
||||
/// </summary>
|
||||
private async Task FileAsync(string path, string sha256, long from, string name, Func<long, byte[], Task<ChunkResult>> upload,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16, useAsync: true);
|
||||
var buffer = new byte[PublishingLimits.ChunkBytes];
|
||||
long offset = 0;
|
||||
while (true)
|
||||
{
|
||||
var read = await ReadFullAsync(stream, buffer, ct).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
hash.AppendData(buffer, 0, read);
|
||||
if (offset + read > from)
|
||||
{
|
||||
var skip = (int)Math.Max(0, from - offset);
|
||||
var chunk = buffer.AsSpan(skip, read - skip).ToArray();
|
||||
// Dernier morceau : l'empreinte locale est vérifiée AVANT de l'envoyer.
|
||||
if (offset + read == stream.Length &&
|
||||
Convert.ToHexString(hash.GetCurrentHash()).ToLowerInvariant() != sha256.ToLowerInvariant())
|
||||
throw Changed(name);
|
||||
await upload(offset + skip, chunk).ConfigureAwait(false);
|
||||
Sent(chunk.Length);
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
if (Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant() != sha256.ToLowerInvariant()) throw Changed(name);
|
||||
}
|
||||
|
||||
/// <summary>Pause avant un nouvel essai : affichée (sinon l'envoi semble figé), sans changer d'étape.</summary>
|
||||
public void Waiting(string reason, TimeSpan delay) =>
|
||||
_progress?.Report(new UploadProgress($"{_step} — {reason}, reprise dans {Math.Max(1, (int)Math.Ceiling(delay.TotalSeconds))} s…",
|
||||
Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done), 0));
|
||||
|
||||
public void Report(string step)
|
||||
{
|
||||
_step = step;
|
||||
var seconds = _clock.Elapsed.TotalSeconds;
|
||||
_progress?.Report(new UploadProgress(step, Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done),
|
||||
seconds > 0.5 ? _sentThisRun / seconds : 0));
|
||||
}
|
||||
|
||||
private void Sent(long bytes)
|
||||
{
|
||||
_done += bytes;
|
||||
_sentThisRun += bytes;
|
||||
Report(_step);
|
||||
}
|
||||
|
||||
private long Size(string sha) => _release.Summary.Objects.TryGetValue(sha.ToLowerInvariant(), out var size)
|
||||
? size
|
||||
: throw new ReleaseCheckException("Le serveur attend un fichier absent de ce manifest : relancez la publication.");
|
||||
|
||||
private static ReleaseCheckException Changed(string name) => new(
|
||||
$"« {name} » a changé depuis la génération de la version (son empreinte ne correspond plus au manifest) : relancez le .bat de publication.");
|
||||
|
||||
private static async Task<int> ReadFullAsync(Stream stream, byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
var total = 0;
|
||||
while (total < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(total), ct).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Coupures
|
||||
|
||||
/// <summary>Coupure réseau, délai dépassé, serveur occupé, trop de requêtes, position décalée : on reprend.</summary>
|
||||
private static bool IsTransient(ApiException ex) =>
|
||||
ex.Status is 0 or 429 or 502 or 504 ||
|
||||
(ex.Status == 503 && ex.Code == ApiErrorCodes.Busy) ||
|
||||
(ex.Status == 409 && ex.Code == ApiErrorCodes.OffsetMismatch);
|
||||
|
||||
/// <summary>
|
||||
/// Attente avant un nouvel essai. Trop de requêtes : le délai annoncé par le serveur (Retry-After),
|
||||
/// sinon (limite de nginx, qui se libère en une fraction de seconde) une pause courte qui double à
|
||||
/// chaque refus consécutif.
|
||||
/// </summary>
|
||||
internal static TimeSpan Backoff(int attempt, ApiException ex)
|
||||
{
|
||||
if (ex.Code == ApiErrorCodes.OffsetMismatch) return TimeSpan.Zero;
|
||||
if (ex.Status == 429)
|
||||
return ex.RetryAfter is { } announced
|
||||
? TimeSpan.FromSeconds(Math.Clamp(Math.Ceiling(announced.TotalSeconds), 1, 60))
|
||||
: TimeSpan.FromSeconds(Math.Min(30, 1 << Math.Min(attempt - 1, 5)));
|
||||
return TimeSpan.FromSeconds(Math.Min(30, 2 * attempt * attempt));
|
||||
}
|
||||
|
||||
private static string PauseReason(ApiException ex) => ex.Status switch
|
||||
{
|
||||
429 => "limite de requêtes du serveur atteinte",
|
||||
0 => "connexion interrompue",
|
||||
503 => "serveur occupé",
|
||||
_ => "serveur indisponible"
|
||||
};
|
||||
|
||||
private static async Task<T> Retry<T>(Func<Task<T>> action, CancellationToken ct)
|
||||
{
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await action().ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
|
||||
{
|
||||
await Task.Delay(Backoff(attempt, ex), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Connexion ouverte : session + clé déverrouillée (en mémoire, ou ouverte dans la puce TPM). La fermer
|
||||
/// déconnecte la session sur le serveur et libère la clé : il faudra de nouveau la phrase de passe (ou le PIN).
|
||||
/// </summary>
|
||||
public sealed class AdminConnection : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IAdminSigner _signer;
|
||||
private readonly IDisposable _key;
|
||||
private int _disposed;
|
||||
|
||||
/// <param name="key">Ce qui retient la clé (clé en mémoire, poignée de la clé TPM) : libéré à la fermeture.</param>
|
||||
internal AdminConnection(LauncherApiClient client, AdminSession session, IAdminSigner signer, IDisposable key, string keyPath,
|
||||
bool isTpm = false)
|
||||
{
|
||||
Client = client;
|
||||
Session = session;
|
||||
_signer = signer;
|
||||
_key = key;
|
||||
KeyPath = keyPath;
|
||||
IsTpm = isTpm;
|
||||
}
|
||||
|
||||
public LauncherApiClient Client { get; }
|
||||
public AdminSession Session { get; }
|
||||
public string KeyPath { get; }
|
||||
public string Fingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
|
||||
|
||||
/// <summary>Clé conservée dans la puce de sécurité (TPM) de ce PC.</summary>
|
||||
public bool IsTpm { get; }
|
||||
|
||||
/// <summary>Fin de session (durée maximale), à l'heure du PC.</summary>
|
||||
public DateTimeOffset LocalExpiresAt => Session.ExpiresAt - Session.ClockOffset;
|
||||
|
||||
/// <summary>
|
||||
/// Libère d'abord la clé (la déconnexion n'est pas signée : elle n'en a pas besoin), puis ferme la
|
||||
/// session sur le serveur — au mieux, sans bloquer : injoignable, elle expirera d'elle-même.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
_key.Dispose();
|
||||
using var timeout = new CancellationTokenSource(LogoutTimeout);
|
||||
try
|
||||
{
|
||||
await Session.LogoutAsync(timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : déconnexion du serveur impossible ({ex.GetType().Name}) ; la session expirera d'elle-même.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Résultat de la création d'une clé (et de son enregistrement auprès du serveur avec une invitation).</summary>
|
||||
/// <param name="Enrollment">Réponse du serveur ; null si aucune invitation n'a été utilisée (clé seulement) ou sans réponse.</param>
|
||||
/// <param name="EnrollmentUnconfirmed">
|
||||
/// Invitation envoyée mais réponse du serveur perdue (délai dépassé, coupure, erreur 5xx) : le serveur a
|
||||
/// peut-être enregistré la clé et consommé l'invitation. La clé est donc CONSERVÉE (message expliquant quoi faire).
|
||||
/// </param>
|
||||
/// <param name="IsTpm">Clé créée dans la puce de sécurité (TPM) de ce PC ; KeyPath est alors son fichier de référence.</param>
|
||||
public sealed record AdminKeyCreation(string KeyPath, string PublicKeyPath, string KeyId, string Fingerprint,
|
||||
EnrollmentResponse? Enrollment, string? EnrollmentUnconfirmed = null, bool IsTpm = false);
|
||||
|
||||
/// <summary>Clé fichier en mémoire, vue comme une clé « libérable » (même contrat que la clé TPM).</summary>
|
||||
internal sealed class FileKeySigner : IDisposableAdminSigner
|
||||
{
|
||||
private readonly EcdsaAdminSigner _inner;
|
||||
|
||||
public FileKeySigner(EcdsaAdminSigner inner) => _inner = inner;
|
||||
|
||||
public byte[] PublicKey => _inner.PublicKey;
|
||||
public string KeyId => _inner.KeyId;
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => _inner.SignAsync(message, ct);
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Création d'une clé d'administrateur sur ce PC — fichier chiffré par une phrase de passe, ou clé
|
||||
/// non exportable dans la puce TPM protégée par un code PIN — et, avec un code d'invitation,
|
||||
/// enregistrement auprès du serveur (preuve de possession). Si le serveur REFUSE (erreur 4xx), la clé
|
||||
/// est supprimée (fichiers, et clé de la puce) ; sans réponse sûre, elle est conservée.
|
||||
/// </summary>
|
||||
public sealed class AdminEnrollmentService
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly IAdminTpm? _tpm;
|
||||
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
public AdminEnrollmentService(LauncherApiClient client, IAdminTpm? tpm = null)
|
||||
{
|
||||
_client = client;
|
||||
_tpm = tpm;
|
||||
}
|
||||
|
||||
/// <param name="keyPath">Fichier .key (clé chiffrée) ou, avec <paramref name="useTpm"/>, fichier de référence .tpmkey.</param>
|
||||
/// <param name="secret">Phrase de passe du fichier (12 caractères au moins) ou code PIN de la clé TPM (6 au moins).</param>
|
||||
/// <param name="useTpm">Vrai : clé créée dans la puce de sécurité (TPM) de ce PC.</param>
|
||||
public async Task<AdminKeyCreation> CreateKeyAsync(string keyPath, string secret, string? invitationCode, string? keyLabel,
|
||||
CancellationToken ct = default, bool useTpm = false)
|
||||
{
|
||||
var minLength = useTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
|
||||
if (secret.Length < minLength)
|
||||
throw new ArgumentException(useTpm
|
||||
? $"Le code PIN doit contenir au moins {minLength} caractères."
|
||||
: $"La phrase de passe doit contenir au moins {minLength} caractères.");
|
||||
string? code = null;
|
||||
if (!string.IsNullOrWhiteSpace(invitationCode))
|
||||
code = InvitationCodes.Normalize(invitationCode)
|
||||
?? throw new ArgumentException("Code d'invitation mal saisi : 32 caractères (lettres A à Z, chiffres 2 à 7), tirets facultatifs.");
|
||||
var label = string.IsNullOrWhiteSpace(keyLabel) ? null : ApiText.CleanLine(keyLabel, ApiText.MaxKeyLabelLength)
|
||||
?? throw new ArgumentException($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus, sans caractère spécial invisible).");
|
||||
|
||||
var fullPath = Path.GetFullPath(keyPath);
|
||||
if (AdminKeyLocator.IsReserved(fullPath))
|
||||
throw new InvalidOperationException($"« {AdminKeyLocator.ReservedFileName} » est réservé à la clé de signature du launcher.");
|
||||
var extension = useTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
if (!fullPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Le fichier de clé doit avoir l'extension {extension}.");
|
||||
var publicPath = Path.ChangeExtension(fullPath, AdminKeyFile.PublicKeyExtension);
|
||||
// Les deux types de clés partagent le même .pub : aucun des trois fichiers ne doit exister.
|
||||
var otherPath = Path.ChangeExtension(fullPath, useTpm ? AdminKeyFile.PrivateKeyExtension : AdminTpmKeyFile.Extension);
|
||||
if (File.Exists(fullPath) || File.Exists(publicPath) || File.Exists(otherPath))
|
||||
throw new IOException($"Une clé existe déjà à cet emplacement : {Path.ChangeExtension(fullPath, null)}. Choisissez un autre nom.");
|
||||
var tpm = useTpm ? _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.") : null;
|
||||
|
||||
// Génération (et chiffrement, ou accès à la puce : lents) hors du fil de l'interface.
|
||||
string? tpmKeyName = null;
|
||||
IDisposableAdminSigner signer;
|
||||
if (tpm is not null)
|
||||
{
|
||||
var keyName = AdminTpmKeyFile.NewKeyName();
|
||||
signer = await BackgroundWork.RunAsync(() =>
|
||||
{
|
||||
var created = tpm.CreateKey(keyName, secret);
|
||||
try
|
||||
{
|
||||
AdminTpmKeyFile.Save(fullPath, keyName, created.PublicKey);
|
||||
return created;
|
||||
}
|
||||
catch
|
||||
{
|
||||
created.Dispose();
|
||||
DeleteTpmKey(tpm, keyName);
|
||||
throw;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
tpmKeyName = keyName;
|
||||
}
|
||||
else
|
||||
{
|
||||
signer = await BackgroundWork.RunAsync<IDisposableAdminSigner>(() =>
|
||||
{
|
||||
var key = AdminKeyFile.Generate();
|
||||
try
|
||||
{
|
||||
AdminKeyFile.Save(key, fullPath, secret);
|
||||
return new FileKeySigner(new EcdsaAdminSigner(key));
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnrollmentResponse? enrollment = null;
|
||||
string? unconfirmed = null;
|
||||
if (code is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
enrollment = await _client.EnrollAsync(code, signer, label, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (IsDefinitiveRefusal(ex) || ex is CryptographicException or ObjectDisposedException)
|
||||
{
|
||||
// Refus certain (invitation invalide, expirée, déjà utilisée…), ou signature de la preuve
|
||||
// impossible (puce bloquée…) avant tout envoi : rien n'a été enregistré.
|
||||
signer.Dispose();
|
||||
if (tpmKeyName is not null)
|
||||
{
|
||||
AdminTpmKeyFile.DeleteFiles(fullPath);
|
||||
DeleteTpmKey(tpm!, tpmKeyName);
|
||||
}
|
||||
else
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
TryDelete(publicPath);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is ApiException or OperationCanceledException or HttpRequestException or IOException)
|
||||
{
|
||||
unconfirmed = $"Le serveur n'a pas confirmé l'enregistrement ({ApiErrorText.Describe(ex)}). " +
|
||||
"Votre clé est conservée : communiquez son empreinte à la personne qui vous a invité. " +
|
||||
"Si elle ne voit aucune clé en attente à votre nom, demandez-lui une nouvelle invitation.";
|
||||
}
|
||||
}
|
||||
var publicKey = signer.PublicKey;
|
||||
return new AdminKeyCreation(fullPath, publicPath, AdminKeys.ComputeKeyId(publicKey), AdminKeys.ComputeFingerprint(publicKey),
|
||||
enrollment, unconfirmed, IsTpm: tpmKeyName is not null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
signer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteTpmKey(IAdminTpm tpm, string keyName)
|
||||
{
|
||||
try
|
||||
{
|
||||
tpm.DeleteKey(keyName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Core.Utils.Log.Warn($"Administration : clé {keyName} non supprimée de la puce ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Le serveur a répondu par un refus (4xx), ou la requête n'a pas pu partir (horloge) : rien d'enregistré.</summary>
|
||||
private static bool IsDefinitiveRefusal(Exception ex) => ex is ApiException api &&
|
||||
((api.Status is >= 400 and < 500 && api.Code != "invalid_response") || api.Code == "clock_skew");
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé en place : sans danger (clé chiffrée, jamais enregistrée sur le serveur)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Clé d'administrateur trouvée sur ce PC.</summary>
|
||||
/// <param name="Path">Chemin complet du fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
|
||||
/// <param name="Fingerprint">Empreinte (null si inconnue : fichier .pub absent).</param>
|
||||
/// <param name="IsTpm">Clé conservée dans la puce de sécurité (TPM) de ce PC : déverrouillée par un code PIN.</param>
|
||||
public sealed record AdminKeyEntry(string Path, string? KeyId, string? Fingerprint, bool IsTpm = false)
|
||||
{
|
||||
public string Name => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||
|
||||
public string Detail => Fingerprint is null ? "Empreinte inconnue (fichier .pub absent)" : $"Empreinte {Fingerprint}";
|
||||
|
||||
/// <summary>Libellé du secret demandé à la connexion.</summary>
|
||||
public string SecretLabel => IsTpm ? "Code PIN de la clé (puce TPM)" : "Phrase de passe de la clé";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recherche des clés d'administrateur : dossier %USERPROFILE%\.launcher-keys (celui de
|
||||
/// tools\admin-key.bat) et fichiers déjà utilisés. La clé de SIGNATURE DU LAUNCHER, rangée dans le
|
||||
/// même dossier, n'est jamais proposée : elle sert à autre chose et ne doit pas circuler.
|
||||
/// </summary>
|
||||
public static class AdminKeyLocator
|
||||
{
|
||||
/// <summary>Clé de signature des mises à jour du launcher (tools\keygen-launcher.bat).</summary>
|
||||
public const string ReservedFileName = "launcher-signing.key";
|
||||
|
||||
private const long MaxKeyFileBytes = 16 * 1024;
|
||||
|
||||
public static string DefaultDirectory =>
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".launcher-keys");
|
||||
|
||||
/// <summary>
|
||||
/// Clé de signature du launcher, copies comprises (« launcher-signing - Copie.key »…) : ne doit jamais
|
||||
/// être déverrouillée par le menu d'administration. Chemin normalisé (« launcher-signing.key. »…).
|
||||
/// </summary>
|
||||
public static bool IsReserved(string path)
|
||||
{
|
||||
string name;
|
||||
try
|
||||
{
|
||||
name = System.IO.Path.GetFileName(System.IO.Path.GetFullPath(path)).TrimEnd('.', ' ');
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return true; // chemin invalide : refusé
|
||||
}
|
||||
return name.StartsWith(System.IO.Path.GetFileNameWithoutExtension(ReservedFileName), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Clés du dossier <paramref name="directory"/> puis des chemins mémorisés (sans doublon).</summary>
|
||||
public static IReadOnlyList<AdminKeyEntry> Find(string directory, IEnumerable<string>? rememberedPaths = null)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
paths.AddRange(Directory.EnumerateFiles(directory, "*" + AdminKeyFile.PrivateKeyExtension, SearchOption.TopDirectoryOnly)
|
||||
.Concat(Directory.EnumerateFiles(directory, "*" + AdminTpmKeyFile.Extension, SearchOption.TopDirectoryOnly))
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// dossier illisible : seulement les chemins mémorisés
|
||||
}
|
||||
paths.AddRange(rememberedPaths ?? Enumerable.Empty<string>());
|
||||
|
||||
var result = new List<AdminKeyEntry>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
// Describe normalise le chemin et ignore les chemins invalides (admin.json modifié à la main…).
|
||||
if (Describe(path) is not { } entry) continue;
|
||||
if (result.Any(e => string.Equals(e.Path, entry.Path, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
result.Add(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Décrit un fichier de clé chiffré ; null s'il n'en est pas un (ou s'il est réservé).</summary>
|
||||
public static AdminKeyEntry? Describe(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = System.IO.Path.GetFullPath(path);
|
||||
var info = new FileInfo(full);
|
||||
if (!info.Exists || info.Length > MaxKeyFileBytes || IsReserved(full)) return null;
|
||||
|
||||
// Référence d'une clé TPM : empreinte calculée depuis la clé publique qu'elle contient
|
||||
// (comparée à celle de la puce à la connexion).
|
||||
if (AdminTpmKeyFile.IsTpmKeyPath(full))
|
||||
{
|
||||
try
|
||||
{
|
||||
var reference = AdminTpmKeyFile.Load(full);
|
||||
return new AdminKeyEntry(full, AdminKeys.ComputeKeyId(reference.PublicKey),
|
||||
AdminKeys.ComputeFingerprint(reference.PublicKey), IsTpm: true);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clé fichier : reconnue à son contenu (extension .key conseillée, mais pas exigée : admin-key.bat --out).
|
||||
if (!File.ReadAllText(full).Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal)) return null;
|
||||
|
||||
string? keyId = null, fingerprint = null;
|
||||
var publicPath = System.IO.Path.ChangeExtension(full, AdminKeyFile.PublicKeyExtension);
|
||||
if (File.Exists(publicPath) && new FileInfo(publicPath).Length <= MaxKeyFileBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spki = AdminKeys.ParsePublicKey(File.ReadAllText(publicPath));
|
||||
keyId = AdminKeys.ComputeKeyId(spki);
|
||||
fingerprint = AdminKeys.ComputeFingerprint(spki);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
// .pub illisible : empreinte inconnue (la clé reste utilisable)
|
||||
}
|
||||
}
|
||||
return new AdminKeyEntry(full, keyId, fingerprint);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emplacement libre pour une nouvelle clé : admin, sinon admin-2, admin-3… Le nom est libre pour
|
||||
/// les deux types de clés (.key, .tpmkey) et pour le .pub qu'ils partageraient.
|
||||
/// </summary>
|
||||
/// <param name="tpm">Vrai : chemin d'un fichier de référence de clé TPM (.tpmkey).</param>
|
||||
public static string SuggestNewKeyPath(string directory, bool tpm = false)
|
||||
{
|
||||
var extension = tpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
for (var i = 1; i < 1000; i++)
|
||||
{
|
||||
var name = System.IO.Path.Combine(directory, i == 1 ? "admin" : $"admin-{i}");
|
||||
if (!File.Exists(name + AdminKeyFile.PrivateKeyExtension) && !File.Exists(name + AdminTpmKeyFile.Extension) &&
|
||||
!File.Exists(name + AdminKeyFile.PublicKeyExtension))
|
||||
return name + extension;
|
||||
}
|
||||
return System.IO.Path.Combine(directory, $"admin-{Guid.NewGuid():N}{extension}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Connexion : ouvre la clé (fichier chiffré déverrouillé en arrière-plan, ou clé de la puce TPM avec
|
||||
/// son code PIN), puis défi signé auprès du serveur.
|
||||
/// </summary>
|
||||
public sealed class AdminLoginService
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly string _clientName;
|
||||
private readonly IAdminTpm? _tpm;
|
||||
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
|
||||
|
||||
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
/// <param name="rootKeys">
|
||||
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
|
||||
/// le nom du fichier (la clé racine reste hors ligne, réservée à tools\sign-keyring.bat et aux mises à jour).
|
||||
/// </param>
|
||||
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null,
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null)
|
||||
{
|
||||
_client = client;
|
||||
_clientName = clientName;
|
||||
_tpm = tpm;
|
||||
_rootKeys = rootKeys ?? Array.Empty<(string, byte[])>();
|
||||
}
|
||||
|
||||
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
|
||||
/// <param name="secret">Phrase de passe du fichier, ou code PIN de la clé TPM.</param>
|
||||
public async Task<AdminConnection> LoginAsync(string keyPath, string secret, CancellationToken ct = default)
|
||||
{
|
||||
if (AdminKeyLocator.IsReserved(keyPath))
|
||||
throw new InvalidOperationException("Ce fichier est la clé de signature du launcher : utilisez votre clé d'administrateur.");
|
||||
keyPath = Path.GetFullPath(keyPath);
|
||||
var isTpm = AdminTpmKeyFile.IsTpmKeyPath(keyPath);
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
throw new ArgumentException(isTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.");
|
||||
|
||||
IAdminSigner signer;
|
||||
IDisposable key;
|
||||
if (isTpm)
|
||||
{
|
||||
var tpm = _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.");
|
||||
var reference = AdminTpmKeyFile.Load(keyPath);
|
||||
var opened = await BackgroundWork.RunAsync(() => tpm.OpenKey(reference.KeyName, reference.PublicKey, secret), ct)
|
||||
.ConfigureAwait(false);
|
||||
// Le fichier de référence ne doit pas pouvoir faire utiliser une AUTRE clé que celle annoncée
|
||||
// (déjà vérifié par la puce avant le code PIN ; contrôlé de nouveau ici).
|
||||
if (!opened.PublicKey.AsSpan().SequenceEqual(reference.PublicKey))
|
||||
{
|
||||
opened.Dispose();
|
||||
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
|
||||
}
|
||||
signer = opened;
|
||||
key = opened;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Déchiffrement volontairement lent (PBKDF2, 600 000 itérations) : hors du fil de l'interface.
|
||||
var opened = await BackgroundWork.RunAsync(() => new EcdsaAdminSigner(AdminKeyFile.Load(keyPath, secret)), ct).ConfigureAwait(false);
|
||||
signer = opened;
|
||||
key = opened;
|
||||
}
|
||||
|
||||
if (_rootKeys.Any(r => string.Equals(r.KeyId, signer.KeyId, StringComparison.Ordinal) || r.PublicKey.AsSpan().SequenceEqual(signer.PublicKey)))
|
||||
{
|
||||
key.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
"Cette clé est la clé racine du launcher (signature des mises à jour et du trousseau) : elle ne doit jamais servir " +
|
||||
"à se connecter. Utilisez votre clé d'administrateur.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
|
||||
return new AdminConnection(_client, session, signer, key, keyPath, isTpm);
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Surveille la session ouverte :
|
||||
/// • verrouillage après <see cref="LockAfter"/> sans activité dans la fenêtre d'administration ;
|
||||
/// • verrouillage à la fin de la durée maximale de la session (8 h) ;
|
||||
/// • tant que l'administrateur est actif, la session est prolongée sur le serveur (toutes les
|
||||
/// <see cref="KeepAliveEvery"/>) ; si le serveur la refuse (expirée, révoquée), verrouillage.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionGuard : IDisposable
|
||||
{
|
||||
public static readonly TimeSpan DefaultLockAfter = TimeSpan.FromMinutes(10);
|
||||
public static readonly TimeSpan KeepAliveEvery = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan TickEvery = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly AdminConnection _connection;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ITimer? _timer;
|
||||
private DateTimeOffset _lastActivity;
|
||||
private DateTimeOffset _lastKeepAlive;
|
||||
private bool _refreshing;
|
||||
private bool _locked;
|
||||
private bool _disposed;
|
||||
|
||||
public AdminSessionGuard(AdminConnection connection, TimeProvider time, IUiDispatcher ui, TimeSpan? lockAfter = null,
|
||||
bool startTimer = true)
|
||||
{
|
||||
_connection = connection;
|
||||
_time = time;
|
||||
LockAfter = lockAfter ?? DefaultLockAfter;
|
||||
_lastActivity = _lastKeepAlive = time.GetUtcNow();
|
||||
if (startTimer) _timer = time.CreateTimer(_ => ui.Post(() => _ = TickAsync()), null, TickEvery, TickEvery);
|
||||
}
|
||||
|
||||
public TimeSpan LockAfter { get; }
|
||||
|
||||
/// <summary>Verrouillage demandé (le message explique pourquoi) ; attendu par <see cref="TickAsync"/>.</summary>
|
||||
public Func<string, Task>? LockRequested { get; set; }
|
||||
|
||||
/// <summary>Le temps restant a changé (affichage).</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>Moment du verrouillage prévu (inactivité ou fin de session).</summary>
|
||||
public DateTimeOffset LockAt
|
||||
{
|
||||
get
|
||||
{
|
||||
var idle = _lastActivity + LockAfter;
|
||||
return idle < _connection.LocalExpiresAt ? idle : _connection.LocalExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan Remaining
|
||||
{
|
||||
get
|
||||
{
|
||||
var remaining = LockAt - _time.GetUtcNow();
|
||||
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
|
||||
}
|
||||
}
|
||||
|
||||
private int _longOperations;
|
||||
|
||||
/// <summary>Opération longue (envoi d'une version) : compte comme une activité jusqu'à sa fin.</summary>
|
||||
public IDisposable BeginLongOperation()
|
||||
{
|
||||
Interlocked.Increment(ref _longOperations);
|
||||
return new LongOperation(this);
|
||||
}
|
||||
|
||||
private sealed class LongOperation : IDisposable
|
||||
{
|
||||
private AdminSessionGuard? _guard;
|
||||
|
||||
public LongOperation(AdminSessionGuard guard) => _guard = guard;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _guard, null) is not { } guard) return;
|
||||
Interlocked.Decrement(ref guard._longOperations);
|
||||
guard._lastActivity = guard._time.GetUtcNow(); // 10 minutes à compter de la fin de l'opération
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
|
||||
public void NotifyActivity()
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (now - _lastActivity < TimeSpan.FromSeconds(1)) return;
|
||||
_lastActivity = now;
|
||||
}
|
||||
|
||||
/// <summary>Vérification périodique (minuterie) ; publique pour les tests.</summary>
|
||||
public async Task TickAsync()
|
||||
{
|
||||
if (_locked || _disposed) return;
|
||||
var now = _time.GetUtcNow();
|
||||
if (Volatile.Read(ref _longOperations) > 0) _lastActivity = now;
|
||||
if (now >= _connection.LocalExpiresAt)
|
||||
{
|
||||
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
if (now - _lastActivity >= LockAfter)
|
||||
{
|
||||
await LockAsync($"Verrouillé après {LockAfter.TotalMinutes:0} minutes d'inactivité.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_refreshing && now - _lastKeepAlive >= KeepAliveEvery && _lastActivity > _lastKeepAlive)
|
||||
{
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
await _connection.Session.RefreshAsync();
|
||||
_lastKeepAlive = _time.GetUtcNow();
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
await LockAsync("Session expirée ou révoquée sur le serveur : reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Serveur momentanément injoignable, délai dépassé… : nouvel essai au prochain passage.
|
||||
// L'inactivité et la durée maximale restent contrôlées localement.
|
||||
Log.Warn($"Administration : prolongation de la session impossible ({ex.GetType().Name}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private async Task LockAsync(string reason)
|
||||
{
|
||||
if (_locked) return;
|
||||
_locked = true;
|
||||
if (LockRequested is { } handler) await handler(reason);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_timer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences du menu d'administration sur ce PC (%AppData%\…\GameLauncher\admin.json) : chemins
|
||||
/// des fichiers de clé utilisés. Aucun secret (les clés restent chiffrées dans leurs fichiers).
|
||||
/// </summary>
|
||||
public sealed class AdminSettings
|
||||
{
|
||||
public const string FileName = "admin.json";
|
||||
private const int MaxRememberedKeys = 10;
|
||||
|
||||
public string? LastKeyPath { get; set; }
|
||||
|
||||
public List<string> KeyPaths { get; set; } = new();
|
||||
|
||||
public static AdminSettings Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path) || new FileInfo(path).Length > 64 * 1024) return new AdminSettings();
|
||||
var settings = JsonSerializer.Deserialize<AdminSettings>(File.ReadAllText(path), JsonDefaults.Options) ?? new AdminSettings();
|
||||
settings.KeyPaths = (settings.KeyPaths ?? new()).Where(p => !string.IsNullOrWhiteSpace(p)).Take(MaxRememberedKeys).ToList();
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"Préférences d'administration illisibles ({path}) : {ex.Message}");
|
||||
return new AdminSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Oublie une clé (supprimée).</summary>
|
||||
public void Forget(string keyPath)
|
||||
{
|
||||
KeyPaths.RemoveAll(p => string.Equals(p, keyPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (string.Equals(LastKeyPath, keyPath, StringComparison.OrdinalIgnoreCase)) LastKeyPath = null;
|
||||
}
|
||||
|
||||
/// <summary>Mémorise la clé utilisée (en tête de liste).</summary>
|
||||
public void Remember(string keyPath)
|
||||
{
|
||||
var full = Path.GetFullPath(keyPath);
|
||||
LastKeyPath = full;
|
||||
KeyPaths.RemoveAll(p => string.Equals(p, full, StringComparison.OrdinalIgnoreCase));
|
||||
KeyPaths.Insert(0, full);
|
||||
if (KeyPaths.Count > MaxRememberedKeys) KeyPaths.RemoveRange(MaxRememberedKeys, KeyPaths.Count - MaxRememberedKeys);
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
FileUtil.WriteAllTextAtomic(path, JsonSerializer.Serialize(this, JsonDefaults.Options));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Préférences d'administration non enregistrées ({path}) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Libellés affichés (français).</summary>
|
||||
public static class AdminTexts
|
||||
{
|
||||
public static string Permission(string permission) => permission switch
|
||||
{
|
||||
AdminPermissions.AdminsManage => "Gestion des administrateurs",
|
||||
AdminPermissions.AuditRead => "Journal d'audit",
|
||||
AdminPermissions.GamesEdit => "Informations des jeux",
|
||||
AdminPermissions.ChangelogEdit => "Notes de version",
|
||||
AdminPermissions.ReleasesPublish => "Publication des versions",
|
||||
_ => permission
|
||||
};
|
||||
|
||||
public static string Role(string? role) => AdminRoles.DisplayName(role);
|
||||
|
||||
/// <summary>Initiales pour l'avatar : « Nicolas Deville » → « ND ».</summary>
|
||||
public static string Initials(string? name)
|
||||
{
|
||||
var parts = (name ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var initials = string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
|
||||
return initials.Length == 0 ? "?" : initials;
|
||||
}
|
||||
|
||||
/// <summary>Durée lisible : « 9 min », « moins d'une minute », « 2 h 05 ».</summary>
|
||||
public static string Duration(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.FromMinutes(1)) return "moins d'une minute";
|
||||
if (duration < TimeSpan.FromHours(1)) return $"{(int)duration.TotalMinutes} min";
|
||||
return $"{(int)duration.TotalHours} h {duration.Minutes:00}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Clé conservée dans la puce TPM, telle que décrite par son fichier de référence.</summary>
|
||||
/// <param name="KeyName">Nom de la clé dans la puce de ce PC.</param>
|
||||
/// <param name="PublicKey">Clé publique (SPKI DER) attendue : comparée à celle de la puce à l'ouverture.</param>
|
||||
public sealed record AdminTpmKeyReference(string KeyName, byte[] PublicKey);
|
||||
|
||||
/// <summary>
|
||||
/// Fichier de référence d'une clé TPM (« admin.tpmkey ») : il ne contient AUCUN secret, seulement le
|
||||
/// nom de la clé dans la puce et sa clé publique. Copié sur un autre PC, il ne sert à rien. Un fichier
|
||||
/// .pub est écrit à côté, comme pour une clé fichier (déclaration du premier propriétaire : add-owner).
|
||||
/// </summary>
|
||||
public static partial class AdminTpmKeyFile
|
||||
{
|
||||
public const string Extension = ".tpmkey";
|
||||
|
||||
/// <summary>
|
||||
/// Longueur minimale du code PIN. Plus courte que la phrase de passe d'un fichier (12) : le code
|
||||
/// ne peut être essayé que sur la puce elle-même, qui se bloque après quelques erreurs.
|
||||
/// </summary>
|
||||
public const int MinPinLength = 6;
|
||||
|
||||
/// <summary>Préfixe des noms de clés créées par le launcher (seules ces clés peuvent être ouvertes).</summary>
|
||||
public const string KeyNamePrefix = "GameLauncher.Admin.";
|
||||
|
||||
private const string Format = "gamelauncher-admin-tpm-key";
|
||||
private const int MaxFileBytes = 16 * 1024;
|
||||
|
||||
[GeneratedRegex(@"\AGameLauncher\.Admin\.[0-9a-f]{32}\z")]
|
||||
private static partial Regex KeyNameRegex();
|
||||
|
||||
public static string NewKeyName() => KeyNamePrefix + Guid.NewGuid().ToString("N");
|
||||
|
||||
public static bool IsValidKeyName(string? keyName) => keyName is not null && KeyNameRegex().IsMatch(keyName);
|
||||
|
||||
public static bool IsTpmKeyPath(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Écrit le fichier de référence et le .pub, sans jamais écraser un fichier existant.</summary>
|
||||
/// <returns>Chemin du fichier .pub.</returns>
|
||||
public static string Save(string path, string keyName, byte[] publicKey)
|
||||
{
|
||||
if (!IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
|
||||
AdminKeys.ParsePublicKey(Convert.ToBase64String(publicKey)); // P-256 attendue
|
||||
if (!IsTpmKeyPath(path)) throw new ArgumentException($"Le fichier de référence doit avoir l'extension {Extension}.");
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
var json = new JsonObject
|
||||
{
|
||||
["format"] = Format,
|
||||
["version"] = 1,
|
||||
["provider"] = "Microsoft Platform Crypto Provider",
|
||||
["keyName"] = keyName,
|
||||
["publicKey"] = Convert.ToBase64String(publicKey)
|
||||
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
WriteNew(path, json);
|
||||
|
||||
var publicPath = Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension);
|
||||
try
|
||||
{
|
||||
WriteNew(publicPath, AdminKeys.ToPem(publicKey));
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(path);
|
||||
throw;
|
||||
}
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
/// <summary>Lit et contrôle un fichier de référence (<see cref="CryptographicException"/> s'il est invalide).</summary>
|
||||
public static AdminTpmKeyReference Load(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
|
||||
if (info.Length > MaxFileBytes) throw new CryptographicException("Fichier de clé TPM inattendu (trop volumineux).");
|
||||
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject
|
||||
?? throw new CryptographicException("Fichier de clé TPM illisible.");
|
||||
if (root["format"]?.GetValue<string>() != Format || root["version"]?.GetValue<int>() != 1)
|
||||
throw new CryptographicException("Ce fichier n'est pas une clé TPM du launcher.");
|
||||
var keyName = root["keyName"]?.GetValue<string>();
|
||||
if (!IsValidKeyName(keyName)) throw new CryptographicException("Fichier de clé TPM invalide (nom de clé).");
|
||||
var publicKey = AdminKeys.ParsePublicKey(root["publicKey"]?.GetValue<string>() ?? "");
|
||||
return new AdminTpmKeyReference(keyName!, publicKey);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or ArgumentException)
|
||||
{
|
||||
throw new CryptographicException("Fichier de clé TPM illisible.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Supprime le fichier de référence et son .pub (clé refusée, création annulée, suppression demandée).</summary>
|
||||
/// <returns>Les fichiers qui n'ont pas pu être supprimés (à supprimer à la main).</returns>
|
||||
internal static IReadOnlyList<string> DeleteFiles(string path)
|
||||
{
|
||||
var left = new List<string>();
|
||||
foreach (var file in new[] { path, Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension) })
|
||||
if (!TryDelete(file)) left.Add(file);
|
||||
return left;
|
||||
}
|
||||
|
||||
private static void WriteNew(string path, string text)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(text);
|
||||
}
|
||||
|
||||
private static bool TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return false; // laissé en place : sans danger (aucun secret dans ces fichiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Runtime.ExceptionServices;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Travail lent (chiffrement de clé) hors du fil de l'interface. L'erreur éventuelle est capturée
|
||||
/// DANS le travail puis relancée ici, dans la méthode asynchrone appelante : une erreur attendue
|
||||
/// (phrase de passe incorrecte…) ne traverse pas Task.Run, ce qui évite que le débogueur de Visual
|
||||
/// Studio ne s'arrête sur une « exception non gérée par l'utilisateur » alors qu'elle est bien gérée
|
||||
/// (message affiché dans le menu d'administration).
|
||||
/// </summary>
|
||||
internal static class BackgroundWork
|
||||
{
|
||||
public static async Task<T> RunAsync<T>(Func<T> work, CancellationToken ct = default)
|
||||
{
|
||||
ExceptionDispatchInfo? failure = null;
|
||||
var result = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return work();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ExceptionDispatchInfo.Capture(ex);
|
||||
return default;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
failure?.Throw();
|
||||
return result!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Résultat de la vérification de la puce de sécurité (TPM) de ce PC.</summary>
|
||||
/// <param name="IsAvailable">Vrai si une clé ECDSA P-256 a pu être créée, utilisée puis supprimée dans la puce.</param>
|
||||
/// <param name="Message">Explication affichée (disponible, ou raison de l'indisponibilité).</param>
|
||||
public sealed record TpmAvailability(bool IsAvailable, string Message)
|
||||
{
|
||||
public static readonly TpmAvailability Unsupported =
|
||||
new(false, "Puce de sécurité (TPM) non prise en charge par ce launcher sur ce système.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protection anti-essais de la puce (compteur COMMUN à toute la puce : codes PIN du launcher, de
|
||||
/// Windows Hello…). Au-delà de <see cref="MaxTries"/> erreurs, la puce refuse tout code ; elle
|
||||
/// « oublie » une erreur à chaque <see cref="RecoveryInterval"/> écoulé, PC allumé.
|
||||
/// </summary>
|
||||
public sealed record TpmLockoutInfo(int Failures, int MaxTries, TimeSpan RecoveryInterval, bool InLockout)
|
||||
{
|
||||
public bool IsLockedOut => InLockout || (MaxTries > 0 && Failures >= MaxTries);
|
||||
|
||||
/// <summary>Codes faux encore permis avant le blocage.</summary>
|
||||
public int RemainingTries => Math.Max(0, MaxTries - Failures);
|
||||
|
||||
/// <summary>Faux : la puce ne se débloque pas seule (un administrateur du PC doit la réinitialiser).</summary>
|
||||
public bool HealsWithTime => RecoveryInterval > TimeSpan.Zero;
|
||||
|
||||
/// <summary>Attente MAXIMALE avant que la puce accepte de nouveau un code (estimation, PC allumé).</summary>
|
||||
public TimeSpan MaxWait => !IsLockedOut || !HealsWithTime
|
||||
? TimeSpan.Zero
|
||||
: RecoveryInterval * Math.Max(1, Failures - MaxTries + 1);
|
||||
}
|
||||
|
||||
/// <summary>Code PIN refusé par la puce, ou puce bloquée par sa protection anti-essais.</summary>
|
||||
public sealed class TpmPinException : CryptographicException
|
||||
{
|
||||
public TpmPinException(string message, bool isLockedOut, Exception? inner = null) : base(message, inner) =>
|
||||
IsLockedOut = isLockedOut;
|
||||
|
||||
public bool IsLockedOut { get; }
|
||||
}
|
||||
|
||||
/// <summary>Clé ouverte (dans la puce ou en mémoire) : elle signe, et se libère à la fermeture de la session.</summary>
|
||||
public interface IDisposableAdminSigner : IAdminSigner, IDisposable
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clés d'administrateur conservées dans la puce de sécurité (TPM 2.0) de ce PC : la clé privée est
|
||||
/// CRÉÉE dans la puce et n'en sort jamais (non exportable) ; la puce signe elle-même. Chaque usage
|
||||
/// exige le code PIN de la clé, vérifié par la puce (blocage temporaire après plusieurs erreurs).
|
||||
/// Implémentation Windows dans le launcher (Microsoft Platform Crypto Provider) ; les méthodes sont
|
||||
/// lentes (accès à la puce) et sont appelées hors du fil de l'interface.
|
||||
/// </summary>
|
||||
public interface IAdminTpm
|
||||
{
|
||||
/// <summary>Vérifie RÉELLEMENT la puce : création, signature et suppression d'une clé de test.</summary>
|
||||
TpmAvailability Check();
|
||||
|
||||
/// <summary>Crée une clé (non exportable) protégée par <paramref name="pin"/> et l'ouvre.</summary>
|
||||
/// <param name="keyName">Nom de la clé dans la puce (<see cref="AdminTpmKeyFile.NewKeyName"/>).</param>
|
||||
IDisposableAdminSigner CreateKey(string keyName, string pin);
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre une clé existante, vérifie qu'elle a bien la clé publique attendue (AVANT tout essai du
|
||||
/// code, qui consommerait un essai de la protection anti-essais), puis vérifie le code PIN
|
||||
/// (signature de test). <see cref="System.Security.Cryptography.CryptographicException"/> avec un
|
||||
/// message clair si la clé diffère, si le code est faux, si la puce est bloquée ou si la clé
|
||||
/// n'existe pas sur ce PC.
|
||||
/// </summary>
|
||||
IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin);
|
||||
|
||||
/// <summary>Supprime la clé de la puce (sans erreur si elle n'existe plus).</summary>
|
||||
void DeleteKey(string keyName);
|
||||
|
||||
/// <summary>
|
||||
/// État de la protection anti-essais (lu dans la puce sans rien modifier), ou null s'il n'est pas
|
||||
/// lisible sur ce PC (Windows peut réserver cette lecture aux administrateurs).
|
||||
/// </summary>
|
||||
TpmLockoutInfo? GetLockoutInfo();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Signing;
|
||||
|
||||
/// <summary>Module « Signature » : page du trousseau et des signatures du contenu (propriétaires).</summary>
|
||||
public sealed class SigningAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "signing";
|
||||
|
||||
public string DisplayName => "Signature";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
SigningPanelViewModel.PanelId,
|
||||
"Signature",
|
||||
"Clés autorisées à signer ce que reçoivent les joueurs (trousseau, signé hors ligne par la clé racine) et état des " +
|
||||
"signatures des versions en ligne et du catalogue.",
|
||||
"ShieldKey",
|
||||
AdminPermissions.AdminsManage,
|
||||
Order: 55,
|
||||
context => new SigningPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
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.
|
||||
/// 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>
|
||||
public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "signing";
|
||||
|
||||
private SigningState? _state;
|
||||
private List<KeyringKey> _proposed = new();
|
||||
private IReadOnlyList<KeyringKeyItem> _keys = Array.Empty<KeyringKeyItem>();
|
||||
private IReadOnlyList<SignedContentItem> _contents = Array.Empty<SignedContentItem>();
|
||||
private string? _statusMessage;
|
||||
|
||||
public SigningPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
|
||||
ExportRequestCommand = new AsyncRelayCommand(ExportRequestAsync, () => !IsBusy && _state is not null && NeedsNewKeyring);
|
||||
PublishSignedCommand = new AsyncRelayCommand(PublishSignedAsync, () => !IsBusy && _state is { KeyringPublishingEnabled: true });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Trousseau publié
|
||||
|
||||
public bool HasKeyring => _state?.Keyring is not null;
|
||||
public bool PublishingEnabled => _state?.KeyringPublishingEnabled == true;
|
||||
public bool ShowPublishingDisabled => _state is { KeyringPublishingEnabled: false };
|
||||
public string? KeyringUrl => _state?.KeyringPublicUrl;
|
||||
|
||||
public string KeyringText => _state?.Keyring is { } keyring
|
||||
? $"Trousseau n° {keyring.Sequence} du {AccountTexts.Date(keyring.IssuedAt)} · {keyring.Keys.Count} clé(s)"
|
||||
: "Aucun trousseau publié : les launchers en mode « require » refusent tout contenu.";
|
||||
|
||||
/// <summary>Signature racine vérifiée avec les clés intégrées à CE launcher.</summary>
|
||||
public string RootText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state?.Keyring is not { } keyring) return "";
|
||||
if (Context.RootKeys.Count == 0) return "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : vérification impossible.";
|
||||
return Keyring.VerifyRoot(keyring, Context.RootKeys, out var error)
|
||||
? $"Signé par la clé racine {keyring.Signature!.RootKeyId} (vérifié par ce launcher)."
|
||||
: $"Refusé par ce launcher : {error}.";
|
||||
}
|
||||
}
|
||||
|
||||
public bool RootValid => _state?.Keyring is { } keyring && Keyring.VerifyRoot(keyring, Context.RootKeys, out _);
|
||||
|
||||
public string RequireText => _state?.RequireSignatures == true
|
||||
? "Le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require)."
|
||||
: "Le serveur accepte encore les publications non signées (LauncherApi:ContentSigning:Require désactivé).";
|
||||
|
||||
// ------------------------------------------------------------------ Trousseau proposé
|
||||
|
||||
/// <summary>Clés proposées (appareils actifs autorisés à publier) et différences avec le trousseau publié.</summary>
|
||||
public IReadOnlyList<KeyringKeyItem> Keys
|
||||
{
|
||||
get => _keys;
|
||||
private set => SetProperty(ref _keys, value);
|
||||
}
|
||||
|
||||
public bool HasChanges => _keys.Any(k => k.Change != KeyChange.None);
|
||||
|
||||
/// <summary>Nouveau trousseau à signer : clés modifiées, ou trousseau publié refusé par ce launcher (à remplacer).</summary>
|
||||
public bool NeedsNewKeyring => _proposed.Count > 0 && (HasChanges || (HasKeyring && !RootValid));
|
||||
|
||||
public string ChangesText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is null) return "";
|
||||
var added = _keys.Count(k => k.Change == KeyChange.Added);
|
||||
var removed = _keys.Count(k => k.Change == KeyChange.Removed);
|
||||
var changed = _keys.Count(k => k.Change == KeyChange.ScopesChanged);
|
||||
if (added + removed + changed == 0)
|
||||
return HasKeyring && !RootValid
|
||||
? "Les clés sont à jour, mais le trousseau publié est refusé par ce launcher : exportez une demande pour le remplacer."
|
||||
: "Le trousseau publié est à jour.";
|
||||
var parts = new List<string>();
|
||||
if (added > 0) parts.Add($"{added} clé(s) à ajouter");
|
||||
if (removed > 0) parts.Add($"{removed} à retirer");
|
||||
if (changed > 0) parts.Add($"{changed} autorisation(s) à modifier");
|
||||
return $"À mettre à jour : {string.Join(", ", parts)}.";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Contenu en ligne
|
||||
|
||||
public IReadOnlyList<SignedContentItem> Contents
|
||||
{
|
||||
get => _contents;
|
||||
private set => SetProperty(ref _contents, value);
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand ExportRequestCommand { get; }
|
||||
public AsyncRelayCommand PublishSignedCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
ExportRequestCommand.RaiseCanExecuteChanged();
|
||||
PublishSignedCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
var state = await Context.Session.GetSigningStateAsync();
|
||||
var admins = await Context.Session.ListAdminsAsync();
|
||||
_state = state;
|
||||
_proposed = Propose(admins);
|
||||
Keys = Compare(state.Keyring, _proposed, admins);
|
||||
Contents = BuildContents(state);
|
||||
foreach (var name in new[] { nameof(HasKeyring), nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(KeyringUrl),
|
||||
nameof(KeyringText), nameof(RootText), nameof(RootValid), nameof(RequireText), nameof(HasChanges), nameof(NeedsNewKeyring), nameof(ChangesText) })
|
||||
OnPropertyChanged(name);
|
||||
ExportRequestCommand.RaiseCanExecuteChanged();
|
||||
PublishSignedCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Appareils actifs des comptes actifs dont le rôle permet de publier une version ou le catalogue.</summary>
|
||||
internal static List<KeyringKey> Propose(IEnumerable<AdminSummary> admins) =>
|
||||
admins.Where(a => a.Status == AdminStatus.Active)
|
||||
.SelectMany(a => a.Keys
|
||||
.Where(k => k.Status == AdminStatus.Active && k.PublicKey is not null)
|
||||
.Select(k => new KeyringKey
|
||||
{
|
||||
KeyId = k.KeyId,
|
||||
PublicKey = k.PublicKey!,
|
||||
Name = Truncate($"{a.DisplayName} — {AccountTexts.KeyLabel(k.Label)}"),
|
||||
Scopes = SigningScopes.FromPermissions(AdminRoles.PermissionsOf(a.Role)).ToList()
|
||||
}))
|
||||
.Where(k => k.Scopes.Count > 0)
|
||||
.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Nom public du trousseau : caractères invisibles ou de mise en forme retirés (refusés par les launchers), longueur bornée.</summary>
|
||||
internal static string Truncate(string name)
|
||||
{
|
||||
var clean = new string(name.Where(c => Keyring.IsDisplaySafe(c.ToString())).ToArray()).Trim();
|
||||
if (clean.Length > Keyring.MaxNameLength) clean = clean[..Keyring.MaxNameLength].TrimEnd();
|
||||
return clean.Length == 0 ? "Appareil" : clean;
|
||||
}
|
||||
|
||||
private static List<KeyringKeyItem> Compare(KeyringDocument? published, List<KeyringKey> proposed, List<AdminSummary> admins)
|
||||
{
|
||||
var current = published?.Keys.ToDictionary(k => k.KeyId, StringComparer.Ordinal) ?? new Dictionary<string, KeyringKey>();
|
||||
var items = proposed.Select(k =>
|
||||
{
|
||||
if (!current.TryGetValue(k.KeyId, out var old)) return new KeyringKeyItem(k, KeyChange.Added);
|
||||
return SameScopes(old, k) ? new KeyringKeyItem(k, KeyChange.None) : new KeyringKeyItem(k, KeyChange.ScopesChanged);
|
||||
}).ToList();
|
||||
foreach (var old in current.Values.Where(k => proposed.All(p => p.KeyId != k.KeyId)))
|
||||
items.Add(new KeyringKeyItem(old, KeyChange.Removed));
|
||||
return items;
|
||||
}
|
||||
|
||||
private static bool SameScopes(KeyringKey a, KeyringKey b) =>
|
||||
a.Scopes.OrderBy(s => s, StringComparer.Ordinal).SequenceEqual(b.Scopes.OrderBy(s => s, StringComparer.Ordinal));
|
||||
|
||||
private List<SignedContentItem> BuildContents(SigningState state)
|
||||
{
|
||||
var proposed = new KeyringDocument { Keys = _proposed };
|
||||
var items = state.Manifests.Select(m => new SignedContentItem(
|
||||
$"Version en ligne de {m.GameId}" + (string.IsNullOrEmpty(m.Version) ? "" : $" ({m.Version})"),
|
||||
ContentKinds.Manifest, m.GameId, m.Digest, m.Signature, m.SignerName, state.Keyring, proposed,
|
||||
"Re-signez-la depuis la page « Publier une version » (dossier de cette version, « Signer la version en ligne »).")).ToList();
|
||||
if (state.Catalog is { } catalog)
|
||||
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)."));
|
||||
return items;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
private async Task ExportRequestAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
var unsignedContents = Contents.Where(c => c.WillBreak).Select(c => c.Title).ToList();
|
||||
if (unsignedContents.Count > 0 &&
|
||||
!Context.Dialogs.Confirm("Contenu à signer d'abord",
|
||||
"Avec ce trousseau, les launchers en mode « require » refuseraient :\n• " + string.Join("\n• ", unsignedContents) +
|
||||
"\n\nSignez-les d'abord avec une clé qui restera dans le trousseau, ou exportez quand même (en mode « warn », " +
|
||||
"ils seront seulement signalés).", "Exporter quand même"))
|
||||
return;
|
||||
|
||||
var request = new KeyringDocument
|
||||
{
|
||||
Sequence = (state.Keyring?.Sequence ?? 0) + 1,
|
||||
IssuedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + Context.Session.ClockOffset),
|
||||
Keys = _proposed
|
||||
};
|
||||
var path = Context.Dialogs.PickSaveFile("Enregistrer la demande de trousseau", "Trousseau (*.json)|*.json",
|
||||
$"keyring-request-{request.Sequence}.json", null);
|
||||
if (path is null) return;
|
||||
if (await RunAsync(() => File.WriteAllTextAsync(path, Keyring.Serialize(request))))
|
||||
StatusMessage = $"Demande n° {request.Sequence} enregistrée ({request.Keys.Count} clé(s)). Signez-la avec tools\\sign-keyring.bat " +
|
||||
"(clé racine, hors ligne), puis « Publier le trousseau signé… ».";
|
||||
}
|
||||
|
||||
private async Task PublishSignedAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
StatusMessage = null;
|
||||
var path = Context.Dialogs.PickOpenFile("Trousseau signé (produit par tools\\sign-keyring.bat)", "Trousseau (*.json)|*.json", null);
|
||||
if (path is null) return;
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length > Keyring.MaxBytes) throw new InvalidDataException("fichier trop volumineux");
|
||||
keyring = Keyring.Parse(await File.ReadAllBytesAsync(path)) ?? throw new InvalidDataException("fichier vide");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
ErrorMessage = $"Trousseau illisible : {ex.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
// Mêmes contrôles que les launchers : un trousseau qu'ils refuseraient n'est pas publié.
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors))
|
||||
{
|
||||
ErrorMessage = "Trousseau invalide : " + string.Join(" ", errors.Take(4));
|
||||
return;
|
||||
}
|
||||
if (Context.RootKeys.Count == 0)
|
||||
{
|
||||
ErrorMessage = "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : impossible de vérifier le trousseau.";
|
||||
return;
|
||||
}
|
||||
if (!Keyring.VerifyRoot(keyring, Context.RootKeys, out var rootError))
|
||||
{
|
||||
ErrorMessage = $"Trousseau refusé : {rootError}. Signez la demande avec tools\\sign-keyring.bat et la clé racine du launcher.";
|
||||
return;
|
||||
}
|
||||
if (state.Keyring is { } current && keyring.Sequence <= current.Sequence)
|
||||
{
|
||||
ErrorMessage = $"Trousseau n° {keyring.Sequence} : le n° {current.Sequence} est déjà publié. Exportez une nouvelle demande.";
|
||||
return;
|
||||
}
|
||||
|
||||
var names = keyring.Keys.Select(k => $"{k.Name} ({string.Join(", ", k.Scopes.Select(SigningScopes.DisplayName))})");
|
||||
if (!Context.Dialogs.Confirm($"Publier le trousseau n° {keyring.Sequence} ?",
|
||||
"Les launchers ne feront plus confiance qu'à ces clés :\n• " + string.Join("\n• ", names), "Publier"))
|
||||
return;
|
||||
if (await RunAsync(async () =>
|
||||
{
|
||||
await Context.Session.PublishKeyringAsync(keyring);
|
||||
await ReloadAsync();
|
||||
}))
|
||||
StatusMessage = $"Trousseau n° {keyring.Sequence} publié. Les launchers l'utilisent dès leur prochain démarrage.";
|
||||
}
|
||||
}
|
||||
|
||||
public enum KeyChange
|
||||
{
|
||||
None,
|
||||
Added,
|
||||
Removed,
|
||||
ScopesChanged
|
||||
}
|
||||
|
||||
/// <summary>Clé du trousseau proposé (ou retirée du trousseau publié).</summary>
|
||||
public sealed class KeyringKeyItem
|
||||
{
|
||||
internal KeyringKeyItem(KeyringKey key, KeyChange change)
|
||||
{
|
||||
Key = key;
|
||||
Change = change;
|
||||
ScopesText = string.Join(", ", key.Scopes.Select(SigningScopes.DisplayName));
|
||||
ChangeText = change switch
|
||||
{
|
||||
KeyChange.Added => "À ajouter",
|
||||
KeyChange.Removed => "À retirer",
|
||||
KeyChange.ScopesChanged => "Autorisations modifiées",
|
||||
_ => "Dans le trousseau"
|
||||
};
|
||||
}
|
||||
|
||||
public KeyringKey Key { get; }
|
||||
public KeyChange Change { get; }
|
||||
public string Name => Key.Name;
|
||||
public string KeyId => Key.KeyId;
|
||||
public string ScopesText { get; }
|
||||
public string ChangeText { get; }
|
||||
public bool IsAdded => Change == KeyChange.Added;
|
||||
public bool IsRemoved => Change == KeyChange.Removed;
|
||||
public bool IsChanged => Change == KeyChange.ScopesChanged;
|
||||
}
|
||||
|
||||
/// <summary>Contenu en ligne et sa signature.</summary>
|
||||
public sealed class SignedContentItem
|
||||
{
|
||||
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign)
|
||||
{
|
||||
Title = title;
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
Text = "Rien n'est publié.";
|
||||
IsValid = true;
|
||||
return;
|
||||
}
|
||||
var signer = signature is null ? null : $"{signerName ?? $"clé {signature.KeyId}"}, le {AccountTexts.Date(signature.SignedAt)}";
|
||||
var now = published is null ? ContentCheck.Fail("aucun trousseau publié") : ContentSigning.Verify(kind, id, digest, signature, published);
|
||||
var next = ContentSigning.Verify(kind, id, digest, signature, proposed);
|
||||
IsValid = now.Valid;
|
||||
WillBreak = !next.Valid;
|
||||
Text = signature is null
|
||||
? $"Non signé. {howToSign}"
|
||||
: now.Valid
|
||||
? $"Signé par {signer} : valide pour les launchers."
|
||||
: $"Signé par {signer}, mais refusé par les launchers : {now.Reason}. {howToSign}";
|
||||
if (signature is not null && now.Valid && !next.Valid)
|
||||
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>Accepté par les launchers avec le trousseau publié.</summary>
|
||||
public bool IsValid { get; }
|
||||
|
||||
/// <summary>Serait refusé avec le trousseau proposé.</summary>
|
||||
public bool WillBreak { get; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Dépendances de la fenêtre d'administration (fournies par le launcher).</summary>
|
||||
public sealed class AdminEnvironment
|
||||
{
|
||||
/// <summary>Client de l'API (adresse intégrée à l'exe).</summary>
|
||||
public required LauncherApiClient Client { get; init; }
|
||||
|
||||
public required ModuleCatalog Modules { get; init; }
|
||||
|
||||
/// <summary>Jeux déclarés dans launcher.json (valeurs par défaut, avant le catalogue).</summary>
|
||||
public required IReadOnlyList<LauncherConfig> Games { get; init; }
|
||||
|
||||
/// <summary>Nom du launcher selon launcher.json.</summary>
|
||||
public string LauncherName { get; init; } = "Launcher";
|
||||
|
||||
/// <summary>Jeu par défaut selon launcher.json.</summary>
|
||||
public string? DefaultGameId { get; init; }
|
||||
|
||||
/// <summary>Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</summary>
|
||||
public IReadOnlyList<string> Themes { get; init; } = new[] { "default" };
|
||||
|
||||
public required IModuleDialogs Dialogs { get; init; }
|
||||
|
||||
public required IUiDispatcher Ui { get; init; }
|
||||
|
||||
/// <summary>Fichier des préférences d'administration (%AppData%\…\GameLauncher\admin.json).</summary>
|
||||
public required string SettingsPath { get; init; }
|
||||
|
||||
public required string LauncherVersion { get; init; }
|
||||
|
||||
public TimeProvider Time { get; init; } = TimeProvider.System;
|
||||
|
||||
/// <summary>
|
||||
/// Clés racine intégrées à l'exe (update-signing.pub) : vérification d'un trousseau signé avant sa publication
|
||||
/// (page « Signature »). Vide : aucune clé intégrée.
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; init; } = Array.Empty<(string, byte[])>();
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
/// <summary>Dossier des clés proposé par défaut (%USERPROFILE%\.launcher-keys).</summary>
|
||||
public string KeysDirectory { get; init; } = AdminKeyLocator.DefaultDirectory;
|
||||
|
||||
/// <summary>Verrouillage après inactivité (10 min par défaut).</summary>
|
||||
public TimeSpan? LockAfter { get; init; }
|
||||
|
||||
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
|
||||
public bool StartTimers { get; init; } = true;
|
||||
|
||||
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
|
||||
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre d'administration : un seul écran à la fois — connexion (<see cref="LoginViewModel"/>),
|
||||
/// création de clé / invitation (<see cref="EnrollViewModel"/>) ou espace d'administration
|
||||
/// (<see cref="AdminWorkspaceViewModel"/>). Verrouiller ferme la session et libère la clé.
|
||||
/// </summary>
|
||||
public sealed class AdminShellViewModel : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
private object _screen;
|
||||
private AdminWorkspaceViewModel? _workspace;
|
||||
private bool _disposed;
|
||||
private Task<TpmAvailability>? _tpmCheck;
|
||||
|
||||
public AdminShellViewModel(AdminEnvironment environment)
|
||||
{
|
||||
Environment = environment;
|
||||
Settings = AdminSettings.Load(environment.SettingsPath);
|
||||
_screen = new LoginViewModel(this, null, null);
|
||||
}
|
||||
|
||||
public AdminEnvironment Environment { get; }
|
||||
|
||||
internal AdminSettings Settings { get; }
|
||||
|
||||
/// <summary>Écran affiché (le launcher choisit la vue selon son type).</summary>
|
||||
public object Screen
|
||||
{
|
||||
get => _screen;
|
||||
private set
|
||||
{
|
||||
var previous = _screen;
|
||||
if (!SetProperty(ref _screen, value)) return;
|
||||
OnPropertyChanged(nameof(IsLoggedIn));
|
||||
if (previous is LoginViewModel login) login.Dispose(); // arrêt de ses minuteries (décompte de la puce)
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoggedIn => Screen is AdminWorkspaceViewModel;
|
||||
|
||||
public string ServerHost => Environment.Client.BaseUrl.Host;
|
||||
|
||||
public string WindowTitle => $"Administration — {ServerHost}";
|
||||
|
||||
/// <summary>Souris ou clavier dans la fenêtre : repousse le verrouillage automatique.</summary>
|
||||
public void NotifyActivity() => _workspace?.Guard.NotifyActivity();
|
||||
|
||||
public void ShowLogin(string? status = null, string? selectKeyPath = null)
|
||||
{
|
||||
if (_disposed) return;
|
||||
Screen = new LoginViewModel(this, status, selectKeyPath);
|
||||
}
|
||||
|
||||
/// <summary>Vérification de la puce TPM (une fois par ouverture de la fenêtre, hors du fil de l'interface).</summary>
|
||||
internal Task<TpmAvailability> CheckTpmAsync() => _tpmCheck ??= RunTpmCheckAsync();
|
||||
|
||||
private async Task<TpmAvailability> RunTpmCheckAsync()
|
||||
{
|
||||
if (Environment.Tpm is not { } tpm) return TpmAvailability.Unsupported;
|
||||
try
|
||||
{
|
||||
var availability = await BackgroundWork.RunAsync(tpm.Check);
|
||||
Log.Info($"Administration : puce TPM {(availability.IsAvailable ? "disponible" : "indisponible")} ({availability.Message})");
|
||||
return availability;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : vérification de la puce TPM impossible ({ex.Message}).");
|
||||
return new TpmAvailability(false, $"Puce de sécurité (TPM) inutilisable : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowEnroll()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Screen = new EnrollViewModel(this);
|
||||
}
|
||||
|
||||
internal async Task OpenWorkspaceAsync(AdminConnection connection)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
return;
|
||||
}
|
||||
AdminWorkspaceViewModel workspace;
|
||||
try
|
||||
{
|
||||
workspace = new AdminWorkspaceViewModel(this, connection);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync(); // clé effacée, session fermée
|
||||
throw;
|
||||
}
|
||||
_workspace = workspace;
|
||||
Screen = workspace;
|
||||
await workspace.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>Ferme la session (serveur + clé en mémoire) et revient à l'écran de connexion.</summary>
|
||||
public Task LockAsync(string? reason) => _workspace is { } workspace ? LockAsync(workspace, reason) : Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Verrouillage demandé par un espace donné (minuterie, session perdue) : sans effet s'il n'est plus
|
||||
/// l'espace courant — une requête tardive d'une session fermée ne doit pas verrouiller la suivante.
|
||||
/// </summary>
|
||||
internal async Task LockAsync(AdminWorkspaceViewModel source, string? reason)
|
||||
{
|
||||
if (!ReferenceEquals(_workspace, source)) return;
|
||||
_workspace = null;
|
||||
try
|
||||
{
|
||||
if (!_disposed) ShowLogin(reason, source.KeyPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await source.DisposeAsync(); // dans tous les cas : clé effacée, session fermée
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fermeture de la fenêtre : déconnexion.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
(_screen as LoginViewModel)?.Dispose();
|
||||
var workspace = _workspace;
|
||||
_workspace = null;
|
||||
if (workspace is not null) await workspace.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Page du menu d'administration dans la navigation (ViewModel créé à la première ouverture).</summary>
|
||||
public sealed class AdminPanelItem : ObservableObject
|
||||
{
|
||||
private AdminPanelViewModel? _viewModel;
|
||||
|
||||
public AdminPanelItem(AdminPanelDescriptor descriptor) => Descriptor = descriptor;
|
||||
|
||||
public AdminPanelDescriptor Descriptor { get; }
|
||||
public string Title => Descriptor.Title;
|
||||
public string Description => Descriptor.Description;
|
||||
public string IconKey => Descriptor.IconKey;
|
||||
|
||||
public AdminPanelViewModel? ViewModel
|
||||
{
|
||||
get => _viewModel;
|
||||
internal set => SetProperty(ref _viewModel, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Espace d'administration (connecté) : navigation entre les pages des modules autorisées par les
|
||||
/// permissions, identité, temps restant avant verrouillage, bouton « Verrouiller ».
|
||||
/// </summary>
|
||||
public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, IAsyncDisposable
|
||||
{
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private readonly AdminConnection _connection;
|
||||
private AdminPanelItem? _selectedPanel;
|
||||
private Task _activation = Task.CompletedTask;
|
||||
private bool _disposed;
|
||||
|
||||
internal AdminWorkspaceViewModel(AdminShellViewModel shell, AdminConnection connection)
|
||||
{
|
||||
_shell = shell;
|
||||
_connection = connection;
|
||||
var environment = shell.Environment;
|
||||
|
||||
Guard = new AdminSessionGuard(connection, environment.Time, environment.Ui, environment.LockAfter, environment.StartTimers);
|
||||
Guard.LockRequested = reason => shell.LockAsync(this, reason);
|
||||
Guard.Changed += () => OnPropertyChanged(nameof(SessionText));
|
||||
|
||||
Panels = environment.Modules.AdminPanelsFor(Session.Profile.Permissions);
|
||||
PanelItems = new ObservableCollection<AdminPanelItem>(Panels.Select(p => new AdminPanelItem(p)));
|
||||
LockCommand = new AsyncRelayCommand(() => shell.LockAsync(this, "Session verrouillée."));
|
||||
}
|
||||
|
||||
internal AdminSessionGuard Guard { get; }
|
||||
|
||||
internal string KeyPath => _connection.KeyPath;
|
||||
|
||||
// ------------------------------------------------------------------ Affichage
|
||||
|
||||
public ObservableCollection<AdminPanelItem> PanelItems { get; }
|
||||
|
||||
public AdminPanelItem? SelectedPanel
|
||||
{
|
||||
get => _selectedPanel;
|
||||
set
|
||||
{
|
||||
if (value is null || ReferenceEquals(value, _selectedPanel)) return;
|
||||
_selectedPanel = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(PanelTitle));
|
||||
OnPropertyChanged(nameof(PanelDescription));
|
||||
_activation = ActivateAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
public string PanelTitle => SelectedPanel?.Title ?? "";
|
||||
public string PanelDescription => SelectedPanel?.Description ?? "";
|
||||
|
||||
public string DisplayName => Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(Session.Profile.DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Session.Profile.Role);
|
||||
public string KeyLabel => (Session.Profile.KeyLabel ?? Path.GetFileNameWithoutExtension(_connection.KeyPath)) +
|
||||
(_connection.IsTpm ? " · puce TPM" : "");
|
||||
public string ServerHost => _shell.ServerHost;
|
||||
|
||||
/// <summary>« Verrouillage dans 9 min ».</summary>
|
||||
public string SessionText => $"Verrouillage dans {AdminTexts.Duration(Guard.Remaining)}";
|
||||
|
||||
public AsyncRelayCommand LockCommand { get; }
|
||||
|
||||
/// <summary>Ouvre la première page et attend son chargement initial.</summary>
|
||||
internal Task StartAsync()
|
||||
{
|
||||
SelectedPanel = PanelItems.FirstOrDefault();
|
||||
return _activation;
|
||||
}
|
||||
|
||||
/// <summary>Chargement de la page affichée (tests).</summary>
|
||||
internal Task Activation => _activation;
|
||||
|
||||
private async Task ActivateAsync(AdminPanelItem item)
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
item.ViewModel ??= item.Descriptor.CreateViewModel(this);
|
||||
await item.ViewModel.ActivateAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Administration : ouverture de la page {item.Descriptor.Id}", ex);
|
||||
Dialogs.ShowMessage("Page indisponible", ApiErrorText.Describe(ex), ModuleMessageKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ IAdminContext
|
||||
|
||||
public AdminSession Session => _connection.Session;
|
||||
public LauncherApiClient Client => _connection.Client;
|
||||
public IReadOnlyList<LauncherConfig> Games => _shell.Environment.Games;
|
||||
public string LauncherName => _shell.Environment.LauncherName;
|
||||
public string? DefaultGameId => _shell.Environment.DefaultGameId;
|
||||
public IReadOnlyList<string> Themes => _shell.Environment.Themes;
|
||||
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
|
||||
|
||||
public void Navigate(string panelId)
|
||||
{
|
||||
if (PanelItems.FirstOrDefault(p => string.Equals(p.Descriptor.Id, panelId, StringComparison.OrdinalIgnoreCase)) is { } item)
|
||||
SelectedPanel = item;
|
||||
}
|
||||
|
||||
public void ReportSessionLost(string reason) => _ = _shell.LockAsync(this, reason);
|
||||
|
||||
// ------------------------------------------------------------------ Fermeture
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Guard.Dispose();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Jeu déclaré dans launcher.json, tel qu'affiché au tableau de bord.</summary>
|
||||
public sealed record DashboardGame(string Name, string GameId, string ManifestHost);
|
||||
|
||||
/// <summary>
|
||||
/// Page d'accueil : compte connecté et permissions, session, serveur, jeux déclarés et, pour les
|
||||
/// propriétaires, les clés en attente d'approbation.
|
||||
/// </summary>
|
||||
public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "admin.dashboard";
|
||||
|
||||
private string _serverVersion = "…";
|
||||
private string _serverId = "…";
|
||||
private int _pendingApprovals;
|
||||
private int _activeInvitations;
|
||||
private string? _lastRefresh;
|
||||
|
||||
public DashboardPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
var profile = context.Session.Profile;
|
||||
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
|
||||
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
|
||||
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
|
||||
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
|
||||
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
|
||||
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
|
||||
}
|
||||
|
||||
/// <summary>Pages « Administrateurs » et « Mes appareils » présentes (modules déclarés, droits).</summary>
|
||||
public bool CanOpenAdmins => Context.Panels.Any(p => p.Id == AdminsPanelViewModel.PanelId);
|
||||
|
||||
public bool CanOpenDevices => Context.Panels.Any(p => p.Id == MyDevicesPanelViewModel.PanelId);
|
||||
|
||||
public RelayCommand OpenAdminsCommand { get; }
|
||||
public RelayCommand OpenDevicesCommand { get; }
|
||||
|
||||
public string DisplayName => Context.Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
|
||||
public IReadOnlyList<string> Permissions { get; }
|
||||
|
||||
public string KeyText => $"{Context.Session.Profile.KeyLabel ?? "Clé"} · {Context.Session.Profile.KeyId}";
|
||||
|
||||
/// <summary>« Session ouverte jusqu'à 23:14 au plus tard » (heure du PC).</summary>
|
||||
public string SessionText =>
|
||||
$"Ouverte au plus tard jusqu'à {(Context.Session.ExpiresAt - Context.Session.ClockOffset).ToLocalTime():HH:mm}";
|
||||
|
||||
public string ServerOrigin => Context.Client.Origin;
|
||||
|
||||
public string ServerVersion
|
||||
{
|
||||
get => _serverVersion;
|
||||
private set => SetProperty(ref _serverVersion, value);
|
||||
}
|
||||
|
||||
/// <summary>Identifiant du serveur (début), pour le support.</summary>
|
||||
public string ServerId
|
||||
{
|
||||
get => _serverId;
|
||||
private set => SetProperty(ref _serverId, value);
|
||||
}
|
||||
|
||||
public string LauncherVersion => Context.LauncherVersion;
|
||||
|
||||
public IReadOnlyList<DashboardGame> Games { get; }
|
||||
|
||||
public bool CanManageAdmins => Context.Session.Has(AdminPermissions.AdminsManage);
|
||||
|
||||
public int PendingApprovals
|
||||
{
|
||||
get => _pendingApprovals;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _pendingApprovals, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasPendingApprovals));
|
||||
OnPropertyChanged(nameof(PendingText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int ActiveInvitations
|
||||
{
|
||||
get => _activeInvitations;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _activeInvitations, value)) OnPropertyChanged(nameof(PendingText));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPendingApprovals => PendingApprovals > 0;
|
||||
|
||||
public string PendingText => PendingApprovals switch
|
||||
{
|
||||
0 => ActiveInvitations == 0 ? "Aucune clé en attente, aucune invitation en cours." : $"Aucune clé en attente · {ActiveInvitations} invitation(s) en cours.",
|
||||
1 => $"1 clé attend votre approbation · {ActiveInvitations} invitation(s) en cours.",
|
||||
var n => $"{n} clés attendent votre approbation · {ActiveInvitations} invitation(s) en cours."
|
||||
};
|
||||
|
||||
public string? LastRefresh
|
||||
{
|
||||
get => _lastRefresh;
|
||||
private set => SetProperty(ref _lastRefresh, value);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged() => RefreshCommand.RaiseCanExecuteChanged();
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshAsync() : Task.CompletedTask;
|
||||
|
||||
public Task RefreshAsync() => RunAsync(async () =>
|
||||
{
|
||||
var info = await Context.Client.GetInfoAsync();
|
||||
ServerVersion = info.ServerVersion;
|
||||
ServerId = info.ServerId.Length > 8 ? info.ServerId[..8] : info.ServerId;
|
||||
|
||||
if (CanManageAdmins)
|
||||
{
|
||||
var admins = await Context.Session.ListAdminsAsync();
|
||||
var invitations = await Context.Session.ListInvitationsAsync();
|
||||
PendingApprovals = admins.Where(a => a.Status != AdminStatus.Revoked)
|
||||
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
|
||||
ActiveInvitations = invitations.Count;
|
||||
}
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// « J'ai une invitation » : création d'une clé sur ce PC et, avec un code d'invitation,
|
||||
/// enregistrement auprès du serveur. Deux rangements au choix :
|
||||
/// • fichier chiffré par une phrase de passe (copiable, sauvegardable) — par défaut ;
|
||||
/// • puce de sécurité (TPM) de ce PC, protégée par un code PIN (non copiable) — proposé seulement
|
||||
/// une fois la puce RÉELLEMENT vérifiée (création, signature et suppression d'une clé de test).
|
||||
/// Affiche ensuite l'EMPREINTE à communiquer à la personne qui approuve. Sans code : clé seule
|
||||
/// (premier propriétaire, à déclarer sur le VPS).
|
||||
/// </summary>
|
||||
public sealed class EnrollViewModel : ObservableObject
|
||||
{
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private string _invitationCode = "";
|
||||
private string _keyLabel;
|
||||
private string _keyPath;
|
||||
private bool _useTpm;
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private AdminKeyCreation? _result;
|
||||
private TpmAvailability? _tpm;
|
||||
|
||||
public EnrollViewModel(AdminShellViewModel shell)
|
||||
{
|
||||
_shell = shell;
|
||||
_keyLabel = System.Environment.MachineName;
|
||||
_keyPath = AdminKeyLocator.SuggestNewKeyPath(shell.Environment.KeysDirectory);
|
||||
BackCommand = new RelayCommand(() => shell.ShowLogin(), () => !IsBusy);
|
||||
ChangePathCommand = new RelayCommand(ChangePath, () => !IsBusy && !IsDone);
|
||||
CopyFingerprintCommand = new RelayCommand(() =>
|
||||
{
|
||||
if (_result is not null) shell.Environment.Dialogs.CopyToClipboard(_result.Fingerprint);
|
||||
});
|
||||
OpenKeyFolderCommand = new RelayCommand(() =>
|
||||
{
|
||||
if (Path.GetDirectoryName(_result?.KeyPath ?? KeyPath) is { } folder && Directory.Exists(folder))
|
||||
shell.Environment.Dialogs.OpenFolder(folder);
|
||||
});
|
||||
ContinueCommand = new RelayCommand(() => shell.ShowLogin(
|
||||
_result is null or { Enrollment: null, EnrollmentUnconfirmed: null } ? null : "Connectez-vous dès que votre clé a été approuvée.",
|
||||
_result?.KeyPath));
|
||||
TpmCheck = CheckTpmAsync();
|
||||
}
|
||||
|
||||
public string InvitationCode
|
||||
{
|
||||
get => _invitationCode;
|
||||
set => SetProperty(ref _invitationCode, value ?? "");
|
||||
}
|
||||
|
||||
/// <summary>Nom de ce PC, visible par les propriétaires (ex. « PC bureau »).</summary>
|
||||
public string KeyLabel
|
||||
{
|
||||
get => _keyLabel;
|
||||
set => SetProperty(ref _keyLabel, value ?? "");
|
||||
}
|
||||
|
||||
public string KeyPath
|
||||
{
|
||||
get => _keyPath;
|
||||
private set => SetProperty(ref _keyPath, value);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Rangement de la clé
|
||||
|
||||
/// <summary>Vérification de la puce en cours (le choix « puce » reste grisé).</summary>
|
||||
public bool IsCheckingTpm => _tpm is null;
|
||||
|
||||
/// <summary>Puce vérifiée et utilisable.</summary>
|
||||
public bool IsTpmAvailable => _tpm?.IsAvailable == true;
|
||||
|
||||
/// <summary>« Vérification de la puce de sécurité… », « Puce TPM 2.0 disponible… » ou la raison de l'indisponibilité.</summary>
|
||||
public string TpmStatus => _tpm?.Message ?? "Vérification de la puce de sécurité (TPM) de ce PC…";
|
||||
|
||||
/// <summary>Le choix « puce de sécurité » n'est dégrisé qu'après une vérification réussie.</summary>
|
||||
public bool CanChooseTpm => IsTpmAvailable && !IsBusy && !IsDone;
|
||||
|
||||
public bool CanChooseFile => !IsBusy && !IsDone;
|
||||
|
||||
/// <summary>Faux : fichier chiffré (par défaut). Vrai : puce de sécurité (TPM) de ce PC.</summary>
|
||||
public bool UseTpm
|
||||
{
|
||||
get => _useTpm;
|
||||
set
|
||||
{
|
||||
if ((value && !IsTpmAvailable) || IsBusy || IsDone || value == _useTpm)
|
||||
{
|
||||
// Refusé (jamais la puce sans vérification réussie) : l'affichage revient à l'état réel.
|
||||
OnPropertyChanged(nameof(UseTpm));
|
||||
OnPropertyChanged(nameof(UseFile));
|
||||
return;
|
||||
}
|
||||
_useTpm = value;
|
||||
ErrorMessage = null;
|
||||
KeyPath = AdminKeyLocator.SuggestNewKeyPath(Path.GetDirectoryName(KeyPath) ?? _shell.Environment.KeysDirectory, value);
|
||||
foreach (var name in new[] { nameof(UseTpm), nameof(UseFile), nameof(MinSecretLength), nameof(SecretLabel),
|
||||
nameof(ConfirmationLabel), nameof(SecretHint), nameof(KeyFileLabel) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
}
|
||||
|
||||
public bool UseFile
|
||||
{
|
||||
get => !_useTpm;
|
||||
set
|
||||
{
|
||||
if (value) UseTpm = false;
|
||||
}
|
||||
}
|
||||
|
||||
public int MinSecretLength => UseTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
|
||||
|
||||
public string SecretLabel => UseTpm
|
||||
? $"Code PIN de la clé (au moins {AdminTpmKeyFile.MinPinLength} caractères)"
|
||||
: $"Phrase de passe (au moins {AdminKeyFile.MinPassphraseLength} caractères)";
|
||||
|
||||
public string ConfirmationLabel => UseTpm ? "Confirmation du code PIN" : "Confirmation";
|
||||
|
||||
public string SecretHint => UseTpm
|
||||
? "Le code PIN est vérifié par la puce elle-même, qui se bloque temporairement après plusieurs erreurs " +
|
||||
"(ces erreurs comptent aussi pour les autres usages de la puce, comme le code PIN Windows Hello). " +
|
||||
"Cette clé ne peut être ni copiée ni sauvegardée : gardez aussi une clé « fichier » de secours."
|
||||
: "Une phrase de plusieurs mots est plus sûre et plus facile à retenir. Elle ne peut pas être récupérée : " +
|
||||
"en cas d'oubli, il faudra une nouvelle invitation.";
|
||||
|
||||
public string KeyFileLabel => UseTpm
|
||||
? "Fichier de référence (aucun secret : la clé reste dans la puce)"
|
||||
: "Fichier de la clé";
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isBusy, value)) return;
|
||||
BackCommand.RaiseCanExecuteChanged();
|
||||
ChangePathCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(CanChooseTpm));
|
||||
OnPropertyChanged(nameof(CanChooseFile));
|
||||
}
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
public bool IsDone => _result is not null;
|
||||
public bool IsForm => _result is null;
|
||||
|
||||
/// <summary>Clé enregistrée auprès du serveur (sinon : clé seule).</summary>
|
||||
public bool IsEnrolled => _result?.Enrollment is not null;
|
||||
public bool IsKeyOnly => _result is { Enrollment: null, EnrollmentUnconfirmed: null };
|
||||
|
||||
/// <summary>Invitation envoyée sans réponse sûre du serveur : clé conservée, marche à suivre affichée.</summary>
|
||||
public bool IsUnconfirmed => _result?.EnrollmentUnconfirmed is not null;
|
||||
public string UnconfirmedText => _result?.EnrollmentUnconfirmed ?? "";
|
||||
|
||||
/// <summary>Clé créée dans la puce de sécurité (TPM).</summary>
|
||||
public bool IsTpmKey => _result?.IsTpm == true;
|
||||
|
||||
public string Fingerprint => _result?.Fingerprint ?? "";
|
||||
public string CreatedKeyPath => _result?.KeyPath ?? "";
|
||||
public string PublicKeyPath => _result?.PublicKeyPath ?? "";
|
||||
public string AccountName => _result?.Enrollment?.DisplayName ?? "";
|
||||
|
||||
public RelayCommand BackCommand { get; }
|
||||
public RelayCommand ChangePathCommand { get; }
|
||||
public RelayCommand CopyFingerprintCommand { get; }
|
||||
public RelayCommand OpenKeyFolderCommand { get; }
|
||||
public RelayCommand ContinueCommand { get; }
|
||||
|
||||
/// <summary>Vérification de la puce lancée à l'ouverture de l'écran (tests).</summary>
|
||||
internal Task TpmCheck { get; }
|
||||
|
||||
/// <summary>Crée la clé (et l'enregistre si un code est saisi). La vue efface les secrets après l'appel.</summary>
|
||||
public async Task SubmitAsync(string secret, string confirmation)
|
||||
{
|
||||
if (IsBusy || IsDone) return;
|
||||
if (UseTpm && !IsTpmAvailable)
|
||||
{
|
||||
ErrorMessage = "La puce de sécurité (TPM) n'est pas utilisable sur ce PC : choisissez « Fichier chiffré ».";
|
||||
return;
|
||||
}
|
||||
if (secret.Length < MinSecretLength)
|
||||
{
|
||||
ErrorMessage = UseTpm
|
||||
? $"Le code PIN doit contenir au moins {MinSecretLength} caractères."
|
||||
: $"La phrase de passe doit contenir au moins {MinSecretLength} caractères.";
|
||||
return;
|
||||
}
|
||||
if (!string.Equals(secret, confirmation, StringComparison.Ordinal))
|
||||
{
|
||||
ErrorMessage = UseTpm ? "Les deux codes PIN sont différents." : "Les deux phrases de passe sont différentes.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
var result = await new AdminEnrollmentService(_shell.Environment.Client, _shell.Environment.Tpm)
|
||||
.CreateKeyAsync(KeyPath, secret, InvitationCode, KeyLabel, useTpm: UseTpm);
|
||||
_shell.Settings.Remember(result.KeyPath);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
var storage = result.IsTpm ? "puce TPM" : "fichier";
|
||||
Log.Info(result switch
|
||||
{
|
||||
{ Enrollment: { } enrollment } =>
|
||||
$"Administration : clé {result.KeyId} ({storage}) enregistrée pour {enrollment.DisplayName}, en attente d'approbation.",
|
||||
{ EnrollmentUnconfirmed: not null } => $"Administration : clé {result.KeyId} ({storage}) créée, enregistrement non confirmé par le serveur.",
|
||||
_ => $"Administration : clé créée ({result.KeyId}, {storage})."
|
||||
});
|
||||
_result = result;
|
||||
foreach (var name in new[] { nameof(IsDone), nameof(IsForm), nameof(IsEnrolled), nameof(IsKeyOnly), nameof(IsUnconfirmed),
|
||||
nameof(UnconfirmedText), nameof(IsTpmKey), nameof(Fingerprint), nameof(CreatedKeyPath),
|
||||
nameof(PublicKeyPath), nameof(AccountName), nameof(CanChooseTpm), nameof(CanChooseFile) })
|
||||
OnPropertyChanged(name);
|
||||
ChangePathCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
if (ex is ApiException api) Log.Warn($"Administration : enregistrement refusé ({api.Status} {api.Code}).");
|
||||
else if (UseTpm) Log.Warn($"Administration : création de la clé TPM impossible ({ex.Message}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckTpmAsync()
|
||||
{
|
||||
var availability = await _shell.CheckTpmAsync();
|
||||
_tpm = availability;
|
||||
foreach (var name in new[] { nameof(IsCheckingTpm), nameof(IsTpmAvailable), nameof(TpmStatus), nameof(CanChooseTpm) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
private void ChangePath()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(KeyPath);
|
||||
var extension = UseTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
var filter = UseTpm ? "Référence de clé TPM (*.tpmkey)|*.tpmkey" : "Clé d'administrateur (*.key)|*.key";
|
||||
var path = _shell.Environment.Dialogs.PickSaveFile("Emplacement de la nouvelle clé", filter,
|
||||
Path.GetFileName(KeyPath), directory is not null && Directory.Exists(directory) ? directory : null);
|
||||
if (path is null) return;
|
||||
if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) path += extension;
|
||||
KeyPath = path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Écran de connexion : choix de la clé, phrase de passe ou code PIN (transmis par la vue, jamais
|
||||
/// stocké dans le ViewModel), connexion par défi signé. Pour les clés de la puce TPM : suppression
|
||||
/// de la clé, et aide quand la puce est bloquée par sa protection anti-essais (temps d'attente).
|
||||
/// </summary>
|
||||
public sealed class LoginViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan LockoutTick = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private AdminKeyEntry? _selectedKey;
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private string? _statusMessage;
|
||||
|
||||
// Protection anti-essais de la puce (commune à toutes les clés TPM de ce PC).
|
||||
private bool _isTpmLocked;
|
||||
private DateTimeOffset? _lockoutUntil;
|
||||
private TimeSpan _lockoutInterval;
|
||||
private bool _lockoutHeals = true;
|
||||
private string? _lockoutText;
|
||||
private ITimer? _lockoutTimer;
|
||||
private bool _disposed;
|
||||
|
||||
public LoginViewModel(AdminShellViewModel shell, string? statusMessage, string? selectKeyPath)
|
||||
{
|
||||
_shell = shell;
|
||||
_statusMessage = statusMessage;
|
||||
BrowseKeyCommand = new RelayCommand(BrowseKey, () => !IsBusy);
|
||||
ShowEnrollCommand = new RelayCommand(shell.ShowEnroll, () => !IsBusy);
|
||||
RefreshCommand = new RelayCommand(() => LoadKeys(SelectedKey?.Path), () => !IsBusy);
|
||||
OpenKeysFolderCommand = new RelayCommand(OpenKeysFolder);
|
||||
DeleteTpmKeyCommand = new AsyncRelayCommand(DeleteTpmKeyAsync, () => IsTpmKey && !IsBusy);
|
||||
RecheckLockoutCommand = new AsyncRelayCommand(() => CheckLockoutAsync(knownLocked: false), () => !IsBusy);
|
||||
LoadKeys(selectKeyPath ?? shell.Settings.LastKeyPath);
|
||||
}
|
||||
|
||||
public ObservableCollection<AdminKeyEntry> Keys { get; } = new();
|
||||
|
||||
public AdminKeyEntry? SelectedKey
|
||||
{
|
||||
get => _selectedKey;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selectedKey, value)) return;
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
OnPropertyChanged(nameof(SecretLabel));
|
||||
OnPropertyChanged(nameof(IsTpmKey));
|
||||
OnPropertyChanged(nameof(ShowTpmLockout));
|
||||
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
|
||||
// Clé de la puce : la puce est-elle bloquée ? (affiché avant même de saisir le code)
|
||||
if (value?.IsTpm == true && !_isTpmLocked) _ = CheckLockoutAsync(knownLocked: false, quiet: true);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasKeys => Keys.Count > 0;
|
||||
|
||||
/// <summary>« Phrase de passe de la clé » ou « Code PIN de la clé (puce TPM) », selon la clé choisie.</summary>
|
||||
public string SecretLabel => SelectedKey?.SecretLabel ?? "Phrase de passe de la clé";
|
||||
|
||||
public bool IsTpmKey => SelectedKey?.IsTpm == true;
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isBusy, value)) return;
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
BrowseKeyCommand.RaiseCanExecuteChanged();
|
||||
ShowEnrollCommand.RaiseCanExecuteChanged();
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
|
||||
RecheckLockoutCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connexion possible. Avec une clé de la puce pendant un blocage dont la fin est connue : non,
|
||||
/// jusqu'à la fin du décompte (un essai serait refusé de toute façon).
|
||||
/// </summary>
|
||||
public bool CanLogin => SelectedKey is not null && !IsBusy && !(IsTpmKey && LockoutBlocksLogin);
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
/// <summary>Information (ex. « Verrouillé après 10 minutes d'inactivité », clé supprimée…).</summary>
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public string ServerHost => _shell.ServerHost;
|
||||
|
||||
public string KeysDirectory => _shell.Environment.KeysDirectory;
|
||||
|
||||
// ------------------------------------------------------------------ Blocage de la puce
|
||||
|
||||
/// <summary>La puce refuse les codes PIN (trop d'erreurs) : bandeau d'aide affiché pour les clés TPM.</summary>
|
||||
public bool IsTpmLocked
|
||||
{
|
||||
get => _isTpmLocked;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isTpmLocked, value)) return;
|
||||
OnPropertyChanged(nameof(ShowTpmLockout));
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowTpmLockout => IsTpmLocked && IsTpmKey;
|
||||
|
||||
/// <summary>« De nouveau utilisable dans 9 min 30 s au plus… », ou l'explication si la durée est inconnue.</summary>
|
||||
public string? LockoutText
|
||||
{
|
||||
get => _lockoutText;
|
||||
private set => SetProperty(ref _lockoutText, value);
|
||||
}
|
||||
|
||||
private bool LockoutBlocksLogin => _isTpmLocked && _lockoutUntil is { } until && until > _shell.Environment.Time.GetUtcNow();
|
||||
|
||||
public RelayCommand BrowseKeyCommand { get; }
|
||||
public RelayCommand ShowEnrollCommand { get; }
|
||||
public RelayCommand RefreshCommand { get; }
|
||||
public RelayCommand OpenKeysFolderCommand { get; }
|
||||
|
||||
/// <summary>Supprime définitivement la clé de la puce sélectionnée (clés TPM uniquement).</summary>
|
||||
public AsyncRelayCommand DeleteTpmKeyCommand { get; }
|
||||
|
||||
/// <summary>Relit l'état de la protection anti-essais de la puce.</summary>
|
||||
public AsyncRelayCommand RecheckLockoutCommand { get; }
|
||||
|
||||
/// <summary>Dernière lecture de l'état de la puce (tests).</summary>
|
||||
internal Task LockoutCheck { get; private set; } = Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Connexion
|
||||
|
||||
/// <summary>Connexion avec la clé choisie. La vue efface la phrase de passe (ou le code) juste après l'appel.</summary>
|
||||
public async Task LoginAsync(string passphrase)
|
||||
{
|
||||
if (SelectedKey is not { } key || IsBusy) return;
|
||||
if (string.IsNullOrEmpty(passphrase))
|
||||
{
|
||||
ErrorMessage = key.IsTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.";
|
||||
return;
|
||||
}
|
||||
if (!CanLogin) return;
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
AdminConnection? connection = null;
|
||||
TpmPinException? pinError = null;
|
||||
try
|
||||
{
|
||||
var environment = _shell.Environment;
|
||||
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm, environment.RootKeys).LoginAsync(key.Path, passphrase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ApiErrorText.Describe(ex, duringLogin: true);
|
||||
if (ex is ApiException api) Log.Warn($"Administration : connexion refusée ({api.Status} {api.Code}).");
|
||||
pinError = ex as TpmPinException;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
|
||||
if (connection is null)
|
||||
{
|
||||
if (pinError is not null) await OnTpmPinRefusedAsync(pinError);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Administration : connexion de {connection.Session.Profile.DisplayName} ({connection.Session.Profile.Role}).");
|
||||
_shell.Settings.Remember(key.Path); // chemin déjà normalisé (AdminKeyLocator)
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath); // erreurs d'écriture journalisées, sans effet
|
||||
try
|
||||
{
|
||||
await _shell.OpenWorkspaceAsync(connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Administration : ouverture de l'espace d'administration", ex);
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Code refusé : essais restants, ou décompte jusqu'à la fin du blocage.</summary>
|
||||
private async Task OnTpmPinRefusedAsync(TpmPinException error)
|
||||
{
|
||||
var info = await CheckLockoutAsync(error.IsLockedOut);
|
||||
if (_isTpmLocked)
|
||||
{
|
||||
ErrorMessage = null; // le bandeau du blocage explique tout
|
||||
return;
|
||||
}
|
||||
if (info is { MaxTries: > 0 })
|
||||
ErrorMessage = $"Code PIN incorrect. Encore {info.RemainingTries} essai(s) avant le blocage temporaire de la puce " +
|
||||
"(compteur commun à la puce : les codes faux de Windows Hello comptent aussi).";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Protection anti-essais
|
||||
|
||||
/// <summary>
|
||||
/// Lit l'état de la puce et met à jour le bandeau : bloquée avec une fin estimée (décompte),
|
||||
/// bloquée sans estimation possible, ou débloquée.
|
||||
/// </summary>
|
||||
/// <param name="knownLocked">La puce vient de répondre « bloquée » (même si l'état n'est pas lisible).</param>
|
||||
/// <param name="quiet">Vérification de fond : rien n'est affiché si la puce n'est pas bloquée.</param>
|
||||
private Task<TpmLockoutInfo?> CheckLockoutAsync(bool knownLocked, bool quiet = false)
|
||||
{
|
||||
// Une lecture à la fois, dans l'ordre des demandes (une lecture de fond ne masque pas un blocage signalé).
|
||||
var task = CheckAfterAsync(LockoutCheck, knownLocked, quiet);
|
||||
LockoutCheck = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
private async Task<TpmLockoutInfo?> CheckAfterAsync(Task previous, bool knownLocked, bool quiet)
|
||||
{
|
||||
try
|
||||
{
|
||||
await previous;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// erreur déjà journalisée par la lecture précédente
|
||||
}
|
||||
return await CheckCoreAsync(knownLocked, quiet);
|
||||
}
|
||||
|
||||
private async Task<TpmLockoutInfo?> CheckCoreAsync(bool knownLocked, bool quiet)
|
||||
{
|
||||
if (_shell.Environment.Tpm is not { } tpm || _disposed) return null;
|
||||
TpmLockoutInfo? info = null;
|
||||
try
|
||||
{
|
||||
info = await BackgroundWork.RunAsync(tpm.GetLockoutInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : état de la protection anti-essais de la puce illisible ({ex.Message}).");
|
||||
}
|
||||
if (_disposed) return info;
|
||||
|
||||
var wasLocked = _isTpmLocked;
|
||||
var locked = info?.IsLockedOut ?? knownLocked;
|
||||
if (!locked)
|
||||
{
|
||||
StopLockout();
|
||||
if (wasLocked && !quiet) StatusMessage = "La puce de sécurité accepte de nouveau les codes PIN : vous pouvez réessayer.";
|
||||
return info;
|
||||
}
|
||||
|
||||
var time = _shell.Environment.Time;
|
||||
_lockoutHeals = info?.HealsWithTime ?? true;
|
||||
_lockoutInterval = info?.RecoveryInterval ?? TimeSpan.Zero;
|
||||
_lockoutUntil = info is { HealsWithTime: true } ? time.GetUtcNow() + info.MaxWait : null;
|
||||
IsTpmLocked = true;
|
||||
if (_lockoutUntil is not null && _lockoutTimer is null && _shell.Environment.StartTimers)
|
||||
_lockoutTimer = time.CreateTimer(_ => _shell.Environment.Ui.Post(() => _ = TickLockoutAsync()), null, LockoutTick, LockoutTick);
|
||||
UpdateLockoutText();
|
||||
Log.Info(info is null
|
||||
? "Administration : puce TPM bloquée (protection anti-essais), durée inconnue."
|
||||
: $"Administration : puce TPM bloquée ({info.Failures}/{info.MaxTries} erreurs, une erreur oubliée toutes les {info.RecoveryInterval}).");
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>Décompte (chaque seconde ; appelé directement par les tests).</summary>
|
||||
internal async Task TickLockoutAsync()
|
||||
{
|
||||
if (!_isTpmLocked || _disposed || !LockoutCheck.IsCompleted) return;
|
||||
UpdateLockoutText();
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
// Fin estimée atteinte : la puce le confirme (sinon, nouvelle estimation).
|
||||
if (_lockoutUntil is { } until && _shell.Environment.Time.GetUtcNow() >= until) await CheckLockoutAsync(knownLocked: false);
|
||||
}
|
||||
|
||||
private void UpdateLockoutText()
|
||||
{
|
||||
if (!_lockoutHeals)
|
||||
{
|
||||
LockoutText = "La puce de sécurité est bloquée après trop de codes PIN faux, et ne se débloque pas d'elle-même sur ce PC : " +
|
||||
"un administrateur de ce PC doit réinitialiser sa protection anti-essais. En attendant, utilisez une clé « fichier ».";
|
||||
return;
|
||||
}
|
||||
if (_lockoutUntil is not { } until)
|
||||
{
|
||||
LockoutText = "La puce de sécurité est temporairement bloquée après trop de codes PIN faux. Elle se débloque d'elle-même " +
|
||||
"au bout d'un moment (de quelques minutes à quelques heures selon le PC), uniquement PC allumé. " +
|
||||
"Réessayez plus tard, ou utilisez une clé « fichier ».";
|
||||
return;
|
||||
}
|
||||
var remaining = until - _shell.Environment.Time.GetUtcNow();
|
||||
LockoutText = remaining > TimeSpan.Zero
|
||||
? $"La puce de sécurité est bloquée après trop de codes PIN faux. Elle sera de nouveau utilisable dans " +
|
||||
$"{FormatWait(remaining)} au plus (une erreur est oubliée toutes les {FormatWait(_lockoutInterval)}, PC allumé)."
|
||||
: "Vérification de la puce…";
|
||||
}
|
||||
|
||||
private void StopLockout()
|
||||
{
|
||||
_lockoutTimer?.Dispose();
|
||||
_lockoutTimer = null;
|
||||
_lockoutUntil = null;
|
||||
LockoutText = null;
|
||||
IsTpmLocked = false;
|
||||
}
|
||||
|
||||
/// <summary>« 9 min 05 s », « 45 s », « 1 h 30 min ».</summary>
|
||||
internal static string FormatWait(TimeSpan wait)
|
||||
{
|
||||
if (wait < TimeSpan.Zero) wait = TimeSpan.Zero;
|
||||
var seconds = (long)Math.Ceiling(wait.TotalSeconds);
|
||||
if (seconds < 60) return $"{seconds} s";
|
||||
if (seconds < 3600) return seconds % 60 == 0 ? $"{seconds / 60} min" : $"{seconds / 60} min {seconds % 60:00} s";
|
||||
var minutes = (seconds + 59) / 60;
|
||||
return minutes % 60 == 0 ? $"{minutes / 60} h" : $"{minutes / 60} h {minutes % 60:00} min";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Suppression d'une clé TPM
|
||||
|
||||
private async Task DeleteTpmKeyAsync()
|
||||
{
|
||||
if (SelectedKey is not { IsTpm: true } key || IsBusy) return;
|
||||
if (_shell.Environment.Tpm is not { } tpm)
|
||||
{
|
||||
ErrorMessage = "La puce de sécurité (TPM) n'est pas accessible sur ce PC : la clé ne peut pas être supprimée ici.";
|
||||
return;
|
||||
}
|
||||
|
||||
var dialogs = _shell.Environment.Dialogs;
|
||||
if (!dialogs.Confirm("Supprimer la clé de la puce ?",
|
||||
$"La clé « {key.Name} » ({key.Detail}) sera effacée DÉFINITIVEMENT de la puce de sécurité de ce PC, " +
|
||||
$"avec ses fichiers {Path.GetFileName(key.Path)} et {Path.GetFileNameWithoutExtension(key.Path)}.pub.\n\n" +
|
||||
"Elle ne pourra plus jamais servir à se connecter, sur aucun PC. Si c'était votre seule clé, il faudra une " +
|
||||
"nouvelle invitation (ou la commande add-key sur le serveur).\n\n" +
|
||||
$"Pensez aussi à la révoquer sur le serveur (identifiant de clé {key.KeyId ?? "inconnu"}).",
|
||||
"Supprimer définitivement", "Annuler", danger: true))
|
||||
return;
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
var reference = AdminTpmKeyFile.Load(key.Path);
|
||||
await BackgroundWork.RunAsync(() =>
|
||||
{
|
||||
tpm.DeleteKey(reference.KeyName);
|
||||
return true;
|
||||
});
|
||||
var left = AdminTpmKeyFile.DeleteFiles(key.Path);
|
||||
_shell.Settings.Forget(key.Path);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
Log.Info($"Administration : clé TPM {key.KeyId} supprimée de la puce ({key.Path}).");
|
||||
|
||||
StatusMessage = $"Clé « {key.Name} » supprimée de la puce de ce PC. Révoquez-la aussi sur le serveur " +
|
||||
$"(identifiant de clé {key.KeyId ?? "inconnu"} : commande revoke-key sur le VPS)." +
|
||||
(left.Count == 0 ? "" : $" Fichier(s) à supprimer à la main : {string.Join(", ", left)}.");
|
||||
LoadKeys(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : suppression de la clé TPM impossible ({ex.Message}).");
|
||||
ErrorMessage = $"Suppression impossible : {ApiErrorText.Describe(ex)}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Clés
|
||||
|
||||
private void LoadKeys(string? select)
|
||||
{
|
||||
Keys.Clear();
|
||||
foreach (var entry in AdminKeyLocator.Find(KeysDirectory, _shell.Settings.KeyPaths)) Keys.Add(entry);
|
||||
SelectedKey = Keys.FirstOrDefault(k => string.Equals(k.Path, select, StringComparison.OrdinalIgnoreCase)) ?? Keys.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(HasKeys));
|
||||
}
|
||||
|
||||
private void BrowseKey()
|
||||
{
|
||||
var path = _shell.Environment.Dialogs.PickOpenFile("Choisir votre clé d'administrateur",
|
||||
"Clé d'administrateur (*.key, *.tpmkey)|*.key;*.tpmkey", Directory.Exists(KeysDirectory) ? KeysDirectory : null);
|
||||
if (path is null) return;
|
||||
|
||||
if (AdminKeyLocator.IsReserved(path))
|
||||
{
|
||||
ErrorMessage = "Ce fichier est la clé de signature du launcher : choisissez votre clé d'administrateur.";
|
||||
return;
|
||||
}
|
||||
if (AdminKeyLocator.Describe(path) is not { } entry)
|
||||
{
|
||||
ErrorMessage = "Ce fichier n'est pas une clé d'administrateur (fichier .key chiffré créé par le launcher ou tools\\admin-key.bat, " +
|
||||
"ou référence .tpmkey d'une clé de la puce de ce PC).";
|
||||
return;
|
||||
}
|
||||
ErrorMessage = null;
|
||||
_shell.Settings.Remember(entry.Path);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
LoadKeys(entry.Path);
|
||||
}
|
||||
|
||||
private void OpenKeysFolder()
|
||||
{
|
||||
Directory.CreateDirectory(KeysDirectory);
|
||||
_shell.Environment.Dialogs.OpenFolder(KeysDirectory);
|
||||
}
|
||||
|
||||
/// <summary>Écran quitté : arrêt du décompte.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_lockoutTimer?.Dispose();
|
||||
_lockoutTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Page du menu d'administration déclarée par un module.</summary>
|
||||
/// <param name="Id">Identifiant unique, ex. « admin.dashboard », « catalog.games ».</param>
|
||||
/// <param name="Title">Titre affiché dans la navigation.</param>
|
||||
/// <param name="Description">Sous-titre affiché en haut de la page.</param>
|
||||
/// <param name="IconKey">Icône du launcher (ressource « Icon.<clé> » de Themes/Icons.xaml).</param>
|
||||
/// <param name="RequiredPermission">Permission nécessaire (null : tout administrateur connecté).</param>
|
||||
/// <param name="Order">Position dans la navigation (croissante).</param>
|
||||
/// <param name="CreateViewModel">Crée le ViewModel de la page (son affichage est un DataTemplate du launcher).</param>
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id,
|
||||
string Title,
|
||||
string Description,
|
||||
string IconKey,
|
||||
string? RequiredPermission,
|
||||
int Order,
|
||||
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
@@ -0,0 +1,78 @@
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Base des pages d'administration : état « occupé », message d'erreur, gestion de la session perdue.</summary>
|
||||
public abstract class AdminPanelViewModel : ObservableObject
|
||||
{
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private bool _activated;
|
||||
|
||||
protected AdminPanelViewModel(IAdminContext context) => Context = context;
|
||||
|
||||
public IAdminContext Context { get; }
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value)) OnBusyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>« Occupé » a changé : réévaluer les commandes de la page (RaiseCanExecuteChanged).</summary>
|
||||
protected virtual void OnBusyChanged()
|
||||
{
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
|
||||
public Task ActivateAsync()
|
||||
{
|
||||
var first = !_activated;
|
||||
_activated = true;
|
||||
return OnActivatedAsync(first);
|
||||
}
|
||||
|
||||
protected virtual Task OnActivatedAsync(bool firstTime) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Exécute une action réseau : « occupé » pendant l'action, erreur affichée en cas d'échec,
|
||||
/// retour à la connexion si la session a expiré. Vrai si l'action a réussi.
|
||||
/// </summary>
|
||||
protected async Task<bool> RunAsync(Func<Task> action)
|
||||
{
|
||||
if (IsBusy) return false;
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ApiErrorText.IsSessionLost(ex))
|
||||
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
|
||||
else
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Ce qu'une page d'administration reçoit : session, jeux déclarés, dialogues, navigation.</summary>
|
||||
public interface IAdminContext
|
||||
{
|
||||
/// <summary>Session ouverte : lectures avec GetAsync, modifications signées avec SendSignedAsync.</summary>
|
||||
AdminSession Session { get; }
|
||||
|
||||
LauncherApiClient Client { get; }
|
||||
|
||||
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier), valeurs par défaut.</summary>
|
||||
IReadOnlyList<LauncherConfig> Games { get; }
|
||||
|
||||
/// <summary>Nom du launcher selon launcher.json.</summary>
|
||||
string LauncherName { get; }
|
||||
|
||||
/// <summary>Jeu par défaut selon launcher.json.</summary>
|
||||
string? DefaultGameId { get; }
|
||||
|
||||
/// <summary>Thèmes intégrés au launcher.</summary>
|
||||
IReadOnlyList<string> Themes { get; }
|
||||
|
||||
IModuleDialogs Dialogs { get; }
|
||||
|
||||
string LauncherVersion { get; }
|
||||
|
||||
/// <summary>Clés racine intégrées à l'exe (vérification du trousseau avant sa publication).</summary>
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; }
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
void Navigate(string panelId);
|
||||
|
||||
/// <summary>La session n'est plus valable : retour à l'écran de connexion avec ce message.</summary>
|
||||
void ReportSessionLost(string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Opération longue en cours (envoi d'une version…) : tant que l'objet renvoyé n'est pas libéré,
|
||||
/// elle compte comme une activité (pas de verrouillage pour inactivité, session prolongée). Le
|
||||
/// verrouillage manuel, celui de Windows et la durée maximale de la session restent appliqués.
|
||||
/// </summary>
|
||||
IDisposable BeginLongOperation();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
/// <summary>Message clair (français) pour une erreur survenue dans un module d'administration.</summary>
|
||||
public static class ApiErrorText
|
||||
{
|
||||
/// <param name="duringLogin">Vrai pendant la connexion : un refus vise la clé, pas la session.</param>
|
||||
public static string Describe(Exception ex, bool duringLogin = false) => ex switch
|
||||
{
|
||||
ApiException { Status: 0, Code: "unreachable" } =>
|
||||
"Serveur d'administration injoignable. Vérifiez votre connexion Internet, puis réessayez.",
|
||||
ApiException { Status: 0, Code: "timeout" } => "Le serveur d'administration ne répond pas. Réessayez dans un instant.",
|
||||
ApiException { Status: 401 } api when duringLogin && api.Code == ApiErrorCodes.Unauthorized =>
|
||||
"Connexion refusée : cette clé n'est pas reconnue par le serveur, est encore en attente d'approbation, ou a été révoquée.",
|
||||
ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized } => "Session expirée : reconnectez-vous.",
|
||||
ApiException api => api.Message,
|
||||
CryptographicException crypto => crypto.Message,
|
||||
FileNotFoundException missing => $"Fichier introuvable : {missing.FileName ?? missing.Message}",
|
||||
IOException or UnauthorizedAccessException => $"Fichier inaccessible : {ex.Message}",
|
||||
ArgumentException or InvalidOperationException => ex.Message,
|
||||
OperationCanceledException => "Opération annulée.",
|
||||
_ => $"Erreur inattendue : {ex.Message}"
|
||||
};
|
||||
|
||||
/// <summary>La session n'est plus valable (expirée, révoquée) : il faut se reconnecter.</summary>
|
||||
public static bool IsSessionLost(Exception ex) =>
|
||||
ex is ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Socle des modules du launcher (sans WPF) : contrat d'un module, registre, pages du menu
|
||||
d'administration, boîtes de dialogue abstraites, MVVM commun. Chaque module (Administration,
|
||||
puis Actualités, Amis…) est une bibliothèque qui référence ce projet : sa logique et ses
|
||||
ViewModels sont testables sans interface ; le launcher (WPF) n'en fournit que l'affichage.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md, § 5.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
<ProjectReference Include="..\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,74 @@
|
||||
using GameLauncher.Modules.Admin;
|
||||
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Module du launcher. Il déclare ce qu'il apporte au registre ; il ne connaît ni les fenêtres ni
|
||||
/// les autres modules. Les modules actifs sont listés dans App (launcher WPF).
|
||||
/// </summary>
|
||||
public interface ILauncherModule
|
||||
{
|
||||
/// <summary>Identifiant stable, ex. « admin », « news ».</summary>
|
||||
string Id { get; }
|
||||
|
||||
string DisplayName { get; }
|
||||
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
/// <summary>Ce qu'un module peut ajouter au launcher.</summary>
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
/// <summary>Page du menu d'administration (affichée seulement aux administrateurs ayant la permission requise).</summary>
|
||||
void AddAdminPanel(AdminPanelDescriptor panel);
|
||||
}
|
||||
|
||||
/// <summary>Modules actifs et ce qu'ils ont déclaré.</summary>
|
||||
public sealed class ModuleCatalog : IModuleRegistry
|
||||
{
|
||||
private readonly List<AdminPanelDescriptor> _adminPanels = new();
|
||||
private ILauncherModule? _registering;
|
||||
|
||||
public ModuleCatalog(IEnumerable<ILauncherModule> modules)
|
||||
{
|
||||
var list = new List<ILauncherModule>();
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(module.Id) || list.Any(m => string.Equals(m.Id, module.Id, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Identifiant de module invalide ou en double : « {module.Id} ».");
|
||||
list.Add(module);
|
||||
_registering = module;
|
||||
try
|
||||
{
|
||||
module.Register(this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_registering = null;
|
||||
}
|
||||
}
|
||||
Modules = list;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ILauncherModule> Modules { get; }
|
||||
|
||||
/// <summary>Toutes les pages d'administration, dans l'ordre d'affichage.</summary>
|
||||
public IReadOnlyList<AdminPanelDescriptor> AdminPanels =>
|
||||
_adminPanels.OrderBy(p => p.Order).ThenBy(p => p.Title, StringComparer.CurrentCulture).ToList();
|
||||
|
||||
/// <summary>Pages visibles pour un administrateur (selon ses permissions ; le serveur reste seul juge).</summary>
|
||||
public IReadOnlyList<AdminPanelDescriptor> AdminPanelsFor(IReadOnlyCollection<string> permissions) =>
|
||||
AdminPanels.Where(p => p.RequiredPermission is null || permissions.Contains(p.RequiredPermission)).ToList();
|
||||
|
||||
void IModuleRegistry.AddAdminPanel(AdminPanelDescriptor panel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(panel);
|
||||
if (_registering is null) throw new InvalidOperationException("Les pages se déclarent dans ILauncherModule.Register.");
|
||||
if (string.IsNullOrWhiteSpace(panel.Id) || _adminPanels.Any(p => string.Equals(p.Id, panel.Id, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Page d'administration invalide ou en double : « {panel.Id} » (module {_registering.Id}).");
|
||||
if (panel.RequiredPermission is { } permission &&
|
||||
(permission.Length > 64 || permission.Count(c => c == '.') != 1 || permission.StartsWith('.') || permission.EndsWith('.')))
|
||||
throw new InvalidOperationException($"Permission invalide pour la page « {panel.Id} » : « {permission} » (format module.action).");
|
||||
_adminPanels.Add(panel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
public enum ModuleMessageKind
|
||||
{
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>Boîtes de dialogue et actions système offertes aux modules (implémentées par le launcher WPF).</summary>
|
||||
public interface IModuleDialogs
|
||||
{
|
||||
void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info);
|
||||
|
||||
bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false);
|
||||
|
||||
/// <param name="filter">Filtre au format Windows, ex. « Clé d'administrateur (*.key)|*.key ».</param>
|
||||
string? PickOpenFile(string title, string filter, string? initialDirectory);
|
||||
|
||||
string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory);
|
||||
|
||||
void CopyToClipboard(string text);
|
||||
|
||||
void OpenFolder(string path);
|
||||
}
|
||||
|
||||
/// <summary>Exécution sur le fil de l'interface (minuteries, notifications venues d'un autre fil).</summary>
|
||||
public interface IUiDispatcher
|
||||
{
|
||||
void Post(Action action);
|
||||
}
|
||||
|
||||
/// <summary>Exécution immédiate (tests, outils en ligne de commande).</summary>
|
||||
public sealed class ImmediateDispatcher : IUiDispatcher
|
||||
{
|
||||
public static readonly ImmediateDispatcher Instance = new();
|
||||
|
||||
public void Post(Action action) => action();
|
||||
}
|
||||
File renamed without changes.
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user