Compare commits
63
Commits
dev/1.2
...
admin/phase4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
920d215fdd | ||
|
|
22468122e0 | ||
|
|
c2c67a63f2 | ||
|
|
d5aa875178 | ||
|
|
b9fea61e64 | ||
|
|
14a76927e1 | ||
|
|
5e68c4b16a | ||
|
|
e5b9ebf3ff | ||
|
|
fecf95ae3e | ||
|
|
3a788c18d2 | ||
|
|
dbe5d9051d | ||
|
|
ae2a5a4ab7 | ||
|
|
e8e08f792e | ||
|
|
c41bcda853 | ||
|
|
e41c3a977e | ||
|
|
f5e6c99aca | ||
|
|
35664045ce | ||
|
|
ad5d92ee8b | ||
|
|
047ec0d1db | ||
|
|
6243bd2574 | ||
|
|
7ee4d84dd2 | ||
|
|
f4ebb680ef | ||
|
|
756a76582d | ||
|
|
b5b922019c | ||
|
|
7ee8da3e67 | ||
|
|
0e8c918922 | ||
|
|
e2f2ae5817 | ||
|
|
9dda708c94 | ||
|
|
a17f0efcd7 |
No files matched your search
@@ -61,3 +61,10 @@
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
|
||||
###############################################################################
|
||||
# Fichiers executes sur le VPS (Linux) : fins de ligne LF obligatoires
|
||||
###############################################################################
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
@@ -412,3 +412,8 @@ FodyWeavers.xsd
|
||||
# Clé PRIVÉE de signature des mises à jour du launcher : ne jamais versionner
|
||||
launcher-signing.key
|
||||
*.download
|
||||
|
||||
# Cles privees (signature du launcher, administrateurs) : jamais dans Git
|
||||
*.key
|
||||
# Donnees locales de la Launcher API (F5)
|
||||
data-dev/
|
||||
@@ -28,9 +28,41 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
server-example\launcher\publier-launcher.txt = server-example\launcher\publier-launcher.txt
|
||||
server-example\launcher\preparer-vps.sh = server-example\launcher\preparer-vps.sh
|
||||
README.md = README.md
|
||||
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
|
||||
tools\admin-key.bat = tools\admin-key.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "server", "server", "{E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF}"
|
||||
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
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Contracts", "src\GameLauncher.Contracts\GameLauncher.Contracts.csproj", "{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.ApiClient", "src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj", "{3BB233EF-1252-4D1D-9808-9C75EB393065}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Server", "server\GameLauncher.Server\GameLauncher.Server.csproj", "{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\AdminKeyTool\AdminKeyTool.csproj", "{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}"
|
||||
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
|
||||
@@ -71,6 +103,62 @@ Global
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.ActiveCfg = Release|x64
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.Build.0 = Release|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.Build.0 = Debug|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.ActiveCfg = Release|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.Build.0 = Release|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.Build.0 = Debug|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.ActiveCfg = Release|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.Build.0 = Release|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.Build.0 = Debug|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.ActiveCfg = Release|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.Build.0 = Release|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.Build.0 = Debug|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.ActiveCfg = Release|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.Build.0 = Release|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.Build.0 = Debug|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{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
|
||||
@@ -80,6 +168,14 @@ Global
|
||||
{7384678B-D7F7-4682-B99D-2DDCBC605BA4} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{35F7D646-FAC8-46A6-ACC8-0E22AE78B603} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
{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,9 +32,16 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| Projet | Rôle |
|
||||
|---|---|
|
||||
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue et notes de version (modèle et limites) (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 ») et « Notes de version » (`Changelog/`) (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 |
|
||||
|
||||
```
|
||||
src/GameLauncher/
|
||||
@@ -42,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)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont les pages « Jeux » et « Notes de 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)
|
||||
│ 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)
|
||||
@@ -62,12 +72,20 @@ 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.
|
||||
|
||||
### Format de `notes.txt`
|
||||
|
||||
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
|
||||
@@ -161,6 +179,13 @@ Le raccourci créé à l'installation pointe vers **le launcher** (`GameLauncher
|
||||
pas vers le jeu : les mises à jour sont ainsi toujours vérifiées. Un raccourci « du jeu » est
|
||||
reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bureau de l'utilisateur).
|
||||
|
||||
* **Créer le raccourci** : à l'installation (case à cocher), ou à tout moment depuis le menu **« … »** ›
|
||||
*Paramètres du jeu* › bloc *Raccourci* (« Créer le raccourci », ou « Mettre à jour » s'il existe déjà).
|
||||
* **Icône** : celle de l'exécutable du jeu (dossier d'installation et exécutable lus dans le registre),
|
||||
ou celle du launcher si le jeu n'est pas installé ou introuvable. Elle suit le jeu s'il est déplacé
|
||||
(localisation, réparation) ; les raccourcis créés par une version précédente prennent l'icône du jeu
|
||||
au démarrage du launcher. Une icône choisie par le joueur n'est jamais remplacée.
|
||||
|
||||
| Situation | Comportement |
|
||||
|---|---|
|
||||
| Raccourci ouvert, launcher **fermé** | Le launcher s'ouvre sur ce jeu, vérifie les mises à jour (et les installe si la mise à jour automatique du jeu est activée), puis **lance le jeu** s'il est prêt, en appliquant le réglage *Lancement du jeu* (réduire / fermer le launcher). Sinon le bandeau explique pourquoi (mise à jour manuelle à faire, réparation, jeu non installé). Hors ligne, le jeu installé est lancé. |
|
||||
@@ -168,7 +193,8 @@ reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bur
|
||||
| Raccourci vers un **autre exemplaire** du launcher, encore présent (copie) | Rien d'automatique ; **Réparer** propose de le rediriger (confirmation) |
|
||||
| Icône personnalisée par le joueur | Conservée lors de la redirection |
|
||||
| Raccourci ouvert alors que le launcher est **déjà ouvert** | La demande est transmise au launcher ouvert : il revient au premier plan sur ce jeu et **lance le jeu** s'il est prêt et pas déjà en cours d'exécution (sinon le bandeau explique pourquoi : mise à jour, réparation, opération en cours…). Rien n'est lancé si une boîte de dialogue est ouverte. |
|
||||
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert. » (inchangé) |
|
||||
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert » (qui rappelle de regarder dans la zone de notification) |
|
||||
| Raccourci ouvert alors que le launcher est **réduit dans la zone de notification** | La fenêtre est réaffichée, puis même comportement que « déjà ouvert » |
|
||||
|
||||
Tant que le launcher n'a pas été relancé depuis son nouvel emplacement, un raccourci vers l'ancien
|
||||
emplacement ne fonctionne pas (Windows le signale, et le retrouve parfois tout seul). Le chemin du
|
||||
@@ -193,17 +219,51 @@ version, une mise à jour (différentielle) est proposée.
|
||||
### Désinstallation
|
||||
Depuis le menu « … » ou depuis **Paramètres Windows › Applications installées** (le launcher est
|
||||
relancé avec `--uninstall <GameId>`). Par défaut seuls les fichiers installés par le launcher sont
|
||||
supprimés ; une option permet de supprimer tout le dossier (refusé pour les dossiers sensibles :
|
||||
racine de disque, Program Files, Documents…).
|
||||
supprimés : les sauvegardes et réglages du jeu (`%APPDATA%\<Société>\<Produit>`) et les fichiers
|
||||
personnels du dossier d'installation sont conservés.
|
||||
|
||||
L'option **« Supprimer aussi les sauvegardes, les réglages, les journaux et tous les autres fichiers du jeu »**
|
||||
(proposée aussi quand les fichiers du jeu ont déjà disparu) supprime en plus :
|
||||
|
||||
| Élément | Emplacement |
|
||||
|---|---|
|
||||
| Tout le dossier d'installation | refusé pour les dossiers sensibles : racine de disque, Program Files, Documents… |
|
||||
| Sauvegardes et réglages | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` |
|
||||
| Journaux de Unity (`Player.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` |
|
||||
| Réglages d'affichage de Unity (résolution, plein écran, position de la fenêtre) | `HKCU\Software\LanisysGames\Exovival Zombie Reborn` |
|
||||
|
||||
Garde-fous : chemin « Société\Produit » complet uniquement (`gameDataFolder`, jamais le dossier de la
|
||||
société entier), jamais le dossier ni la clé du launcher (`GameLauncher`) ni le dossier d'installation ;
|
||||
dossier et clé de la société retirés seulement s'ils sont vides. Ce qui n'a pas pu être supprimé (fichier
|
||||
ouvert) est indiqué dans le bandeau et le journal.
|
||||
|
||||
**Launcher placé dans un dossier supprimé** — dossier du jeu, ou (avec l'option) dossier des sauvegardes dans
|
||||
Roaming ou des journaux dans LocalLow, à toute profondeur : la confirmation l'indique (« Le launcher se trouve
|
||||
dans ce dossier / dans le dossier des données du jeu : il sera conservé. ») et ses fichiers sont épargnés :
|
||||
son exécutable, `launcher.json`, les fichiers temporaires de sa mise à jour (`GameLauncher.exe.old`,
|
||||
`.download`…) et, en version de développement, les bibliothèques chargées depuis son dossier. Tout le reste
|
||||
est supprimé, les sous-dossiers vides sont retirés, et le bandeau final indique où le launcher a été conservé.
|
||||
Les trois dossiers suivent la même règle (`FileUtil.DeleteDirectoryContents`).
|
||||
|
||||
**Fichier bloqué** (jeu mal fermé, antivirus…) : la désinstallation ne s'interrompt plus. Les fichiers sont
|
||||
supprimés un par un ; ceux qui ne peuvent pas l'être sont signalés (bandeau + liste complète dans le journal),
|
||||
puis registre, raccourci et données du jeu sont traités normalement.
|
||||
|
||||
### Registre (HKEY_CURRENT_USER, aucun droit administrateur requis)
|
||||
```
|
||||
HKCU\Software\<Publisher>\<GameId>
|
||||
InstallPath, Version, Executable, InstallDate, LauncherPath
|
||||
HKCU\Software\<Publisher>\GameLauncher (ex. …\LanisysGames\GameLauncher)
|
||||
LauncherPath emplacement du launcher (une seule fois)
|
||||
HKCU\Software\<Publisher>\GameLauncher\Games\<GameId> (ex. …\GameLauncher\Games\EZR)
|
||||
InstallPath, Version, Executable, InstallDate
|
||||
HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<Publisher>.<GameId>
|
||||
DisplayName, DisplayVersion, Publisher, InstallLocation, DisplayIcon,
|
||||
UninstallString ("GameLauncher.exe" --uninstall <GameId>), EstimatedSize…
|
||||
```
|
||||
* `LauncherPath` est écrit une seule fois pour tous les jeux, à l'installation d'un jeu et à chaque démarrage
|
||||
(s'il y a au moins un jeu installé), et supprimé à la désinstallation du dernier jeu. Seul le launcher
|
||||
réellement ouvert l'écrit : un second launcher, arrêté par « Le launcher est déjà ouvert » (ou qui transmet
|
||||
un raccourci au launcher ouvert), ne touche pas au registre. Il est informatif : la vérification des
|
||||
raccourcis compare la cible de chaque raccourci à l'exécutable en cours, pas au registre.
|
||||
|
||||
### Paramètres
|
||||
|
||||
@@ -211,19 +271,65 @@ Deux fenêtres distinctes :
|
||||
|
||||
| Accès | Contenu | Portée |
|
||||
|---|---|---|
|
||||
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire / fermer le launcher pendant la partie), dossier des journaux | Commun à tous les jeux |
|
||||
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Informations* (versions, taille, dossier du jeu, journaux) | Jeu sélectionné uniquement |
|
||||
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire — dans la barre des tâches ou la zone de notification — ou fermer le launcher pendant la partie), bloc *Fermeture du launcher* (la croix ferme ou réduit dans la zone de notification), dossier des journaux | Commun à tous les jeux |
|
||||
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Raccourci* (créer / mettre à jour le raccourci du Bureau), bloc *Informations* (versions, taille ; boutons *dossier du jeu*, *dossier des journaux* du jeu dans LocalLow, *dossier des données* du jeu dans Roaming) | Jeu sélectionné uniquement |
|
||||
|
||||
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
|
||||
sont repris de ceux du jeu affiché par défaut.
|
||||
|
||||
### Zone de notification (icônes cachées, près de l'horloge)
|
||||
|
||||
Deux options des *Paramètres du launcher* (désactivées par défaut) :
|
||||
|
||||
| Option | Effet |
|
||||
|---|---|
|
||||
| *Lancement du jeu* › Réduire le launcher pendant la partie › **Dans la zone de notification** | Pendant la partie, la fenêtre est masquée et une icône apparaît près de l'horloge ; la fenêtre revient à la fermeture du jeu |
|
||||
| *Fermeture du launcher* › **La croix réduit le launcher dans la zone de notification** | La croix (et Alt+F4) masque la fenêtre au lieu de quitter : téléchargements, mises à jour et surveillance du jeu continuent |
|
||||
|
||||
L'icône n'est affichée que lorsque la fenêtre est masquée. **Clic gauche** : réouvre le launcher.
|
||||
**Clic droit** : menu *Ouvrir* / *Quitter* (même style arrondi que les autres menus, couleurs du thème
|
||||
du jeu affiché). *Quitter* demande confirmation si une opération est en cours (la fenêtre est alors
|
||||
réaffichée). À la fermeture de session ou à l'arrêt de Windows, le launcher se ferme normalement.
|
||||
L'icône est gérée par l'API Windows (`Shell_NotifyIcon`, `Platform/TrayIcon.cs`), sans Windows Forms,
|
||||
et réapparaît si l'Explorateur Windows redémarre. Le menu est une petite fenêtre (`Views/TrayMenuWindow`)
|
||||
et non un `ContextMenu` WPF : launcher masqué, un `ContextMenu` ne peut pas capturer la souris et se
|
||||
referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) ou avec Échap.
|
||||
|
||||
### Autres fichiers
|
||||
* Paramètres d'un jeu : `%AppData%\<Publisher>\<GameId>Launcher\settings.json`
|
||||
* Paramètres du launcher (communs) : `%AppData%\<Publisher>\<LauncherId>\launcher-settings.json`
|
||||
(en mono-jeu, `<LauncherId>` = `<GameId>Launcher` : même dossier que ci-dessus)
|
||||
* Journaux : `%AppData%\<Publisher>\<GameId>Launcher\logs\launcher.log` (accessibles depuis les deux fenêtres de paramètres) ;
|
||||
en multi-jeux : `%AppData%\<Publisher>\<launcherId>\logs\` (+ `launcher-state.json` : dernier jeu affiché)
|
||||
```
|
||||
%AppData%\<Publisher>\GameLauncher (ex. %AppData%\LanisysGames\GameLauncher)
|
||||
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
|
||||
├─ self-update.json mise à jour du launcher (§7)
|
||||
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
|
||||
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
|
||||
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
|
||||
```
|
||||
* État de l'installation : `<dossier du jeu>\.launcher\state.json` (dossier caché)
|
||||
* Nom du dossier et de la clé de registre : `launcher.json` → `"dataFolder"` (à la racine ; `"GameLauncher"`
|
||||
par défaut). Il est indépendant de `launcherId`, qui ne doit plus changer (§7).
|
||||
|
||||
**Versions 1.2.1 et antérieures** : les données étaient dans `%AppData%\<Publisher>\<LauncherId>` (ex.
|
||||
`EZRLauncher`, + `<GameId>Launcher` par jeu en multi-jeux) et dans `HKCU\Software\<Publisher>\<GameId>`.
|
||||
Au démarrage, le launcher les déplace (renommage du dossier, paramètres de chaque jeu vers `Games\<GameId>`,
|
||||
copie vérifiée puis suppression de l'ancienne clé de registre). Ce déplacement n'a lieu que si aucun autre
|
||||
launcher n'est ouvert, et jamais au redémarrage qui suit une mise à jour du launcher (l'ancienne version doit
|
||||
pouvoir être remise en place) : l'ancien emplacement est alors utilisé pour la session et le déplacement est
|
||||
fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\<Publisher>.<GameId>`) ne changent pas.
|
||||
|
||||
### Données du jeu (Unity)
|
||||
|
||||
| Contenu | Emplacement | Bouton (*Paramètres du jeu* › *Informations*) |
|
||||
|---|---|---|
|
||||
| Sauvegardes (`Data\SavedGame`) et réglages (`Data\Config`) | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` (Roaming, à côté de `GameLauncher`) | *Ouvrir le dossier des données* |
|
||||
| Journaux de Unity (`Player.log`, `Player-prev.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` | *Ouvrir le dossier des journaux* |
|
||||
|
||||
* « LanisysGames\Exovival Zombie Reborn » correspond à *Company Name* / *Product Name* des Player Settings
|
||||
de Unity ; côté launcher : `launcher.json` → `gameDataFolder` (par défaut `publisher\gameName`).
|
||||
* Le jeu choisit l'emplacement dans `SerializationAsset.cs` (UHFPS, classe `ExovivalDataLocation`). Au
|
||||
premier lancement d'une version qui l'intègre, le dossier `Data` de LocalLow est copié vers Roaming,
|
||||
vérifié (mêmes fichiers, mêmes tailles), puis supprimé de LocalLow. En cas d'échec, le jeu continue
|
||||
d'utiliser LocalLow et réessaie au lancement suivant. Dans l'éditeur Unity, rien ne change (`Assets/Data`).
|
||||
* `Player.log` reste dans LocalLow : son emplacement est fixé par Unity (`Application.persistentDataPath`).
|
||||
|
||||
## 5. Personnalisation
|
||||
|
||||
@@ -235,8 +341,9 @@ sont repris de ceux du jeu affiché par défaut.
|
||||
| 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` |
|
||||
|
||||
### Thèmes
|
||||
|
||||
@@ -277,7 +384,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 |
|
||||
|
||||
@@ -367,5 +474,72 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
|
||||
|
||||
### Fichiers
|
||||
|
||||
* Préférences : `%AppData%\<Publisher>\<LauncherId>\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
|
||||
* Préférences : `%AppData%\<Publisher>\GameLauncher\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
|
||||
* À côté de l'exe, temporairement : `GameLauncher.exe.<version>.download`, `GameLauncher.exe.old`, `GameLauncher.exe.replaced`, `GameLauncher.exe.failed`
|
||||
|
||||
## 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.
|
||||
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**. Les autres pages de gestion arrivent dans les phases suivantes.
|
||||
|
||||
* **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.
|
||||
* **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
|
||||
la clé (un jeton volé ne permet que de lire). Journal d'audit de chaque action.
|
||||
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu et
|
||||
publication), éditeur (contenu).
|
||||
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
|
||||
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
|
||||
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
|
||||
|
||||
| Pour… | Où |
|
||||
|---|---|
|
||||
| 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é et se connecter : aussi depuis le launcher |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
@@ -0,0 +1,475 @@
|
||||
# Module d'administration et socle modulaire — Architecture
|
||||
|
||||
> Document de référence du chantier « module d'administration ». Il fixe les décisions
|
||||
> d'architecture et de sécurité, puis découpe le travail en phases indépendantes.
|
||||
> Statut des phases : voir § 13.
|
||||
|
||||
## 1. Objectifs
|
||||
|
||||
* Un **menu d'administration intégré au launcher**, accessible par une entrée discrète, protégé par
|
||||
une **authentification par clé publique/privée**.
|
||||
* Depuis ce menu :
|
||||
* **éditer les notes de version existantes** avec un éditeur graphique qui respecte le format
|
||||
enrichi actuel (titre, sous-titre, introduction, sections, modifications, commentaires,
|
||||
étiquettes, sous-points, notes) ;
|
||||
* **publier une nouvelle version du jeu** (archive ZIP + `manifest.json` + dossier `objects/`)
|
||||
produite par l'outil `.bat` / ManifestBuilder, qui **reste l'outil de génération** ;
|
||||
* **modifier les informations des jeux** (nom affiché, accroche, description, liens, thème…),
|
||||
les jeux restant **déclarés dans `launcher.json`** (solution Visual Studio) ;
|
||||
* **gérer les administrateurs** (inviter, approuver, changer le rôle, révoquer).
|
||||
* Un **socle modulaire** : le menu d'administration est le premier module ; les fonctionnalités
|
||||
futures « à la Steam » (actualités, amis, succès, sauvegardes dans le cloud, boutique…) s'y
|
||||
brancheront de la même façon, côté launcher **et** côté serveur.
|
||||
|
||||
## 2. Principe de sécurité fondamental
|
||||
|
||||
Le launcher est distribué à tous les joueurs : **tout ce qu'il contient est public**, et toute
|
||||
vérification faite uniquement dans l'exécutable peut être contournée (exécutable modifié).
|
||||
|
||||
Conséquences :
|
||||
|
||||
1. **Le serveur est la seule barrière de sécurité.** Chaque action d'administration est vérifiée
|
||||
par le serveur (authentification, droits, validation du contenu). Le bouton caché n'est qu'un
|
||||
confort : le cacher ne protège rien.
|
||||
2. **Aucun secret dans l'exécutable.** L'exe ne contient que des éléments publics : adresses,
|
||||
clés *publiques*.
|
||||
3. **Le serveur ne stocke aucun secret utilisable** : uniquement des clés publiques. Une fuite
|
||||
complète de ses données ne permet pas de se connecter.
|
||||
4. **Toute modification est signée par la clé de l'administrateur** (en plus de la session) : un
|
||||
jeton de session volé ne permet que de lire.
|
||||
5. **Le serveur valide tout ce qu'il écrit** avec le même code que le launcher
|
||||
(`GameLauncher.Core`) et écrit de façon atomique (fichier temporaire puis renommage), avec
|
||||
sauvegarde de la version précédente et journal d'audit.
|
||||
6. Les éléments qui touchent à la sécurité ou au disque du joueur (URL des serveurs, clés de
|
||||
confiance, dossiers supprimés à la désinstallation…) **restent dans l'exe** et ne peuvent pas
|
||||
être modifiés depuis le serveur, ni par un `launcher.json` posé à côté de l'exe.
|
||||
|
||||
## 3. Vue d'ensemble
|
||||
|
||||
```
|
||||
PC de l'administrateur VPS (Debian 13)
|
||||
┌──────────────────────────────┐ HTTPS ┌─────────────────────────────────────────────┐
|
||||
│ GameLauncher.exe │──────────►│ nginx (TLS, limites, fichiers statiques) │
|
||||
│ ├─ Module « Administration »│ │ ├─ /exovivalzombie/… manifest, zip, objects│
|
||||
│ │ 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) │
|
||||
└─────────────────────────────────────────────┘
|
||||
PC d'un joueur : lit uniquement les fichiers statiques (aucun accès à l'API d'administration).
|
||||
```
|
||||
|
||||
## 4. Projets de la solution
|
||||
|
||||
| Projet | Cible | Rôle |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `tools/ManifestBuilder`, `tools/LauncherPublisher` | net8.0 | Inchangés (le `.bat` de génération est conservé) |
|
||||
| `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`
|
||||
(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
|
||||
|
||||
### 5.1 Côté serveur (phase 1)
|
||||
|
||||
```csharp
|
||||
public interface IApiModule
|
||||
{
|
||||
string Name { get; }
|
||||
void ConfigureServices(IServiceCollection services, LauncherApiOptions options);
|
||||
void MapEndpoints(IEndpointRouteBuilder api); // sous /api/v1
|
||||
}
|
||||
```
|
||||
|
||||
Modules livrés en phase 1 : `System` (infos, santé), `Auth` (défi, connexion, session, enrôlement),
|
||||
`Admins` (gestion des administrateurs), `Audit` (journal). Les phases suivantes ajoutent `Catalog`,
|
||||
`Changelog`, `Releases`, puis les modules « joueurs ». Chaque module déclare ses **permissions**
|
||||
(`AdminPermissions`), protège ses routes avec `.RequirePermission(...)` et ses modifications avec
|
||||
`.RequireSignedRequest()`.
|
||||
|
||||
### 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", puis "news", "friends"…
|
||||
string DisplayName { get; }
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
void AddAdminPanel(AdminPanelDescriptor panel); // page du menu d'administration
|
||||
// Plus tard : AddPage (section de la fenêtre principale), services partagés…
|
||||
}
|
||||
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id, string Title, string Description, string IconKey,
|
||||
string? RequiredPermission, // null : toute personne connectée
|
||||
int Order, Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
```
|
||||
|
||||
* `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
|
||||
|
||||
### 6.1 Clés
|
||||
|
||||
* ECDSA **P-256 / SHA-256**, signature au format IEEE P1363 (64 octets) — même famille que la
|
||||
signature des mises à jour du launcher, entièrement fournie par .NET (aucune dépendance).
|
||||
* `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 :
|
||||
* **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)
|
||||
|
||||
```
|
||||
launcher serveur
|
||||
│ POST /api/v1/auth/challenge {keyId} ───► défi SANS ÉTAT : clé + échéance (60 s) + nonce 256 bits,
|
||||
│ scellés par HMAC (secret en mémoire) : rien à stocker
|
||||
│ ◄─── {challengeId, nonce, serverId} (même réponse pour une clé inconnue)
|
||||
│ signe le message de connexion ─────────► POST /api/v1/auth/login
|
||||
│ vérifie : sceau, clé visée, échéance, signature,
|
||||
│ puis clé et compte actifs, défi jamais utilisé (rejeu)
|
||||
│ ◄─── jeton de session + droits jeton 256 bits (seule son empreinte est gardée)
|
||||
```
|
||||
|
||||
Message signé (séparation de domaine, une ligne par champ) :
|
||||
|
||||
```
|
||||
GameLauncher/Admin/Login/v1
|
||||
origin:https://launcher.lanisys.fr
|
||||
server:<serverId>
|
||||
challenge:<challengeId>
|
||||
nonce:<nonce>
|
||||
key:<keyId>
|
||||
```
|
||||
|
||||
`origin` (adresse publique de l'API) et `serverId` (identifiant aléatoire propre au serveur)
|
||||
empêchent de réutiliser une signature auprès d'un autre serveur.
|
||||
|
||||
### 6.3 Sessions
|
||||
|
||||
* En mémoire uniquement (un redémarrage du service déconnecte tout le monde) ; **20 min
|
||||
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
|
||||
|
||||
Toute requête qui **modifie** quelque chose porte, en plus du jeton, une signature de la clé de
|
||||
l'administrateur :
|
||||
|
||||
```
|
||||
GameLauncher/Admin/Request/v1
|
||||
origin:<origine>
|
||||
server:<serverId>
|
||||
session:<identifiant de session>
|
||||
method:POST
|
||||
path:/api/v1/admins/invitations
|
||||
body-sha256:<SHA-256 du corps>
|
||||
timestamp:<secondes Unix>
|
||||
nonce:<aléatoire>
|
||||
```
|
||||
|
||||
En-têtes `X-GL-Timestamp`, `X-GL-Nonce`, `X-GL-Signature`. Le serveur refuse une horloge décalée
|
||||
de plus de 2 minutes et tout nonce déjà vu (rejeu). Bénéfices : un jeton volé ne permet aucune
|
||||
modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
|
||||
### 6.5 Défenses complémentaires
|
||||
|
||||
| Menace | Défense |
|
||||
|---|---|
|
||||
| Force brute / saturation | Limites par IP (IPv6 : par /64 ; nginx + ASP.NET Core) ; défis sans état (aucune réserve à saturer) ; sessions en nombre borné |
|
||||
| Énumération des clés | Défi identique pour une clé connue ou inconnue ; erreurs génériques |
|
||||
| Rejeu | Défi à usage unique (60 s) ; nonce + horodatage des requêtes signées |
|
||||
| Afflux de tentatives dans l'audit | Échecs sans signature valide détaillés 10 fois par minute au plus, puis résumés ; échecs AVEC signature valide (clé révoquée ou en attente, rejeu) toujours détaillés |
|
||||
| Vol du jeton | Inutile pour modifier (requêtes signées) ; courte durée ; lié à la clé |
|
||||
| Fuite des données du serveur | Uniquement des clés publiques et des empreintes de codes d'invitation |
|
||||
| Compromission du compte `deploy` | Données de l'API hors du chroot SFTP (`/var/lib/launcher-api`) |
|
||||
| En-têtes falsifiés | `X-Forwarded-*` acceptés uniquement depuis 127.0.0.1 ; HTTPS exigé derrière nginx |
|
||||
| Usurpation par un autre administrateur | Un nouvel appareil n'est approuvé que par le titulaire du compte ; droits de l'auteur revérifiés au moment d'écrire |
|
||||
| Erreur de manipulation | Impossible de se révoquer soi-même, de changer son propre rôle, ou de supprimer le dernier propriétaire |
|
||||
| Perte de toutes les clés | Récupération par l'outil en ligne de commande, sur le VPS (accès SSH administrateur) |
|
||||
|
||||
## 7. Rôles et permissions
|
||||
|
||||
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
|
||||
| `audit.read` | ✔ | ✔ | | Journal d'audit |
|
||||
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
|
||||
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
|
||||
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
|
||||
|
||||
Les permissions sont des chaînes « module.action » : un module futur déclare les siennes
|
||||
(`news.edit`, `community.moderate`…) et les rôles qui les reçoivent.
|
||||
|
||||
## 8. Ajouter un administrateur
|
||||
|
||||
1. Un **propriétaire** crée une invitation (nom, rôle) : le serveur renvoie **une seule fois** un
|
||||
code à usage unique (160 bits, `ABCD-EFGH-…`, valable 24 h ; seule son empreinte est stockée).
|
||||
2. Il transmet ce code au futur administrateur.
|
||||
3. Le futur administrateur ouvre le menu d'administration › « J'ai une invitation » : le launcher
|
||||
crée sa clé, l'envoie avec le code et une **preuve de possession** (signature), et affiche son
|
||||
**empreinte**.
|
||||
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.
|
||||
(Phase 2 : étapes 3 et 4 dans le launcher ; création de l'invitation et approbation avec
|
||||
`tools\admin-key.bat` jusqu'à la page « Administrateurs » de la phase 6.)
|
||||
|
||||
**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**.
|
||||
Personne d'autre — pas même un propriétaire — ne peut ajouter une clé à un compte déjà actif
|
||||
(sinon un propriétaire pourrait révoquer les clés d'un autre puis y ajouter la sienne et agir sous
|
||||
son nom). Tous ses PC perdus : restauration sur le VPS (`add-key`, accès SSH), ou nouveau compte.
|
||||
Le **premier propriétaire** est créé sur le VPS avec l'outil en ligne de commande (preuve
|
||||
d'identité = accès SSH administrateur) :
|
||||
|
||||
```
|
||||
sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub
|
||||
```
|
||||
|
||||
## 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. 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 |
|
||||
|---|:-:|:-:|---|
|
||||
| `launcherId`, `dataFolder` | ✔ | | Identité du produit, dossiers et registre |
|
||||
| `publisher`, `gameId` | ✔ | | Identité, registre, raccourcis |
|
||||
| `manifestUrl`, `launcherUpdateUrl` | ✔ | | Sources de confiance |
|
||||
| `apiUrl`, `catalogUrl` (nouveaux) | ✔ | | Sources de confiance (jamais remplaçables par un `launcher.json` externe) |
|
||||
| `gameDataFolder` | ✔ | | Dossiers supprimés à la désinstallation : jamais piloté à distance |
|
||||
| `defaultInstallDirectory`, `gameExecutable`, `gameArguments` | ✔ | | Disque et exécution sur le PC du joueur |
|
||||
| `gameName`, `tagline` | valeur par défaut | ✔ | Affichage |
|
||||
| `description` (nouveau, texte long), `links` (Discord, YouTube… https) | | ✔ | Affichage |
|
||||
| `websiteUrl` | valeur par défaut | ✔ | https uniquement |
|
||||
| `theme` | valeur par défaut | ✔ | Uniquement un thème **intégré** (pas de XAML distant) |
|
||||
| `icon`, `background` | valeur par défaut | ✔ | Images https du même serveur, taille et dimensions bornées, mises en cache |
|
||||
| `updateCheckIntervalMinutes` | valeur par défaut | ✔ | Borné (0 ou 5 à 1440) |
|
||||
| `launcherName`, `defaultGame`, ordre et visibilité des jeux | valeur par défaut | ✔ | Affichage |
|
||||
|
||||
Fonctionnement côté joueur : `catalog.json` (statique, sur le serveur) est lu au démarrage,
|
||||
validé et borné comme les notes de version, puis **mis en cache** (`%AppData%\…\GameLauncher`).
|
||||
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).
|
||||
|
||||
### 9.1 Mise en œuvre (phase 3 ✅)
|
||||
|
||||
**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`.
|
||||
* **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)
|
||||
|
||||
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 :
|
||||
|
||||
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).
|
||||
|
||||
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é.
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
|
||||
|
||||
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 :
|
||||
|
||||
* `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.
|
||||
|
||||
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
|
||||
|
||||
## 13. Phases
|
||||
|
||||
| Phase | Contenu | Statut |
|
||||
|---|---|---|
|
||||
| **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 : 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) | à 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 |
|
||||
|
||||
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
|
||||
|
||||
| Option | Pourquoi elle n'a pas été retenue |
|
||||
|---|---|
|
||||
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
|
||||
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
|
||||
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde |
|
||||
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Outil en ligne de commande, à lancer SUR LE VPS sous le compte du service (accès SSH
|
||||
/// administrateur = preuve d'identité). Sert à créer le premier propriétaire et à reprendre la main
|
||||
/// si toutes les clés sont perdues. Le service n'a pas besoin d'être arrêté : il relit admins.json.
|
||||
///
|
||||
/// sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub
|
||||
/// </summary>
|
||||
public static class AdminCli
|
||||
{
|
||||
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
|
||||
|
||||
public static bool IsCommand(string[] args) =>
|
||||
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
|
||||
{
|
||||
var o = output ?? Console.Out;
|
||||
try
|
||||
{
|
||||
var command = args[0].ToLowerInvariant();
|
||||
if (command == "help") return Help(o);
|
||||
var options = ReadOptions(args);
|
||||
|
||||
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
|
||||
// appartiendraient à root et le service ne pourrait plus les lire.
|
||||
if (dataDirectoryOverride is null && !OperatingSystem.IsWindows() && Environment.UserName == "root")
|
||||
throw new InvalidOperationException(
|
||||
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
|
||||
|
||||
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
|
||||
if (command != "add-owner" && command != "server-id" &&
|
||||
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
|
||||
throw new InvalidOperationException(
|
||||
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
|
||||
if (output is null) Console.Error.WriteLine($"Données : {dataDirectory}");
|
||||
|
||||
var store = new AdminStore(dataDirectory);
|
||||
var audit = new AuditLog(dataDirectory, TimeProvider.System);
|
||||
var actor = $"cli ({Environment.UserName})";
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "add-owner": return AddOwner(store, audit, actor, options, o);
|
||||
case "add-key": return AddKey(store, audit, actor, options, o);
|
||||
case "list": return List(store, o);
|
||||
case "revoke-admin": return RevokeAdmin(store, audit, actor, options, o);
|
||||
case "revoke-key": return RevokeKey(store, audit, actor, options, o);
|
||||
case "server-id":
|
||||
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
|
||||
return 0;
|
||||
default: return Help(o);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or InvalidDataException or IOException
|
||||
or UnauthorizedAccessException or CryptographicException or ApiRejection or StoreBusyException)
|
||||
{
|
||||
(output ?? Console.Error).WriteLine($"Erreur : {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static int AddOwner(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
var name = ApiText.CleanLine(Get(options, "name"), ApiText.MaxDisplayNameLength)
|
||||
?? throw new ArgumentException("--name obligatoire (64 caractères au plus).");
|
||||
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
|
||||
var keyText = File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg;
|
||||
var spki = AdminKeys.ParsePublicKey(keyText);
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var id = store.Update(db =>
|
||||
{
|
||||
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
|
||||
var admin = new AdminRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = name,
|
||||
Role = AdminRoles.Owner,
|
||||
Status = AdminStatus.Active,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor,
|
||||
ApprovedAt = now,
|
||||
ApprovedBy = actor,
|
||||
Keys =
|
||||
{
|
||||
new KeyRecord
|
||||
{
|
||||
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
|
||||
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
|
||||
}
|
||||
}
|
||||
};
|
||||
db.Admins.Add(admin);
|
||||
return admin.Id;
|
||||
});
|
||||
audit.Write("cli.add_owner", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Propriétaire créé : {name} ({id:D})");
|
||||
o.WriteLine($" Clé : {keyId}");
|
||||
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Restaure l'accès d'un administrateur qui a perdu tous ses appareils (clé active immédiatement).</summary>
|
||||
private static int AddKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
if (!Guid.TryParse(Get(options, "admin"), out var adminId)) throw new ArgumentException("--admin <identifiant> obligatoire (voir « list »).");
|
||||
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
|
||||
var spki = AdminKeys.ParsePublicKey(File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg);
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var admin = db.FindAdmin(adminId) ?? throw new InvalidOperationException("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw new InvalidOperationException("Cet administrateur est révoqué : créez un nouveau compte.");
|
||||
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
|
||||
admin.Keys.Add(new KeyRecord
|
||||
{
|
||||
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
|
||||
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
|
||||
});
|
||||
if (admin.Status == AdminStatus.Pending)
|
||||
{
|
||||
admin.Status = AdminStatus.Active;
|
||||
admin.ApprovedAt = now;
|
||||
admin.ApprovedBy = actor;
|
||||
}
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.add_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Clé ajoutée à {name} : {keyId}");
|
||||
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int List(AdminStore store, TextWriter o)
|
||||
{
|
||||
var db = store.Read();
|
||||
if (db.Admins.Count == 0) o.WriteLine("Aucun administrateur.");
|
||||
foreach (var admin in db.Admins.OrderBy(a => a.CreatedAt))
|
||||
{
|
||||
o.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
foreach (var key in admin.Keys)
|
||||
o.WriteLine($" clé {key.KeyId} {AdminKeys.ComputeFingerprint(key.PublicKeyBytes())} {key.Status,-8} {key.Label}" +
|
||||
(key.LastUsedAt is { } used ? $" (dernière connexion {used:yyyy-MM-dd HH:mm} UTC)" : ""));
|
||||
}
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var invitation in db.Invitations.Where(i => i.ExpiresAt > now))
|
||||
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.Role}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RevokeAdmin(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
if (!Guid.TryParse(Get(options, "id"), out var id)) throw new ArgumentException("--id <identifiant> obligatoire (voir « list »).");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var admin = db.FindAdmin(id) ?? throw new InvalidOperationException("Administrateur introuvable.");
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor;
|
||||
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
|
||||
{
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = now;
|
||||
key.RevokedBy = actor;
|
||||
}
|
||||
db.Invitations.RemoveAll(i => i.ForAdminId == id);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.revoke_admin", AuditResults.Ok, actorName: actor, target: name);
|
||||
o.WriteLine($"Administrateur révoqué : {name}. Ses sessions sont fermées à sa prochaine requête.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RevokeKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
var keyId = Get(options, "key")?.ToLowerInvariant();
|
||||
if (!AdminKeys.IsValidKeyId(keyId)) throw new ArgumentException("--key <identifiant de clé> obligatoire (voir « list »).");
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var (admin, key) = db.FindKey(keyId!) ?? throw new InvalidOperationException("Clé introuvable.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = DateTimeOffset.UtcNow;
|
||||
key.RevokedBy = actor;
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.revoke_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Clé {keyId} de {name} révoquée.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int Help(TextWriter o)
|
||||
{
|
||||
o.WriteLine("""
|
||||
Launcher API — outil d'administration (à lancer sur le VPS, sous le compte du service)
|
||||
|
||||
add-owner --name <nom> --public-key <fichier.pub | Base64> [--label <poste>]
|
||||
Crée un propriétaire actif (premier administrateur, ou reprise de la main).
|
||||
add-key --admin <identifiant> --public-key <fichier.pub | Base64> [--label <poste>]
|
||||
Restaure l'accès d'un administrateur qui a perdu tous ses appareils.
|
||||
list Administrateurs, clés (empreintes) et invitations en cours.
|
||||
revoke-admin --id <identifiant> Révoque un administrateur et toutes ses clés.
|
||||
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
|
||||
server-id Affiche l'identifiant de ce serveur.
|
||||
|
||||
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
|
||||
""");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadOptions(string[] args)
|
||||
{
|
||||
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
if (!args[i].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException($"Argument inattendu : {args[i]}");
|
||||
if (i + 1 >= args.Length) throw new ArgumentException($"Valeur manquante pour {args[i]}");
|
||||
options[args[i][2..]] = args[++i];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Même configuration que le service : appsettings.json, puis appsettings.{environnement}.json
|
||||
/// (Production par défaut, comme le service systemd), puis variables d'environnement.
|
||||
/// </summary>
|
||||
private static string ResolveDataDirectory(Dictionary<string, string> options)
|
||||
{
|
||||
if (Get(options, "data-dir") is { Length: > 0 } explicitDirectory) return Path.GetFullPath(explicitDirectory);
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
var directory = configuration[$"{LauncherApiOptions.Section}:DataDirectory"] ?? new LauncherApiOptions().DataDirectory;
|
||||
return Path.GetFullPath(Path.IsPathRooted(directory) ? directory : Path.Combine(AppContext.BaseDirectory, directory));
|
||||
}
|
||||
|
||||
private static string? Get(Dictionary<string, string> options, string name) =>
|
||||
options.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Launcher API : serveur d'administration (et, plus tard, des fonctionnalités « joueurs »).
|
||||
Déployé sur le VPS derrière nginx (https://<hote>/api/), service systemd durci.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md et server\deploy\LISEZMOI.md.
|
||||
|
||||
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
|
||||
tools\publish-server.bat
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Server</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Server</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>Launcher API</Product>
|
||||
<!-- Pas de dépendance à libicu sur le VPS ; les textes restent en UTF-8. -->
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Refus d'une requête (levé n'importe où, y compris dans <see cref="Storage.AdminStore.Update{T}"/> :
|
||||
/// rien n'est alors écrit). Transformé en réponse <see cref="ApiError"/> par ServerApp.
|
||||
/// </summary>
|
||||
public sealed class ApiRejection : Exception
|
||||
{
|
||||
public ApiRejection(int status, string code, string message) : base(message)
|
||||
{
|
||||
Status = status;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public int Status { get; }
|
||||
public string Code { get; }
|
||||
|
||||
public static ApiRejection BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => new(400, code, message);
|
||||
public static ApiRejection NotFound(string message = "Élément introuvable.") => new(404, ApiErrorCodes.NotFound, message);
|
||||
public static ApiRejection Conflict(string message, string code = ApiErrorCodes.Conflict) => new(409, code, message);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>Réponses d'erreur uniformes (<see cref="ApiError"/>), sans détail interne.</summary>
|
||||
public static class ApiResults
|
||||
{
|
||||
public static IResult Error(int status, string code, string message) =>
|
||||
Results.Json(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options, statusCode: status);
|
||||
|
||||
public static IResult BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => Error(400, code, message);
|
||||
public static IResult Unauthorized(string message = "Authentification requise.", string code = ApiErrorCodes.Unauthorized) =>
|
||||
Error(401, code, message);
|
||||
public static IResult Forbidden(string message = "Action non autorisée.") => Error(403, ApiErrorCodes.Forbidden, message);
|
||||
public static IResult NotFound(string message = "Élément introuvable.") => Error(404, ApiErrorCodes.NotFound, message);
|
||||
public static IResult Conflict(string message, string code = ApiErrorCodes.Conflict) => Error(409, code, message);
|
||||
public static IResult Busy() => Error(503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
|
||||
|
||||
/// <summary>Écrit une erreur directement dans la réponse (middlewares, authentification).</summary>
|
||||
public static Task WriteAsync(HttpContext context, int status, string code, string message)
|
||||
{
|
||||
context.Response.StatusCode = status;
|
||||
return context.Response.WriteAsJsonAsync(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
/// <summary>Adresse IP du client (celle transmise par nginx, seul proxy de confiance).</summary>
|
||||
public static string? ClientIp(this HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Partition des limites de débit : adresse IPv4, ou préfixe /64 en IPv6 (un abonné dispose
|
||||
/// généralement de tout un /64 : sans regroupement, chaque adresse aurait sa propre limite).
|
||||
/// </summary>
|
||||
public static string RateLimitPartition(this HttpContext context)
|
||||
{
|
||||
var ip = context.Connection.RemoteIpAddress;
|
||||
if (ip is null) return "inconnue";
|
||||
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
|
||||
if (ip.AddressFamily != AddressFamily.InterNetworkV6) return ip.ToString();
|
||||
var bytes = ip.GetAddressBytes();
|
||||
Array.Clear(bytes, 8, 8);
|
||||
return new IPAddress(bytes) + "/64";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Module du serveur : enregistre ses services et ses routes (sous « /api »). Les modules futurs
|
||||
/// (catalogue, notes de version, publication, puis fonctionnalités « joueurs ») s'ajoutent à
|
||||
/// <see cref="ApiModules.All"/> sans toucher au reste.
|
||||
///
|
||||
/// Règles : routes de lecture protégées par RequirePermission(…) (ou RequireAdmin()) ; routes qui
|
||||
/// modifient protégées en plus par RequireSignedRequest() ; chaque modification écrite dans l'audit.
|
||||
/// </summary>
|
||||
public interface IApiModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
void ConfigureServices(IServiceCollection services, LauncherApiOptions options);
|
||||
|
||||
void MapEndpoints(IEndpointRouteBuilder api);
|
||||
}
|
||||
|
||||
public static class ApiModules
|
||||
{
|
||||
/// <summary>Modules actifs, dans l'ordre d'enregistrement.</summary>
|
||||
public static IReadOnlyList<IApiModule> All { get; } = new IApiModule[]
|
||||
{
|
||||
new Modules.System.SystemModule(),
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Noms des limites de débit (voir ServerApp).</summary>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
public const string Enrollment = "enrollment";
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>Réglages de la Launcher API (section « LauncherApi » d'appsettings.json ou variables d'environnement).</summary>
|
||||
public sealed class LauncherApiOptions
|
||||
{
|
||||
public const string Section = "LauncherApi";
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données privées (administrateurs, audit, identifiant du serveur). Jamais dans le
|
||||
/// dossier servi par nginx. Relatif : par rapport au dossier de l'application.
|
||||
/// </summary>
|
||||
public string DataDirectory { get; set; } = "/var/lib/launcher-api";
|
||||
|
||||
/// <summary>
|
||||
/// Adresse publique de l'API, sans chemin (ex. « https://launcher.lanisys.fr »). Incluse dans les
|
||||
/// messages signés : une signature produite pour un autre serveur est refusée.
|
||||
/// </summary>
|
||||
public string PublicOrigin { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Exige que les requêtes arrivent en HTTPS par le proxy (en-tête X-Forwarded-Proto de nginx).
|
||||
/// Seule la route de santé reste accessible en HTTP, et seulement depuis la machine elle-même.
|
||||
/// </summary>
|
||||
public bool RequireHttps { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Proxys autorisés à transmettre l'adresse et le protocole du client (X-Forwarded-For/Proto).
|
||||
/// Vide : nginx sur la même machine (127.0.0.1 et ::1). Pas de valeur par défaut dans le tableau :
|
||||
/// la configuration .NET AJOUTE les valeurs lues à celles d'un tableau déjà rempli, ce qui
|
||||
/// laisserait 127.0.0.1 de confiance même si la configuration en désigne d'autres.
|
||||
/// </summary>
|
||||
public string[] TrustedProxies { get; set; } = Array.Empty<string>();
|
||||
|
||||
private static readonly string[] DefaultTrustedProxies = { "127.0.0.1", "::1" };
|
||||
|
||||
public SessionSettings Sessions { get; set; } = new();
|
||||
|
||||
public LimitSettings Limits { get; set; } = new();
|
||||
|
||||
public CatalogSettings Catalog { 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;
|
||||
|
||||
/// <summary>Durée de validité d'un code d'invitation.</summary>
|
||||
public int InvitationLifetimeHours { get; set; } = 24;
|
||||
|
||||
public sealed class SessionSettings
|
||||
{
|
||||
public int IdleMinutes { get; set; } = 20;
|
||||
public int MaxHours { get; set; } = 8;
|
||||
public int MaxPerAdmin { get; set; } = 5;
|
||||
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>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>
|
||||
public int RequestsPerMinute { get; set; } = 300;
|
||||
|
||||
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
|
||||
public int AuthPerMinute { get; set; } = 20;
|
||||
|
||||
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
|
||||
public int EnrollmentsPerHour { get; set; } = 10;
|
||||
|
||||
/// <summary>Invitations actives simultanément.</summary>
|
||||
public int MaxActiveInvitations { get; set; } = 20;
|
||||
|
||||
/// <summary>Taille maximale d'un corps de requête (octets).</summary>
|
||||
public long MaxRequestBodyBytes { get; set; } = 64 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>Origine normalisée (« https://hote[:port] »), calculée par <see cref="Validate"/>.</summary>
|
||||
public string NormalizedOrigin { get; private set; } = "";
|
||||
|
||||
/// <summary>Dossier des données en chemin absolu, calculé par <see cref="Validate"/>.</summary>
|
||||
public string DataDirectoryFullPath { get; private set; } = "";
|
||||
|
||||
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>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)
|
||||
{
|
||||
if (!Uri.TryCreate(PublicOrigin, UriKind.Absolute, out var origin) ||
|
||||
!string.IsNullOrEmpty(origin.Query) || origin.AbsolutePath != "/")
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:PublicOrigin doit être une adresse sans chemin, ex. « https://launcher.lanisys.fr ».");
|
||||
if (origin.Scheme != Uri.UriSchemeHttps && !(origin.Scheme == Uri.UriSchemeHttp && origin.IsLoopback && !RequireHttps))
|
||||
throw new InvalidOperationException($"{Section}:PublicOrigin doit être en https://.");
|
||||
NormalizedOrigin = Contracts.Security.SignedMessages.NormalizeOrigin(origin);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DataDirectory))
|
||||
throw new InvalidOperationException($"{Section}:DataDirectory est obligatoire.");
|
||||
DataDirectoryFullPath = Path.GetFullPath(Path.IsPathRooted(DataDirectory)
|
||||
? DataDirectory
|
||||
: Path.Combine(contentRoot, DataDirectory));
|
||||
|
||||
var proxies = new List<IPAddress>();
|
||||
foreach (var proxy in TrustedProxies is { Length: > 0 } configured ? configured : DefaultTrustedProxies)
|
||||
{
|
||||
if (!IPAddress.TryParse(proxy, out var address))
|
||||
throw new InvalidOperationException($"{Section}:TrustedProxies : adresse invalide « {proxy} ».");
|
||||
proxies.Add(address);
|
||||
}
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
ValidateCatalog();
|
||||
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.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 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Construction du serveur : réglages, sécurité (proxy, HTTPS, en-têtes, limites, authentification)
|
||||
/// puis modules. Utilisé par Program et par les tests (serveur démarré en mémoire).
|
||||
/// </summary>
|
||||
public static class ServerApp
|
||||
{
|
||||
public static WebApplication Build(string[] args, Action<WebApplicationBuilder>? configure = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory // appsettings.json à côté de l'exécutable, quel que soit le dossier courant
|
||||
});
|
||||
configure?.Invoke(builder);
|
||||
|
||||
var options = builder.Configuration.GetSection(LauncherApiOptions.Section).Get<LauncherApiOptions>() ?? new LauncherApiOptions();
|
||||
options.Validate(builder.Environment.ContentRootPath);
|
||||
builder.Services.AddSingleton(Options.Create(options));
|
||||
builder.Services.TryAddTimeProvider();
|
||||
|
||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
{
|
||||
kestrel.AddServerHeader = false;
|
||||
kestrel.Limits.MaxRequestBodySize = options.Limits.MaxRequestBodyBytes;
|
||||
kestrel.Limits.MaxRequestHeaderCount = 40;
|
||||
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
|
||||
kestrel.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(forwarded =>
|
||||
{
|
||||
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
forwarded.ForwardLimit = 1;
|
||||
forwarded.KnownNetworks.Clear();
|
||||
forwarded.KnownProxies.Clear();
|
||||
foreach (var proxy in options.TrustedProxyAddresses) forwarded.KnownProxies.Add(proxy);
|
||||
});
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(json =>
|
||||
{
|
||||
var source = ApiJson.Options;
|
||||
json.SerializerOptions.PropertyNamingPolicy = source.PropertyNamingPolicy;
|
||||
json.SerializerOptions.PropertyNameCaseInsensitive = source.PropertyNameCaseInsensitive;
|
||||
json.SerializerOptions.DefaultIgnoreCondition = source.DefaultIgnoreCondition;
|
||||
json.SerializerOptions.ReadCommentHandling = source.ReadCommentHandling;
|
||||
json.SerializerOptions.AllowTrailingCommas = source.AllowTrailingCommas;
|
||||
json.SerializerOptions.MaxDepth = source.MaxDepth;
|
||||
json.SerializerOptions.NumberHandling = source.NumberHandling;
|
||||
});
|
||||
// Corps JSON invalide : exception transformée en erreur 400 lisible (voir HandleErrors).
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>(o => o.ThrowOnBadRequest = true);
|
||||
|
||||
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.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
|
||||
});
|
||||
|
||||
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
|
||||
// mais initialisée par le framework. Ses clés restent dans le dossier privé des données (rien dans $HOME).
|
||||
var dataProtection = Path.Combine(options.DataDirectoryFullPath, "data-protection");
|
||||
SecureFiles.EnsureDirectory(options.DataDirectoryFullPath);
|
||||
SecureFiles.EnsureDirectory(dataProtection);
|
||||
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtection));
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore.DataProtection", LogLevel.Error);
|
||||
|
||||
builder.Services.AddSingleton<ServerIdentity>();
|
||||
builder.Services.AddSingleton<AdminStore>();
|
||||
builder.Services.AddAdminAuthentication();
|
||||
foreach (var module in ApiModules.All) module.ConfigureServices(builder.Services, options);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.Use(SecurityHeaders);
|
||||
app.Use(HandleErrors);
|
||||
app.Use((context, next) => RequireHttps(context, next, options));
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRequestBodyCapture(); // après les limites et les droits : une requête refusée n'est pas lue
|
||||
|
||||
var api = app.MapGroup(ApiRoutes.Prefix);
|
||||
foreach (var module in ApiModules.All) module.MapEndpoints(api);
|
||||
app.MapFallback((HttpContext context) => ApiResults.NotFound("Adresse inconnue."));
|
||||
|
||||
// Identité et fichiers vérifiés dès le démarrage (un fichier illisible empêche le démarrage).
|
||||
var identity = app.Services.GetRequiredService<ServerIdentity>();
|
||||
var admins = app.Services.GetRequiredService<AdminStore>().Read();
|
||||
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
|
||||
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
|
||||
if (!admins.UsableOwners().Any())
|
||||
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
|
||||
return app;
|
||||
}
|
||||
|
||||
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
|
||||
{
|
||||
PermitLimit = permits,
|
||||
Window = window,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
};
|
||||
|
||||
private static void TryAddTimeProvider(this IServiceCollection services)
|
||||
{
|
||||
if (services.All(s => s.ServiceType != typeof(TimeProvider))) services.AddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
/// <summary>En-têtes de sécurité sur toutes les réponses (l'API ne sert jamais de page web).</summary>
|
||||
private static Task SecurityHeaders(HttpContext context, Func<Task> next)
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
headers.CacheControl = "no-store";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.XContentTypeOptions = "nosniff";
|
||||
headers.XFrameOptions = "DENY";
|
||||
headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
|
||||
headers["Referrer-Policy"] = "no-referrer";
|
||||
headers["Cross-Origin-Resource-Policy"] = "same-origin";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
/// <summary>Exceptions → réponses <see cref="ApiError"/> sans détail interne (le détail va dans journalctl).</summary>
|
||||
private static async Task HandleErrors(HttpContext context, Func<Task> next)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (ApiRejection rejection) when (!context.Response.HasStarted)
|
||||
{
|
||||
await ApiResults.WriteAsync(context, rejection.Status, rejection.Code, rejection.Message);
|
||||
}
|
||||
catch (StoreBusyException ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogWarning("{Message}", ex.Message);
|
||||
await ApiResults.WriteAsync(context, 503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
|
||||
}
|
||||
catch (BadHttpRequestException ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
var status = ex.StatusCode == StatusCodes.Status413PayloadTooLarge ? 413 : 400;
|
||||
await ApiResults.WriteAsync(context, status, ApiErrorCodes.InvalidRequest,
|
||||
status == 413 ? "Requête trop volumineuse." : "Requête invalide.");
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// client parti
|
||||
}
|
||||
catch (Exception ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogError(ex, "Erreur sur {Path}", context.Request.Path);
|
||||
await ApiResults.WriteAsync(context, 500, ApiErrorCodes.Error, "Erreur interne du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTPS exigé (via nginx). Exception : la route de santé, appelée en HTTP depuis la machine
|
||||
/// elle-même (surveillance), sans passer par nginx.
|
||||
/// </summary>
|
||||
private static Task RequireHttps(HttpContext context, Func<Task> next, LauncherApiOptions options)
|
||||
{
|
||||
if (!options.RequireHttps || context.Request.IsHttps) return next();
|
||||
var local = context.Connection.RemoteIpAddress is { } ip && System.Net.IPAddress.IsLoopback(ip) &&
|
||||
!context.Request.Headers.ContainsKey("X-Forwarded-For");
|
||||
if (local && context.Request.Path.Equals(ApiRoutes.Prefix + "/" + ApiRoutes.Health, StringComparison.OrdinalIgnoreCase))
|
||||
return next();
|
||||
return ApiResults.WriteAsync(context, 400, ApiErrorCodes.HttpsRequired, "HTTPS obligatoire.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Admins;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion des administrateurs (permission admins.manage = propriétaires) : invitations,
|
||||
/// approbation des clés par empreinte, changement de rôle, révocation d'une clé ou d'un compte.
|
||||
/// Toutes les modifications sont des requêtes signées.
|
||||
///
|
||||
/// Nouvel appareil d'un compte actif : approuvé par le TITULAIRE du compte, depuis un de ses appareils
|
||||
/// déjà actifs. Personne d'autre ne peut ajouter une clé à son nom, pas même un propriétaire (qui
|
||||
/// pourrait sinon révoquer ses clés puis y ajouter la sienne). Compte sans aucun appareil actif :
|
||||
/// restauration sur le VPS (outil en ligne de commande, « add-key »).
|
||||
///
|
||||
/// Garde-fous : on ne change pas son propre rôle, on ne se révoque pas soi-même, on ne révoque pas
|
||||
/// la clé de sa propre session, et il reste toujours au moins un propriétaire capable de se connecter.
|
||||
/// </summary>
|
||||
public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
public string Name => "Admins";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var manage = api.MapGroup("").RequirePermission(AdminPermissions.AdminsManage);
|
||||
|
||||
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 ;
|
||||
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
|
||||
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();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/role", ChangeRole).RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/revoke", RevokeAdmin).RequireSignedRequest();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private static IResult List(AdminStore store) =>
|
||||
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
|
||||
|
||||
private static IResult ListInvitations(AdminStore store, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
return Results.Json(store.Read().Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(ToSummary).ToList(), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Invitations
|
||||
|
||||
private static IResult CreateInvitation(CreateInvitationRequest request, HttpContext context, AdminStore store,
|
||||
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var code = InvitationCodes.Generate();
|
||||
var limits = options.Value;
|
||||
|
||||
var invitation = store.Update(db =>
|
||||
{
|
||||
if (request.ForAdminId == actor.Admin.Id) EnsureActor(db, actor, AdminPermission.None); // son propre appareil
|
||||
else EnsureActor(db, actor);
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
|
||||
if (!actor.Has(AdminPermissions.AdminsManage) && db.Invitations.Count(i => i.CreatedById == actor.Admin.Id) >= 2)
|
||||
throw ApiRejection.Conflict("Vous avez déjà 2 invitations en cours : utilisez-les ou attendez leur expiration.");
|
||||
if (db.Invitations.Count >= limits.Limits.MaxActiveInvitations)
|
||||
throw ApiRejection.Conflict("Trop d'invitations en cours : supprimez-en avant d'en créer une autre.");
|
||||
|
||||
string displayName, role;
|
||||
if (request.ForAdminId is { } adminId)
|
||||
{
|
||||
var target = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (target.Status == AdminStatus.Revoked)
|
||||
throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
(displayName, role) = (target.DisplayName, target.Role);
|
||||
}
|
||||
else
|
||||
{
|
||||
displayName = ApiText.CleanLine(request.DisplayName, ApiText.MaxDisplayNameLength)
|
||||
?? throw ApiRejection.BadRequest($"Nom obligatoire ({ApiText.MaxDisplayNameLength} caractères au plus).");
|
||||
role = AdminRoles.IsValid(request.Role) ? request.Role! : throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
}
|
||||
|
||||
var record = new InvitationRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CodeHash = SignedMessages.HashInvitationCode(InvitationCodes.Normalize(code)!),
|
||||
DisplayName = displayName,
|
||||
Role = role,
|
||||
ForAdminId = request.ForAdminId,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor.Admin.DisplayName,
|
||||
CreatedById = actor.Admin.Id,
|
||||
ExpiresAt = now + TimeSpan.FromHours(limits.InvitationLifetimeHours)
|
||||
};
|
||||
db.Invitations.Add(record);
|
||||
return record;
|
||||
});
|
||||
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
|
||||
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 =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
});
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Clés
|
||||
|
||||
/// <summary>Approuve une clé en attente : l'empreinte saisie doit être celle de la clé.</summary>
|
||||
private static IResult ApproveKey(Guid adminId, string keyId, ApproveKeyRequest request, HttpContext context,
|
||||
AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
// Nouvel appareil d'un compte déjà actif : seul son titulaire l'approuve (depuis un de ses
|
||||
// appareils actifs). S'il les a tous perdus : outil en ligne de commande sur le VPS (add-key).
|
||||
if (admin.Status == AdminStatus.Active && !admin.Keys.Any(k => k.Status == AdminStatus.Active && k.KeyId != key.KeyId))
|
||||
throw new ApiRejection(409, ApiErrorCodes.Conflict,
|
||||
$"{admin.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.");
|
||||
if (admin.Status == AdminStatus.Active)
|
||||
{
|
||||
EnsureActor(db, actor, AdminPermission.None);
|
||||
if (actor.Admin.Id != admin.Id)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
$"Ce nouvel appareil doit être approuvé par {admin.DisplayName} lui-même, depuis un de ses appareils déjà actifs.");
|
||||
}
|
||||
else EnsureActor(db, actor);
|
||||
if (key.Status != AdminStatus.Pending) throw ApiRejection.Conflict("Cette clé n'est pas en attente d'approbation.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
if (!AdminKeys.FingerprintMatches(key.PublicKeyBytes(), request.Fingerprint))
|
||||
{
|
||||
Audit(audit, context, "admins.key_approve", $"{admin.DisplayName} / clé {keyId}", "empreinte différente", AuditResults.Denied);
|
||||
throw new ApiRejection(400, ApiErrorCodes.FingerprintMismatch,
|
||||
"L'empreinte saisie ne correspond pas à cette clé. Ne l'approuvez pas : vérifiez-la de nouveau avec la personne invitée.");
|
||||
}
|
||||
|
||||
key.Status = AdminStatus.Active;
|
||||
key.ApprovedAt = now;
|
||||
key.ApprovedBy = actor.Admin.DisplayName;
|
||||
if (admin.Status == AdminStatus.Pending)
|
||||
{
|
||||
admin.Status = AdminStatus.Active;
|
||||
admin.ApprovedAt = now;
|
||||
admin.ApprovedBy = actor.Admin.DisplayName;
|
||||
}
|
||||
return admin.DisplayName;
|
||||
});
|
||||
Audit(audit, context, "admins.key_approve", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult RevokeKey(Guid adminId, string keyId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (string.Equals(keyId, actor.Key.KeyId, StringComparison.Ordinal))
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer la clé de votre session en cours.");
|
||||
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
if (key.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cette clé est déjà révoquée.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = time.GetUtcNow();
|
||||
key.RevokedBy = actor.Admin.DisplayName;
|
||||
EnsureUsableOwner(db);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
sessions.RemoveWhere(s => s.KeyId == keyId);
|
||||
Audit(audit, context, "admins.key_revoke", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
|
||||
private static IResult ChangeRole(Guid adminId, ChangeRoleRequest request, HttpContext context, AdminStore store, AuditLog audit)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre rôle.");
|
||||
if (!AdminRoles.IsValid(request.Role)) throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
|
||||
var (name, previous) = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
var old = admin.Role;
|
||||
admin.Role = request.Role;
|
||||
EnsureUsableOwner(db);
|
||||
return (admin.DisplayName, old);
|
||||
});
|
||||
// Les droits sont relus à chaque requête : le nouveau rôle s'applique immédiatement.
|
||||
Audit(audit, context, "admins.role", name, $"{previous} → {request.Role}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult RevokeAdmin(Guid adminId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer votre propre compte.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est déjà révoqué.");
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor.Admin.DisplayName;
|
||||
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
|
||||
{
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = now;
|
||||
key.RevokedBy = actor.Admin.DisplayName;
|
||||
}
|
||||
db.Invitations.RemoveAll(i => i.ForAdminId == adminId);
|
||||
EnsureUsableOwner(db);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
sessions.RemoveWhere(s => s.AdminId == adminId);
|
||||
Audit(audit, context, "admins.revoke", name);
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static (AdminRecord Admin, KeyRecord Key) FindKey(AdminDatabase db, Guid adminId, string keyId)
|
||||
{
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
var key = admin.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))
|
||||
?? throw ApiRejection.NotFound("Clé introuvable.");
|
||||
return (admin, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revérifie l'auteur DANS la modification (sous verrou) : s'il a été révoqué ou rétrogradé entre
|
||||
/// l'authentification de sa requête et l'écriture (autre propriétaire, outil en ligne de commande),
|
||||
/// rien n'est écrit.
|
||||
/// </summary>
|
||||
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage)
|
||||
{
|
||||
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
|
||||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active ||
|
||||
(required == AdminPermission.Manage && !AdminRoles.Has(current.Admin.Role, AdminPermissions.AdminsManage)))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
required == AdminPermission.Manage ? "Vos droits ne permettent pas cette action." : "Vos droits ont changé entre-temps : action refusée.");
|
||||
}
|
||||
|
||||
private enum AdminPermission { None, Manage }
|
||||
|
||||
/// <summary>Annule la modification s'il ne reste plus aucun propriétaire capable de se connecter.</summary>
|
||||
private static void EnsureUsableOwner(AdminDatabase db)
|
||||
{
|
||||
if (!db.UsableOwners().Any())
|
||||
throw new ApiRejection(409, ApiErrorCodes.LastOwner,
|
||||
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
|
||||
string result = AuditResults.Ok)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, result, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
|
||||
internal static AdminSummary ToSummary(AdminRecord admin) => new()
|
||||
{
|
||||
Id = admin.Id,
|
||||
DisplayName = admin.DisplayName,
|
||||
Role = admin.Role,
|
||||
Status = admin.Status,
|
||||
CreatedAt = admin.CreatedAt,
|
||||
Keys = admin.Keys.Select(k => new AdminKeySummary
|
||||
{
|
||||
KeyId = k.KeyId,
|
||||
Label = k.Label,
|
||||
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()),
|
||||
AddedAt = k.AddedAt,
|
||||
LastUsedAt = k.LastUsedAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static InvitationSummary ToSummary(InvitationRecord invitation) => new()
|
||||
{
|
||||
Id = invitation.Id,
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
ForAdminId = invitation.ForAdminId,
|
||||
CreatedAt = invitation.CreatedAt,
|
||||
CreatedBy = invitation.CreatedBy,
|
||||
ExpiresAt = invitation.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Audit;
|
||||
|
||||
/// <summary>Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord.</summary>
|
||||
public sealed class AuditModule : IApiModule
|
||||
{
|
||||
public string Name => "Audit";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<AuditLog>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api) =>
|
||||
api.MapGet(ApiRoutes.Audit, (int? limit, string? cursor, AuditLog audit) =>
|
||||
{
|
||||
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
return Results.Json(audit.Read(limit ?? 50, cursor), ApiJson.Options);
|
||||
}).RequirePermission(AdminPermissions.AuditRead);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authentification par clé : défi, connexion (signature du défi), session, déconnexion, et
|
||||
/// enrôlement d'une clé avec un code d'invitation (preuve de possession de la clé).
|
||||
/// </summary>
|
||||
public sealed class AuthModule : IApiModule
|
||||
{
|
||||
private const string LoginRefused = "Connexion refusée.";
|
||||
|
||||
public string Name => "Auth";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<ChallengeStore>();
|
||||
services.AddSingleton<SessionStore>();
|
||||
services.AddSingleton<ReplayCache>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapPost(ApiRoutes.AuthChallenge, Challenge).RequireRateLimiting(RateLimitPolicies.Auth);
|
||||
api.MapPost(ApiRoutes.AuthLogin, Login).RequireRateLimiting(RateLimitPolicies.Auth);
|
||||
api.MapPost(ApiRoutes.AuthLogout, Logout).RequireAdmin();
|
||||
api.MapGet(ApiRoutes.AuthMe, Me).RequireAdmin();
|
||||
api.MapPost(ApiRoutes.Enrollment, Enroll).RequireRateLimiting(RateLimitPolicies.Enrollment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Défi de connexion. Même réponse que la clé soit connue ou non (pas d'énumération des clés) :
|
||||
/// la vérification a lieu à la connexion.
|
||||
/// </summary>
|
||||
private static IResult Challenge(ChallengeRequest request, ChallengeStore challenges, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
if (!AdminKeys.IsValidKeyId(request.KeyId)) return ApiResults.BadRequest("Identifiant de clé invalide.");
|
||||
var challenge = challenges.Create(request.KeyId);
|
||||
return Results.Json(new ChallengeResponse
|
||||
{
|
||||
ChallengeId = challenge.Id,
|
||||
Nonce = challenge.Nonce,
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ExpiresAt = challenge.ExpiresAt,
|
||||
ServerTime = time.GetUtcNow()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Login(LoginRequest request, HttpContext context, ChallengeStore challenges, SessionStore sessions,
|
||||
AdminStore store, ServerIdentity server, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var ip = context.ClientIp();
|
||||
var signature = AdminKeys.DecodeSignature(request.Signature);
|
||||
if (!AdminKeys.IsValidKeyId(request.KeyId) || string.IsNullOrEmpty(request.ChallengeId) ||
|
||||
request.ChallengeId.Length > ChallengeStore.MaxIdLength || signature is null)
|
||||
return ApiResults.BadRequest("Demande de connexion incomplète.");
|
||||
|
||||
// 1. Échecs AVANT toute signature valide (défi, clé ou signature) : bruit possible, journal limité.
|
||||
AdminRecord? admin = null;
|
||||
KeyRecord? key = null;
|
||||
string? unsigned = null;
|
||||
if (!challenges.TryValidate(request.ChallengeId, request.KeyId, out var challenge))
|
||||
unsigned = "défi inconnu, expiré ou destiné à une autre clé";
|
||||
else if (store.Read().FindKey(request.KeyId) is not { } found)
|
||||
unsigned = "clé inconnue";
|
||||
else
|
||||
{
|
||||
(admin, key) = found;
|
||||
if (!AdminKeys.Verify(key.PublicKeyBytes(),
|
||||
SignedMessages.Login(server.Origin, server.ServerId, challenge.Id, challenge.Nonce, key.KeyId), signature))
|
||||
unsigned = "signature invalide";
|
||||
}
|
||||
if (unsigned is not null || admin is null || key is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("auth.login", request.KeyId, ip, unsigned);
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
// 2. Signature VALIDE mais connexion refusée (clé révoquée ou en attente, défi rejoué) : signal fort,
|
||||
// toujours journalisé en détail (ex. clé volée puis révoquée encore utilisée).
|
||||
// (Le défi n'est marqué « utilisé » qu'une fois la connexion acceptée : une clé révoquée ou en
|
||||
// attente ne peut pas remplir la liste des défis utilisés.)
|
||||
string? refused = null;
|
||||
if (admin.Status != AdminStatus.Active) refused = $"administrateur {admin.Status}";
|
||||
else if (key.Status != AdminStatus.Active) refused = $"clé {key.Status}";
|
||||
else if (!challenges.TryMarkUsed(challenge)) refused = "défi déjà utilisé (rejeu)";
|
||||
if (refused is not null)
|
||||
{
|
||||
audit.WriteSignedRefusal("auth.login", admin.Id, admin.DisplayName, key.KeyId, ip, refused);
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
|
||||
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
|
||||
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
|
||||
// admins.json à chaque connexion).
|
||||
var now = time.GetUtcNow();
|
||||
if (key.LastUsedAt is null || now - key.LastUsedAt > TimeSpan.FromHours(1))
|
||||
{
|
||||
try
|
||||
{
|
||||
store.Update(db =>
|
||||
{
|
||||
if (db.FindKey(key.KeyId) is { } current) current.Key.LastUsedAt = now;
|
||||
});
|
||||
}
|
||||
catch (StoreBusyException)
|
||||
{
|
||||
// sans conséquence
|
||||
}
|
||||
}
|
||||
|
||||
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
|
||||
var authenticated = new AuthenticatedAdmin(session, admin, key, AdminRoles.PermissionsOf(admin.Role));
|
||||
return Results.Json(new LoginResponse
|
||||
{
|
||||
Token = token,
|
||||
SessionId = session.Id,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
IdleExpiresAt = sessions.IdleExpiresAt(session),
|
||||
Admin = authenticated.ToProfile()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
sessions.Remove(admin.Session);
|
||||
audit.Write("auth.logout", AuditResults.Ok, admin.Admin.Id, admin.Admin.DisplayName, admin.Key.KeyId, context.ClientIp());
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult Me(HttpContext context, SessionStore sessions)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
return Results.Json(new SessionInfo
|
||||
{
|
||||
SessionId = admin.Session.Id,
|
||||
ExpiresAt = admin.Session.ExpiresAt,
|
||||
IdleExpiresAt = sessions.IdleExpiresAt(admin.Session),
|
||||
Admin = admin.ToProfile()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrôlement d'une clé avec un code d'invitation. La clé (et, pour une nouvelle personne, le
|
||||
/// compte) reste EN ATTENTE jusqu'à l'approbation par un propriétaire, qui doit saisir l'empreinte
|
||||
/// communiquée par la personne invitée. Le code est consommé (usage unique).
|
||||
/// </summary>
|
||||
private static IResult Enroll(EnrollmentRequest request, HttpContext context, AdminStore store, ServerIdentity server,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var ip = context.ClientIp();
|
||||
var code = InvitationCodes.Normalize(request.InvitationCode);
|
||||
var proof = AdminKeys.DecodeSignature(request.Proof);
|
||||
if (code is null || proof is null) return Reject("code ou preuve mal formés");
|
||||
|
||||
byte[] spki;
|
||||
try
|
||||
{
|
||||
spki = AdminKeys.ParsePublicKey(request.PublicKey);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return ApiResults.BadRequest("Clé publique invalide : une clé ECDSA P-256 est attendue.");
|
||||
}
|
||||
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var codeHash = SignedMessages.HashInvitationCode(code);
|
||||
if (!AdminKeys.Verify(spki, SignedMessages.Enrollment(server.Origin, server.ServerId, codeHash, keyId), proof))
|
||||
return Reject("preuve de possession invalide", keyId);
|
||||
|
||||
var label = ApiText.CleanLine(request.KeyLabel, ApiText.MaxKeyLabelLength);
|
||||
if (request.KeyLabel is not null && label is null)
|
||||
return ApiResults.BadRequest($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus).");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var result = store.Update(db =>
|
||||
{
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
|
||||
var invitation = db.Invitations.FirstOrDefault(i => string.Equals(i.CodeHash, codeHash, StringComparison.Ordinal));
|
||||
if (invitation is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("admins.enroll", keyId, ip, "code inconnu, expiré ou déjà utilisé");
|
||||
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
if (db.FindKey(keyId) is not null)
|
||||
throw ApiRejection.Conflict("Cette clé est déjà enregistrée.");
|
||||
|
||||
var key = new KeyRecord
|
||||
{
|
||||
KeyId = keyId,
|
||||
PublicKey = Convert.ToBase64String(spki),
|
||||
Label = label,
|
||||
Status = AdminStatus.Pending,
|
||||
AddedAt = now
|
||||
};
|
||||
|
||||
AdminRecord admin;
|
||||
if (invitation.ForAdminId is { } existingId)
|
||||
{
|
||||
admin = db.FindAdmin(existingId) ?? throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid,
|
||||
"Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
if (admin.Status == AdminStatus.Revoked)
|
||||
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
admin = new AdminRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
Status = AdminStatus.Pending,
|
||||
CreatedAt = now,
|
||||
CreatedBy = invitation.CreatedBy
|
||||
};
|
||||
db.Admins.Add(admin);
|
||||
}
|
||||
admin.Keys.Add(key);
|
||||
db.Invitations.Remove(invitation); // usage unique
|
||||
return (admin.Id, admin.DisplayName, admin.Status, InvitationId: invitation.Id);
|
||||
});
|
||||
|
||||
audit.Write("admins.enroll", AuditResults.Ok, result.Id, result.DisplayName, keyId, ip,
|
||||
target: $"invitation {result.InvitationId:D}", details: label);
|
||||
return Results.Json(new EnrollmentResponse
|
||||
{
|
||||
AdminId = result.Id,
|
||||
DisplayName = result.DisplayName,
|
||||
KeyId = keyId,
|
||||
Fingerprint = AdminKeys.ComputeFingerprint(spki),
|
||||
Status = AdminStatus.Pending
|
||||
}, ApiJson.Options);
|
||||
|
||||
IResult Reject(string reason, string? key = null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("admins.enroll", key, ip, reason);
|
||||
return ApiResults.Error(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
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)" : ""));
|
||||
|
||||
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).");
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", Describe(previous, published));
|
||||
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,35 @@
|
||||
using System.Reflection;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.System;
|
||||
|
||||
/// <summary>Informations publiques du serveur et état de santé.</summary>
|
||||
public sealed class SystemModule : IApiModule
|
||||
{
|
||||
public string Name => "System";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var version = typeof(SystemModule).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? "?";
|
||||
var plus = version.IndexOf('+');
|
||||
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
|
||||
|
||||
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
|
||||
{
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ApiVersion = ApiRoutes.Version,
|
||||
ServerVersion = version,
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm }
|
||||
}, ApiJson.Options));
|
||||
|
||||
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Server.Hosting;
|
||||
|
||||
namespace GameLauncher.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Sans commande : démarre la Launcher API (service systemd).
|
||||
/// Avec une commande (add-owner, list, revoke-admin, revoke-key, server-id, help) : outil
|
||||
/// d'administration en ligne de commande, à lancer SUR LE VPS sous le compte du service.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (AdminCli.IsCommand(args)) return AdminCli.Run(args);
|
||||
|
||||
var app = ServerApp.Build(args);
|
||||
await app.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"GameLauncher.Server": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://127.0.0.1:5080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
|
||||
{
|
||||
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
public AdminProfile ToProfile() => new()
|
||||
{
|
||||
Id = Admin.Id,
|
||||
DisplayName = Admin.DisplayName,
|
||||
Role = Admin.Role,
|
||||
Permissions = Permissions,
|
||||
KeyId = Key.KeyId,
|
||||
KeyLabel = Key.Label
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
|
||||
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le rôle est relu
|
||||
/// à chaque requête : un changement de rôle s'applique aussitôt.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AdminSession";
|
||||
public const string PermissionClaim = "perm";
|
||||
private const string ContextKey = "GameLauncher.Admin";
|
||||
|
||||
private readonly SessionStore _sessions;
|
||||
private readonly AdminStore _store;
|
||||
|
||||
public AdminSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
|
||||
SessionStore sessions, AdminStore store)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public static AuthenticatedAdmin? Get(HttpContext context) =>
|
||||
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedAdmin : null;
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(header)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
|
||||
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var match = _store.Read().FindKey(session.KeyId);
|
||||
if (match is not { } found || found.Admin.Id != session.AdminId ||
|
||||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
|
||||
{
|
||||
_sessions.Remove(session);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
|
||||
}
|
||||
|
||||
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
|
||||
new(ClaimTypes.Name, found.Admin.DisplayName),
|
||||
new(ClaimTypes.Role, found.Admin.Role),
|
||||
new("key", found.Key.KeyId),
|
||||
new("sid", session.Id)
|
||||
};
|
||||
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
|
||||
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Authentification requise (session absente ou expirée).");
|
||||
|
||||
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
|
||||
}
|
||||
|
||||
public static class AdminAuthorization
|
||||
{
|
||||
public static string PolicyName(string permission) => "perm:" + permission;
|
||||
|
||||
/// <summary>Route réservée aux administrateurs disposant de cette permission.</summary>
|
||||
public static TBuilder RequirePermission<TBuilder>(this TBuilder builder, string permission)
|
||||
where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PolicyName(permission));
|
||||
|
||||
/// <summary>Route réservée à un administrateur connecté (quelles que soient ses permissions).</summary>
|
||||
public static TBuilder RequireAdmin<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(AdminSessionHandler.SchemeName);
|
||||
|
||||
public static IServiceCollection AddAdminAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AdminSessionHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
|
||||
foreach (var permission in AdminPermissions.All)
|
||||
options.AddPolicy(PolicyName(permission),
|
||||
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Administrateur authentifié de la requête (les routes protégées en ont toujours un).</summary>
|
||||
public static AuthenticatedAdmin Admin(this HttpContext context) =>
|
||||
AdminSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequireAdmin/RequirePermission.");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Défi de connexion : à signer avec la clé, valable quelques secondes, utilisable une seule fois.</summary>
|
||||
public sealed record LoginChallenge(string Id, string Nonce, string KeyId, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Défis de connexion SANS ÉTAT : l'identifiant du défi contient la clé visée, l'échéance et le
|
||||
/// nonce, scellés par un HMAC (secret aléatoire, en mémoire). Il n'existe donc aucune réserve
|
||||
/// partagée qu'un attaquant pourrait remplir ou vider : demander des défis ne coûte rien au serveur.
|
||||
/// Seuls les défis UTILISÉS avec succès sont mémorisés (jusqu'à leur échéance) pour refuser le rejeu ;
|
||||
/// on n'y entre qu'avec une signature valide.
|
||||
/// </summary>
|
||||
public sealed class ChallengeStore
|
||||
{
|
||||
private const int NonceLength = 32;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 8 + 8 + NonceLength; // clé (8 octets) + échéance + nonce
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Admin/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly TimeSpan _lifetime;
|
||||
private readonly int _maxUsed;
|
||||
|
||||
public ChallengeStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_lifetime = TimeSpan.FromSeconds(options.Value.ChallengeLifetimeSeconds);
|
||||
_maxUsed = 100_000;
|
||||
}
|
||||
|
||||
/// <summary>Longueur maximale d'un identifiant de défi (contrôle des entrées).</summary>
|
||||
public const int MaxIdLength = 96;
|
||||
|
||||
public LoginChallenge Create(string keyId)
|
||||
{
|
||||
var expires = _time.GetUtcNow() + _lifetime;
|
||||
var payload = new byte[PayloadLength];
|
||||
Convert.FromHexString(keyId).CopyTo(payload, 0);
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(8), expires.ToUnixTimeSeconds());
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(16));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new LoginChallenge(Base64Url.Encode(id), Base64Url.Encode(payload.AsSpan(16)), keyId,
|
||||
DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds()));
|
||||
}
|
||||
|
||||
/// <summary>Vérifie le sceau, la clé visée et l'échéance (sans consommer le défi).</summary>
|
||||
public bool TryValidate(string challengeId, string keyId, out LoginChallenge challenge)
|
||||
{
|
||||
challenge = null!;
|
||||
if (challengeId.Length > MaxIdLength || !Base64Url.TryDecode(challengeId, out var id) ||
|
||||
id.Length != PayloadLength + MacLength || !AdminKeysHex(keyId, out var keyBytes))
|
||||
return false;
|
||||
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
|
||||
if (!payload[..8].SequenceEqual(keyBytes)) return false;
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[8..16]));
|
||||
if (expires <= _time.GetUtcNow() || expires > _time.GetUtcNow() + _lifetime + TimeSpan.FromSeconds(1)) return false;
|
||||
|
||||
challenge = new LoginChallenge(challengeId, Base64Url.Encode(payload[16..]), keyId, expires);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Marque le défi comme utilisé (après vérification de la signature). Faux s'il l'était déjà (rejeu).</summary>
|
||||
public bool TryMarkUsed(LoginChallenge challenge)
|
||||
{
|
||||
if (_used.Count >= _maxUsed)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
foreach (var (id, expires) in _used)
|
||||
if (expires <= now) _used.TryRemove(id, out _);
|
||||
if (_used.Count >= _maxUsed) throw new StoreBusyException("Trop de connexions récentes.");
|
||||
}
|
||||
return _used.TryAdd(challenge.Id, challenge.ExpiresAt);
|
||||
}
|
||||
|
||||
private byte[] Mac(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var data = new byte[Domain.Length + payload.Length];
|
||||
Domain.CopyTo(data, 0);
|
||||
payload.CopyTo(data.AsSpan(Domain.Length));
|
||||
return HMACSHA256.HashData(_secret, data)[..MacLength];
|
||||
}
|
||||
|
||||
private static bool AdminKeysHex(string keyId, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (!Contracts.Security.AdminKeys.IsValidKeyId(keyId)) return false;
|
||||
bytes = Convert.FromHexString(keyId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Nonces des requêtes signées déjà vus (pendant la fenêtre de tolérance d'horloge) : refus des rejeux.</summary>
|
||||
public sealed class ReplayCache
|
||||
{
|
||||
private const int Max = 100_000;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _seen = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ReplayCache(TimeProvider time) => _time = time;
|
||||
|
||||
/// <summary>Faux si ce nonce a déjà été utilisé.</summary>
|
||||
public bool TryRegister(string key, TimeSpan keepFor)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_seen.Count >= Max)
|
||||
{
|
||||
foreach (var (k, expires) in _seen)
|
||||
if (expires <= now) _seen.TryRemove(k, out _);
|
||||
if (_seen.Count >= Max) throw new StoreBusyException("Trop de requêtes signées récentes.");
|
||||
}
|
||||
|
||||
if (_seen.TryAdd(key, now + keepFor)) return true;
|
||||
// Entrée expirée encore présente : elle peut être remplacée.
|
||||
if (_seen.TryGetValue(key, out var existing) && existing <= now && _seen.TryUpdate(key, now + keepFor, existing))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Session d'un administrateur (en mémoire uniquement).</summary>
|
||||
public sealed class AdminSessionState
|
||||
{
|
||||
/// <summary>Identifiant public de la session (inclus dans les requêtes signées).</summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>SHA-256 du jeton : le jeton lui-même n'est jamais conservé.</summary>
|
||||
public required string TokenHash { get; init; }
|
||||
|
||||
public required Guid AdminId { get; init; }
|
||||
public required string KeyId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? Ip { get; init; }
|
||||
public string? ClientName { get; init; }
|
||||
|
||||
private long _lastSeenTicks;
|
||||
|
||||
public DateTimeOffset LastSeenAt
|
||||
{
|
||||
get => new(Interlocked.Read(ref _lastSeenTicks), TimeSpan.Zero);
|
||||
set => Interlocked.Exchange(ref _lastSeenTicks, value.UtcTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sessions en mémoire : un redémarrage du service déconnecte tout le monde. Durée d'inactivité
|
||||
/// et durée maximale bornées ; nombre de sessions borné par administrateur et au total.
|
||||
/// </summary>
|
||||
public sealed class SessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AdminSessionState> _byTokenHash = new(StringComparer.Ordinal);
|
||||
private readonly object _createLock = new();
|
||||
private readonly TimeProvider _time;
|
||||
private readonly LauncherApiOptions.SessionSettings _settings;
|
||||
|
||||
public SessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_settings = options.Value.Sessions;
|
||||
}
|
||||
|
||||
public TimeSpan IdleTimeout => TimeSpan.FromMinutes(_settings.IdleMinutes);
|
||||
|
||||
public int Count => _byTokenHash.Count;
|
||||
|
||||
/// <summary>Crée une session ; renvoie le jeton (à transmettre une seule fois au client).</summary>
|
||||
public (string Token, AdminSessionState Session) Create(Guid adminId, string keyId, string? ip, string? clientName)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var token = Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
var session = new AdminSessionState
|
||||
{
|
||||
Id = Base64Url.Encode(RandomNumberGenerator.GetBytes(16)),
|
||||
TokenHash = Hash(token),
|
||||
AdminId = adminId,
|
||||
KeyId = keyId,
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now + TimeSpan.FromHours(_settings.MaxHours),
|
||||
Ip = ip,
|
||||
ClientName = clientName,
|
||||
LastSeenAt = now
|
||||
};
|
||||
|
||||
lock (_createLock)
|
||||
{
|
||||
RemoveExpired();
|
||||
// Au-delà du nombre autorisé pour cet administrateur : les plus anciennes sont fermées.
|
||||
var own = _byTokenHash.Values.Where(s => s.AdminId == adminId).OrderBy(s => s.CreatedAt).ToList();
|
||||
foreach (var old in own.Take(Math.Max(0, own.Count - _settings.MaxPerAdmin + 1)))
|
||||
_byTokenHash.TryRemove(old.TokenHash, out _);
|
||||
if (_byTokenHash.Count >= _settings.MaxTotal) throw new StoreBusyException("Trop de sessions ouvertes.");
|
||||
_byTokenHash[session.TokenHash] = session;
|
||||
}
|
||||
return (token, session);
|
||||
}
|
||||
|
||||
/// <summary>Session correspondant au jeton, si elle est encore valide ; l'activité est enregistrée.</summary>
|
||||
public AdminSessionState? Find(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || token.Length > 256) return null;
|
||||
var hash = Hash(token);
|
||||
if (!_byTokenHash.TryGetValue(hash, out var session)) return null;
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
if (IsExpired(session, now))
|
||||
{
|
||||
_byTokenHash.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
session.LastSeenAt = now;
|
||||
return session;
|
||||
}
|
||||
|
||||
public DateTimeOffset IdleExpiresAt(AdminSessionState session)
|
||||
{
|
||||
var idle = session.LastSeenAt + IdleTimeout;
|
||||
return idle < session.ExpiresAt ? idle : session.ExpiresAt;
|
||||
}
|
||||
|
||||
public void Remove(AdminSessionState session) => _byTokenHash.TryRemove(session.TokenHash, out _);
|
||||
|
||||
public int RemoveWhere(Func<AdminSessionState, bool> predicate)
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var (hash, session) in _byTokenHash)
|
||||
if (predicate(session) && _byTokenHash.TryRemove(hash, out _)) removed++;
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void RemoveExpired()
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
RemoveWhere(s => IsExpired(s, now));
|
||||
}
|
||||
|
||||
private bool IsExpired(AdminSessionState session, DateTimeOffset now) =>
|
||||
now >= session.ExpiresAt || now - session.LastSeenAt >= IdleTimeout;
|
||||
|
||||
private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Requêtes signées : toute route qui modifie quelque chose exige, en plus de la session, une
|
||||
/// signature de la clé de l'administrateur sur la méthode, le chemin, le corps, l'heure et un nonce
|
||||
/// (<see cref="SignedMessages.Request"/>). Un jeton de session volé ne permet donc aucune modification.
|
||||
/// </summary>
|
||||
public static class SignedRequests
|
||||
{
|
||||
private const string BodyKey = "GameLauncher.Body";
|
||||
|
||||
/// <summary>
|
||||
/// Middleware : copie en mémoire le corps des requêtes qui modifient (taille bornée par Kestrel),
|
||||
/// pour pouvoir le signer après la lecture JSON par la route.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseRequestBodyCapture(this IApplicationBuilder app) => app.Use(async (context, next) =>
|
||||
{
|
||||
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();
|
||||
context.Items[BodyKey] = body;
|
||||
context.Request.Body = new MemoryStream(body, writable: false);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
/// <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) =>
|
||||
{
|
||||
var context = invocation.HttpContext;
|
||||
var services = context.RequestServices;
|
||||
var failure = Verify(context, services.GetRequiredService<ServerIdentity>(), services.GetRequiredService<ReplayCache>(),
|
||||
services.GetRequiredService<TimeProvider>());
|
||||
if (failure is null) return await next(invocation);
|
||||
|
||||
var admin = AdminSessionHandler.Get(context);
|
||||
services.GetRequiredService<AuditLog>().Write("request.signature", AuditResults.Denied, admin?.Admin.Id,
|
||||
admin?.Admin.DisplayName, admin?.Key.KeyId, context.ClientIp(), $"{context.Request.Method} {context.Request.Path}",
|
||||
failure.Value.Message);
|
||||
return ApiResults.Unauthorized(failure.Value.Message, failure.Value.Code);
|
||||
});
|
||||
|
||||
private static (string Code, string Message)? Verify(HttpContext context, ServerIdentity server, ReplayCache replays, TimeProvider time)
|
||||
{
|
||||
var admin = AdminSessionHandler.Get(context);
|
||||
if (admin is null) return (ApiErrorCodes.Unauthorized, "Authentification requise.");
|
||||
|
||||
var headers = context.Request.Headers;
|
||||
var timestampText = headers[SignedRequestHeaders.Timestamp].ToString();
|
||||
var nonce = headers[SignedRequestHeaders.Nonce].ToString();
|
||||
var signature = AdminKeys.DecodeSignature(headers[SignedRequestHeaders.Signature].ToString());
|
||||
if (string.IsNullOrEmpty(timestampText) || string.IsNullOrEmpty(nonce) || signature is null)
|
||||
return (ApiErrorCodes.SignatureRequired, "Cette action doit être signée par votre clé.");
|
||||
|
||||
if (!long.TryParse(timestampText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var timestamp))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
|
||||
if (nonce.Length is < 16 or > 64 || !Base64Url.TryDecode(nonce, out _))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Nonce de signature invalide.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var skew = SignedRequestHeaders.MaxClockSkew;
|
||||
DateTimeOffset signedAt;
|
||||
try
|
||||
{
|
||||
signedAt = DateTimeOffset.FromUnixTimeSeconds(timestamp);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
|
||||
}
|
||||
if (signedAt < now - skew || signedAt > now + skew)
|
||||
return (ApiErrorCodes.ClockSkew, "L'horloge de votre PC est décalée de plus de 2 minutes : corrigez l'heure de Windows.");
|
||||
|
||||
var body = context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
|
||||
var request = context.Request;
|
||||
var message = SignedMessages.Request(server.Origin, server.ServerId, admin.Session.Id, request.Method,
|
||||
request.PathBase.Add(request.Path).ToUriComponent() + request.QueryString.ToUriComponent(), body, timestamp, nonce);
|
||||
if (!AdminKeys.Verify(admin.Key.PublicKeyBytes(), message, signature))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Signature de la requête invalide.");
|
||||
|
||||
// Nonce enregistré seulement une fois la signature vérifiée.
|
||||
if (!replays.TryRegister(admin.Session.Id + ":" + nonce, skew + skew + TimeSpan.FromMinutes(1)))
|
||||
return (ApiErrorCodes.Replay, "Requête déjà reçue (rejeu refusé).");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Contenu de admins.json. Ne contient AUCUN secret : clés publiques et empreintes de codes
|
||||
/// d'invitation uniquement.
|
||||
/// </summary>
|
||||
public sealed class AdminDatabase
|
||||
{
|
||||
public int FormatVersion { get; set; } = 1;
|
||||
public List<AdminRecord> Admins { get; set; } = new();
|
||||
public List<InvitationRecord> Invitations { get; set; } = new();
|
||||
|
||||
public AdminRecord? FindAdmin(Guid id) => Admins.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <summary>Administrateur possédant cette clé (quel que soit l'état de la clé).</summary>
|
||||
public (AdminRecord Admin, KeyRecord Key)? FindKey(string keyId)
|
||||
{
|
||||
foreach (var admin in Admins)
|
||||
foreach (var key in admin.Keys)
|
||||
if (string.Equals(key.KeyId, keyId, StringComparison.Ordinal)) return (admin, key);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Propriétaires actifs disposant d'au moins une clé active (ceux qui peuvent encore se connecter).</summary>
|
||||
public IEnumerable<AdminRecord> UsableOwners() =>
|
||||
Admins.Where(a => a.Role == AdminRoles.Owner && a.Status == AdminStatus.Active &&
|
||||
a.Keys.Any(k => k.Status == AdminStatus.Active));
|
||||
}
|
||||
|
||||
public sealed class AdminRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public List<KeyRecord> Keys { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class KeyRecord
|
||||
{
|
||||
public string KeyId { get; set; } = "";
|
||||
|
||||
/// <summary>Clé publique : SPKI en Base64.</summary>
|
||||
public string PublicKey { get; set; } = "";
|
||||
|
||||
public string? Label { get; set; }
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset AddedAt { get; set; }
|
||||
public DateTimeOffset? LastUsedAt { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
|
||||
public byte[] PublicKeyBytes() => Convert.FromBase64String(PublicKey);
|
||||
}
|
||||
|
||||
public sealed class InvitationRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Empreinte du code (<see cref="SignedMessages.HashInvitationCode"/>) ; le code n'est jamais stocké.</summary>
|
||||
public string CodeHash { get; set; } = "";
|
||||
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
|
||||
/// <summary>Invitation « nouvel appareil » pour un administrateur existant.</summary>
|
||||
public Guid? ForAdminId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public Guid? CreatedById { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Administrateurs et invitations (admins.json dans le dossier des données).
|
||||
/// • Lecture : copie en cache, relue automatiquement si le fichier a changé (outil en ligne de
|
||||
/// commande) — une révocation faite sur le VPS prend effet sans redémarrer le service.
|
||||
/// • Écriture : <see cref="Update{T}"/> relit le fichier sous verrou inter-processus, applique la
|
||||
/// modification sur une copie, puis remplace le fichier atomiquement (ancienne version en .bak).
|
||||
/// • Fichier illisible : le serveur refuse tout (aucune reprise silencieuse d'une sauvegarde, qui
|
||||
/// pourrait réactiver un administrateur révoqué).
|
||||
/// </summary>
|
||||
public sealed class AdminStore
|
||||
{
|
||||
public const string FileName = "admins.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = true };
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly object _cacheLock = new(); // court : échange du cache
|
||||
private readonly object _writeLock = new(); // un seul écrivain dans ce processus
|
||||
private AdminDatabase? _cache;
|
||||
private (DateTime WriteTime, long Length) _cacheStamp;
|
||||
|
||||
public AdminStore(IOptions<LauncherApiOptions> options) : this(options.Value.DataDirectoryFullPath) { }
|
||||
|
||||
public AdminStore(string dataDirectory)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
}
|
||||
|
||||
public string FilePath => _path;
|
||||
|
||||
/// <summary>
|
||||
/// Instantané en lecture seule (ne pas le modifier : utiliser <see cref="Update{T}"/>).
|
||||
/// </summary>
|
||||
public AdminDatabase Read()
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
var stamp = Stamp();
|
||||
if (_cache is not null && stamp == _cacheStamp) return _cache;
|
||||
_cache = Load();
|
||||
_cacheStamp = stamp;
|
||||
return _cache;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modification atomique. <paramref name="change"/> travaille sur une copie fraîchement relue ;
|
||||
/// s'il lève une exception, rien n'est écrit.
|
||||
/// </summary>
|
||||
public T Update<T>(Func<AdminDatabase, T> change)
|
||||
{
|
||||
// Les lectures ne sont jamais bloquées par une écriture en attente du verrou fichier
|
||||
// (le renommage atomique leur donne l'ancienne ou la nouvelle version, jamais un mélange).
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(_lockPath, LockTimeout);
|
||||
var database = Load();
|
||||
var result = change(database);
|
||||
SecureFiles.WriteAllTextAtomic(_path, JsonSerializer.Serialize(database, Json), keepBackup: true);
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache = database;
|
||||
_cacheStamp = Stamp();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(Action<AdminDatabase> change) => Update(db =>
|
||||
{
|
||||
change(db);
|
||||
return true;
|
||||
});
|
||||
|
||||
private AdminDatabase Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return new AdminDatabase();
|
||||
try
|
||||
{
|
||||
var database = JsonSerializer.Deserialize<AdminDatabase>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException("fichier vide");
|
||||
if (database.FormatVersion != 1) throw new InvalidDataException($"format {database.FormatVersion} inconnu");
|
||||
database.Admins ??= new();
|
||||
database.Invitations ??= new();
|
||||
foreach (var admin in database.Admins) admin.Keys ??= new();
|
||||
return database;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
throw new InvalidDataException($"{_path} est illisible ({ex.Message}). Restaurez {FileName}.bak APRÈS l'avoir vérifié : " +
|
||||
"c'est la version précédant la dernière modification (une révocation récente y serait annulée).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private (DateTime, long) Stamp()
|
||||
{
|
||||
var info = new FileInfo(_path);
|
||||
return info.Exists ? (info.LastWriteTimeUtc, info.Length) : (DateTime.MinValue, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Journal d'audit : un fichier JSON Lines par mois (audit/audit-2026-10.jsonl), en ajout seul.
|
||||
/// Chaque action d'administration y figure (réussie ou refusée), avec l'auteur, la clé et l'adresse IP.
|
||||
/// Les entrées sont aussi écrites dans le journal du service (journalctl).
|
||||
/// </summary>
|
||||
public sealed class AuditLog
|
||||
{
|
||||
public const int MaxPageSize = 200;
|
||||
private const int MaxFieldLength = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Échecs SANS authentification (connexion refusée, code d'invitation faux) détaillés au plus
|
||||
/// ce nombre de fois par minute ; au-delà, ils sont comptés et résumés en une ligne : une
|
||||
/// avalanche de tentatives ne peut ni noyer les vraies entrées ni remplir le disque.
|
||||
/// </summary>
|
||||
public const int MaxAnonymousFailuresPerMinute = 10;
|
||||
|
||||
private readonly object _throttleLock = new();
|
||||
private DateTimeOffset _throttleWindow;
|
||||
private int _throttleCount;
|
||||
private int _suppressed;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = false };
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<AuditLog>? _logger;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public AuditLog(IOptions<LauncherApiOptions> options, TimeProvider time, ILogger<AuditLog> logger)
|
||||
: this(options.Value.DataDirectoryFullPath, time, logger) { }
|
||||
|
||||
public AuditLog(string dataDirectory, TimeProvider time, ILogger<AuditLog>? logger = null)
|
||||
{
|
||||
_directory = Path.Combine(dataDirectory, "audit");
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
SecureFiles.EnsureDirectory(_directory);
|
||||
}
|
||||
|
||||
public void Write(string action, string result, Guid? actorId = null, string? actorName = null, string? keyId = null,
|
||||
string? ip = null, string? target = null, string? details = null)
|
||||
{
|
||||
var entry = new AuditEntry
|
||||
{
|
||||
Timestamp = _time.GetUtcNow(),
|
||||
Action = Clean(action) ?? "?",
|
||||
Result = Clean(result) ?? "?",
|
||||
ActorId = actorId,
|
||||
ActorName = Clean(actorName),
|
||||
KeyId = Clean(keyId),
|
||||
Ip = Clean(ip),
|
||||
Target = Clean(target),
|
||||
Details = Clean(details)
|
||||
};
|
||||
|
||||
_logger?.LogInformation("AUDIT {Action} {Result} acteur={Actor} clé={KeyId} ip={Ip} cible={Target} {Details}",
|
||||
entry.Action, entry.Result, entry.ActorName ?? entry.ActorId?.ToString(), entry.KeyId, entry.Ip, entry.Target, entry.Details);
|
||||
|
||||
var line = JsonSerializer.Serialize(entry, Json) + "\n";
|
||||
var path = Path.Combine(_directory, $"audit-{entry.Timestamp.UtcDateTime:yyyy-MM}.jsonl");
|
||||
try
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
|
||||
using var stream = SecureFiles.OpenAppend(path);
|
||||
stream.Write(Encoding.UTF8.GetBytes(line));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or StoreBusyException)
|
||||
{
|
||||
// L'action elle-même n'est pas annulée, mais l'échec est visible dans journalctl.
|
||||
if (_logger is not null) _logger.LogError(ex, "Écriture du journal d'audit impossible : {Line}", line.TrimEnd());
|
||||
else Console.Error.WriteLine($"ATTENTION : journal d'audit non écrit ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Échec d'une requête non authentifiée (voir <see cref="MaxAnonymousFailuresPerMinute"/>).</summary>
|
||||
public void WriteAnonymousFailure(string action, string? keyId, string? ip, string? details)
|
||||
{
|
||||
int suppressed;
|
||||
lock (_throttleLock)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (now - _throttleWindow >= TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_throttleWindow = now;
|
||||
_throttleCount = 0;
|
||||
}
|
||||
if (_throttleCount >= MaxAnonymousFailuresPerMinute)
|
||||
{
|
||||
if (_suppressed++ == 0)
|
||||
_logger?.LogWarning("AUDIT afflux de tentatives refusées sans authentification (dernière : {Action} ip={Ip}) : " +
|
||||
"détail suspendu, résumé à la minute suivante.", action, ip);
|
||||
return;
|
||||
}
|
||||
_throttleCount++;
|
||||
suppressed = _suppressed;
|
||||
_suppressed = 0;
|
||||
}
|
||||
if (suppressed > 0)
|
||||
Write("audit.suppressed", Contracts.Dtos.AuditResults.Denied,
|
||||
details: $"{suppressed} tentative(s) refusée(s) sans authentification non détaillée(s) (afflux de requêtes)");
|
||||
Write(action, Contracts.Dtos.AuditResults.Denied, keyId: keyId, ip: ip, details: details);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, (DateTimeOffset Window, int Suppressed)> _perKey = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Refus d'une requête correctement SIGNÉE (clé révoquée ou en attente, rejeu) : toujours
|
||||
/// détaillé, au plus une ligne par clé et par minute (les suivantes sont comptées et signalées
|
||||
/// sur la ligne suivante). Seules les clés enregistrées produisent des signatures valides : la
|
||||
/// table reste petite.
|
||||
/// </summary>
|
||||
public void WriteSignedRefusal(string action, Guid? actorId, string? actorName, string keyId, string? ip, string details)
|
||||
{
|
||||
int suppressed;
|
||||
lock (_throttleLock)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_perKey.TryGetValue(keyId, out var state) && now - state.Window < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_perKey[keyId] = (state.Window, state.Suppressed + 1);
|
||||
return;
|
||||
}
|
||||
suppressed = state.Suppressed;
|
||||
_perKey[keyId] = (now, 0);
|
||||
if (_perKey.Count > 10_000) _perKey.Clear();
|
||||
}
|
||||
Write(action, Contracts.Dtos.AuditResults.Denied, actorId, actorName, keyId, ip,
|
||||
details: suppressed > 0 ? $"{details} (+{suppressed} refus identique(s) la minute précédente)" : details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
|
||||
/// précédente (« aaaa-MM:position »). Les fichiers sont lus depuis la fin, par blocs : la mémoire
|
||||
/// utilisée ne dépend pas de leur taille.
|
||||
/// </summary>
|
||||
public AuditPage Read(int limit, string? cursor)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, MaxPageSize);
|
||||
var months = Directory.Exists(_directory)
|
||||
? Directory.GetFiles(_directory, "audit-*.jsonl").Select(Path.GetFileName).OfType<string>()
|
||||
.Select(name => name["audit-".Length..^".jsonl".Length])
|
||||
.Where(month => DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
.OrderByDescending(month => month, StringComparer.Ordinal).ToList()
|
||||
: new List<string>();
|
||||
|
||||
string? startMonth = null;
|
||||
var startOffset = long.MaxValue;
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
var parts = cursor.Split(':');
|
||||
if (parts.Length != 2 || !months.Contains(parts[0]) ||
|
||||
!long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out startOffset))
|
||||
return new AuditPage();
|
||||
startMonth = parts[0];
|
||||
}
|
||||
|
||||
var entries = new List<AuditEntry>(limit);
|
||||
for (var m = 0; m < months.Count; m++)
|
||||
{
|
||||
var month = months[m];
|
||||
if (startMonth is not null && string.CompareOrdinal(month, startMonth) > 0) continue;
|
||||
var end = month == startMonth ? startOffset : long.MaxValue;
|
||||
// Lignes illisibles (écriture interrompue, modification manuelle) ignorées sans rien masquer :
|
||||
// la lecture continue jusqu'à obtenir « limit » entrées lisibles.
|
||||
foreach (var (start, line) in ReadBackward(Path.Combine(_directory, $"audit-{month}.jsonl"), end))
|
||||
{
|
||||
if (TryParse(line) is not { } entry) continue;
|
||||
entries.Add(entry);
|
||||
if (entries.Count == limit)
|
||||
{
|
||||
var more = start > 0 || m < months.Count - 1;
|
||||
return new AuditPage { Entries = entries, NextCursor = more ? $"{month}:{start}" : null };
|
||||
}
|
||||
}
|
||||
}
|
||||
return new AuditPage { Entries = entries };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lignes situées avant <paramref name="end"/> (octets), de la dernière à la première, avec leur
|
||||
/// position, lues par blocs depuis la fin. Une ligne démesurée (plus de 1 Mo) est sautée.
|
||||
/// </summary>
|
||||
private static IEnumerable<(long Start, string Line)> ReadBackward(string path, long end)
|
||||
{
|
||||
if (!File.Exists(path)) yield break;
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
var position = Math.Min(end, stream.Length);
|
||||
var carry = Array.Empty<byte>();
|
||||
var skipping = false; // fin de ligne démesurée abandonnée : on cherche son début
|
||||
const int blockSize = 64 * 1024;
|
||||
const int maxLine = 1024 * 1024;
|
||||
|
||||
while (position > 0)
|
||||
{
|
||||
var size = (int)Math.Min(blockSize, position);
|
||||
position -= size;
|
||||
var data = new byte[size + carry.Length];
|
||||
stream.Position = position;
|
||||
stream.ReadExactly(data, 0, size);
|
||||
carry.CopyTo(data, size);
|
||||
|
||||
var lineEnd = data.Length;
|
||||
for (var i = data.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (data[i] != (byte)'\n') continue;
|
||||
if (!skipping && lineEnd > i + 1) yield return (position + i + 1, Encoding.UTF8.GetString(data, i + 1, lineEnd - i - 1));
|
||||
skipping = false;
|
||||
lineEnd = i;
|
||||
}
|
||||
carry = data[..lineEnd];
|
||||
if (carry.Length > maxLine)
|
||||
{
|
||||
carry = Array.Empty<byte>();
|
||||
skipping = true;
|
||||
}
|
||||
}
|
||||
if (!skipping && carry.Length > 0) yield return (0, Encoding.UTF8.GetString(carry));
|
||||
}
|
||||
|
||||
private static AuditEntry? TryParse(string line)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuditEntry>(line, Json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Clean(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var sb = new StringBuilder(Math.Min(value.Length, MaxFieldLength));
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (sb.Length >= MaxFieldLength) break;
|
||||
sb.Append(char.IsControl(c) ? ' ' : c);
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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 const UnixFileMode PublicFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
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>
|
||||
/// 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)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_publicFile!)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(_publicFile)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PublicFileMode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json));
|
||||
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, PublicFileMode);
|
||||
File.Move(temp, _publicFile!, 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 catalogue public impossible ({_publicFile}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
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,310 @@
|
||||
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.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 = 64L * 1024 * 1024;
|
||||
|
||||
private const string HistoryFolder = "manifest-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
|
||||
private static readonly JsonSerializerOptions ManifestJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
/// <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 (SecureFiles.AcquireLock(Path.Combine(_dataDirectory, $"manifest-{key.ToLowerInvariant()}.lock"), LockTimeout))
|
||||
{
|
||||
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(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, ManifestJson)!.AsObject();
|
||||
foreach (var name in EditableFields)
|
||||
{
|
||||
foreach (var existing in replacement.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
replacement.Remove(existing);
|
||||
if (edited[name] is { } value) replacement[name] = value.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 json = root.ToJsonString(ManifestJson);
|
||||
var newBytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json);
|
||||
if (newBytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
|
||||
|
||||
Backup(key, bytes);
|
||||
WriteManifest(path, newBytes, baseSha256);
|
||||
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 new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
$"Notes de version non modifiables pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
|
||||
}
|
||||
|
||||
private static byte[] ReadManifest(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
|
||||
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
private static JsonObject Parse(byte[] bytes, string gameId)
|
||||
{
|
||||
JsonObject root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(StripBom(bytes), documentOptions: ReadOptions) as JsonObject
|
||||
?? throw new InvalidDataException("le manifest n'est pas un objet JSON");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or ArgumentException)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest illisible : {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
|
||||
var manifestGameId = 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 = 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>
|
||||
private static List<ChangelogEntry?> Entries(JsonArray? changelog)
|
||||
{
|
||||
try
|
||||
{
|
||||
return changelog?.Deserialize<List<ChangelogEntry?>>(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>
|
||||
private static JsonArray? ChangelogArray(JsonObject root)
|
||||
{
|
||||
foreach (var (name, value) in root)
|
||||
if (string.Equals(name, "changelog", StringComparison.OrdinalIgnoreCase))
|
||||
return value as JsonArray;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? StringValue(JsonObject node, string name)
|
||||
{
|
||||
foreach (var (key, value) in node)
|
||||
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
|
||||
return value is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
|
||||
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
|
||||
|
||||
public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
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);
|
||||
|
||||
// ------------------------------------------------------------------ Écriture
|
||||
|
||||
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération).</summary>
|
||||
private void Backup(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 la modification.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace le manifest publié : fichier temporaire caché (refusé par nginx) dans le même dossier,
|
||||
/// droits du manifest existant, empreinte revérifiée, puis renommage atomique.
|
||||
/// </summary>
|
||||
private static void WriteManifest(string path, byte[] bytes, string baseSha256)
|
||||
{
|
||||
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.");
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
// Le fichier remplaçant appartient au compte du service, avec le groupe du dossier SEULEMENT si
|
||||
// celui-ci a le bit setgid : sinon nginx (groupe www-data) pourrait perdre l'accès au manifest.
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) &&
|
||||
!File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : le manifest remplacé ne serait plus lisible par nginx.");
|
||||
}
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temp, new FileStreamOptions
|
||||
{
|
||||
Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None
|
||||
}))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, File.GetUnixFileMode(path));
|
||||
|
||||
// Dernier contrôle : un envoi (WinSCP…) pendant la préparation n'est pas écrasé.
|
||||
if (!string.Equals(Sha256(File.ReadAllBytes(path)), baseSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw Conflict();
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fichier temporaire laissé : sans conséquence (nom unique, caché)
|
||||
}
|
||||
if (ex is ApiRejection) throw;
|
||||
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Remplacement du manifest refusé par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
|
||||
public sealed class ManifestWriteRefusedException : IOException
|
||||
{
|
||||
public ManifestWriteRefusedException(string message) : base(message) { }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers privés du serveur : dossier 0700 et fichiers 0600 sous Linux (le service tourne sous un
|
||||
/// compte dédié), écriture atomique (fichier temporaire puis renommage) et verrou inter-processus
|
||||
/// (le service et l'outil en ligne de commande peuvent écrire en même temps).
|
||||
/// </summary>
|
||||
internal static class SecureFiles
|
||||
{
|
||||
private const UnixFileMode DirectoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
private const UnixFileMode FileMode600 = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
public static void EnsureDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path)) return;
|
||||
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
|
||||
else Directory.CreateDirectory(path, DirectoryMode);
|
||||
}
|
||||
|
||||
/// <summary>Remplace le contenu d'un fichier de façon atomique ; l'ancienne version est copiée en « .bak ».</summary>
|
||||
public static void WriteAllTextAtomic(string path, string content, bool keepBackup)
|
||||
{
|
||||
var temp = path + ".tmp";
|
||||
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
|
||||
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (keepBackup && File.Exists(path)) File.Copy(path, path + ".bak", overwrite: true);
|
||||
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
|
||||
}
|
||||
|
||||
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
|
||||
public static bool TryCreateNew(string path, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, CreateOptions(System.IO.FileMode.CreateNew));
|
||||
stream.Write(Encoding.UTF8.GetBytes(content));
|
||||
stream.Flush(flushToDisk: true);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) when (File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ouvre un fichier en ajout (journal d'audit), créé en 0600.</summary>
|
||||
public static FileStream OpenAppend(string path) =>
|
||||
new(path, Private(new FileStreamOptions { Mode = System.IO.FileMode.Append, Access = FileAccess.Write, Share = FileShare.Read }));
|
||||
|
||||
/// <summary>
|
||||
/// Verrou exclusif inter-processus (fichier « .lock »). Réessaie pendant <paramref name="timeout"/>,
|
||||
/// puis lève <see cref="StoreBusyException"/>.
|
||||
/// </summary>
|
||||
public static FileStream AcquireLock(string lockPath, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(lockPath, Private(new FileStreamOptions
|
||||
{
|
||||
Mode = System.IO.FileMode.OpenOrCreate,
|
||||
Access = FileAccess.ReadWrite,
|
||||
Share = FileShare.None
|
||||
}));
|
||||
}
|
||||
catch (IOException) when (DateTime.UtcNow < deadline)
|
||||
{
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
throw new StoreBusyException($"Fichier verrouillé : {Path.GetFileName(lockPath)}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static FileStreamOptions CreateOptions(System.IO.FileMode mode) =>
|
||||
Private(new FileStreamOptions { Mode = mode, Access = FileAccess.Write, Share = FileShare.None });
|
||||
|
||||
/// <summary>Fichier créé en 0600 sous Linux (lecture/écriture pour le compte du service uniquement).</summary>
|
||||
private static FileStreamOptions Private(FileStreamOptions options)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = FileMode600;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Données momentanément inaccessibles (verrou tenu par un autre processus) : réponse 503.</summary>
|
||||
public sealed class StoreBusyException : Exception
|
||||
{
|
||||
public StoreBusyException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant aléatoire et permanent de ce serveur (fichier « server-id »), inclus dans les messages
|
||||
/// signés : une signature destinée à un autre serveur (test, ancien VPS…) est refusée.
|
||||
/// </summary>
|
||||
public sealed class ServerIdentity
|
||||
{
|
||||
public const string FileName = "server-id";
|
||||
|
||||
public ServerIdentity(IOptions<LauncherApiOptions> options) : this(options.Value) { }
|
||||
|
||||
public ServerIdentity(LauncherApiOptions options)
|
||||
{
|
||||
Origin = options.NormalizedOrigin;
|
||||
ServerId = LoadOrCreate(options.DataDirectoryFullPath);
|
||||
}
|
||||
|
||||
public string ServerId { get; }
|
||||
|
||||
/// <summary>Adresse publique de l'API (« https://hote »).</summary>
|
||||
public string Origin { get; }
|
||||
|
||||
public static string LoadOrCreate(string dataDirectory)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
var path = Path.Combine(dataDirectory, FileName);
|
||||
if (!File.Exists(path))
|
||||
SecureFiles.TryCreateNew(path, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant());
|
||||
|
||||
var id = File.ReadAllText(path).Trim();
|
||||
if (id.Length != 32 || !id.All(Uri.IsHexDigit))
|
||||
throw new InvalidDataException($"{path} : identifiant de serveur invalide.");
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
// Développement (F5 dans Visual Studio) : HTTP local, données dans le dossier de compilation.
|
||||
"LauncherApi": {
|
||||
"PublicOrigin": "http://127.0.0.1:5080",
|
||||
"DataDirectory": "data-dev",
|
||||
"RequireHttps": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
// Écoute locale uniquement : seul nginx (même machine) joint l'API.
|
||||
"Urls": "http://127.0.0.1:5080",
|
||||
|
||||
"LauncherApi": {
|
||||
// Adresse publique de l'API, SANS chemin : elle est incluse dans les messages signés.
|
||||
"PublicOrigin": "https://launcher.lanisys.fr",
|
||||
// Données privées (administrateurs, audit) : JAMAIS dans le dossier servi par nginx.
|
||||
"DataDirectory": "/var/lib/launcher-api",
|
||||
"RequireHttps": true,
|
||||
"TrustedProxies": [ "127.0.0.1", "::1" ],
|
||||
"Sessions": {
|
||||
"IdleMinutes": 20,
|
||||
"MaxHours": 8,
|
||||
"MaxPerAdmin": 5
|
||||
},
|
||||
"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": ""
|
||||
},
|
||||
// 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,
|
||||
"AuthPerMinute": 20,
|
||||
"EnrollmentsPerHour": 10,
|
||||
"MaxActiveInvitations": 20,
|
||||
"MaxRequestBodyBytes": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
# Launcher API — installation sur le VPS
|
||||
|
||||
La Launcher API est le serveur d'administration du launcher (voir `docs/ADMIN-ARCHITECTURE.md`).
|
||||
Elle tourne sur le VPS de téléchargement, derrière nginx, à l'adresse
|
||||
`https://launcher.lanisys.fr/api/`. Ce guide suppose le VPS déjà préparé avec
|
||||
« Serveur de téléchargement Exovival – VPS Debian 13 » (compte `admin` en SSH, compte `deploy`,
|
||||
nginx + HTTPS).
|
||||
|
||||
Compter 20 minutes la première fois. Les commandes `PS>` se tapent dans PowerShell sur ton PC,
|
||||
les autres sur le VPS.
|
||||
|
||||
## 1. Publier le serveur (PC)
|
||||
|
||||
Double-clique `tools\publish-server.bat`. Il produit dans `Publish\Server\` :
|
||||
|
||||
* `launcher-api.tar.gz` : le serveur pour Linux x64, **autonome** (aucun .NET à installer sur le VPS) ;
|
||||
* `installer-api.sh` : le script d'installation ;
|
||||
* `launcher-api.tar.gz.sha256` : l'empreinte de l'archive, aussi affichée à la fin du script.
|
||||
|
||||
## 2. Envoyer et installer (PC puis VPS)
|
||||
|
||||
```
|
||||
PS> scp "Publish\Server\launcher-api.tar.gz" "Publish\Server\installer-api.sh" admin@launcher.lanisys.fr:/tmp/
|
||||
```
|
||||
|
||||
```
|
||||
sudo sh /tmp/installer-api.sh /tmp/launcher-api.tar.gz <empreinte affichée par publish-server.bat>
|
||||
rm /tmp/launcher-api.tar.gz /tmp/installer-api.sh
|
||||
```
|
||||
|
||||
L'empreinte garantit que l'archive installée (en root) est exactement celle produite sur ton PC :
|
||||
une archive altérée pendant le transfert ou dans `/tmp` est refusée sans rien modifier.
|
||||
|
||||
Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), le service systemd
|
||||
`launcher-api` (compte `deploy`, durci, aucune connexion sortante), le démarre et vérifie qu'il répond. Il doit finir par
|
||||
`{"status":"ok"}`. Les données privées sont dans `/var/lib/launcher-api` (droits 700).
|
||||
|
||||
> Réglages propres au VPS (autre domaine, durées de session…) : crée
|
||||
> `/opt/launcher-api/appsettings.Production.json` avec uniquement les valeurs à changer, par ex.
|
||||
> `{ "LauncherApi": { "PublicOrigin": "https://dl.autredomaine.fr" } }`, puis
|
||||
> `sudo systemctl restart launcher-api`. Ce fichier est conservé lors des mises à jour.
|
||||
|
||||
## 3. Brancher nginx (VPS)
|
||||
|
||||
1. Limite de requêtes : ajoute cette ligne dans `/etc/nginx/conf.d/securite.conf`
|
||||
(à côté de `limit_conn_zone`) :
|
||||
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
|
||||
```
|
||||
2. Ouvre `/etc/nginx/sites-available/dl-exovival` et colle le bloc `location ^~ /api/ { … }` de
|
||||
`server/deploy/nginx-api.conf` dans le bloc **HTTPS** (`listen 443 ssl`), avant `location / {`.
|
||||
3. Vérifie et recharge :
|
||||
|
||||
```
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
4. Teste depuis ton PC :
|
||||
|
||||
```
|
||||
PS> curl.exe -s https://launcher.lanisys.fr/api/v1/info
|
||||
```
|
||||
|
||||
Réponse attendue : `{"serverId":"…","origin":"https://launcher.lanisys.fr",…}`. Si `origin` est
|
||||
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.
|
||||
|
||||
## 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 :
|
||||
|
||||
| Fichier | Contenu |
|
||||
|---|---|
|
||||
| `%USERPROFILE%\.launcher-keys\admin.key` | Clé **privée**, chiffrée par ta phrase de passe. **Sauvegarde-la** (clé USB, gestionnaire de mots de passe) ; jamais dans Git, jamais sur le serveur. |
|
||||
| `%USERPROFILE%\.launcher-keys\admin.pub` | Clé publique : c'est elle qu'on donne au serveur. |
|
||||
|
||||
Note l'**empreinte** affichée (`AB12 CD34 EF56 7890 1A2B`).
|
||||
|
||||
> 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)
|
||||
|
||||
```
|
||||
PS> scp "$env:USERPROFILE\.launcher-keys\admin.pub" admin@launcher.lanisys.fr:/tmp/owner.pub
|
||||
```
|
||||
|
||||
```
|
||||
sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub --label "PC bureau"
|
||||
rm /tmp/owner.pub
|
||||
```
|
||||
|
||||
**Vérifie que l'empreinte affichée est celle de l'étape 4.** L'accès SSH administrateur est la
|
||||
preuve d'identité : c'est pour cela que le premier propriétaire se crée sur le VPS.
|
||||
|
||||
## 6. Tester la connexion (PC)
|
||||
|
||||
```
|
||||
PS> tools\admin-key.bat login-test --api https://launcher.lanisys.fr/api/
|
||||
```
|
||||
|
||||
Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste des permissions.
|
||||
|
||||
## 7. Ajouter un administrateur
|
||||
|
||||
Depuis la phase 2, la personne invitée peut créer et enregistrer sa clé **dans le launcher** (menu
|
||||
d'administration › « J'ai une invitation », code collé) : l'étape 2 ci-dessous se fait alors sans
|
||||
ligne de commande, et le launcher affiche l'empreinte à te dicter. Inviter et approuver se font avec
|
||||
`tools\admin-key.bat` jusqu'à la page « Administrateurs » du launcher (phase 6) :
|
||||
|
||||
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** : launcher › menu d'administration › « J'ai une invitation » (ou `admin-key.bat`, puis
|
||||
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>`) ; 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>"`.
|
||||
|
||||
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.
|
||||
|
||||
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`).
|
||||
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
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)
|
||||
|
||||
| Situation | Commande |
|
||||
|---|---|
|
||||
| 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 |
|
||||
|
||||
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
|
||||
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
|
||||
mêmes réglages que le service (`appsettings.json` puis `appsettings.Production.json`).
|
||||
|
||||
`admins.json` illisible : le service refuse de démarrer. `admins.json.bak` est la version qui
|
||||
précède la **dernière** modification : vérifie-la avant de la restaurer (si cette dernière
|
||||
modification était une révocation, la restaurer réactiverait la clé révoquée ; refais alors la
|
||||
révocation).
|
||||
|
||||
Le service n'a pas besoin d'être redémarré : une révocation ferme immédiatement les sessions concernées.
|
||||
|
||||
## 10. Surveillance
|
||||
|
||||
| Pour… | Commande |
|
||||
|---|---|
|
||||
| Journal du service (connexions, refus, erreurs) | `journalctl -u launcher-api -f` |
|
||||
| Journal d'audit (une ligne JSON par action) | `sudo tail -n 20 /var/lib/launcher-api/audit/audit-$(date +%Y-%m).jsonl` |
|
||||
| Niveau de durcissement du service | `systemd-analyze security launcher-api` |
|
||||
| État | `systemctl status launcher-api` |
|
||||
|
||||
**Sauvegarde** : `/var/lib/launcher-api/admins.json` (une copie `.bak` de la version précédente est
|
||||
gardée à côté). Il ne contient aucun secret (clés publiques uniquement), mais sa perte oblige à
|
||||
recréer les administrateurs.
|
||||
|
||||
## Fichiers sur le VPS
|
||||
|
||||
```
|
||||
/opt/launcher-api/ binaires (root, lecture seule pour le service)
|
||||
├─ GameLauncher.Server serveur + outil en ligne de commande
|
||||
├─ appsettings.json réglages livrés (remplacés à chaque mise à jour)
|
||||
└─ appsettings.Production.json TES réglages (facultatif, conservé)
|
||||
/var/lib/launcher-api/ données privées (deploy, 700)
|
||||
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
|
||||
├─ server-id identifiant aléatoire du serveur
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
├─ 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 par la page Notes de version (20 derniers)
|
||||
└─ 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,93 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Installe ou met à jour la Launcher API sur le VPS (compte admin, avec sudo) :
|
||||
# sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>
|
||||
# L'empreinte est affichée par tools\publish-server.bat (fichier launcher-api.tar.gz.sha256) :
|
||||
# une archive modifiée pendant le transfert ou dans /tmp est refusée.
|
||||
#
|
||||
# • binaires dans /opt/launcher-api (propriétaire root : le service ne peut pas les modifier) ;
|
||||
# • service systemd « launcher-api » (compte deploy, voir launcher-api.service) ;
|
||||
# • appsettings.Production.json (réglages propres au VPS) conservé d'une version à l'autre ;
|
||||
# • en cas d'échec du démarrage, la version précédente est remise en place.
|
||||
# =============================================================================
|
||||
set -eu
|
||||
|
||||
ARCHIVE="${1:-}"
|
||||
EXPECTED_SHA256=$(printf '%s' "${2:-}" | tr 'A-F' 'a-f')
|
||||
INSTALL_DIR=/opt/launcher-api
|
||||
SERVICE=launcher-api
|
||||
SERVICE_USER=deploy
|
||||
|
||||
fail() { echo "ERREUR : $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "lancez ce script avec sudo."
|
||||
[ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ] || fail "utilisation : sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>"
|
||||
printf '%s' "$EXPECTED_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "empreinte SHA-256 manquante ou invalide (voir launcher-api.tar.gz.sha256)."
|
||||
id "$SERVICE_USER" >/dev/null 2>&1 || fail "le compte $SERVICE_USER n'existe pas (guide du VPS, étape 9)."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl est nécessaire (sudo apt install -y curl)."
|
||||
|
||||
TMPROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPROOT"' EXIT
|
||||
# Copie privée de l'archive (dossier 700), PUIS vérification : plus rien ne peut la modifier entre-temps.
|
||||
cp "$ARCHIVE" "$TMPROOT/archive.tar.gz"
|
||||
ACTUAL_SHA256=$(sha256sum "$TMPROOT/archive.tar.gz" | cut -d' ' -f1)
|
||||
[ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ] || fail "empreinte différente : archive altérée ou incomplète. Rien n'a été modifié."
|
||||
WORK="$TMPROOT/files"
|
||||
mkdir "$WORK"
|
||||
tar -xzf "$TMPROOT/archive.tar.gz" -C "$WORK" --no-same-owner
|
||||
[ -f "$WORK/GameLauncher.Server" ] || fail "archive invalide : GameLauncher.Server absent."
|
||||
[ -f "$WORK/deploy/launcher-api.service" ] || fail "archive invalide : deploy/launcher-api.service absent."
|
||||
|
||||
echo "== Préparation de la nouvelle version"
|
||||
rm -rf "$INSTALL_DIR.new"
|
||||
mkdir -p "$INSTALL_DIR.new"
|
||||
cp -a "$WORK/." "$INSTALL_DIR.new/"
|
||||
rm -rf "$INSTALL_DIR.new/deploy"
|
||||
if [ -f "$INSTALL_DIR/appsettings.Production.json" ]; then
|
||||
cp -a "$INSTALL_DIR/appsettings.Production.json" "$INSTALL_DIR.new/"
|
||||
fi
|
||||
chown -R root:root "$INSTALL_DIR.new"
|
||||
find "$INSTALL_DIR.new" -type d -exec chmod 755 {} +
|
||||
find "$INSTALL_DIR.new" -type f -exec chmod 644 {} +
|
||||
chmod 755 "$INSTALL_DIR.new/GameLauncher.Server"
|
||||
|
||||
echo "== Installation du service"
|
||||
UNIT=/etc/systemd/system/$SERVICE.service
|
||||
rm -f "$UNIT.old"
|
||||
[ -f "$UNIT" ] && cp -a "$UNIT" "$UNIT.old"
|
||||
systemctl stop $SERVICE 2>/dev/null || true
|
||||
install -m 644 -o root -g root "$WORK/deploy/launcher-api.service" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
|
||||
rm -rf "$INSTALL_DIR.old"
|
||||
[ -d "$INSTALL_DIR" ] && mv "$INSTALL_DIR" "$INSTALL_DIR.old"
|
||||
mv "$INSTALL_DIR.new" "$INSTALL_DIR"
|
||||
systemctl enable --now $SERVICE
|
||||
|
||||
echo "== Vérification"
|
||||
OK=0
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS http://127.0.0.1:5080/api/v1/health >/dev/null 2>&1; then OK=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$OK" -ne 1 ]; then
|
||||
echo "La nouvelle version ne répond pas. Dernières lignes du journal :" >&2
|
||||
journalctl -u $SERVICE -n 30 --no-pager >&2 || true
|
||||
if [ -d "$INSTALL_DIR.old" ]; then
|
||||
echo "Retour à la version précédente." >&2
|
||||
systemctl stop $SERVICE || true
|
||||
rm -rf "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR" "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
|
||||
[ -f "$UNIT.old" ] && cp -a "$UNIT.old" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
systemctl start $SERVICE || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher API opérationnelle :"
|
||||
curl -fsS http://127.0.0.1:5080/api/v1/health; echo
|
||||
echo "Journal : journalctl -u $SERVICE -f"
|
||||
echo "Premier propriétaire (une seule fois) : voir LISEZMOI.md, étape 5."
|
||||
@@ -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
|
||||
@@ -0,0 +1,76 @@
|
||||
# =============================================================================
|
||||
# Launcher API : service systemd (installé par installer-api.sh).
|
||||
# /etc/systemd/system/launcher-api.service
|
||||
#
|
||||
# Le service tourne sous le compte « deploy » (déjà propriétaire des fichiers du jeu, sans
|
||||
# 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
|
||||
# =============================================================================
|
||||
[Unit]
|
||||
Description=Launcher API (administration du launcher)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/launcher-api
|
||||
ExecStart=/opt/launcher-api/GameLauncher.Server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
Environment=DOTNET_NOLOGO=1
|
||||
|
||||
# Données privées : /var/lib/launcher-api (administrateurs, audit, identifiant du serveur)
|
||||
StateDirectory=launcher-api
|
||||
StateDirectoryMode=0700
|
||||
UMask=0077
|
||||
|
||||
# --- Durcissement ------------------------------------------------------------
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
# Réseau : uniquement la machine elle-même (nginx). Aucune connexion sortante possible, même en cas de compromission.
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RemoveIPC=yes
|
||||
SystemCallArchitectures=native
|
||||
# MemoryDenyWriteExecute n'est PAS activé : le compilateur JIT de .NET a besoin de mémoire exécutable.
|
||||
# SystemCallFilter n'est pas activé : .NET utilise des appels système variés selon les versions
|
||||
# (un filtre trop strict provoquerait des arrêts difficiles à diagnostiquer).
|
||||
|
||||
# --- Ressources (petit VPS) ------------------------------------------------------
|
||||
MemoryMax=512M
|
||||
TasksMax=256
|
||||
LimitNOFILE=8192
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,34 @@
|
||||
# =============================================================================
|
||||
# Launcher API derrière nginx — DEUX ajouts (voir LISEZMOI.md, étape 3) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf (contexte « http », à côté de limit_conn_zone) :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
|
||||
# « ^~ » : aucune autre règle (manifest, objects, .zip…) ne s'applique sous /api/.
|
||||
location ^~ /api/ {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
# Requêtes d'administration : petites. (La publication de versions, en phase 5, aura sa propre règle.)
|
||||
client_max_body_size 64k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
# REMPLACE l'en-tête (au lieu d'ajouter à celui du client) : l'adresse IP transmise ne peut pas être falsifiée.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux (écrit par la Launcher API) — un ajout (voir LISEZMOI.md, étape 3 bis) :
|
||||
#
|
||||
# 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;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'administrateur dans un fichier chiffré par une phrase de passe : PKCS#8 chiffré (AES-256,
|
||||
/// PBKDF2-SHA256, 600 000 itérations), comme la clé de signature des mises à jour du launcher.
|
||||
/// Le fichier seul ne sert à rien sans la phrase de passe.
|
||||
/// </summary>
|
||||
public static class AdminKeyFile
|
||||
{
|
||||
public const int MinPassphraseLength = 12;
|
||||
public const string PrivateKeyExtension = ".key";
|
||||
public const string PublicKeyExtension = ".pub";
|
||||
|
||||
private static readonly PbeParameters Encryption = new(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 600_000);
|
||||
|
||||
public static ECDsa Generate() => ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre la clé chiffrée (refuse d'écraser un fichier existant) et sa clé publique à côté
|
||||
/// (même nom, extension .pub). Renvoie le chemin de la clé publique.
|
||||
/// </summary>
|
||||
public static string Save(ECDsa key, string path, ReadOnlySpan<char> passphrase)
|
||||
{
|
||||
if (passphrase.Length < MinPassphraseLength)
|
||||
throw new ArgumentException($"La phrase de passe doit contenir au moins {MinPassphraseLength} caractères.");
|
||||
if (File.Exists(path)) throw new IOException($"Le fichier existe déjà : {path}");
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
var pem = key.ExportEncryptedPkcs8PrivateKeyPem(passphrase, Encryption);
|
||||
using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Déchiffre la clé. <see cref="CryptographicException"/> si la phrase de passe est fausse.</summary>
|
||||
public static ECDsa Load(string path, ReadOnlySpan<char> passphrase)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
|
||||
if (info.Length > 16 * 1024) throw new CryptographicException("Fichier de clé inattendu (trop volumineux).");
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
if (!pem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal))
|
||||
throw new CryptographicException("Ce fichier n'est pas une clé privée chiffrée.");
|
||||
|
||||
var key = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
key.ImportFromEncryptedPem(pem, passphrase);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
key.Dispose();
|
||||
throw new CryptographicException("Phrase de passe incorrecte (ou fichier de clé endommagé).", ex);
|
||||
}
|
||||
try
|
||||
{
|
||||
AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // clé P-256 uniquement
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Session d'administration ouverte. Le jeton reste en mémoire (jamais écrit sur le disque) ;
|
||||
/// chaque requête qui modifie quelque chose est signée par la clé (<see cref="SignedMessages.Request"/>).
|
||||
/// Les modules du launcher ajoutent leurs propres appels avec <see cref="GetAsync{T}"/> et
|
||||
/// <see cref="SendSignedAsync{T}"/>.
|
||||
/// </summary>
|
||||
public sealed class AdminSession : IAsyncDisposable
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly IAdminSigner _signer;
|
||||
private readonly TimeSpan _clockOffset;
|
||||
private string? _token;
|
||||
|
||||
internal AdminSession(LauncherApiClient client, IAdminSigner signer, string serverId, LoginResponse login, TimeSpan clockOffset)
|
||||
{
|
||||
_client = client;
|
||||
_signer = signer;
|
||||
_clockOffset = clockOffset;
|
||||
_token = login.Token;
|
||||
ServerId = serverId;
|
||||
SessionId = login.SessionId;
|
||||
Profile = login.Admin;
|
||||
ExpiresAt = login.ExpiresAt;
|
||||
IdleExpiresAt = login.IdleExpiresAt;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
// ------------------------------------------------------------------ Session
|
||||
|
||||
/// <summary>Relit la session (droits à jour) et la prolonge.</summary>
|
||||
public async Task<SessionInfo> RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
var info = await GetAsync<SessionInfo>(ApiRoutes.AuthMe, ct).ConfigureAwait(false);
|
||||
Profile = info.Admin;
|
||||
IdleExpiresAt = info.IdleExpiresAt;
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>Ferme la session sur le serveur (sans erreur si elle avait déjà expiré).</summary>
|
||||
public async Task LogoutAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_token is null) return;
|
||||
try
|
||||
{
|
||||
await SendCoreAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuthLogout, null, signed: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
// session déjà expirée, serveur injoignable : le jeton est oublié de toute façon
|
||||
}
|
||||
finally
|
||||
{
|
||||
_token = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await LogoutAsync().ConfigureAwait(false);
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
|
||||
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
|
||||
|
||||
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
|
||||
|
||||
public Task<InvitationCreated> CreateInvitationAsync(CreateInvitationRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<InvitationCreated>(HttpMethod.Post, ApiRoutes.Invitations, request, ct);
|
||||
|
||||
public Task DeleteInvitationAsync(Guid invitationId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Invitation(invitationId), (object?)null, ct);
|
||||
|
||||
public Task<AdminSummary> ApproveKeyAsync(Guid adminId, string keyId, string fingerprint, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyApprove(adminId, keyId), new ApproveKeyRequest { Fingerprint = fingerprint }, ct);
|
||||
|
||||
public Task<AdminSummary> RevokeKeyAsync(Guid adminId, string keyId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyRevoke(adminId, keyId), (object?)null, ct);
|
||||
|
||||
public Task<AdminSummary> ChangeRoleAsync(Guid adminId, string role, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRole(adminId), new ChangeRoleRequest { Role = role }, ct);
|
||||
|
||||
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
|
||||
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, CancellationToken ct = default)
|
||||
{
|
||||
var route = $"{ApiRoutes.Audit}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += "&cursor=" + Uri.EscapeDataString(cursor);
|
||||
return GetAsync<AuditPage>(route, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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);
|
||||
}
|
||||
|
||||
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) =>
|
||||
SendCoreAsync<T>(HttpMethod.Get, route, null, signed: false, ct);
|
||||
|
||||
/// <summary>Requête qui modifie : corps JSON (ou aucun corps si null), signée par la clé.</summary>
|
||||
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)
|
||||
{
|
||||
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
|
||||
|
||||
(long Timestamp, string Nonce, byte[] Signature)? signature = null;
|
||||
if (signed)
|
||||
{
|
||||
var timestamp = (DateTimeOffset.UtcNow + _clockOffset).ToUnixTimeSeconds();
|
||||
var nonce = Base64Url.Encode(RandomNumberGenerator.GetBytes(16));
|
||||
var path = _client.Resolve(route).PathAndQuery;
|
||||
var message = SignedMessages.Request(_client.Origin, ServerId, SessionId, method.Method, path,
|
||||
body ?? Array.Empty<byte>(), timestamp, nonce);
|
||||
signature = (timestamp, nonce, await _signer.SignAsync(message, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _client.SendAsync<T>(method, route, body, request =>
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
if (signature is { } s)
|
||||
{
|
||||
request.Headers.Add(SignedRequestHeaders.Timestamp, s.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
_token = null; // session expirée ou révoquée : il faudra se reconnecter
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>Erreur renvoyée par la Launcher API (ou problème de communication).</summary>
|
||||
public sealed class ApiException : Exception
|
||||
{
|
||||
public ApiException(int status, string code, string message, Exception? inner = null) : base(message, inner)
|
||||
{
|
||||
Status = status;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
/// <summary>Code HTTP (0 : serveur injoignable).</summary>
|
||||
public int Status { get; }
|
||||
|
||||
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
|
||||
public string Code { get; }
|
||||
|
||||
public bool IsUnauthorized => Status == 401;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Client de la Launcher API (sans WPF) : connexion par défi signé, requêtes signées, appels typés,
|
||||
fichier de clé d'administrateur chiffré. Utilisé par le launcher (module Administration),
|
||||
par tools\AdminKeyTool et par les tests. Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.ApiClient</RootNamespace>
|
||||
<AssemblyName>GameLauncher.ApiClient</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
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é) ; clé de la puce TPM dans le launcher (Platform\TpmKeyStore, phase 2b).
|
||||
/// </summary>
|
||||
public interface IAdminSigner
|
||||
{
|
||||
/// <summary>Clé publique (SPKI DER).</summary>
|
||||
byte[] PublicKey { get; }
|
||||
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256/SHA-256 (IEEE P1363, 64 octets). Peut demander une confirmation à l'utilisateur.</summary>
|
||||
ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Clé ECDSA P-256 en mémoire.</summary>
|
||||
public sealed class EcdsaAdminSigner : IAdminSigner, IDisposable
|
||||
{
|
||||
private readonly ECDsa _key;
|
||||
|
||||
/// <param name="key">Clé privée P-256 ; elle appartient désormais à ce signataire (libérée par <see cref="Dispose"/>).</param>
|
||||
public EcdsaAdminSigner(ECDsa key)
|
||||
{
|
||||
_key = key;
|
||||
PublicKey = AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // vérifie P-256
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
public string Fingerprint => AdminKeys.ComputeFingerprint(PublicKey);
|
||||
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) =>
|
||||
ValueTask.FromResult(AdminKeys.Sign(_key, message));
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Client de la Launcher API. L'adresse vient de la configuration INTÉGRÉE à l'exécutable (jamais
|
||||
/// d'un fichier modifiable) ; HTTPS obligatoire (HTTP accepté uniquement en local, pour le
|
||||
/// développement). Les redirections ne sont jamais suivies (le jeton ne part que vers cette adresse).
|
||||
/// </summary>
|
||||
public sealed class LauncherApiClient : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsHttp;
|
||||
|
||||
/// <param name="apiBaseUrl">Adresse de base, ex. « https://launcher.lanisys.fr/api/ ».</param>
|
||||
/// <param name="handler">Pour les tests ; par défaut un gestionnaire sans redirection automatique.</param>
|
||||
public LauncherApiClient(Uri apiBaseUrl, string userAgent, HttpMessageHandler? handler = null)
|
||||
{
|
||||
if (!apiBaseUrl.IsAbsoluteUri ||
|
||||
!(apiBaseUrl.Scheme == Uri.UriSchemeHttps || (apiBaseUrl.Scheme == Uri.UriSchemeHttp && apiBaseUrl.IsLoopback)))
|
||||
throw new ArgumentException("L'adresse de l'API doit être en https://.", nameof(apiBaseUrl));
|
||||
if (!string.IsNullOrEmpty(apiBaseUrl.Query) || !string.IsNullOrEmpty(apiBaseUrl.Fragment))
|
||||
throw new ArgumentException("L'adresse de l'API ne doit contenir ni paramètres ni fragment.", nameof(apiBaseUrl));
|
||||
|
||||
BaseUrl = apiBaseUrl.AbsoluteUri.EndsWith('/') ? apiBaseUrl : new Uri(apiBaseUrl.AbsoluteUri + "/");
|
||||
Origin = SignedMessages.NormalizeOrigin(BaseUrl);
|
||||
|
||||
_ownsHttp = true;
|
||||
_http = new HttpClient(handler ?? new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(15),
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}, disposeHandler: handler is null)
|
||||
{
|
||||
Timeout = RequestTimeout
|
||||
};
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public Uri BaseUrl { get; }
|
||||
|
||||
/// <summary>Origine (« https://hote ») incluse dans les messages signés : celle de l'adresse configurée.</summary>
|
||||
public string Origin { get; }
|
||||
|
||||
public Task<ServerInfo> GetInfoAsync(CancellationToken ct = default) =>
|
||||
SendAsync<ServerInfo>(HttpMethod.Get, ApiRoutes.Info, null, null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Connexion : défi, signature par la clé, ouverture de la session. L'heure du serveur est
|
||||
/// relevée au passage (les requêtes signées restent valides même si l'heure du PC est décalée).
|
||||
/// </summary>
|
||||
public async Task<AdminSession> LoginAsync(IAdminSigner signer, string? clientName = null, CancellationToken ct = default)
|
||||
{
|
||||
var challenge = await SendAsync<ChallengeResponse>(HttpMethod.Post, ApiRoutes.AuthChallenge,
|
||||
Serialize(new ChallengeRequest { KeyId = signer.KeyId }), null, ct).ConfigureAwait(false);
|
||||
var clockOffset = challenge.ServerTime == default ? TimeSpan.Zero : challenge.ServerTime - DateTimeOffset.UtcNow;
|
||||
if (clockOffset.Duration() > MaxClockOffset)
|
||||
throw new ApiException(0, "clock_skew",
|
||||
$"L'horloge de votre PC diffère de plus d'une heure de celle du serveur ({clockOffset.TotalHours:+0.0;-0.0} h) : corrigez la date et l'heure de Windows.");
|
||||
EnsureSameOrigin(challenge.Origin);
|
||||
|
||||
var message = SignedMessages.Login(Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, signer.KeyId);
|
||||
var signature = await signer.SignAsync(message, ct).ConfigureAwait(false);
|
||||
var login = await SendAsync<LoginResponse>(HttpMethod.Post, ApiRoutes.AuthLogin, Serialize(new LoginRequest
|
||||
{
|
||||
ChallengeId = challenge.ChallengeId,
|
||||
KeyId = signer.KeyId,
|
||||
Signature = Convert.ToBase64String(signature),
|
||||
ClientName = clientName
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
|
||||
return new AdminSession(this, signer, challenge.ServerId, login, clockOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrôle la clé avec un code d'invitation. Renvoie l'empreinte à communiquer au propriétaire,
|
||||
/// qui doit la saisir pour approuver la clé.
|
||||
/// </summary>
|
||||
public async Task<EnrollmentResponse> EnrollAsync(string invitationCode, IAdminSigner signer, string? keyLabel,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var code = InvitationCodes.Normalize(invitationCode)
|
||||
?? throw new ApiException(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation mal saisi.");
|
||||
var info = await GetInfoAsync(ct).ConfigureAwait(false);
|
||||
EnsureSameOrigin(info.Origin);
|
||||
|
||||
var proof = await signer.SignAsync(
|
||||
SignedMessages.Enrollment(Origin, info.ServerId, SignedMessages.HashInvitationCode(code), signer.KeyId), ct).ConfigureAwait(false);
|
||||
return await SendAsync<EnrollmentResponse>(HttpMethod.Post, ApiRoutes.Enrollment, Serialize(new EnrollmentRequest
|
||||
{
|
||||
InvitationCode = code,
|
||||
PublicKey = Convert.ToBase64String(signer.PublicKey),
|
||||
KeyLabel = keyLabel,
|
||||
Proof = Convert.ToBase64String(proof)
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Transport
|
||||
|
||||
internal static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options);
|
||||
|
||||
/// <summary>
|
||||
/// Adresse d'une route, OBLIGATOIREMENT sous l'adresse de base : une route absolue ou
|
||||
/// « //autre-hote/… » (construite par erreur à partir d'une donnée reçue) ne doit jamais
|
||||
/// emporter le jeton ailleurs.
|
||||
/// </summary>
|
||||
internal Uri Resolve(string route)
|
||||
{
|
||||
if (string.IsNullOrEmpty(route) || route.StartsWith('/') || route.StartsWith('\\') || route.Contains("://", StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Route invalide : « {route} ».", nameof(route));
|
||||
var uri = new Uri(BaseUrl, route);
|
||||
if (uri.Scheme != BaseUrl.Scheme || uri.Host != BaseUrl.Host || uri.Port != BaseUrl.Port ||
|
||||
!uri.AbsolutePath.StartsWith(BaseUrl.AbsolutePath, StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Route hors de l'API : « {route} ».", nameof(route));
|
||||
return uri;
|
||||
}
|
||||
|
||||
/// <summary>Écart d'horloge maximal accepté avec le serveur (au-delà : l'heure du PC est à corriger).</summary>
|
||||
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)
|
||||
{
|
||||
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" };
|
||||
}
|
||||
prepare?.Invoke(request);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
|
||||
}
|
||||
catch (TaskCanceledException 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);
|
||||
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
|
||||
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
|
||||
?? throw new JsonException("réponse vide");
|
||||
return value;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur illisible.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > ApiJson.MaxResponseBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static ApiException ToException(HttpResponseMessage response, byte[] body)
|
||||
{
|
||||
var status = (int)response.StatusCode;
|
||||
try
|
||||
{
|
||||
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
|
||||
return new ApiException(status, error.Code, error.Message);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// réponse d'erreur non JSON (nginx, proxy…)
|
||||
}
|
||||
return 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}).")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
|
||||
private void EnsureSameOrigin(string serverOrigin)
|
||||
{
|
||||
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiException(0, "origin_mismatch",
|
||||
$"Le serveur se présente comme « {serverOrigin} » au lieu de « {Origin} » : configuration à vérifier (PublicOrigin).");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp) _http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Réponse sans contenu (204).</summary>
|
||||
public sealed class NoContent
|
||||
{
|
||||
public static readonly NoContent Value = new();
|
||||
private NoContent() { }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Options JSON communes au launcher et au serveur (strictes : ni commentaires, ni virgules finales).</summary>
|
||||
public static class ApiJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = Create();
|
||||
|
||||
/// <summary>Taille maximale d'une réponse JSON lue par le client (hors transferts de fichiers).</summary>
|
||||
public const int MaxResponseBytes = 1024 * 1024;
|
||||
|
||||
public static JsonSerializerOptions Create() => new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
AllowTrailingCommas = false,
|
||||
MaxDepth = 32,
|
||||
NumberHandling = JsonNumberHandling.Strict
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Routes de la Launcher API, relatives à l'adresse de base de l'API
|
||||
/// (ex. « https://launcher.lanisys.fr/api/ »). Le serveur les publie sous « /api/ ».
|
||||
/// </summary>
|
||||
public static class ApiRoutes
|
||||
{
|
||||
/// <summary>Préfixe des routes côté serveur (nginx transmet « /api/… » sans le modifier).</summary>
|
||||
public const string Prefix = "/api";
|
||||
|
||||
public const string Version = "v1";
|
||||
|
||||
// ------------------------------------------------------------------ Système
|
||||
public const string Info = "v1/info";
|
||||
public const string Health = "v1/health";
|
||||
|
||||
// ------------------------------------------------------------------ Authentification
|
||||
public const string AuthChallenge = "v1/auth/challenge";
|
||||
public const string AuthLogin = "v1/auth/login";
|
||||
public const string AuthLogout = "v1/auth/logout";
|
||||
public const string AuthMe = "v1/auth/me";
|
||||
|
||||
/// <summary>Enrôlement d'une clé avec un code d'invitation (sans session).</summary>
|
||||
public const string Enrollment = "v1/enrollment";
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
public const string Admins = "v1/admins";
|
||||
public const string Invitations = "v1/admins/invitations";
|
||||
|
||||
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";
|
||||
public static string KeyApprove(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/approve";
|
||||
public static string KeyRevoke(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/revoke";
|
||||
|
||||
// ------------------------------------------------------------------ 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}";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Contrôle des textes courts saisis dans le menu d'administration (noms, libellés).</summary>
|
||||
public static class ApiText
|
||||
{
|
||||
public const int MaxDisplayNameLength = 64;
|
||||
public const int MaxKeyLabelLength = 64;
|
||||
public const int MaxClientNameLength = 96;
|
||||
|
||||
/// <summary>
|
||||
/// Texte d'une ligne : espaces normalisés, sans caractère de contrôle ni de mise en forme
|
||||
/// bidirectionnelle. Null si vide ou trop long (après nettoyage).
|
||||
/// </summary>
|
||||
public static string? CleanLine(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var sb = new StringBuilder(value.Length);
|
||||
var previousSpace = false;
|
||||
foreach (var c in value.Trim())
|
||||
{
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (!previousSpace) sb.Append(' ');
|
||||
previousSpace = true;
|
||||
continue;
|
||||
}
|
||||
if (char.IsControl(c) || IsFormatting(c)) return null;
|
||||
sb.Append(c);
|
||||
previousSpace = false;
|
||||
}
|
||||
var text = sb.ToString();
|
||||
return text.Length == 0 || text.Length > maxLength ? null : text;
|
||||
}
|
||||
|
||||
/// <summary>Caractères invisibles de mise en forme (bidirectionnels, largeur nulle…).</summary>
|
||||
private static bool IsFormatting(char c) =>
|
||||
char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Base64 « URL » sans remplissage (nonces, jetons).</summary>
|
||||
public static class Base64Url
|
||||
{
|
||||
public static string Encode(ReadOnlySpan<byte> data) =>
|
||||
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public static bool TryDecode(string? text, out byte[] data)
|
||||
{
|
||||
data = Array.Empty<byte>();
|
||||
if (string.IsNullOrEmpty(text) || text.Length > 4096) return false;
|
||||
foreach (var c in text)
|
||||
if (!(c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_')) return false;
|
||||
|
||||
var s = text.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
case 1: return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(s);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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>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; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record AdminKeySummary
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? Label { get; init; }
|
||||
public string Status { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte des clés actives ou révoquées. Volontairement absente pour une clé EN ATTENTE :
|
||||
/// l'approbateur doit saisir celle que lui communique le nouvel administrateur.
|
||||
/// </summary>
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdminSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public string Status { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
|
||||
}
|
||||
|
||||
public sealed record CreateInvitationRequest
|
||||
{
|
||||
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>Rôle du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>Renseigné : invitation « nouvel appareil » pour cet administrateur existant.</summary>
|
||||
public Guid? ForAdminId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InvitationSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public Guid? ForAdminId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InvitationCreated
|
||||
{
|
||||
public InvitationSummary Invitation { get; init; } = new();
|
||||
|
||||
/// <summary>Code à transmettre au futur administrateur. Affiché UNE SEULE FOIS (le serveur n'en garde que l'empreinte).</summary>
|
||||
public string Code { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ApproveKeyRequest
|
||||
{
|
||||
/// <summary>Empreinte communiquée par le nouvel administrateur (« AB12 CD34 EF56 7890 1A2B »).</summary>
|
||||
public string Fingerprint { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ChangeRoleRequest
|
||||
{
|
||||
public string Role { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record AuditEntry
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Action, ex. « auth.login », « admins.invite ».</summary>
|
||||
public string Action { get; init; } = "";
|
||||
|
||||
/// <summary>« ok », « denied » ou « failed ».</summary>
|
||||
public string Result { get; init; } = "";
|
||||
|
||||
public Guid? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? Ip { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AuditPage
|
||||
{
|
||||
public IReadOnlyList<AuditEntry> Entries { get; init; } = Array.Empty<AuditEntry>();
|
||||
|
||||
/// <summary>Curseur à passer dans « cursor » pour la page suivante (entrées plus anciennes), ou null.</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
public static class AuditResults
|
||||
{
|
||||
public const string Ok = "ok";
|
||||
public const string Denied = "denied";
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record ChallengeRequest
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ChallengeResponse
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string Nonce { get; init; } = "";
|
||||
public string ServerId { get; init; } = "";
|
||||
public string Origin { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Heure du serveur : le launcher s'y cale pour horodater ses requêtes signées (PC mal réglé).</summary>
|
||||
public DateTimeOffset ServerTime { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Login"/>.</summary>
|
||||
public string Signature { get; init; } = "";
|
||||
|
||||
/// <summary>Description du poste, pour le journal (ex. « GameLauncher 1.4.0 »). Facultatif.</summary>
|
||||
public string? ClientName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Administrateur connecté et ses droits.</summary>
|
||||
public sealed record AdminProfile
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? KeyLabel { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LoginResponse
|
||||
{
|
||||
/// <summary>Jeton de session (en-tête « Authorization: Bearer … »). À garder en mémoire uniquement.</summary>
|
||||
public string Token { get; init; } = "";
|
||||
|
||||
/// <summary>Identifiant public de la session, inclus dans les requêtes signées.</summary>
|
||||
public string SessionId { get; init; } = "";
|
||||
|
||||
/// <summary>Fin de session au plus tard (durée maximale).</summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Fin de session sans activité.</summary>
|
||||
public DateTimeOffset IdleExpiresAt { get; init; }
|
||||
|
||||
public AdminProfile Admin { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Session en cours (GET v1/auth/me) : prolonge la session (activité).</summary>
|
||||
public sealed record SessionInfo
|
||||
{
|
||||
public string SessionId { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset IdleExpiresAt { get; init; }
|
||||
public AdminProfile Admin { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Enrôlement d'une clé avec un code d'invitation.</summary>
|
||||
public sealed record EnrollmentRequest
|
||||
{
|
||||
public string InvitationCode { get; init; } = "";
|
||||
|
||||
/// <summary>Clé publique (SPKI en Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Nom du poste (ex. « PC bureau »).</summary>
|
||||
public string? KeyLabel { get; init; }
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Enrollment"/> : preuve de possession.</summary>
|
||||
public string Proof { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record EnrollmentResponse
|
||||
{
|
||||
public Guid AdminId { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte à communiquer au propriétaire, qui la saisit pour approuver.</summary>
|
||||
public string Fingerprint { get; init; } = "";
|
||||
|
||||
public string Status { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
/// <summary>Informations publiques du serveur (GET v1/info).</summary>
|
||||
public sealed record ServerInfo
|
||||
{
|
||||
/// <summary>Identifiant aléatoire propre à ce serveur, inclus dans les messages signés.</summary>
|
||||
public string ServerId { get; init; } = "";
|
||||
|
||||
/// <summary>Adresse publique de l'API (« https://hote »), incluse dans les messages signés.</summary>
|
||||
public string Origin { get; init; } = "";
|
||||
|
||||
public string ApiVersion { get; init; } = ApiRoutes.Version;
|
||||
|
||||
public string ServerVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
|
||||
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed record HealthStatus
|
||||
{
|
||||
public string Status { get; init; } = "ok";
|
||||
}
|
||||
|
||||
/// <summary>Erreur renvoyée par l'API (corps des réponses 4xx/5xx).</summary>
|
||||
public sealed record ApiError
|
||||
{
|
||||
public int Status { get; init; }
|
||||
|
||||
/// <summary>Code stable, voir <see cref="ApiErrorCodes"/>.</summary>
|
||||
public string Code { get; init; } = ApiErrorCodes.Error;
|
||||
|
||||
/// <summary>Message destiné à l'administrateur (français).</summary>
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
public static class ApiErrorCodes
|
||||
{
|
||||
public const string Error = "error";
|
||||
public const string InvalidRequest = "invalid_request";
|
||||
public const string Unauthorized = "unauthorized";
|
||||
public const string Forbidden = "forbidden";
|
||||
public const string NotFound = "not_found";
|
||||
public const string Conflict = "conflict";
|
||||
public const string RateLimited = "rate_limited";
|
||||
public const string SignatureRequired = "signature_required";
|
||||
public const string SignatureInvalid = "signature_invalid";
|
||||
public const string ClockSkew = "clock_skew";
|
||||
public const string Replay = "replay";
|
||||
public const string InvitationInvalid = "invitation_invalid";
|
||||
public const string FingerprintMismatch = "fingerprint_mismatch";
|
||||
public const string LastOwner = "last_owner";
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Protocole partagé entre le launcher et la Launcher API (serveur) : routes, objets échangés,
|
||||
rôles et permissions, messages signés et contrôle des clés publiques.
|
||||
Aucune dépendance (ni WPF, ni ASP.NET Core) : référencé par le launcher, le serveur et les outils.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Contracts</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Contracts</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Clés des administrateurs : ECDSA P-256, signatures SHA-256 au format IEEE P1363 (64 octets).
|
||||
/// Même famille que la signature des mises à jour du launcher ; tout est fourni par .NET.
|
||||
/// </summary>
|
||||
public static class AdminKeys
|
||||
{
|
||||
public const string Algorithm = "ECDSA-P256-SHA256";
|
||||
public const int SignatureLength = 64;
|
||||
|
||||
/// <summary>Taille maximale d'une clé publique SPKI encodée (une clé P-256 fait 91 octets).</summary>
|
||||
private const int MaxSpkiLength = 512;
|
||||
|
||||
private const DSASignatureFormat Format = DSASignatureFormat.IeeeP1363FixedFieldConcatenation;
|
||||
private const string P256Oid = "1.2.840.10045.3.1.7";
|
||||
|
||||
/// <summary>Identifiant d'une clé : 16 premiers caractères hexadécimaux (minuscules) du SHA-256 de la clé publique.</summary>
|
||||
public static string ComputeKeyId(ReadOnlySpan<byte> subjectPublicKeyInfo) =>
|
||||
Convert.ToHexString(SHA256.HashData(subjectPublicKeyInfo))[..16].ToLowerInvariant();
|
||||
|
||||
/// <summary>Nombre de caractères hexadécimaux de l'empreinte (80 bits).</summary>
|
||||
public const int FingerprintLength = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte à comparer entre humains (téléphone, message) : « AB12 CD34 EF56 7890 1A2B » (80 bits).
|
||||
/// Calculée avec une séparation de domaine, donc SANS RAPPORT avec <see cref="ComputeKeyId"/> :
|
||||
/// connaître l'identifiant d'une clé (visible dans les listes et les adresses) ne donne pas son
|
||||
/// empreinte. Seule la personne qui détient la clé peut la communiquer.
|
||||
/// </summary>
|
||||
public static string ComputeFingerprint(ReadOnlySpan<byte> subjectPublicKeyInfo)
|
||||
{
|
||||
var prefix = System.Text.Encoding.ASCII.GetBytes("GameLauncher/Admin/Fingerprint/v1\n");
|
||||
var data = new byte[prefix.Length + subjectPublicKeyInfo.Length];
|
||||
prefix.CopyTo(data, 0);
|
||||
subjectPublicKeyInfo.CopyTo(data.AsSpan(prefix.Length));
|
||||
var hex = Convert.ToHexString(SHA256.HashData(data))[..FingerprintLength];
|
||||
return string.Join(' ', Enumerable.Range(0, FingerprintLength / 4).Select(i => hex.Substring(i * 4, 4)));
|
||||
}
|
||||
|
||||
/// <summary>Empreinte saisie par un humain → 20 caractères hexadécimaux majuscules, ou null.</summary>
|
||||
public static string? NormalizeFingerprint(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || text.Length > 64) return null;
|
||||
var hex = new string(text.Where(c => !char.IsWhiteSpace(c) && c != '-' && c != ':').ToArray()).ToUpperInvariant();
|
||||
return hex.Length == FingerprintLength && hex.All(Uri.IsHexDigit) ? hex : null;
|
||||
}
|
||||
|
||||
/// <summary>Compare une empreinte saisie avec celle d'une clé (en temps constant).</summary>
|
||||
public static bool FingerprintMatches(ReadOnlySpan<byte> subjectPublicKeyInfo, string? typed)
|
||||
{
|
||||
var normalized = NormalizeFingerprint(typed);
|
||||
if (normalized is null) return false;
|
||||
var expected = ComputeFingerprint(subjectPublicKeyInfo).Replace(" ", "");
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.ASCII.GetBytes(expected), System.Text.Encoding.ASCII.GetBytes(normalized));
|
||||
}
|
||||
|
||||
public static bool IsValidKeyId(string? keyId) =>
|
||||
keyId is { Length: 16 } && keyId.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
|
||||
|
||||
/// <summary>
|
||||
/// Lit une clé publique (SPKI en Base64, ou PEM « BEGIN PUBLIC KEY ») et vérifie qu'il s'agit
|
||||
/// d'une clé ECDSA P-256 valide. Renvoie le SPKI ré-encodé (forme canonique).
|
||||
/// </summary>
|
||||
/// <exception cref="CryptographicException">Clé illisible ou d'un autre type.</exception>
|
||||
public static byte[] ParsePublicKey(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || text.Length > 4096)
|
||||
throw new CryptographicException("Clé publique absente ou trop longue.");
|
||||
|
||||
using var ec = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
if (text.Contains("-----BEGIN", StringComparison.Ordinal))
|
||||
{
|
||||
if (!text.Contains("-----BEGIN PUBLIC KEY-----", StringComparison.Ordinal))
|
||||
throw new CryptographicException("Une clé PUBLIQUE est attendue (jamais la clé privée).");
|
||||
ec.ImportFromPem(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
var der = Convert.FromBase64String(text.Trim());
|
||||
if (der.Length > MaxSpkiLength) throw new CryptographicException("Clé publique trop longue.");
|
||||
ec.ImportSubjectPublicKeyInfo(der, out var read);
|
||||
if (read != der.Length) throw new CryptographicException("Clé publique mal formée.");
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new CryptographicException("Clé publique illisible.", ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new CryptographicException("Clé publique illisible.", ex);
|
||||
}
|
||||
|
||||
EnsureP256(ec);
|
||||
return ec.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
/// <summary>Clé publique au format PEM (export, outil en ligne de commande).</summary>
|
||||
public static string ToPem(ReadOnlySpan<byte> subjectPublicKeyInfo) =>
|
||||
PemEncoding.WriteString("PUBLIC KEY", subjectPublicKeyInfo);
|
||||
|
||||
/// <summary>Signe un message (côté launcher).</summary>
|
||||
public static byte[] Sign(ECDsa privateKey, ReadOnlySpan<byte> message)
|
||||
{
|
||||
EnsureP256(privateKey);
|
||||
return privateKey.SignData(message, HashAlgorithmName.SHA256, Format);
|
||||
}
|
||||
|
||||
/// <summary>Vérifie une signature (côté serveur). Ne lève jamais d'exception : faux si quoi que ce soit ne va pas.</summary>
|
||||
public static bool Verify(ReadOnlySpan<byte> subjectPublicKeyInfo, ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
if (signature.Length != SignatureLength || subjectPublicKeyInfo.Length is 0 or > MaxSpkiLength) return false;
|
||||
try
|
||||
{
|
||||
using var ec = ECDsa.Create();
|
||||
ec.ImportSubjectPublicKeyInfo(subjectPublicKeyInfo, out _);
|
||||
if (!IsP256(ec)) return false;
|
||||
return ec.VerifyData(message, signature, HashAlgorithmName.SHA256, Format);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signature encodée en Base64 → octets, ou null si l'encodage ou la taille ne conviennent pas.</summary>
|
||||
public static byte[]? DecodeSignature(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64) || base64.Length > 128) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
return bytes.Length == SignatureLength ? bytes : null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureP256(ECDsa ec)
|
||||
{
|
||||
if (!IsP256(ec)) throw new CryptographicException("Une clé ECDSA P-256 est attendue.");
|
||||
}
|
||||
|
||||
private static bool IsP256(ECDsa ec)
|
||||
{
|
||||
if (ec.KeySize != 256) return false;
|
||||
var curve = ec.ExportParameters(false).Curve;
|
||||
return curve.IsNamed && (curve.Oid.Value == P256Oid || curve.Oid.FriendlyName is "nistP256" or "ECDSA_P256");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Permissions d'administration, au format « module.action ». Un module futur ajoute les siennes
|
||||
/// ici (ou dans son propre fichier de contrats) et les attribue aux rôles dans <see cref="AdminRoles"/>.
|
||||
/// Le serveur est seul juge : côté launcher, elles servent uniquement à masquer les pages inutiles.
|
||||
/// </summary>
|
||||
public static class AdminPermissions
|
||||
{
|
||||
/// <summary>Gestion des administrateurs : invitations, approbation, rôles, révocation.</summary>
|
||||
public const string AdminsManage = "admins.manage";
|
||||
|
||||
/// <summary>Lecture du journal d'audit.</summary>
|
||||
public const string AuditRead = "audit.read";
|
||||
|
||||
/// <summary>Informations modifiables des jeux (nom affiché, accroche, liens…) — phase 3.</summary>
|
||||
public const string GamesEdit = "games.edit";
|
||||
|
||||
/// <summary>Édition des notes de version existantes — phase 4.</summary>
|
||||
public const string ChangelogEdit = "changelog.edit";
|
||||
|
||||
/// <summary>Publication d'une nouvelle version du jeu — phase 5.</summary>
|
||||
public const string ReleasesPublish = "releases.publish";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>Rôles des administrateurs et permissions associées.</summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
/// <summary>Tous les droits, dont la gestion des administrateurs.</summary>
|
||||
public const string Owner = "owner";
|
||||
|
||||
/// <summary>Contenu et publication, sans gestion des administrateurs.</summary>
|
||||
public const string Admin = "admin";
|
||||
|
||||
/// <summary>Contenu uniquement (informations des jeux, notes de version).</summary>
|
||||
public const string Editor = "editor";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Owner, Admin, Editor };
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> Map =
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
[Owner] = AdminPermissions.All,
|
||||
[Admin] = new[]
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
|
||||
public static bool IsValid(string? role) => role is not null && Map.ContainsKey(role);
|
||||
|
||||
/// <summary>Permissions d'un rôle (aucune pour un rôle inconnu).</summary>
|
||||
public static IReadOnlyList<string> PermissionsOf(string? role) =>
|
||||
role is not null && Map.TryGetValue(role, out var permissions) ? permissions : Array.Empty<string>();
|
||||
|
||||
public static bool Has(string? role, string permission) => PermissionsOf(role).Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
public static string DisplayName(string? role) => role switch
|
||||
{
|
||||
Owner => "Propriétaire",
|
||||
Admin => "Administrateur",
|
||||
Editor => "Éditeur",
|
||||
_ => "Inconnu"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>États d'un administrateur ou d'une clé.</summary>
|
||||
public static class AdminStatus
|
||||
{
|
||||
/// <summary>Enrôlé avec une invitation, en attente d'approbation (empreinte vérifiée par un propriétaire).</summary>
|
||||
public const string Pending = "pending";
|
||||
public const string Active = "active";
|
||||
public const string Revoked = "revoked";
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Codes d'invitation : 160 bits aléatoires, en Base32 (sans 0/1/8/9 ambigus), groupés par 4 :
|
||||
/// « ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ23-4567 ». Le serveur ne garde que leur empreinte.
|
||||
/// </summary>
|
||||
public static class InvitationCodes
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
private const int Length = 32; // 32 × 5 bits = 160 bits
|
||||
|
||||
public static string Generate()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(Length * 5 / 8);
|
||||
var chars = new char[Length];
|
||||
int buffer = 0, bits = 0, index = 0;
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
buffer = (buffer << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5)
|
||||
{
|
||||
chars[index++] = Alphabet[(buffer >> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < Length; i++)
|
||||
{
|
||||
if (i > 0 && i % 4 == 0) sb.Append('-');
|
||||
sb.Append(chars[i]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Code saisi → 32 caractères majuscules sans séparateurs, ou null s'il est mal formé.</summary>
|
||||
public static string? Normalize(string? typed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typed) || typed.Length > 80) return null;
|
||||
var sb = new StringBuilder(Length);
|
||||
foreach (var c in typed)
|
||||
{
|
||||
if (c is '-' or ' ') continue;
|
||||
var u = char.ToUpperInvariant(c);
|
||||
if (Alphabet.IndexOf(u) < 0) return null;
|
||||
sb.Append(u);
|
||||
}
|
||||
return sb.Length == Length ? sb.ToString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Messages signés par la clé d'un administrateur. Chaque type de message commence par un en-tête
|
||||
/// propre (séparation de domaine) : une signature produite pour un usage ne vaut jamais pour un
|
||||
/// autre. Une ligne par champ, « nom:valeur », valeurs sans retour à la ligne, UTF-8, « \n ».
|
||||
/// Le launcher et le serveur construisent le message avec ce même code.
|
||||
/// </summary>
|
||||
public static class SignedMessages
|
||||
{
|
||||
public const string LoginHeader = "GameLauncher/Admin/Login/v1";
|
||||
public const string EnrollmentHeader = "GameLauncher/Admin/Enrollment/v1";
|
||||
public const string RequestHeader = "GameLauncher/Admin/Request/v1";
|
||||
|
||||
/// <summary>Message signé pour se connecter (réponse au défi).</summary>
|
||||
public static byte[] Login(string origin, string serverId, string challengeId, string nonce, string keyId) =>
|
||||
Build(LoginHeader,
|
||||
("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce), ("key", keyId));
|
||||
|
||||
/// <summary>
|
||||
/// Preuve de possession de la clé lors d'un enrôlement : seule l'empreinte du code d'invitation est
|
||||
/// signée (le code lui-même n'apparaît pas dans la signature).
|
||||
/// </summary>
|
||||
public static byte[] Enrollment(string origin, string serverId, string invitationCodeHash, string keyId) =>
|
||||
Build(EnrollmentHeader,
|
||||
("origin", origin), ("server", serverId), ("invitation", invitationCodeHash), ("key", keyId));
|
||||
|
||||
/// <summary>Message signé d'une requête qui modifie quelque chose (en plus du jeton de session).</summary>
|
||||
/// <param name="pathAndQuery">Chemin tel que reçu par le serveur, ex. « /api/v1/admins/invitations ».</param>
|
||||
public static byte[] Request(string origin, string serverId, string sessionId, string method, string pathAndQuery,
|
||||
ReadOnlySpan<byte> body, long timestamp, string nonce) =>
|
||||
Build(RequestHeader,
|
||||
("origin", origin), ("server", serverId), ("session", sessionId),
|
||||
("method", method.ToUpperInvariant()), ("path", pathAndQuery),
|
||||
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
|
||||
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
|
||||
|
||||
/// <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)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
/// <summary>Origine normalisée d'une adresse : « https://hote[:port] », sans chemin ni barre finale.</summary>
|
||||
public static string NormalizeOrigin(Uri uri) => uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static byte[] Build(string header, params (string Name, string Value)[] fields)
|
||||
{
|
||||
var sb = new StringBuilder(header).Append('\n');
|
||||
foreach (var (name, value) in fields)
|
||||
{
|
||||
if (value is null || value.IndexOfAny(new[] { '\n', '\r' }) >= 0 || value.Length > 2048)
|
||||
throw new ArgumentException($"Valeur invalide pour le champ signé « {name} ».");
|
||||
sb.Append(name).Append(':').Append(value).Append('\n');
|
||||
}
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>En-têtes HTTP des requêtes signées et tolérances associées.</summary>
|
||||
public static class SignedRequestHeaders
|
||||
{
|
||||
public const string Timestamp = "X-GL-Timestamp";
|
||||
public const string Nonce = "X-GL-Nonce";
|
||||
public const string Signature = "X-GL-Signature";
|
||||
|
||||
/// <summary>Décalage d'horloge maximal accepté entre le launcher et le serveur.</summary>
|
||||
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(2);
|
||||
}
|
||||
@@ -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,190 @@
|
||||
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.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);
|
||||
|
||||
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl)
|
||||
: this(http.GetBytesAsync, dataDirectory, catalogUrl) { }
|
||||
|
||||
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
|
||||
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl)
|
||||
{
|
||||
_download = download;
|
||||
_catalogUrl = catalogUrl;
|
||||
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 ou illisible).</summary>
|
||||
public CatalogDocument? LoadCached()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
|
||||
return Parse(File.ReadAllBytes(CachePath), out _);
|
||||
}
|
||||
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);
|
||||
var catalog = Parse(bytes, out var errors);
|
||||
if (catalog is null) return null;
|
||||
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
|
||||
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(catalog, 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>Lecture + contrôle (valeurs invalides retirées). Null si le format est inconnu.</summary>
|
||||
private CatalogDocument? Parse(byte[] bytes, out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
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 CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
|
||||
@@ -15,7 +15,16 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
|
||||
private const string GamesFolderName = "Games";
|
||||
private const string GameSettingsFileName = "settings.json";
|
||||
|
||||
private string? _dataDirectory;
|
||||
private readonly Dictionary<string, string> _gameSettingsPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private LauncherCatalog(string launcherId, string launcherName, string publisher, string? defaultGameId,
|
||||
IReadOnlyList<LauncherConfig> games, bool isMultiGameFormat)
|
||||
@@ -25,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; }
|
||||
@@ -54,11 +76,169 @@ public sealed class LauncherCatalog
|
||||
public string? LauncherUpdateUrl { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du launcher (journaux, dernier jeu sélectionné).
|
||||
/// Avec un seul jeu, c'est le même dossier qu'auparavant : %AppData%\Publisher\GameIdLauncher.
|
||||
/// 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 DataDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher, LauncherId);
|
||||
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>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
|
||||
/// identifie le produit lors des mises à jour et ne doit plus changer.
|
||||
/// </summary>
|
||||
public string DataFolderName { get; private set; } = DefaultDataFolderName;
|
||||
|
||||
/// <summary>%AppData%\<Publisher></summary>
|
||||
public string PublisherDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du launcher : %AppData%\<Publisher>\GameLauncher (paramètres du
|
||||
/// launcher, mise à jour du launcher, journaux, dernier jeu affiché ; paramètres de chaque jeu
|
||||
/// dans Games\<GameId>). Voir <see cref="PrepareStorage"/>.
|
||||
/// </summary>
|
||||
public string DataDirectory => _dataDirectory ?? Path.Combine(PublisherDirectory, DataFolderName);
|
||||
|
||||
/// <summary>
|
||||
/// Ancien dossier des données (launcher 1.2.1 et antérieurs) : %AppData%\<Publisher>\<LauncherId>
|
||||
/// (ex. EZRLauncher avec un seul jeu).
|
||||
/// </summary>
|
||||
public string LegacyDataDirectory => Path.Combine(PublisherDirectory, LauncherId);
|
||||
|
||||
/// <summary>Vrai si l'ancien dossier est encore utilisé pour cette session (déplacement différé ou impossible).</summary>
|
||||
public bool UsesLegacyStorage { get; private set; }
|
||||
|
||||
/// <summary>Paramètres propres à un jeu : <DataDirectory>\Games\<GameId>\settings.json.</summary>
|
||||
public string GetGameSettingsPath(LauncherConfig game) =>
|
||||
_gameSettingsPaths.TryGetValue(game.GameId, out var path)
|
||||
? path
|
||||
: Path.Combine(DataDirectory, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les dossiers à utiliser et, si <paramref name="allowMigration"/>, déplace les données
|
||||
/// des versions précédentes :
|
||||
/// • %AppData%\<Publisher>\<LauncherId> (ex. EZRLauncher) → %AppData%\<Publisher>\GameLauncher
|
||||
/// (renommage du dossier, instantané) ;
|
||||
/// • paramètres de chaque jeu (settings.json à la racine de l'ancien dossier, ou dans
|
||||
/// %AppData%\<Publisher>\<GameId>Launcher) → GameLauncher\Games\<GameId>\settings.json.
|
||||
/// Sans déplacement (autre instance ouverte, démarrage après une mise à jour du launcher) ou en cas
|
||||
/// d'échec, l'ancien dossier reste utilisé pour cette session : rien n'est perdu, le déplacement est
|
||||
/// retenté au démarrage suivant.
|
||||
/// </summary>
|
||||
/// <returns>Messages à écrire dans le journal (le journal n'est pas encore ouvert) ; « ! » = avertissement.</returns>
|
||||
public IReadOnlyList<string> PrepareStorage(bool allowMigration)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var target = Path.Combine(PublisherDirectory, DataFolderName);
|
||||
var legacy = LegacyDataDirectory;
|
||||
_dataDirectory = target;
|
||||
UsesLegacyStorage = false;
|
||||
_gameSettingsPaths.Clear();
|
||||
|
||||
if (!SamePath(target, legacy) && !Directory.Exists(target) && Directory.Exists(legacy))
|
||||
{
|
||||
if (!allowMigration)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"Dossier des données : {legacy} utilisé pour cette session (déplacement vers {target} au prochain démarrage).");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(legacy, target); // même dossier parent : simple renommage
|
||||
notes.Add($"Données du launcher déplacées : {legacy} → {target}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"! Déplacement de {legacy} vers {target} impossible ({ex.Message}) : l'ancien dossier reste utilisé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var game in Games)
|
||||
{
|
||||
var legacyGameDirectory = Path.Combine(PublisherDirectory, game.GameId + "Launcher");
|
||||
if (UsesLegacyStorage)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = Path.Combine(legacyGameDirectory, GameSettingsFileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = Path.Combine(target, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
_gameSettingsPaths[game.GameId] = path;
|
||||
if (File.Exists(path)) continue;
|
||||
|
||||
// Ancien emplacement : à la racine du dossier déplacé (un seul jeu : même dossier que le
|
||||
// launcher) ou dans le dossier propre au jeu (plusieurs jeux).
|
||||
var candidates = new List<string>();
|
||||
if (SamePath(legacyGameDirectory, legacy)) candidates.Add(Path.Combine(target, GameSettingsFileName));
|
||||
candidates.Add(Path.Combine(legacyGameDirectory, GameSettingsFileName));
|
||||
var source = candidates.FirstOrDefault(File.Exists);
|
||||
if (source is null) continue;
|
||||
|
||||
if (!allowMigration)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.Move(source, path);
|
||||
notes.Add($"[{game.GameId}] Paramètres du jeu déplacés : {source} → {path}");
|
||||
|
||||
// Ancien dossier propre au jeu, désormais vide : supprimé.
|
||||
var sourceDirectory = Path.GetDirectoryName(source)!;
|
||||
if (!SamePath(sourceDirectory, target) && Directory.Exists(sourceDirectory) &&
|
||||
!Directory.EnumerateFileSystemEntries(sourceDirectory).Any())
|
||||
Directory.Delete(sourceDirectory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
notes.Add($"! [{game.GameId}] Déplacement des paramètres du jeu impossible ({ex.Message}) : {source} reste utilisé.");
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
|
||||
void UseLegacy()
|
||||
{
|
||||
_dataDirectory = legacy;
|
||||
UsesLegacyStorage = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b) =>
|
||||
string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar),
|
||||
Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Nom du mutex garantissant une seule instance du launcher.</summary>
|
||||
public string SingleInstanceName =>
|
||||
@@ -66,10 +246,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)
|
||||
{
|
||||
@@ -103,7 +284,12 @@ public sealed class LauncherCatalog
|
||||
return new LauncherCatalog(single.GameId + "Launcher", single.GameName, single.Publisher, single.GameId,
|
||||
new[] { single }, isMultiGameFormat: false)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root)
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,10 +339,25 @@ public sealed class LauncherCatalog
|
||||
var defaultGame = Get(root, "defaultGame")?.GetValue<string>();
|
||||
return new LauncherCatalog(launcherId, launcherName, publisher, defaultGame, games, isMultiGameFormat: true)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root)
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
private static string ReadDataFolder(JsonObject root)
|
||||
{
|
||||
var name = Get(root, "dataFolder")?.GetValue<string>()?.Trim();
|
||||
if (string.IsNullOrEmpty(name)) return DefaultDataFolderName;
|
||||
if (name is "." or ".." || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.IndexOfAny(new[] { '\\', '/', ':' }) >= 0)
|
||||
throw new InvalidDataException("launcher.json : dataFolder doit être un simple nom de dossier (ex. \"GameLauncher\").");
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string? ReadUpdateUrl(JsonObject root)
|
||||
{
|
||||
var url = Get(root, "launcherUpdateUrl")?.GetValue<string>();
|
||||
@@ -167,6 +368,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,59 @@ 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,
|
||||
/// le dossier des données (%APPDATA%\Société\Produit : sauvegardes, réglages) et celui des
|
||||
/// journaux de Unity (%USERPROFILE%\AppData\LocalLow\Société\Produit : Player.log).
|
||||
/// Facultatif : Publisher\GameName par défaut.
|
||||
/// </summary>
|
||||
public string? GameDataFolder { get; set; }
|
||||
|
||||
/// <summary>Chemin relatif « Société\Produit » (voir <see cref="GameDataFolder"/>).</summary>
|
||||
public string GameDataRelativePath =>
|
||||
(string.IsNullOrWhiteSpace(GameDataFolder) ? Path.Combine(Publisher, GameName) : GameDataFolder.Trim())
|
||||
.Replace('/', '\\').Trim('\\');
|
||||
|
||||
/// <summary>Sauvegardes et réglages du jeu : %APPDATA%\Société\Produit (Roaming).</summary>
|
||||
public string GameDataDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), GameDataRelativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Journaux de Unity (Player.log) : %USERPROFILE%\AppData\LocalLow\Société\Produit. Emplacement fixé
|
||||
/// par Unity (Application.persistentDataPath) : LocalLow est le dossier voisin de %LOCALAPPDATA%.
|
||||
/// </summary>
|
||||
public string GameLogsDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var appData = Path.GetDirectoryName(local.TrimEnd(Path.DirectorySeparatorChar)) ?? local;
|
||||
return Path.Combine(appData, "LocalLow", GameDataRelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
public string ExpandedDefaultInstallDirectory =>
|
||||
Environment.ExpandEnvironmentVariables(DefaultInstallDirectory ?? "");
|
||||
|
||||
@@ -81,5 +135,21 @@ public sealed class LauncherConfig
|
||||
throw new InvalidDataException("launcher.json : GameName est obligatoire.");
|
||||
if (!Uri.TryCreate(ManifestUrl, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
throw new InvalidDataException("launcher.json : ManifestUrl doit être une URL http(s) absolue.");
|
||||
if (!IsSafeRelativeFolder(GameDataRelativePath))
|
||||
throw new InvalidDataException(
|
||||
"launcher.json : gameDataFolder doit être un chemin relatif « Société\\Produit » (sans lecteur, sans « .. »).");
|
||||
}
|
||||
|
||||
/// <summary>Chemin relatif simple : pas de lecteur, pas de « .. », pas de caractère interdit.</summary>
|
||||
private static bool IsSafeRelativeFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path) || path.Contains(':')) return false;
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return path.Split('\\').All(part => part.Length > 0 && part != "." && part != ".." &&
|
||||
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);
|
||||
@@ -5,7 +5,7 @@ namespace GameLauncher.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences du launcher, communes à tous les jeux (roue crantée › Paramètres du launcher) :
|
||||
/// %AppData%\<Publisher>\<LauncherId>\launcher-settings.json.
|
||||
/// %AppData%\<Publisher>\GameLauncher\launcher-settings.json (voir LauncherCatalog.DataDirectory).
|
||||
/// (La mise à jour automatique du launcher lui-même est dans self-update.json.)
|
||||
/// </summary>
|
||||
public sealed class LauncherPreferences
|
||||
@@ -16,6 +16,18 @@ public sealed class LauncherPreferences
|
||||
/// <summary>Fermer le launcher au lancement du jeu.</summary>
|
||||
public bool CloseOnLaunch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Avec <see cref="MinimizeOnLaunch"/> : pendant la partie, le launcher est réduit dans la zone de
|
||||
/// notification (icône près de l'horloge) au lieu de la barre des tâches.
|
||||
/// </summary>
|
||||
public bool MinimizeToTray { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// La croix de la fenêtre réduit le launcher dans la zone de notification au lieu de le quitter
|
||||
/// (il se quitte alors par le menu de son icône › Quitter).
|
||||
/// </summary>
|
||||
public bool CloseToTray { get; set; }
|
||||
|
||||
public static string GetPath(string dataDirectory) => Path.Combine(dataDirectory, "launcher-settings.json");
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace GameLauncher.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences propres à UN jeu (menu « … » › Paramètres du jeu) :
|
||||
/// %AppData%\{Publisher}\{GameId}Launcher\settings.json.
|
||||
/// %AppData%\{Publisher}\GameLauncher\Games\{GameId}\settings.json (voir LauncherCatalog.GetGameSettingsPath ;
|
||||
/// launcher 1.2.1 et antérieurs : %AppData%\{Publisher}\{GameId}Launcher\settings.json).
|
||||
/// Les préférences communes à tous les jeux sont dans <see cref="LauncherPreferences"/>.
|
||||
/// </summary>
|
||||
public sealed class LauncherSettings
|
||||
@@ -26,13 +27,6 @@ public sealed class LauncherSettings
|
||||
|
||||
public LauncherSettings Clone() => (LauncherSettings)MemberwiseClone();
|
||||
|
||||
public static string GetDefaultPath(LauncherConfig config) =>
|
||||
Path.Combine(GetDataDirectory(config), "settings.json");
|
||||
|
||||
/// <summary>%AppData%\{Publisher}\{GameId}Launcher</summary>
|
||||
public static string GetDataDirectory(LauncherConfig config) =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
config.Publisher, config.GameId + "Launcher");
|
||||
|
||||
public static LauncherSettings Load(string path)
|
||||
{
|
||||
|
||||
@@ -24,4 +24,14 @@ public sealed record VerifyResult(
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une désinstallation.</summary>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved);
|
||||
/// <param name="DeletedFiles">Fichiers supprimés.</param>
|
||||
/// <param name="RemainingFiles">Fichiers personnels conservés (hors fichiers bloqués et fichiers du launcher).</param>
|
||||
/// <param name="FolderRemoved">Le dossier du jeu a disparu.</param>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved)
|
||||
{
|
||||
/// <summary>Fichiers qui n'ont pas pu être supprimés (fichier ouvert, antivirus…).</summary>
|
||||
public IReadOnlyList<string> LockedFiles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Fichiers du launcher conservés (launcher placé dans le dossier du jeu).</summary>
|
||||
public int LauncherFilesKept { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace GameLauncher.Core.SelfUpdate;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences de mise à jour du launcher lui-même, communes à tous les jeux :
|
||||
/// %AppData%\<Publisher>\<LauncherId>\self-update.json
|
||||
/// %AppData%\<Publisher>\GameLauncher\self-update.json (voir LauncherCatalog.DataDirectory)
|
||||
/// </summary>
|
||||
public sealed class SelfUpdateSettings
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -428,10 +428,15 @@ public sealed class GameInstaller
|
||||
/// <summary>
|
||||
/// Désinstalle le jeu. Par défaut, seuls les fichiers installés par le launcher sont supprimés
|
||||
/// (les sauvegardes/configurations créées par le jeu sont conservées).
|
||||
/// Avec <paramref name="deleteEverything"/>, le dossier entier est supprimé.
|
||||
/// Avec <paramref name="deleteEverything"/>, tout le contenu du dossier est supprimé.
|
||||
///
|
||||
/// Les fichiers de <paramref name="keepFiles"/> (le launcher, s'il est dans le dossier du jeu) ne
|
||||
/// sont jamais supprimés. Un fichier bloqué (jeu mal fermé, antivirus…) n'interrompt pas la
|
||||
/// désinstallation : tout ce qui peut l'être est supprimé, le reste est signalé (LockedFiles).
|
||||
/// </summary>
|
||||
public Task<UninstallResult> UninstallAsync(string installDir, GameManifest? manifest, bool deleteEverything,
|
||||
IProgress<OperationProgress>? progress, CancellationToken ct)
|
||||
IProgress<OperationProgress>? progress, CancellationToken ct,
|
||||
IReadOnlyCollection<string>? keepFiles = null)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
@@ -439,15 +444,30 @@ public sealed class GameInstaller
|
||||
Log.Info($"Désinstallation de {installDir} (tout supprimer : {deleteEverything})");
|
||||
if (!Directory.Exists(installDir)) return new UninstallResult(0, 0, true);
|
||||
|
||||
var keep = new HashSet<string>(
|
||||
(keepFiles ?? Array.Empty<string>()).Where(f => LauncherSelfFiles.IsInside(f, installDir)).Select(Path.GetFullPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (keep.Count > 0) Log.Info($"Fichiers du launcher conservés dans le dossier du jeu : {string.Join(", ", keep.Where(File.Exists))}");
|
||||
|
||||
if (deleteEverything)
|
||||
{
|
||||
if (PathHelper.IsDangerousDirectory(installDir))
|
||||
throw new InvalidOperationException($"Le dossier « {installDir} » est protégé : suppression complète refusée.");
|
||||
|
||||
progress?.Report(new OperationProgress("Suppression du dossier du jeu", installDir, 0, true));
|
||||
var count = Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories).Count();
|
||||
FileUtil.DeleteDirectoryRecursive(installDir);
|
||||
return new UninstallResult(count, 0, true);
|
||||
// Fichier par fichier : le launcher est épargné et un fichier bloqué n'arrête pas le reste.
|
||||
var deletion = FileUtil.DeleteDirectoryContents(installDir, keep, (index, total) =>
|
||||
{
|
||||
if (index % 25 == 1 || index == total)
|
||||
progress?.Report(new OperationProgress("Suppression du dossier du jeu",
|
||||
$"{index}/{total} fichiers", index * 100.0 / total));
|
||||
}, ct);
|
||||
Log.Info($"Désinstallation : {deletion.DeletedFiles} fichier(s) supprimé(s), {deletion.LockedFiles.Count} bloqué(s), " +
|
||||
$"{deletion.KeptFiles} fichier(s) du launcher conservé(s).");
|
||||
return new UninstallResult(deletion.DeletedFiles, 0, deletion.FolderRemoved)
|
||||
{
|
||||
LockedFiles = deletion.LockedFiles,
|
||||
LauncherFilesKept = deletion.KeptFiles
|
||||
};
|
||||
}
|
||||
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -457,6 +477,7 @@ public sealed class GameInstaller
|
||||
|
||||
var deleted = 0;
|
||||
var index = 0;
|
||||
var locked = new List<string>();
|
||||
foreach (var relative in paths)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -468,28 +489,29 @@ public sealed class GameInstaller
|
||||
}
|
||||
|
||||
string full;
|
||||
try { full = PathHelper.ResolveSafe(installDir, relative); }
|
||||
try { full = Path.GetFullPath(PathHelper.ResolveSafe(installDir, relative)); }
|
||||
catch { continue; }
|
||||
if (File.Exists(full) && FileUtil.TryDeleteFile(full)) deleted++;
|
||||
if (!File.Exists(full) || keep.Contains(full)) continue;
|
||||
if (FileUtil.TryDeleteFile(full)) deleted++;
|
||||
else locked.Add(full);
|
||||
}
|
||||
|
||||
try { FileUtil.DeleteDirectoryRecursive(InstallStateStore.GetMetaDirectory(installDir)); }
|
||||
catch (Exception ex) { Log.Warn($"Suppression de .launcher impossible : {ex.Message}"); }
|
||||
|
||||
FileUtil.DeleteEmptyDirectories(installDir);
|
||||
var remaining = Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories).Count();
|
||||
var removed = false;
|
||||
if (PathHelper.IsDirectoryEmpty(installDir))
|
||||
{
|
||||
try { Directory.Delete(installDir); removed = true; }
|
||||
catch (Exception ex) { Log.Warn($"Suppression du dossier impossible : {ex.Message}"); }
|
||||
}
|
||||
var remainingFiles = Directory.EnumerateFiles(installDir, "*", FileUtil.AllFiles).Select(Path.GetFullPath).ToList();
|
||||
var launcherKept = remainingFiles.Count(keep.Contains);
|
||||
var personal = remainingFiles.Count - launcherKept - locked.Count(remainingFiles.Contains);
|
||||
var removed = FileUtil.TryRemoveEmptyDirectory(installDir);
|
||||
|
||||
Log.Info($"Désinstallation : {deleted} fichier(s) supprimé(s), {remaining} conservé(s).");
|
||||
return new UninstallResult(deleted, remaining, removed);
|
||||
Log.Info($"Désinstallation : {deleted} fichier(s) supprimé(s), {personal} fichier(s) personnel(s) conservé(s), " +
|
||||
$"{locked.Count} bloqué(s), {launcherKept} fichier(s) du launcher conservé(s).");
|
||||
return new UninstallResult(deleted, Math.Max(0, personal), removed) { LockedFiles = locked, LauncherFilesKept = launcherKept };
|
||||
}, ct);
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------------ Utilitaires
|
||||
|
||||
private static void RemoveObsoleteFiles(string installDir, IEnumerable<string> oldPaths, GameManifest manifest, InstallState state)
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -1,7 +1,76 @@
|
||||
namespace GameLauncher.Core.Utils;
|
||||
|
||||
/// <summary>Résultat de <see cref="FileUtil.DeleteDirectoryContents"/>.</summary>
|
||||
/// <param name="DeletedFiles">Fichiers supprimés.</param>
|
||||
/// <param name="LockedFiles">Fichiers qui n'ont pas pu être supprimés (fichier ouvert, antivirus…).</param>
|
||||
/// <param name="KeptFiles">Fichiers conservés volontairement (fichiers du launcher).</param>
|
||||
/// <param name="FolderRemoved">Le dossier a disparu (il était vide à la fin).</param>
|
||||
public sealed record DirectoryDeletion(int DeletedFiles, IReadOnlyList<string> LockedFiles, int KeptFiles, bool FolderRemoved);
|
||||
|
||||
public static class FileUtil
|
||||
{
|
||||
/// <summary>Tous les fichiers, y compris cachés (ex. dossier .launcher) ; dossiers inaccessibles ignorés.</summary>
|
||||
public static readonly EnumerationOptions AllFiles = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Supprime le contenu d'un dossier fichier par fichier, puis les sous-dossiers vides, puis le
|
||||
/// dossier lui-même s'il est vide. Utilisé par la désinstallation (dossier du jeu, sauvegardes,
|
||||
/// journaux) :
|
||||
/// • les fichiers de <paramref name="keepFiles"/> (le launcher, s'il se trouve dans ce dossier)
|
||||
/// ne sont jamais supprimés ;
|
||||
/// • un fichier bloqué n'interrompt pas la suppression : il est seulement signalé.
|
||||
/// </summary>
|
||||
/// <param name="onProgress">(index, total) : avancement, facultatif.</param>
|
||||
public static DirectoryDeletion DeleteDirectoryContents(string directory, IReadOnlyCollection<string>? keepFiles = null,
|
||||
Action<int, int>? onProgress = null, CancellationToken ct = default)
|
||||
{
|
||||
directory = Path.GetFullPath(directory);
|
||||
if (!Directory.Exists(directory)) return new DirectoryDeletion(0, Array.Empty<string>(), 0, true);
|
||||
|
||||
var keep = new HashSet<string>(
|
||||
(keepFiles ?? Array.Empty<string>()).Where(f => LauncherSelfFiles.IsInside(f, directory)).Select(Path.GetFullPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var files = Directory.EnumerateFiles(directory, "*", AllFiles).ToList();
|
||||
var locked = new List<string>();
|
||||
int deleted = 0, kept = 0;
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
onProgress?.Invoke(i + 1, files.Count);
|
||||
|
||||
var file = Path.GetFullPath(files[i]);
|
||||
if (keep.Contains(file)) { kept++; continue; }
|
||||
if (TryDeleteFile(file)) deleted++;
|
||||
else locked.Add(file);
|
||||
}
|
||||
|
||||
DeleteEmptyDirectories(directory);
|
||||
return new DirectoryDeletion(deleted, locked, kept, TryRemoveEmptyDirectory(directory));
|
||||
}
|
||||
|
||||
/// <summary>Supprime le dossier s'il est vide. Vrai s'il n'existe plus.</summary>
|
||||
public static bool TryRemoveEmptyDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory)) return true;
|
||||
if (Directory.EnumerateFileSystemEntries(directory).Any()) return false;
|
||||
Directory.Delete(directory);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Suppression du dossier impossible ({directory}) : {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Écrit un fichier de façon atomique (fichier temporaire puis remplacement).</summary>
|
||||
public static void WriteAllTextAtomic(string path, string content)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace GameLauncher.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers du launcher lui-même, à conserver quand il se trouve dans le dossier d'un jeu que l'on
|
||||
/// désinstalle (option « Supprimer aussi… ») : un exécutable en cours d'exécution ne peut de toute
|
||||
/// façon pas être supprimé, et il sert encore (autres jeux, réinstallation).
|
||||
/// </summary>
|
||||
public static class LauncherSelfFiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Exécutable du launcher, launcher.json à côté, fichiers temporaires de sa mise à jour
|
||||
/// (GameLauncher.exe.old / .download / .replaced / .failed), et en version de développement
|
||||
/// (plusieurs fichiers) les bibliothèques chargées depuis son dossier.
|
||||
/// </summary>
|
||||
/// <returns>Chemins complets (comparaison sans tenir compte de la casse).</returns>
|
||||
public static IReadOnlyCollection<string> Get(string launcherExePath)
|
||||
{
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string exe;
|
||||
try { exe = Path.GetFullPath(launcherExePath); }
|
||||
catch { return files; }
|
||||
var directory = Path.GetDirectoryName(exe);
|
||||
if (string.IsNullOrEmpty(directory)) return files;
|
||||
|
||||
files.Add(exe);
|
||||
files.Add(Path.Combine(directory, "launcher.json"));
|
||||
|
||||
// GameLauncher.exe.old, GameLauncher.exe.1.2.2.download… et, en développement, GameLauncher.dll,
|
||||
// GameLauncher.Core.dll, GameLauncher.deps.json, GameLauncher.runtimeconfig.json, .pdb…
|
||||
var entryName = Assembly.GetEntryAssembly()?.GetName().Name ?? Path.GetFileNameWithoutExtension(exe);
|
||||
foreach (var pattern in new[] { Path.GetFileName(exe) + ".*", entryName + ".*" })
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(directory, pattern)) files.Add(Path.GetFullPath(file));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Liste des fichiers du launcher incomplète : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Version de développement : bibliothèques réellement chargées depuis le dossier du launcher.
|
||||
// (Version publiée « fichier unique » : Location est vide, rien à ajouter.)
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
if (assembly.IsDynamic) continue;
|
||||
string location;
|
||||
try { location = assembly.Location; }
|
||||
catch { continue; }
|
||||
if (string.IsNullOrEmpty(location) || !IsInside(location, directory)) continue;
|
||||
var full = Path.GetFullPath(location);
|
||||
files.Add(full);
|
||||
files.Add(Path.ChangeExtension(full, ".pdb"));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si <paramref name="path"/> est <paramref name="directory"/> ou se trouve dedans (à toute profondeur).</summary>
|
||||
public static bool IsInside(string path, string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var root = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(full, root, StringComparison.OrdinalIgnoreCase) ||
|
||||
full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,269 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
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
|
||||
{
|
||||
var published = await Context.Session.PublishCatalogAsync(draft, 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,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,73 @@
|
||||
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;
|
||||
|
||||
/// <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>
|
||||
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
|
||||
{
|
||||
_client = client;
|
||||
_clientName = clientName;
|
||||
_tpm = tpm;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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,129 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user