Compare commits
208
Commits
admin/phase2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b398e4059a | ||
|
|
69f943ea53 | ||
|
|
4c368cb1e0 | ||
|
|
eca812a718 | ||
|
|
e4f2d443e8 | ||
|
|
6e7b480db5 | ||
|
|
1c7735d25a | ||
|
|
7b153b81ee | ||
|
|
8fe67053e1 | ||
|
|
927df6c2b2 | ||
|
|
197f5186d0 | ||
|
|
98cf17c294 | ||
|
|
4da86bcc6a | ||
|
|
52761f53b6 | ||
|
|
cf75a22f19 | ||
|
|
009c84de9f | ||
|
|
9f9b9b9d4d | ||
|
|
12ab8377c9 | ||
|
|
5aa22df409 | ||
|
|
3df009912b | ||
|
|
89570947aa | ||
|
|
14b2167218 | ||
|
|
2e4d2ee76e | ||
|
|
7d9032fe20 | ||
|
|
ccd6b1a430 | ||
|
|
e4a21c6f5d | ||
|
|
1a7f45c38b | ||
|
|
b48d4222db | ||
|
|
51eecbb57a | ||
|
|
edd2633455 | ||
|
|
0e26e6eb11 | ||
|
|
916b53446a | ||
|
|
0e371518b5 | ||
|
|
153e498345 | ||
|
|
1e9f49a733 | ||
|
|
5f6c667030 | ||
|
|
93dd844221 | ||
|
|
5ad8025946 | ||
|
|
080000e13c | ||
|
|
3422b92f81 | ||
|
|
683cc7a9dc | ||
|
|
7bc229de53 | ||
|
|
83e3d61634 | ||
|
|
b088c6caa7 | ||
|
|
658a1cce72 | ||
|
|
bac8ebeb55 | ||
|
|
76eaa48342 | ||
|
|
97df0ba721 | ||
|
|
f0f403ce66 | ||
|
|
977e2ae892 | ||
|
|
1913fcdb01 | ||
|
|
f25051c627 | ||
|
|
82ad4ec500 | ||
|
|
a4ef606ff7 | ||
|
|
fcb27d40b1 | ||
|
|
45140ba78a | ||
|
|
5457e66144 | ||
|
|
05c5e24327 | ||
|
|
d920702353 | ||
|
|
e96edfce4f | ||
|
|
11721e9260 | ||
|
|
5d9f413f57 | ||
|
|
cde3b337e7 | ||
|
|
1c198f1204 | ||
|
|
2d26416a7a | ||
|
|
24de56cdcb | ||
|
|
8a7e51fa07 | ||
|
|
e18ebf818f | ||
|
|
821d31ce30 | ||
|
|
db4c874e13 | ||
|
|
64fb944038 | ||
|
|
bc3bf4fbba | ||
|
|
ade18e84d2 | ||
|
|
df48ee142e | ||
|
|
39a0088ed2 | ||
|
|
3a2d87b726 | ||
|
|
eeb4818df4 | ||
|
|
3772b339e6 | ||
|
|
a9f5f39f36 | ||
|
|
86c98eef99 | ||
|
|
4bdc627d6b | ||
|
|
a13b1841f5 | ||
|
|
efbe9b03f9 | ||
|
|
6207147202 | ||
|
|
c9a491622b | ||
|
|
c210754a18 | ||
|
|
389593a3b4 | ||
|
|
22ee3a6dee | ||
|
|
a72761ccd0 | ||
|
|
62011342ec | ||
|
|
0f93b784f5 | ||
|
|
3ad27965fd | ||
|
|
69e710d3b7 | ||
|
|
d8219acaab | ||
|
|
987dab286b | ||
|
|
6592b09d14 | ||
|
|
35b47b1b9d | ||
|
|
b67636e29b | ||
|
|
db37cd9d46 | ||
|
|
ffbd98d800 | ||
|
|
c281ca1781 | ||
|
|
dbb843b1bb | ||
|
|
745a9f4f63 | ||
|
|
314703d7e5 | ||
|
|
dd661210a5 | ||
|
|
fb0f70a4ff | ||
|
|
735a5c0a3e | ||
|
|
3b8492d5fb | ||
|
|
7529519684 | ||
|
|
1b58860347 | ||
|
|
5647ba0cfb | ||
|
|
be2c4fe17d | ||
|
|
a29f688bdb | ||
|
|
be5c409e2f | ||
|
|
5b090a74ed | ||
|
|
dc55ab4843 | ||
|
|
e6cd32aa15 | ||
|
|
d98bac7eaf | ||
|
|
2da7d11bd0 | ||
|
|
94cf7147e3 | ||
|
|
5ac3d153ab | ||
|
|
7d26a4d59c | ||
|
|
924735a0dc | ||
|
|
d5a992ad62 | ||
|
|
e2523d1bff | ||
|
|
d444940b63 | ||
|
|
8e69ec108e | ||
|
|
84bcb80108 | ||
|
|
e9f650ef9e | ||
|
|
27b57526ee | ||
|
|
3996f580f6 | ||
|
|
ca0eb044e8 | ||
|
|
5b0999e47c | ||
|
|
9f6210598c | ||
|
|
08e1476726 | ||
|
|
818716b7a9 | ||
|
|
4971c926ac | ||
|
|
99502a2003 | ||
|
|
81108699c3 | ||
|
|
7cce8676b4 | ||
|
|
0f8f64f00f | ||
|
|
68ada960cd | ||
|
|
b08cad3e8e | ||
|
|
42fc1e58cc | ||
|
|
b2f1415bff | ||
|
|
d499bfb11a | ||
|
|
bddd46e9bb | ||
|
|
b774ca119f | ||
|
|
fbfb456b5d | ||
|
|
15b234995b | ||
|
|
3eada0b878 | ||
|
|
92ae5cbe63 | ||
|
|
86593a43ee | ||
|
|
8a7902e5e8 | ||
|
|
50ef1025d8 | ||
|
|
bcae86f1a1 | ||
|
|
bcde2a0dac | ||
|
|
86fef875a6 | ||
|
|
1b397ec930 | ||
|
|
c89bd62aff | ||
|
|
f556848da4 | ||
|
|
1cf9e87e82 | ||
|
|
dc09c3ecef | ||
|
|
cbcc0b4f44 | ||
|
|
d0db02d7f3 | ||
|
|
63c2e37dad | ||
|
|
78780c20f1 | ||
|
|
3b95317d4c | ||
|
|
bb165c4be0 | ||
|
|
d2cb48b901 | ||
|
|
e06ddacf04 | ||
|
|
2e4b779be5 | ||
|
|
10558a6e5f | ||
|
|
54941a1009 | ||
|
|
bd8b76015f | ||
|
|
0d3d76a68e | ||
|
|
8862047e36 | ||
|
|
25c87d672e | ||
|
|
d687414872 | ||
|
|
fb71661f17 | ||
|
|
19ccd3bf9a | ||
|
|
370443f3d4 | ||
|
|
213cb9a533 | ||
|
|
dc740e0452 | ||
|
|
72e979afd6 | ||
|
|
1d5f85a77c | ||
|
|
629fb600e9 | ||
|
|
9455277c25 | ||
|
|
f1032df74a | ||
|
|
a341bc641a | ||
|
|
9b544cbc03 | ||
|
|
001ab82fda | ||
|
|
67f4c02efc | ||
|
|
8808fe2615 | ||
|
|
bd5b9d46ca | ||
|
|
688b726e24 | ||
|
|
929b27dbeb | ||
|
|
090d8c64ec | ||
|
|
93f3ba630c | ||
|
|
89775bd73b | ||
|
|
944e47dea7 | ||
|
|
c44c9fe3ef | ||
|
|
6d44b5680a | ||
|
|
a6b2f2d6bb | ||
|
|
e888b4037b | ||
|
|
417dd78df5 | ||
|
|
c8836a9baf | ||
|
|
9aa3876a0e |
No files matched your search
@@ -68,3 +68,4 @@
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
website/deploy/*.conf text eol=lf
|
||||
@@ -5,6 +5,7 @@ Publish/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
Claude outputs/
|
||||
|
||||
# ---> VisualStudio
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Company>Xbhost</Company>
|
||||
<Authors>Xbhost</Authors>
|
||||
<Copyright>© Xbhost</Copyright>
|
||||
<Company>LanisysGames</Company>
|
||||
<Authors>LanisysGames</Authors>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -30,7 +30,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
README.md = README.md
|
||||
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
|
||||
tools\admin-key.bat = tools\admin-key.bat
|
||||
tools\ui-snapshot.bat = tools\ui-snapshot.bat
|
||||
tools\sign-keyring.bat = tools\sign-keyring.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
tools\publish-site.bat = tools\publish-site.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
@@ -40,8 +43,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
|
||||
server\deploy\installer-api.sh = server\deploy\installer-api.sh
|
||||
server\deploy\launcher-api-catalog.conf = server\deploy\launcher-api-catalog.conf
|
||||
server\deploy\launcher-api-games.conf = server\deploy\launcher-api-games.conf
|
||||
server\deploy\launcher-api.service = server\deploy\launcher-api.service
|
||||
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
|
||||
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.conf
|
||||
server\deploy\nginx-publications.conf = server\deploy\nginx-publications.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
|
||||
@@ -54,12 +61,29 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Server", "serv
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\AdminKeyTool\AdminKeyTool.csproj", "{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UiSnapshot", "tools\UiSnapshot\UiSnapshot.csproj", "{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}"
|
||||
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
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
|
||||
website\deploy\installer-site.sh = website\deploy\installer-site.sh
|
||||
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
|
||||
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
|
||||
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -156,6 +180,30 @@ Global
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.Build.0 = Debug|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.ActiveCfg = Release|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.Build.0 = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.Build.0 = Debug|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -173,6 +221,10 @@ Global
|
||||
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -32,15 +32,18 @@ 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/`), mode maintenance (`Maintenance/`) |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés (voir §8) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
|
||||
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
|
||||
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord (vues dans `src\GameLauncher\Views\Admin`) |
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
|
||||
```
|
||||
@@ -49,14 +52,18 @@ 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), Admin.xaml (menu d'administration)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont les pages « Jeux », « Notes de version » et « Publier une version »),
|
||||
│ Maintenance.xaml (palette rouge / orange du mode maintenance)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow,
|
||||
│ FeedbackWindow (bouton « Aide »)
|
||||
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
|
||||
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
|
||||
│ GameViewModel (machine à états d'un jeu),
|
||||
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
|
||||
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
|
||||
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
|
||||
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration),
|
||||
│ FeedbackWindowService (bouton « Aide »), interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
|
||||
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
|
||||
```
|
||||
@@ -71,12 +78,25 @@ dotnet run --project tools\ManifestBuilder -c Release -- ^
|
||||
--exe MonJeu.exe ^
|
||||
--game-id MonJeu ^
|
||||
--notes notes.txt ^
|
||||
--remote-manifest https://launcher.example/monjeu/manifest.json ^
|
||||
--exclude "*.pdb;*.log"
|
||||
```
|
||||
|
||||
L'historique des notes est conservé d'une version à l'autre (30 versions) et affiché dans le
|
||||
launcher (panneau « Notes de version », bouton **Agrandir** pour les lire en grand).
|
||||
|
||||
`--remote-manifest` (recommandé, déjà dans `tools\publish-example.bat`) : l'historique est repris du
|
||||
manifest **en ligne**, et non du `manifest.json` local. Les notes corrigées depuis le menu
|
||||
d'administration (page « Notes de version », §8) ne sont donc pas écrasées à la publication
|
||||
suivante. Pas de manifest en ligne (première publication) : historique local. Serveur injoignable :
|
||||
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
|
||||
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
**Mise en ligne** : depuis le menu d'administration, page **Publier une version** (§8) : choisissez le
|
||||
`manifest.json` du dossier `--output`, puis « Publier ». Seuls les fichiers absents du serveur sont
|
||||
envoyés, l'envoi reprend après une coupure, et le manifest est mis en ligne en dernier. WinSCP reste
|
||||
possible (objects/ et l'archive d'abord, `manifest.json` en dernier), mais pas les deux en même temps.
|
||||
|
||||
### Format de `notes.txt`
|
||||
|
||||
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
|
||||
@@ -176,6 +196,11 @@ reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bur
|
||||
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.
|
||||
* **Nom** : le nom affiché du jeu (page « Jeux » du menu d'administration), sinon celui de `launcher.json`.
|
||||
Un raccourci nommé par le launcher est renommé quand ce nom change : au démarrage du launcher, avec
|
||||
« Mettre à jour » (*Paramètres du jeu* › *Raccourci*) et avec **Réparer**. Un raccourci renommé par le
|
||||
joueur garde son nom, et aucun fichier existant du Bureau n'est remplacé (le nom de `launcher.json`
|
||||
est alors conservé).
|
||||
|
||||
| Situation | Comportement |
|
||||
|---|---|
|
||||
@@ -268,6 +293,16 @@ Deux fenêtres distinctes :
|
||||
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
|
||||
sont repris de ceux du jeu affiché par défaut.
|
||||
|
||||
### Plein écran
|
||||
|
||||
La fenêtre principale et le menu d'administration ont deux modes : **fenêtré** (taille d'origine) ou
|
||||
**plein écran** (fenêtre agrandie sur tout l'écran, la barre des tâches restant visible). Bascule : bouton entre « Réduire » et
|
||||
« Fermer », ou **F11**. Le mode de chaque fenêtre est retenu pour la fois suivante. Les fenêtres ne se
|
||||
redimensionnent jamais par les bords ou les coins ; la mise en page s'adapte à la place disponible
|
||||
(plein écran : colonnes plus larges ; petit écran ou mise à l'échelle 125-150 % : contenu réduit d'un
|
||||
bloc, sans rien rogner). Les autres fenêtres (paramètres, notes de version agrandies, boîtes de
|
||||
dialogue) gardent leur taille fixe.
|
||||
|
||||
### Zone de notification (icônes cachées, près de l'horloge)
|
||||
|
||||
Deux options des *Paramètres du launcher* (désactivées par défaut) :
|
||||
@@ -292,6 +327,7 @@ referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) o
|
||||
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
|
||||
├─ self-update.json mise à jour du launcher (§7)
|
||||
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
|
||||
├─ window-modes.json plein écran ou fenêtré, par fenêtre (« main », « admin »)
|
||||
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
|
||||
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
|
||||
```
|
||||
@@ -332,7 +368,8 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
|
||||
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
|
||||
| Icône | Remplacez `Assets/launcher.ico` |
|
||||
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` |
|
||||
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
|
||||
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
|
||||
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
|
||||
|
||||
@@ -375,7 +412,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). En Release, son `apiUrl` est ignorée (seule celle intégrée à l'exe compte) |
|
||||
| `--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 |
|
||||
|
||||
@@ -393,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
|
||||
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
|
||||
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
|
||||
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
|
||||
simplement qu'une nouvelle version existe.
|
||||
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
|
||||
|
||||
### Sécurité
|
||||
|
||||
@@ -405,6 +442,7 @@ simplement qu'une nouvelle version existe.
|
||||
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
|
||||
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
|
||||
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
|
||||
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
|
||||
|
||||
### Déroulement
|
||||
|
||||
@@ -427,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
|
||||
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
|
||||
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
|
||||
|
||||
### Version prioritaire (correctif critique)
|
||||
|
||||
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
|
||||
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
|
||||
|
||||
| Point | Comportement |
|
||||
|---|---|
|
||||
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
|
||||
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
|
||||
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
|
||||
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
|
||||
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
|
||||
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
|
||||
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
|
||||
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
|
||||
|
||||
### Mise en place (une seule fois)
|
||||
|
||||
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
|
||||
@@ -454,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
|
||||
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
|
||||
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
|
||||
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
|
||||
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
|
||||
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
|
||||
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
|
||||
|
||||
Vérification à tout moment :
|
||||
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
|
||||
@@ -472,9 +529,54 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
|
||||
|
||||
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) ;
|
||||
les pages de gestion arrivent dans les phases suivantes. Rien ne change pour les joueurs.
|
||||
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
|
||||
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
|
||||
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
|
||||
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
|
||||
|
||||
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
|
||||
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
|
||||
page ; après « Recharger » ou « Publier », la page remonte en haut pour montrer le bandeau du résultat. La
|
||||
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
|
||||
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
|
||||
|
||||
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
|
||||
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
|
||||
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
|
||||
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
|
||||
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
|
||||
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
|
||||
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
|
||||
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
|
||||
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
|
||||
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
|
||||
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 decies.
|
||||
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
|
||||
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
|
||||
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
|
||||
l'envoi avec les règles du serveur ; informations techniques facultatives (versions, Windows, langue) montrées avant
|
||||
l'envoi, cochées par défaut pour un bug. Demande **anonyme** : aucun compte, aucune adresse IP enregistrée ; le
|
||||
joueur reçoit une référence `FB-XXXX-XXXX`. **Captures d'écran** : 3 au plus (fichier ou Ctrl+V), image reconnue à
|
||||
son contenu, réencodée en PNG/JPEG **sans métadonnées** (ni GPS, ni appareil), revérifiée par le serveur (PNG/JPEG
|
||||
seulement, 4 Mo). En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
|
||||
sans `apiUrl` ; réception à activer sur le serveur (`server/deploy/LISEZMOI.md`, étape 3 octies).
|
||||
* **Page « Formulaires des demandes »** (permission `feedback.forms`) : types de demande (1 à 8 : ajout, suppression,
|
||||
ordre d'affichage, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées ou non) et
|
||||
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
|
||||
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
|
||||
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi. Journal d'audit : chaque publication indique
|
||||
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
|
||||
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
|
||||
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
|
||||
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
|
||||
demande contenant un **lien** (http, https, www, domaine, IP, e-mail, formes déguisées…) est signalée par un
|
||||
avertissement, liens désamorcés et jamais cliquables. Statut, note et suppression sont des requêtes **signées**.
|
||||
Chaque consultation est inscrite au journal d'audit.
|
||||
Purge automatique : 12 mois après la clôture, 24 mois au plus ; le journal d'audit est purgé au-delà de 12 mois.
|
||||
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
|
||||
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
|
||||
vérifiée par le serveur, protège l'administration.
|
||||
@@ -494,14 +596,108 @@ les pages de gestion arrivent dans les phases suivantes. Rien ne change pour les
|
||||
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.
|
||||
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code
|
||||
(blocage lu dans la puce : attribut `inLockout` et compteur d'erreurs `TPM_PT_LOCKOUT_COUNTER`).
|
||||
|
||||
* **Page « Jeux »** (permission `games.edit`) : nom affiché, accroche, description, liens (Discord,
|
||||
YouTube…), site, thème intégré, icône et fond (bouton **« Parcourir… »** : l'image choisie dans
|
||||
l'explorateur Windows est contrôlée puis envoyée au serveur, sans client FTP ; aperçu, adresse
|
||||
renseignée automatiquement), fréquence des recherches de mise à jour, jeu masqué,
|
||||
nom du launcher, jeu affiché au premier lancement, ordre des jeux. Champ vide = valeur de
|
||||
`launcher.json`. « Publier » contrôle les valeurs puis écrit `catalog.json` sur le serveur (requête
|
||||
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
|
||||
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
|
||||
le nom affiché renomme le raccourci du Bureau (voir « Raccourcis du Bureau »), mais ni le registre,
|
||||
ni les dossiers. Le **nom du launcher** apparaît dans les paramètres du launcher (roue crantée),
|
||||
l'info-bulle de l'icône de la zone de notification et, avec plusieurs jeux, le titre de la fenêtre
|
||||
(barre des tâches) ; avec un seul jeu, l'en-tête montre le nom du jeu. Le catalogue publié est pris
|
||||
en compte au démarrage suivant du launcher.
|
||||
* **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.
|
||||
* **Plusieurs jeux** (pages « Notes de version » et « Publier une version ») : la page s'ouvre sur le
|
||||
**choix du jeu** (une tuile par jeu de `launcher.json`, aucun jeu par défaut) et n'affiche rien d'autre
|
||||
avant ce choix ; une liste « Jeu » permet ensuite d'en changer (pas pendant un chargement, un envoi, ni
|
||||
avec des notes non enregistrées sans confirmation). Rien d'un jeu ne reste affiché pour un autre
|
||||
(dossier choisi, version en ligne, signature) ; un envoi interrompu reste attaché à son jeu. Les
|
||||
confirmations et résultats de publication nomment le jeu. Avec un seul jeu, il est choisi d'office.
|
||||
Page « Fichiers du serveur » : le jeu à nettoyer se choisit de la même façon.
|
||||
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
|
||||
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
|
||||
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
|
||||
si même numéro ou version inférieure), puis envoi : manifest, fichiers **absents du serveur**
|
||||
seulement (petits fichiers par lots, gros par morceaux), archive par morceaux de 4 Mo, chaque fichier
|
||||
vérifié (SHA-256) des deux côtés, et mise en ligne avec le manifest **en dernier** (historique des
|
||||
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
|
||||
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 quater.
|
||||
* **Page « Mes appareils »** (tout administrateur) : vos PC (clés), avec leur empreinte et leur dernière
|
||||
connexion. « Ajouter un appareil » crée une invitation « nouvel appareil » (code affiché une seule
|
||||
fois) ; sur le nouveau PC, « J'ai une invitation » ; de retour ici, l'appareil en attente s'approuve
|
||||
en saisissant l'empreinte affichée sur le nouveau PC. Personne d'autre ne peut approuver un appareil
|
||||
à votre nom. Révoquer un appareil perdu : propriétaires (sinon, demandez à un propriétaire).
|
||||
* **Page « Administrateurs »** (permission `admins.manage`) : inviter (nom, groupe → code
|
||||
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
|
||||
qu'il vous dicte, changer le groupe d'un compte, révoquer un appareil ou un compte, supprimer une invitation.
|
||||
Impossible de changer son propre groupe, de se révoquer, ou de retirer le dernier propriétaire actif ;
|
||||
seuls les groupes dont vous avez toutes les permissions sont proposés.
|
||||
* **Page « Groupes et permissions »** (permission `admins.manage`) : créer, renommer, décrire et supprimer des
|
||||
groupes d'administrateurs, cocher leurs permissions ; créer des permissions **personnalisées**
|
||||
(« module.action », pour un module futur) et les supprimer (retirées de tous les groupes). Les permissions
|
||||
**intégrées** (vérifiées par le code) ne se suppriment pas. Le groupe **Propriétaire** a toujours toutes les
|
||||
permissions (ni modifiable ni supprimable). On ne modifie pas les permissions de son propre groupe, on n'accorde
|
||||
que des permissions que l'on a soi-même, et un groupe qui a des membres ou des invitations ne se supprime pas.
|
||||
Effet immédiat (sessions ouvertes comprises), requêtes signées, journal d'audit. Mise en place :
|
||||
`server/deploy/LISEZMOI.md`, étape 3 septies.
|
||||
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
|
||||
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
|
||||
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
|
||||
(`keyring.json`), signé **hors ligne** par la clé racine du launcher avec `tools\sign-keyring.bat` et publié
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
|
||||
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
|
||||
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
de 200). Filtres par **période** (aujourd'hui, 7 ou 30 derniers jours, ce mois-ci, mois précédent, ou dates
|
||||
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, appareil, IP, clé…). La période est demandée au
|
||||
serveur (paramètre `before`) et les pages sont chargées jusqu'à son début (5000 entrées au plus) ; l'**export
|
||||
CSV** reprend toutes les entrées filtrées, période comprise. Le journal se surveille lui-même : chaque
|
||||
**consultation** (ouverture, « Actualiser », période changée) est inscrite par le serveur (« Journal d'audit
|
||||
consulté » : auteur, appareil, IP, nombre d'entrées lues), et chaque **export** l'est avant l'écriture du
|
||||
fichier (« Journal d'audit exporté (CSV) » : nombre d'entrées, période, filtres) ; si le serveur ne peut pas
|
||||
l'inscrire, le fichier n'est pas écrit. Catégorie de filtre « Consultations et exports du journal ».
|
||||
Chaque entrée indique l'**appareil** sous le nom choisi à l'enregistrement de sa clé (« J'ai une
|
||||
invitation »), jamais le nom Windows du PC (colonne « Appareil » de l'export CSV).
|
||||
* **Page « Fichiers du serveur »** (permission `files.manage` : propriétaires et administrateurs) : contenu du
|
||||
site public (dossiers, tailles, dates, espace libre du disque) et état de chaque fichier : **Utilisé** (archive,
|
||||
fichier d'une version, exécutable du launcher, image du catalogue), **Protégé** (`.json`), **Inutilisé**
|
||||
(supprimable) ou **Non vérifiable**. Un fichier inutilisé se supprime après confirmation ; « Nettoyer le
|
||||
dossier objects » supprime les fichiers d'un jeu que la version en ligne n'utilise plus (analyse d'abord). Le
|
||||
serveur relit tous les manifests du site avant chaque suppression et refuse tout fichier encore désigné, ainsi
|
||||
que pendant une publication. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 sexies.
|
||||
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
|
||||
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
|
||||
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
|
||||
PNG/JPEG du même serveur, dans `catalog/images/` (envoyées depuis la page Jeux sous le nom de leur
|
||||
empreinte SHA-256, vérifiée par le launcher au téléchargement ; ou déposées à la main sous un nouveau nom).
|
||||
Installation sur le VPS : `server/deploy/LISEZMOI.md`, étape 3 bis.
|
||||
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
|
||||
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
|
||||
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
|
||||
la clé (un jeton volé ne permet que de lire). Journal d'audit de chaque action.
|
||||
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu et
|
||||
publication), éditeur (contenu).
|
||||
* **Groupes** (anciennement « rôles ») : propriétaire (tout, dont la gestion des administrateurs ; groupe
|
||||
système), administrateur (contenu, publication, journal d'audit, fichiers du serveur), éditeur (contenu) par
|
||||
défaut ; modifiables et complétables depuis la page « Groupes et permissions ».
|
||||
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
|
||||
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
|
||||
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
|
||||
@@ -510,5 +706,47 @@ les pages de gestion arrivent dans les phases suivantes. Rien ne change pour les
|
||||
|---|---|
|
||||
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
|
||||
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) ; créer sa clé et se connecter : aussi depuis le launcher |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
|
||||
## 9. Site vitrine
|
||||
|
||||
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
|
||||
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
|
||||
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
|
||||
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
|
||||
|
||||
| Page | Contenu |
|
||||
|---|---|
|
||||
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
|
||||
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
|
||||
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
|
||||
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
|
||||
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
|
||||
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
|
||||
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
|
||||
|
||||
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
|
||||
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
|
||||
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
|
||||
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
|
||||
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
|
||||
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
|
||||
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
|
||||
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
|
||||
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
|
||||
pas affichées (`Site:Launcher:HiddenSections`).
|
||||
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
|
||||
toujours encodés (jamais interprétés comme du HTML).
|
||||
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
|
||||
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
|
||||
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
|
||||
aucune donnée écrite sur le disque.
|
||||
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
|
||||
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
|
||||
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
|
||||
liens `javascript:`/`data:` retirés, images du site uniquement.
|
||||
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
|
||||
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
|
||||
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
|
||||
+721
-50
@@ -67,8 +67,9 @@ Conséquences :
|
||||
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu (inchangée) : manifest, téléchargement, installation… |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques |
|
||||
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
|
||||
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
|
||||
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
|
||||
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
|
||||
@@ -76,8 +77,9 @@ Conséquences :
|
||||
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
|
||||
|
||||
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
|
||||
(+ `Core` à partir de la phase 3) ; `Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ;
|
||||
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`.
|
||||
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
|
||||
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
|
||||
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
|
||||
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
|
||||
|
||||
## 5. Modules
|
||||
@@ -138,6 +140,48 @@ public sealed record AdminPanelDescriptor(
|
||||
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
|
||||
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
|
||||
|
||||
### 5.3 Affichage : mode fenêtré ou plein écran, mise en page responsive (✅)
|
||||
|
||||
Deux modes seulement, pour la fenêtre principale et le menu d'administration (les autres fenêtres —
|
||||
paramètres, notes agrandies, boîtes de dialogue — gardent leur taille fixe) : **fenêtré** (taille
|
||||
d'origine, jamais redimensionnable par les bords ou les coins) et **plein écran** au sens d'une fenêtre
|
||||
Windows agrandie (toute la zone utile de l'écran où se trouve la fenêtre, la barre des tâches restant visible,
|
||||
même en masquage automatique ; pas de plein écran exclusif comme un jeu). Bascule : bouton entre « Réduire » et « Fermer », ou **F11**.
|
||||
Le mode est mémorisé par fenêtre (`%AppData%\…\GameLauncher\window-modes.json`, ex.
|
||||
`{ "main": true, "admin": false }` ; simple préférence, sans donnée personnelle).
|
||||
|
||||
* `Controls/WindowModeAssist` (propriétés attachées, aucun code dans la fenêtre) :
|
||||
`ui:WindowModeAssist.AllowFullScreen="True"` + `ui:WindowModeAssist.PersistenceKey="clé"` sur la
|
||||
fenêtre ; bouton `<Button Style="{StaticResource WindowModeButtonStyle}" />` (icône et infobulle
|
||||
selon le mode, masqué si la fenêtre ne le permet pas). En plein écran, les ressources de la fenêtre
|
||||
nommées `WindowChrome.*` (CornerRadius, Thickness : arrondis, marge de l'ombre) passent à 0 ; elles
|
||||
sont utilisées en `DynamicResource`. En mode fenêtré, la taille est limitée à la zone utile de
|
||||
l'écran de la fenêtre. Déplacement par la barre de titre : `WindowModeAssist.DragMove(window)` (sans
|
||||
effet en plein écran) ; réaffichage d'une fenêtre réduite : `WindowModeAssist.RestoreFromMinimized`
|
||||
(jamais `WindowState = Normal`, qui ferait quitter le plein écran).
|
||||
* `Controls/ScaleToFitDecorator` (responsive) : `DesignWidth` × `DesignHeight` = taille du contenu
|
||||
en mode fenêtré. Plus de place (plein écran) : le contenu s'étale (colonnes fluides, listes et
|
||||
ascenseurs) ; moins de place (petit écran, mise à l'échelle Windows 125-150 %) : il est mis en page
|
||||
à cette taille puis réduit d'un bloc. Rien n'est jamais rogné : tous les boutons restent
|
||||
accessibles, tous les textes visibles. Le contenu n'est jamais agrandi.
|
||||
* **Nouvelle page d'administration** (module futur) : elle s'affiche dans la fenêtre d'administration
|
||||
et profite donc du plein écran sans rien déclarer. Règles de mise en page pour qu'elle reste
|
||||
lisible dans les deux modes :
|
||||
* ne pas fixer la largeur d'un bloc de texte (`MaxWidth` sur un bandeau…) : texte en
|
||||
`TextWrapping="Wrap"` dans un `DockPanel` ou une colonne `*` d'un `Grid`, jamais dans un
|
||||
`StackPanel` horizontal (il ne revient alors jamais à la ligne) ;
|
||||
* listes de tuiles : `WrapPanel` ; libellés sur une ligne : `TextTrimming="CharacterEllipsis"` +
|
||||
infobulle ;
|
||||
* colonnes de largeur fixe : seulement pour une liste de navigation (≤ 260) ; la partie
|
||||
principale en `*` ;
|
||||
* page en deux colonnes qui défilent séparément : `HasSplitLayout => true` ; la page reçoit la
|
||||
hauteur visible (au moins 360 px, sinon la page entière défile en plus) ;
|
||||
* bandeaux d'erreur et de résultat : reprendre ceux des pages existantes (`AdminErrorBannerStyle`,
|
||||
`AdminInfoBannerStyle`, icône à gauche dans un `DockPanel`).
|
||||
* **Futur module du launcher** affiché dans sa propre fenêtre : `AllowFullScreen` seulement si la fenêtre
|
||||
contient assez d'informations pour le mériter, avec une `PersistenceKey` propre et un
|
||||
`ScaleToFitDecorator` autour du contenu.
|
||||
|
||||
## 6. Authentification par clé publique/privée
|
||||
|
||||
### 6.1 Clés
|
||||
@@ -263,22 +307,75 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
| Erreur de manipulation | Impossible de se révoquer soi-même, de changer son propre rôle, ou de supprimer le dernier propriétaire |
|
||||
| Perte de toutes les clés | Récupération par l'outil en ligne de commande, sur le VPS (accès SSH administrateur) |
|
||||
|
||||
## 7. Rôles et permissions
|
||||
## 7. Groupes et permissions
|
||||
|
||||
Chaque administrateur appartient à **un groupe** (anciennement « rôle » : le champ `role` des comptes et des
|
||||
invitations contient l'identifiant du groupe). Ses droits sont les permissions de ce groupe, **relues à chaque
|
||||
requête** (une modification s'applique immédiatement, sessions ouvertes comprises). Groupes par défaut, créés au
|
||||
premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les droits des anciens rôles :
|
||||
|
||||
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
|
||||
| `admins.manage` | ✔ | | | Gestion des administrateurs (page « Administrateurs ») |
|
||||
| `audit.read` | ✔ | ✔ | | Journal d'audit |
|
||||
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
|
||||
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
|
||||
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
|
||||
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
|
||||
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
|
||||
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
|
||||
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
|
||||
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
|
||||
|
||||
Les permissions sont des chaînes « module.action » : un module futur déclare les siennes
|
||||
(`news.edit`, `community.moderate`…) et les rôles qui les reçoivent.
|
||||
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
|
||||
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
|
||||
d'un propriétaire, page « Groupes et permissions »). Les groupes par défaut créés **après** l'ajout la reçoivent.
|
||||
|
||||
Les permissions sont des chaînes « module.action ». **Intégrées** : déclarées dans le code
|
||||
(`AdminPermissions.All`), vérifiées par les routes du serveur et les pages du launcher, jamais supprimables.
|
||||
**Personnalisées** : créées depuis la page « Groupes et permissions » (`news.edit`, `community.moderate`…),
|
||||
attribuables aux groupes en prévision d'un module futur ; supprimées, elles sont retirées de tous les groupes.
|
||||
|
||||
### 7.1 Page « Groupes et permissions » (✅)
|
||||
|
||||
Module serveur `Groups` (`GroupsModule`, données dans `admins.json` : `groups`, `customPermissions`) et page
|
||||
launcher `GroupsPanelViewModel` (module `AccountsAdminModule`), permission `admins.manage`.
|
||||
|
||||
| Route | Signée | Rôle |
|
||||
|---|:-:|---|
|
||||
| `GET v1/groups` | | Groupes (permissions effectives, membres, invitations, révision) et permissions connues |
|
||||
| `POST v1/groups` | ✔ | Crée un groupe (nom, description, permissions) ; identifiant tiré du nom (`moderateurs-forum`) |
|
||||
| `PUT v1/groups/{id}` | ✔ | Modifie un groupe ; révision attendue (409 `revision_conflict` sinon) |
|
||||
| `DELETE v1/groups/{id}` | ✔ | Supprime un groupe sans membre (actif ou en attente) ni invitation en cours |
|
||||
| `POST v1/permissions` | ✔ | Crée une permission personnalisée (« module.action », libellé, description) |
|
||||
| `DELETE v1/permissions/{name}` | ✔ | Supprime une permission personnalisée (retirée de tous les groupes) |
|
||||
|
||||
Garde-fous (serveur, sous le verrou d'`admins.json`, auteur et droits revérifiés au moment d'écrire —
|
||||
`AdminRules`) :
|
||||
* le groupe **Propriétaire** (`owner`) est un groupe système : toujours **toutes** les permissions, y compris
|
||||
les personnalisées créées plus tard ; ni modifiable ni supprimable ; seul un propriétaire le donne, le
|
||||
retire ou gère (groupe, révocation, appareils) un autre propriétaire ;
|
||||
* **pas d'élévation** : on n'accorde à un groupe, et on ne place quelqu'un (invitation, changement de groupe,
|
||||
approbation d'un compte en attente) que dans un groupe dont on a soi-même toutes les permissions ; on ne gère
|
||||
pas un administrateur qui a des permissions que l'on n'a pas ;
|
||||
* on ne modifie pas les permissions de **son propre groupe** (comme on ne change pas son propre groupe) ;
|
||||
* identifiants : `[a-z][a-z0-9-]{1,31}`, jamais réutilisés tant qu'un compte (même révoqué) ou une invitation
|
||||
les désigne ; noms uniques, une ligne, sans caractère invisible ; 50 groupes, 40 permissions personnalisées ;
|
||||
* audit : `admins.group_create`, `admins.group_update` (permissions ajoutées/retirées), `admins.group_delete`,
|
||||
`admins.permission_create`, `admins.permission_delete`.
|
||||
|
||||
`admins.manage` donnée à un autre groupe que Propriétaire équivaut presque aux droits d'un propriétaire (gestion
|
||||
des comptes et des groupes, dans la limite de ses propres permissions) : la page demande une confirmation
|
||||
renforcée. Trousseau de signature (phase 7) : les autorisations `releases`/`catalog` d'une clé suivent désormais les
|
||||
permissions **de son groupe** ; la page Signature signale un trousseau à refaire après un changement.
|
||||
|
||||
Côté launcher : `AdminProfile`, `AdminSummary` et `InvitationSummary` portent `RoleName` (nom du groupe) et
|
||||
`AdminSummary.Permissions` ; la page « Administrateurs » ne propose que les groupes attribuables. La page ne
|
||||
propose que ce que le serveur accepte (cases grisées avec la raison en info-bulle) ; le serveur reste seul juge.
|
||||
|
||||
## 8. Ajouter un administrateur
|
||||
|
||||
1. Un **propriétaire** crée une invitation (nom, rôle) : le serveur renvoie **une seule fois** un
|
||||
1. Un **propriétaire** crée une invitation (nom, groupe) : le serveur renvoie **une seule fois** un
|
||||
code à usage unique (160 bits, `ABCD-EFGH-…`, valable 24 h ; seule son empreinte est stockée).
|
||||
2. Il transmet ce code au futur administrateur.
|
||||
3. Le futur administrateur ouvre le menu d'administration › « J'ai une invitation » : le launcher
|
||||
@@ -287,8 +384,8 @@ Les permissions sont des chaînes « module.action » : un module futur déclare
|
||||
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
|
||||
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
|
||||
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
|
||||
(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.)
|
||||
(Étapes 3 et 4 : « J'ai une invitation » ; étapes 1 et 5 : page « Administrateurs » du launcher,
|
||||
phase 6. `tools\admin-key.bat` reste possible en ligne de commande.)
|
||||
|
||||
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
|
||||
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
|
||||
@@ -306,8 +403,8 @@ sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas"
|
||||
|
||||
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 sera lue, en Release, dans le `launcher.json` intégré à l'exe. Les champs actuels de `launcher.json` se
|
||||
répartissent ainsi :
|
||||
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 |
|
||||
|---|:-:|:-:|---|
|
||||
@@ -330,47 +427,616 @@ validé et borné comme les notes de version, puis **mis en cache** (`%AppData%\
|
||||
Sans réseau : dernier cache, sinon valeurs de `launcher.json`. Le serveur écrit `catalog.json`
|
||||
seulement via l'API (permission `games.edit`, requête signée).
|
||||
|
||||
## 10. Notes de version (phase 4)
|
||||
### 9.1 Mise en œuvre (phase 3 ✅)
|
||||
|
||||
* Éditeur graphique dans le menu admin : arborescence version › sections › éléments, édition des
|
||||
champs du format enrichi, **aperçu en direct avec les mêmes modèles que le launcher**
|
||||
(`Themes/Changelog.xaml`) et les mêmes bornes (`ChangelogLimits`).
|
||||
**Format** (`Contracts/Catalog`) : `{ "format": "gamelauncher-catalog", "version": 1, "revision", "updatedAt",
|
||||
"launcher": { "launcherName", "defaultGame", "gameOrder" }, "games": { "<gameId>": { "gameName", "tagline",
|
||||
"description", "websiteUrl", "links": [{ "kind", "label", "url" }], "theme", "icon", "background",
|
||||
"updateCheckIntervalMinutes", "hidden" } } }`. Un champ absent = valeur de `launcher.json`.
|
||||
`CatalogRules` (partagé) borne tout : 48 Ko (sous la limite de 64 Ko des requêtes de l'API), 50 jeux,
|
||||
noms 80 caractères, accroche 200, description 2000 caractères / 40 lignes, 8 liens, adresses https
|
||||
(500 caractères, sans identifiants), images `.png`/`.jpg` du **même hôte que le catalogue**, thème =
|
||||
identifiant (`[a-z0-9-]`, jamais un chemin), intervalle 0 ou 5 à 1440. Le serveur **refuse** une
|
||||
valeur invalide (400) ; le launcher l'**ignore** (valeur de `launcher.json`).
|
||||
|
||||
**Serveur** (module `Catalog`, `Storage/CatalogStore`) :
|
||||
|
||||
* `GET v1/catalog` (administrateur connecté) : catalogue publié, publication activée ou non, adresse publique.
|
||||
* `PUT v1/catalog` (`games.edit` + requête signée) : `{ baseRevision, catalog }`. Révision différente
|
||||
de celle publiée → 409 `revision_conflict` (rien n'est écrasé) ; contrôle `CatalogRules` ; écriture
|
||||
de la référence privée (`/var/lib/launcher-api/catalog.json` + `.bak`), puis du **fichier public**
|
||||
(fichier temporaire du même dossier, droits 644 fixés explicitement malgré `UMask=0077`, renommage
|
||||
atomique) ; en cas d'échec du fichier public, la référence privée est remise en état (503) ;
|
||||
historique des 20 dernières versions ; audit `catalog.publish` (jeux modifiés).
|
||||
* Réglages `LauncherApi:Catalog:PublicFile` (vide = publication désactivée, 503 `not_configured`) et
|
||||
`PublicUrl` (défaut : `PublicOrigin` + `/catalog/catalog.json`). Au démarrage : chemin absolu `.json`,
|
||||
dossier existant, dossier public et données privées séparés (aucun dans l'autre, liens résolus).
|
||||
* VPS : dossier public (ex. `/srv/exovival/www/catalog`) autorisé en écriture par un complément
|
||||
systemd (`ReadWritePaths`), `catalog.json` servi par nginx sans cache (`server/deploy/LISEZMOI.md`, étape 3 bis).
|
||||
|
||||
**Launcher (joueur)** (`Core/Catalog`) :
|
||||
|
||||
* `catalogUrl` lue, en Release, **uniquement** dans le `launcher.json` intégré (comme `apiUrl`).
|
||||
* Démarrage : le cache est appliqué immédiatement ; le catalogue à jour est téléchargé en parallèle
|
||||
de la vérification de mise à jour du launcher et appliqué s'il arrive en 2,5 s (nouvelles images :
|
||||
1,5 s de plus) ; sinon il est mis en cache pour le démarrage suivant.
|
||||
* Téléchargements bornés (48 Ko, images 4 Mo), redirections suivies **uniquement** vers le même
|
||||
serveur (aucune requête vers un autre hôte), images contrôlées (signature PNG/JPEG), mises en cache
|
||||
sous l'empreinte de leur adresse (changer d'image = nouveau nom de fichier), fonds réduits à
|
||||
3840 px au chargement.
|
||||
* `CatalogMerger` : valeurs de `launcher.json` restaurées avant chaque application ; jeux inconnus
|
||||
ignorés ; thèmes intégrés uniquement ; images du cache uniquement ; jeu masqué toujours affiché s'il
|
||||
est installé ou demandé (raccourci) ; au moins un jeu affiché. Le nom du catalogue est un **nom
|
||||
affiché** : registre, raccourci, dossier d'installation proposé et dossier de données gardent le
|
||||
`gameName` de `launcher.json`.
|
||||
* Fiche du jeu : description et boutons de liens (icônes génériques : site, forum, vidéo…).
|
||||
|
||||
**Menu d'administration** : module `CatalogAdminModule` (permission `games.edit`), page « Jeux » :
|
||||
liste « Launcher » + jeux (ordre ↑ ↓), champs vides = valeur de `launcher.json` (rappelée sous le
|
||||
champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles que le serveur), saisie
|
||||
suspendue pendant l'envoi, conflit de révision signalé avec rechargement proposé, bandeau si la
|
||||
publication n'est pas configurée.
|
||||
|
||||
## 10. Notes de version (phase 4 ✅)
|
||||
|
||||
* **Plusieurs jeux** (ici et dans « Publier une version ») : la page s'ouvre sur le choix du jeu
|
||||
(`GameTile`, une tuile par jeu de `launcher.json`, aucun jeu par défaut ; un seul jeu : choisi d'office)
|
||||
et ne charge rien avant ; liste « Jeu » ensuite (désactivée pendant un chargement ou un envoi). Une
|
||||
réponse du serveur arrivée après un changement de jeu est ignorée ; dossier, version en ligne et
|
||||
signature sont effacés au changement ; un envoi interrompu est mémorisé par jeu. « Fichiers du
|
||||
serveur » : jeu à nettoyer sans choix par défaut s'il y en a plusieurs.
|
||||
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
|
||||
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
|
||||
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
|
||||
**aperçu en direct avec les mêmes modèles que le launcher** (`Themes/Changelog.xaml`) et les mêmes
|
||||
bornes (`ChangelogLimits`). Une version à l'ancien format (liste à plat) est présentée en section(s)
|
||||
sans titre et enregistrée au format enrichi.
|
||||
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
|
||||
* Enregistrement : le launcher envoie le `manifest.json` modifié avec l'empreinte de la version
|
||||
qu'il a lue (concurrence optimiste) ; le serveur vérifie que **seules les notes ont changé**
|
||||
(fichiers, archive, version, exécutable identiques), valide, sauvegarde l'ancien manifest puis
|
||||
remplace atomiquement.
|
||||
* **Cohérence avec ManifestBuilder** : aujourd'hui il reprend l'historique des notes depuis le
|
||||
`manifest.json` *local*, ce qui écraserait les corrections faites en ligne. ManifestBuilder
|
||||
recevra une option `--remote-manifest <url>` (ajoutée au `.bat`) : l'historique est repris du
|
||||
serveur, seule la nouvelle version vient de `notes.txt`.
|
||||
* **Modèle partagé** : `ChangelogEntry`/`ChangelogSection`/`ChangelogItem` et `ChangelogLimits` sont
|
||||
dans `Contracts` (`GameLauncher.Contracts.Changelog`), utilisés par le launcher, ManifestBuilder, le
|
||||
menu d'administration et le serveur (qui ne référence pas `Core`). `ChangelogLimits.Check` signale
|
||||
les dépassements sans rien tronquer ; `Normalize` tronque (texte + « … » dans la longueur maximale).
|
||||
* **API** :
|
||||
* `GET v1/games/{gameId}/changelog` (administrateur connecté) : notes du manifest publié (liste à
|
||||
plat omise quand les sections existent) et **empreinte SHA-256** du manifest lu ;
|
||||
* `PUT v1/games/{gameId}/changelog/{version}` (`changelog.edit` + requête signée) : notes d'**une**
|
||||
version (60 Ko au plus, sous la limite de 64 Ko des requêtes) + empreinte de base. Identifiant et
|
||||
version : caractères sûrs uniquement (jamais utilisés dans un chemin).
|
||||
* **Serveur** (`Storage/ChangelogStore`) : manifest configuré par jeu (`LauncherApi:Games:<id>:ManifestFile`,
|
||||
chemin absolu, dossier existant, séparé des données privées ; `gameId` du manifest vérifié).
|
||||
Sous verrou : empreinte comparée (409 `revision_conflict` si le manifest a changé : nouvelle version
|
||||
publiée, autre administrateur), contrôle des bornes (400), entrée de la version remplacée dans
|
||||
l'arbre JSON (numéro, date et champs inconnus conservés, quelle que soit la casse ; liste à plat
|
||||
régénérée pour les anciens launchers), budget de tout l'historique vérifié, copie de l'ancien
|
||||
manifest dans les données privées (20 dernières), écriture d'un fichier temporaire caché du même
|
||||
dossier avec les droits du manifest existant, **empreinte revérifiée juste avant** le renommage
|
||||
atomique. Refus (503) si le manifest est un lien symbolique, ou si le dossier n'a pas le bit setgid
|
||||
alors que nginx lit le manifest par son groupe. Audit `changelog.edit`.
|
||||
* **Droits sur le VPS** : le service doit pouvoir écrire dans le dossier du jeu (complément systemd
|
||||
`ReadWritePaths`, `server/deploy/LISEZMOI.md` étape 3 ter). C'est un compromis : un service compromis
|
||||
pourrait alors modifier les fichiers du jeu servis aux joueurs (le manifest n'est pas encore signé,
|
||||
voir phase 7). L'autorisation est limitée au seul dossier du jeu ; sans `LauncherApi:Games`, rien
|
||||
n'est modifiable et le dossier reste en lecture seule pour le service.
|
||||
* **Cohérence avec ManifestBuilder** : option `--remote-manifest <url>` (ajoutée à
|
||||
`tools\publish-example.bat`) : l'historique est repris du manifest **en ligne**, seule la nouvelle
|
||||
version vient de `notes.txt`. 404 = première publication (historique local) ; toute autre erreur
|
||||
arrête la publication. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
## 11. Publication d'une version (phase 5)
|
||||
## 11. Publication d'une version (phase 5 ✅)
|
||||
|
||||
Depuis le menu admin › « Publier une version » : choix du dossier produit par le `.bat`, contrôle
|
||||
local (manifest valide, archive et objets présents avec les bonnes empreintes), puis envoi :
|
||||
Depuis le menu admin › « Publier une version » (module `PublishAdminModule`, permission
|
||||
`releases.publish`) : choix du `manifest.json` produit par le `.bat`, contrôle local (manifest valide,
|
||||
archive et objets présents avec la bonne taille), puis envoi :
|
||||
|
||||
1. `objects/` : seuls les objets **absents du serveur** (le serveur répond à une liste d'empreintes) ;
|
||||
2. l'archive ZIP, **par morceaux** (8 Mo, reprise possible, chaque morceau et le fichier final
|
||||
vérifiés par SHA-256) ;
|
||||
3. `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
|
||||
1. le manifest (par morceaux), contrôlé par le serveur ; il répond par la liste des objets **absents**
|
||||
de son dossier `objects/` ;
|
||||
2. ces objets seulement : petits fichiers **par lots** (une requête pour des centaines de fichiers),
|
||||
gros fichiers par morceaux ;
|
||||
3. l'archive ZIP **par morceaux** de 4 Mo (reprise à la position reçue) ;
|
||||
4. la mise en ligne : `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
|
||||
|
||||
Le serveur vérifie que chaque fichier référencé existe avec la bonne taille et la bonne empreinte
|
||||
avant d'accepter le manifest. L'historique des notes du serveur est conservé.
|
||||
**Contrôles communs** (`Contracts/Publishing/ManifestCheck`, launcher et serveur) : identifiant de jeu
|
||||
et version attendus ; archive = simple nom `.zip` à côté du manifest ; objets dans `objects/` uniquement ;
|
||||
chemins relatifs sûrs, sans doublon ; exécutable présent ; 10 000 fichiers, 128 Go d'archive au plus.
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
|
||||
**Envoi** (`Storage/PublicationStore`, module `Publishing`) : toutes les requêtes sont **signées** (y
|
||||
compris chaque morceau : la signature couvre l'empreinte du corps). Seules les routes d'envoi acceptent
|
||||
8 Mo (limite propre à la route, côté Kestrel et nginx) ; le reste de l'API garde 64 Ko. Dépôt dans un
|
||||
dossier **caché** du dossier du jeu (`.publish-<id>`, 0700, refusé par nginx) : la mise en ligne ne
|
||||
consiste qu'en renommages sur le même disque. Chaque morceau doit suivre exactement ce qui a été reçu
|
||||
(409 `offset_mismatch` sinon) ; l'empreinte SHA-256 est calculée au fil des morceaux (recalculée si le
|
||||
service a redémarré) et vérifiée avant d'accepter le fichier. Espace disque vérifié (507). Une
|
||||
publication par jeu ; celle d'un autre administrateur n'est remplaçable qu'après 30 min sans envoi ;
|
||||
abandon au bout de 48 h. Limite de requêtes : les routes d'envoi de morceaux ont leur propre limite
|
||||
(`Limits:PublicationUploadsPerMinute`, 1500/min par IP, politique `publication-upload`, à la place de la
|
||||
limite générale) ; côté nginx, zone `publication_par_ip` (25/s). Chaque refus 429 du serveur annonce
|
||||
son délai (`Retry-After`).
|
||||
|
||||
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
|
||||
distribuer un faux jeu. La phase 7 ajoute :
|
||||
**Mise en ligne** : tout est revérifié, puis contrôles **avant tout déplacement** (liens symboliques,
|
||||
droits, dossiers `objects/xx`) ; objets, archive (l'ancienne, de même nom, gardée de côté et remise en
|
||||
place si l'écriture du manifest échoue), puis manifest. Refus si les fichiers en ligne ont changé
|
||||
pendant l'envoi (empreinte du manifest **hors notes** : une correction de notes reste permise).
|
||||
Notes : celles de la version publiée, puis l'historique **du serveur** (corrections en ligne
|
||||
conservées), dans les bornes du launcher. Résultat gardé 24 h : une réponse perdue n'empêche pas de
|
||||
constater la mise en ligne. Audit : `release.start`, `release.publish`, `release.cancel`.
|
||||
Dossiers `objects/xx` manquants : créés **sans demander de bit spécial** (systemd refuse au service toute
|
||||
demande de setuid/setgid, `RestrictSUIDSGID=yes`) ; le groupe `www-data` vient du bit setgid de `objects/`,
|
||||
et les droits sont ceux du dossier du jeu, mais sans setgid (le fixer sous le masque du service le retire) :
|
||||
sans conséquence pour le launcher, qui y déplace des fichiers déjà du bon groupe, mais un fichier déposé
|
||||
avec WinSCP y prendrait le groupe `deploy`. D'où l'étape 3 quater : `objects/` et ses 256 sous-dossiers
|
||||
créés sur le VPS en `2750`.
|
||||
|
||||
* `keyring.json` : liste des clés publiques des administrateurs, **signée par la clé racine**
|
||||
(votre clé de signature du launcher, déjà intégrée à l'exe) ;
|
||||
* une **signature détachée** (`manifest.json.sig`, `catalog.json.sig`) produite par la clé de
|
||||
l'administrateur au moment de la publication ou de l'édition — les anciens launchers ignorent
|
||||
ces fichiers ;
|
||||
* côté joueur, vérification puis refus de tout contenu non signé par une clé du trousseau.
|
||||
**Launcher** (`Modules.Admin/Publishing/ReleaseUploader`) : les empreintes des fichiers sont vérifiées
|
||||
pendant leur lecture (un fichier modifié depuis le `.bat` est refusé) ; coupures (réseau, délai,
|
||||
429, serveur occupé, position décalée) : l'état est relu sur le serveur et l'envoi continue, après une
|
||||
pause affichée sur la page (429 : délai `Retry-After` du serveur, sinon 1 s doublée à chaque refus,
|
||||
30 s au plus) ; « Interrompre »
|
||||
garde ce qui a été reçu, « Publier » reprend. La mise en ligne elle-même ne s'interrompt pas. Pendant
|
||||
l'envoi, `IAdminContext.BeginLongOperation()` évite le verrouillage pour inactivité (verrouillage manuel,
|
||||
de Windows et durée maximale de session toujours appliqués).
|
||||
|
||||
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
|
||||
**VPS** : bloc nginx `server/deploy/nginx-publications.conf`, écriture dans le dossier du jeu
|
||||
(`games.conf`, étape 3 ter), dossier `objects` existant avec le bit setgid (`LISEZMOI.md`, étape 3 quater).
|
||||
|
||||
## 11 bis. Administrateurs et journal d'audit (phase 6 ✅)
|
||||
|
||||
Module `AccountsAdminModule` (pages « Mes appareils » et « Administrateurs ») et module
|
||||
`AuditAdminModule` (page « Journal d'audit »), sur les routes de la phase 1. La page ne propose que ce
|
||||
que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste seul juge).
|
||||
|
||||
* **Mes appareils** (tout administrateur connecté) : nouvelle route `GET v1/admins/me` (son compte, ses
|
||||
clés, ses invitations « nouvel appareil » en cours ; empreinte masquée pour une clé en attente, comme
|
||||
dans la liste complète). Ajout d'un PC : invitation `ForAdminId` = soi, puis approbation depuis ce PC
|
||||
avec l'empreinte lue sur le nouveau. Révoquer un de ses appareils reste réservé à `admins.manage`.
|
||||
Un administrateur peut supprimer une invitation « nouvel appareil » **créée par lui pour lui**
|
||||
(`DELETE v1/admins/invitations/{id}` ouverte à tout administrateur, droit revérifié dans la
|
||||
modification) ; les autres exigent `admins.manage`. `InvitationSummary.CreatedById` ajouté.
|
||||
* **Administrateurs** (`admins.manage`) : comptes en attente d'abord, puis actifs (vous en premier), les
|
||||
révoqués sur demande ; par compte : rôle (liste + « Appliquer », confirmation renforcée pour
|
||||
« Propriétaire »), révocation ; par appareil : approbation (empreinte saisie : format contrôlé localement,
|
||||
comparaison par le serveur en temps constant), révocation (sauf la clé de la session en cours). Nouvel
|
||||
appareil d'un autre compte actif : la page explique qu'il l'approuve lui-même ; compte sans appareil
|
||||
actif : restauration sur le VPS (`add-key`).
|
||||
* **Code d'invitation** : affiché une seule fois (copie dans le presse-papiers, « J'ai transmis le code »
|
||||
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
|
||||
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
|
||||
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
|
||||
texte ; limite des 5 000 entrées signalée) ; **période** (« du / au », raccourcis) : `GET v1/audit?before=`
|
||||
(ISO 8601, les mois postérieurs ne sont pas lus) puis pages suivantes jusqu'au début de la période, filtre
|
||||
local en plus ; **consultations inscrites** : chaque `GET v1/audit` sans curseur (ouverture, « Actualiser »,
|
||||
période) écrit `audit.view` APRÈS la lecture (auteur, clé, IP, entrées lues, borne `before`) ; les pages
|
||||
suivantes (curseur) n'en réécrivent une que si la dernière consultation inscrite de la même clé date de plus
|
||||
de 10 minutes (un curseur forgé ne permet donc pas de lire sans trace) ; l'outil `AdminKeyTool` est tracé
|
||||
de la même façon ; **exports inscrits** : `POST v1/audit/export` (signé, `audit.read` ; nombre d'entrées,
|
||||
période, filtres en clair, bornés) écrit `audit.export` AVANT que le launcher n'écrive le fichier, qui n'est
|
||||
pas écrit en cas de refus ou d'erreur (le contenu du fichier reste celui déjà lu : un client modifié pourrait
|
||||
sauter l'annonce, mais pas la lecture, elle-même tracée) ; export CSV des entrées filtrées (période
|
||||
comprise), toutes les cellules entre guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
|
||||
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
|
||||
* **Appareil** : chaque entrée liée à une clé affiche le **nom de l'appareil choisi à l'enregistrement de
|
||||
la clé** (formulaire « J'ai une invitation »), jamais le nom Windows du PC. Le serveur l'ajoute à la
|
||||
lecture (`AuditEntry.KeyLabel`, d'après `admins.json`) : il n'est jamais écrit dans les fichiers du
|
||||
journal (ajout seul), et les anciennes entrées en profitent aussi ; colonne « Appareil » dans l'export
|
||||
CSV, recherche comprise. À la connexion (`auth.login`), le détail est « appareil « Nom » · GameLauncher
|
||||
x.y.z » : le launcher n'envoie plus le nom Windows du PC (`LoginRequest.ClientName` = logiciel et
|
||||
version) et le serveur retire celui qu'ajoutaient les launchers jusqu'à la version 2.4.1 (il n'est ni
|
||||
journalisé ni conservé dans la session). Les lignes déjà écrites par un ancien serveur gardent leur texte
|
||||
d'origine (journal en ajout seul).
|
||||
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
|
||||
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
|
||||
tant qu'un code non transmis est affiché.
|
||||
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
|
||||
|
||||
### 11 ter. Fichiers du serveur
|
||||
|
||||
Module serveur `Files` (`FilesModule`, `ServerFilesStore`) et module launcher `FilesAdminModule` (page « Fichiers du
|
||||
serveur »), permission `files.manage`.
|
||||
|
||||
| Route | Signée | Rôle |
|
||||
|---|:-:|---|
|
||||
| `GET v1/files?path=` | | Contenu d'un dossier du site (2 000 éléments au plus, tailles des sous-dossiers dans la limite de 200 000 fichiers parcourus), espace disque, jeux nettoyables |
|
||||
| `POST v1/files/delete` | ✔ | Supprime UN fichier (`path`, `size` vue dans la liste : refus s'il a changé) |
|
||||
| `GET v1/games/{id}/objects/prune` | | Analyse : objets du dossier `objects` que plus aucun manifest n'utilise (nombre, taille, empreinte du manifest analysé) |
|
||||
| `POST v1/games/{id}/objects/prune` | ✔ | Nettoyage, refusé si le manifest a changé depuis l'analyse (`revision_conflict`) |
|
||||
|
||||
* **Racine** : `LauncherApi:Files:Root`, sinon le parent commun des dossiers des jeux et du catalogue ; jamais la
|
||||
racine du disque ni un dossier mêlé aux données privées. Chemins relatifs uniquement (ni `..`, ni `\`, ni `:`, ni
|
||||
élément caché) ; aucun lien symbolique sur le chemin ; un dossier n'est jamais supprimé.
|
||||
* **Références** : à chaque requête, tous les `manifest.json` et `launcher-update.json` du site (dossiers cachés et
|
||||
liens exclus, 50 000 dossiers au plus) et le catalogue configuré sont relus ; les adresses sont résolues comme le
|
||||
fait le launcher (relatives au fichier, `..` compris ; adresse complète du même serveur rattachée au site grâce à
|
||||
l'emplacement et à l'adresse du catalogue). État d'un fichier : `protected` (`.json`), `in_use`, `unused`,
|
||||
`unknown` (fichier de description illisible dans le dossier, ou adresse du même serveur impossible à rattacher :
|
||||
tout le site), `link`.
|
||||
* **Suppression** : verrou d'écriture du manifest de chaque jeu configuré qui contient le fichier, refus pendant une
|
||||
publication (dossier `.publish-…` à côté d'un manifest parent), références relues sous le verrou, seul un fichier
|
||||
`unused` est supprimé. Audit `files.delete` (chemin relatif, taille libérée).
|
||||
* **Nettoyage** : jeux configurés uniquement, `objectsBaseUrl` = `objects/` ; seuls les fichiers
|
||||
`objects/<2 hex>/<empreinte>.bin` au nom exact, ni liens, que le manifest du jeu n'utilise pas ET qu'aucun autre
|
||||
fichier de description du site ne désigne ni ne rend invérifiable. Sous le verrou du jeu, refus pendant une
|
||||
publication. Audit `files.prune`.
|
||||
* **Erreurs** : refus métier en 409 (`publication_in_progress`, `revision_conflict`) ; droits du dossier
|
||||
(`ReadWritePaths` de systemd) ou fichier occupé en 503 sans chemin du serveur (détail dans journalctl).
|
||||
|
||||
### 11 quater. Images du catalogue envoyées depuis le menu (✅)
|
||||
|
||||
Page « Jeux » › icône ou image de fond › **« Parcourir… »** (explorateur Windows) : plus besoin de client FTP.
|
||||
|
||||
1. Le launcher lit le fichier (taille contrôlée avant la lecture) et applique **les mêmes contrôles que le
|
||||
serveur** (`Contracts/Catalog/CatalogImages.Check`) : PNG ou JPEG reconnu à son **contenu**, 4 Mo au plus,
|
||||
fichier complet sans données ajoutées après l'image (bloc `IEND` / marqueur `EOI`), dimensions lues dans
|
||||
l'en-tête et bornées (icône 32–2048 px, fond 640 × 360 à 7680 × 4320 px, 40 mégapixels au plus).
|
||||
2. `PUT v1/catalog/images/{icon|background}` (`games.edit`, **requête signée** : la signature couvre l'empreinte
|
||||
du contenu) ; corps binaire, seule route du catalogue à 4 Mo + 64 Ko (Kestrel `WithBodyLimit`, nginx
|
||||
`location ^~ /api/v1/catalog/images/`, 5 Mo).
|
||||
3. Serveur (`Storage/CatalogImageStore`) : contrôle complet avant toute écriture, dossier `catalog/images` à côté
|
||||
du catalogue public (créé à l'installation, jamais par le service ; refusé s'il est un lien), nom
|
||||
`<sha256>.png|.jpg` (même contenu déjà présent : rien n'est écrit), écriture atomique (fichier temporaire caché
|
||||
puis renommage) en 0644, 300 images / 300 Mo au plus, espace disque vérifié (507). Audit
|
||||
`catalog.image_upload` (refus compris).
|
||||
4. Le launcher vérifie que l'adresse renvoyée est en https, sur l'hôte du catalogue, et porte l'empreinte du
|
||||
fichier envoyé, puis la met dans le champ (aperçu décodé en mémoire). L'image n'est visible des joueurs
|
||||
qu'après **« Publier »** (catalogue signé).
|
||||
5. Joueurs (`CatalogService`) : une image dont le nom est une empreinte est **vérifiée** au téléchargement ; son
|
||||
adresse figurant dans le catalogue signé, l'image est couverte par cette signature. Les images inutilisées se
|
||||
suppriment depuis « Fichiers du serveur ».
|
||||
|
||||
### 11 quinquies. Demandes des joueurs : bouton « Aide » (7d ✅)
|
||||
|
||||
**Besoin** : depuis le launcher, un joueur envoie, **sans compte**, un **avis**, une **demande d'évolution**, un **bug**
|
||||
ou **autre chose**, rattaché au **jeu sélectionné** ; les administrateurs les lisent par jeu dans le menu
|
||||
d'administration, sans pouvoir répondre (un futur module « comptes » permettra l'échange ; les demandes anonymes
|
||||
actuelles resteront sans propriétaire : aucun secret local n'est conservé pour les rattacher plus tard).
|
||||
|
||||
**Formulaires partagés** (`Contracts/Feedback`) : `FeedbackForms` décrit une fois chaque type et ses champs
|
||||
prédéfinis (texte, choix, note 1 à 5 ; obligatoires ou non) ; le launcher génère le formulaire, le serveur valide
|
||||
avec `FeedbackRules` (mêmes règles des deux côtés), le menu affiche les libellés. Titre 5 à 120 caractères, textes
|
||||
2 000 caractères / 40 lignes, total 8 000, corps 16 Ko ; caractères de contrôle, invisibles, de mise en forme,
|
||||
d'inversion du sens d'écriture, d'usage privé et demi-caractères refusés ; fins de ligne normalisées.
|
||||
|
||||
**Route anonyme** (première route d'écriture ouverte à tous ; module serveur `Feedback`) :
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `POST v1/feedback/challenge` | anonyme | | Défi anti-spam sans état (HMAC, secret en mémoire) |
|
||||
| `POST v1/feedback` | anonyme | | Dépôt : `{ reference }` seulement (+ jeton d'envoi si des captures sont annoncées) |
|
||||
| `PUT v1/feedback/attachments/{n}` | anonyme + jeton | | Capture d'écran n° n (1 à 3) de la demande que le jeton désigne |
|
||||
| `GET v1/feedback/overview` | `feedback.read` | | Compteurs par jeu, réception activée, plafond atteint, durées de conservation |
|
||||
| `GET v1/games/{id}/feedback?kind=&status=&cursor=&limit=` | `feedback.read` | | Liste (100 par page, curseur stable, filtres `open`/`closed`/statut) |
|
||||
| `GET v1/games/{id}/feedback/{id}` | `feedback.read` | | Détail |
|
||||
| `GET v1/games/{id}/feedback/{id}/attachments/{n}` | `feedback.read` | | Capture d'écran (octets bruts, `nosniff`) |
|
||||
| `PUT v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Statut et note interne (révision attendue, 409 sinon) |
|
||||
| `DELETE v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Suppression définitive |
|
||||
|
||||
Défenses, dans l'ordre de traitement : réception désactivée par défaut (`LauncherApi:Feedback:Enabled`, 503) ;
|
||||
limite de débit ASP.NET (`feedback`, 10/min par IP) et nginx (`feedback_par_ip`, 6/min) ; corps 16 Ko ; **preuve de
|
||||
travail** (`FeedbackProof` : SHA-256 avec 18 bits à zéro par défaut, défi à usage unique valable 10 min) ; jeu parmi
|
||||
ceux configurés (`Feedback:Games`, sinon `Games` ; l'identifiant **configuré** sert seul à construire un chemin, `.`
|
||||
et `..` refusés) ; contenu ; 3 demandes / 10 min et 10 / jour par IP (IPv6 : /64), **comptées en mémoire seulement**
|
||||
(429 + `Retry-After`) ; plafonds globaux (300 / jour / jeu, 5 000 / jeu, 1 Go captures comprises, 500 Mo libres :
|
||||
503 `feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
|
||||
et le statut.
|
||||
|
||||
**Captures d'écran** (3 au plus par demande, 4 Mo chacune, **PNG ou JPEG uniquement**) : le joueur choisit une image
|
||||
(PNG, JPEG, BMP, GIF, WebP, reconnue à son **contenu**, jamais à l'extension ; 30 Mo au plus, dimensions lues avant
|
||||
tout décodage) ou la colle (Ctrl+V) ; Windows la décode (preuve que c'est une image), elle est réduite (3 840 px,
|
||||
surface de la limite serveur) et **réencodée sans métadonnées** (ni GPS, ni appareil, ni date) ; le nom du fichier
|
||||
n'est jamais envoyé. Envoi en deux temps : la demande annonce `AttachmentCount`, le serveur répond avec un **jeton
|
||||
d'envoi** `<id>.<secret>` (128 bits, seule son empreinte SHA-256 est gardée, en mémoire, 15 min, comparaison à temps
|
||||
constant) valable pour CETTE demande et les numéros annoncés ; chaque capture part en `PUT` (corps 4 Mo + 16 Ko,
|
||||
en-tête `X-GL-Feedback-Upload`). Le serveur contrôle **réellement** l'image sans bibliothèque de décodage
|
||||
(`FeedbackImages.Sanitize`) : PNG (signature, IHDR, CRC de chaque bloc, données zlib décompressées et taille exacte
|
||||
des lignes vérifiée, filtres valides, IEND final sans données après) ou JPEG (marqueurs, SOF0/1/2, tables, SOS, EOI,
|
||||
rien après) ; dimensions 16 à 4 096 px, 9,4 Mpx au plus ; puis **retire toutes les métadonnées** (blocs PNG
|
||||
facultatifs : textes, EXIF, profil… ; JPEG : APP1 à APP15 dont EXIF/XMP/GPS, commentaires). Une capture refusée
|
||||
n'annule pas la demande (le joueur est prévenu). Audit `feedback.attachment` sans IP. Dans le menu, chaque capture est
|
||||
**décodée à nouveau** par le launcher (contrôle + dimensions) avant affichage ; aucun fichier n'est écrit sur le PC.
|
||||
|
||||
**Liens** (`FeedbackLinks`) : toute demande contenant un lien est signalée (icône dans la liste, bandeau
|
||||
d'avertissement et champs concernés surlignés dans la fiche ; liens affichés **désamorcés**, ex. `hxxps[://]site[.]com`,
|
||||
jamais cliquables). Formes reconnues : `http(s)://`, `ftp://`, `file:`, schémas dangereux (`javascript:`, `data:`,
|
||||
`vbscript:`, `ms-*:`, `steam:`…), `www.`, domaines nus avec extension connue (`site.ru`, `exemple.com/chemin`), adresses
|
||||
IP, chemins réseau `\\serveur\partage`, adresses e-mail, formes déguisées (`hxxp`, `[.]`, `(dot)`, ` dot `,
|
||||
caractères pleine chasse).
|
||||
|
||||
**Anonymat** : ni adresse IP ni identifiant du PC dans la demande ; audit `feedback.submit` **sans IP** (référence,
|
||||
jeu, type) ; refus résumés par `WriteAnonymousFailure` sans IP ; nginx `access_log off` sur les deux routes ;
|
||||
informations techniques facultatives (versions du launcher et du jeu, Windows, langue), cochées par défaut pour un
|
||||
bug seulement, contenu exact montré au joueur avant l'envoi. Agent utilisateur générique (`GameLauncher/<version>`).
|
||||
Le launcher ne contacte l'API qu'à l'envoi.
|
||||
|
||||
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` (captures :
|
||||
`<id>-<n>.png|jpg` à côté, supprimées avec la demande) dans les données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
|
||||
et signalé), verrou en mémoire (seul le service écrit ici). **SQLite évalué et écarté pour ce module** : volumes bornés,
|
||||
aucune relation, une demande = un document modifié isolément, purge = suppression de fichiers ; SQLite ajouterait une
|
||||
dépendance native (paquet NuGet + bibliothèque `e_sqlite3`) à maintenir sur le VPS et une sauvegarde spécifique, sans
|
||||
gain de sécurité. À réévaluer avec le module « comptes + échanges » (relations comptes ⇄ demandes ⇄ messages,
|
||||
requêtes croisées, volumes non bornés), où SQLite serait légitime. Captures : fichiers également (des blobs binaires
|
||||
en base alourdiraient sauvegardes et purge sans rien apporter) (requêtes paramétrées uniquement, fichier 0600 dans
|
||||
les données privées, mode WAL, sauvegarde par l'API de sauvegarde).
|
||||
|
||||
**Consultations et traitement** : `feedback.view` inscrit à chaque ouverture de liste (première page) et à chaque
|
||||
lecture d'une demande (une fois par demande et par clé toutes les 10 min), sans le contenu ; `feedback.update`
|
||||
(ancien → nouveau statut, note modifiée oui/non), `feedback.delete` (référence). Statuts : Nouvelle, En cours, Traitée,
|
||||
Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur. Comme partout dans le menu, les **écritures**
|
||||
(`PUT`, `DELETE`) sont **signées** par la clé de l'administrateur (horodatage + nonce, rejeu refusé) ; les lectures
|
||||
(liste, détail, captures) exigent la session et `feedback.read`, revérifiés à chaque requête, et sont journalisées.
|
||||
Le journal d'audit du menu affiche des libellés lisibles (« Demande de joueur reçue », « … consultée », « … traitée »,
|
||||
« Capture d'écran reçue »…) et une catégorie « Demandes des joueurs ».
|
||||
|
||||
**Conservation** (`Hosting/RetentionService`, au démarrage puis toutes les 24 h) : demande close supprimée
|
||||
`RetentionClosedMonths` (12) après sa clôture, toute demande au plus `RetentionMaxMonths` (24) après réception
|
||||
(`feedback.purge`) ; **journal d'audit** : fichiers mensuels au-delà de `Audit:RetentionMonths` (12) supprimés
|
||||
(`audit.purge`) — corrige l'écart relevé par la fiche RGPD.
|
||||
|
||||
**Launcher (joueur)** : bouton **Aide** (bulle avec point d'interrogation) dans la barre de titre, absent sans
|
||||
`apiUrl` (même règle que le menu : `launcher.json` intégré en Release). Fenêtre modale `FeedbackWindow` (le jeu est
|
||||
figé à l'ouverture) : type (4 tuiles) › formulaire (contrôle local, compteurs de caractères, étoiles, saisie conservée
|
||||
en cas d'échec, Échap sans effet pendant la saisie) › référence (copie). Formulaire limité à une hauteur de base
|
||||
(500 px, moins sur un petit écran) avec ascenseur ; la fenêtre reste centrée quand l'étape change ; molette gérée
|
||||
par `WheelAssist` comme dans le menu. ViewModel `FeedbackFormViewModel`
|
||||
(`GameLauncher.Modules.Feedback`), envoi par `LauncherApiClient.SendFeedbackAsync` (défi résolu hors du fil de
|
||||
l'interface).
|
||||
|
||||
**Menu d'administration** : module `FeedbackAdminModule`, page **« Demandes des joueurs »** (`feedback.read`) : choix
|
||||
du jeu (tuiles avec compteurs ; un seul jeu : choisi d'office), filtres type / statut (« À traiter » par défaut) /
|
||||
recherche, liste et fiche en deux colonnes (texte brut, sélectionnable, jamais interprété), statut et note
|
||||
(`feedback.manage`), suppression confirmée ; bandeaux « réception désactivée » et « plafond atteint ». Tableau de bord :
|
||||
carte « Demandes des joueurs » (nouvelles demandes par jeu).
|
||||
|
||||
### 11 sexies. Formulaires des demandes modifiables (7e ✅)
|
||||
|
||||
**Besoin** : gérer depuis le menu les types de demande (**1 à 8** : ajout, suppression, **ordre d'affichage**, nom,
|
||||
description, icône, exemple de titre, informations techniques cochées ou non, **captures d'écran autorisées ou non**,
|
||||
type masqué) et leurs champs (ajout, retrait, ordre, libellé, aide, obligatoire, longueur, choix), **communs à tous
|
||||
les jeux**. Les types de champs proposés restent ceux ci-dessous.
|
||||
|
||||
**Icônes** (`Contracts/Feedback/FeedbackIcons`) : l'une des **quatre icônes d'origine** (`Star`, `Lightbulb`, `Bug`,
|
||||
`Forum`), ou une icône **importée depuis un fichier .ico** (extension et en-tête vérifiés ; 1 Mo au plus) qui doit
|
||||
contenir une image de **64 × 64 pixels** (PNG intégré, revérifié, ou bitmap 24/32 bits avec son masque ; 256 couleurs
|
||||
et moins refusés ; les autres tailles sont ignorées). Lecture sans bibliothèque, puis réécriture en **PNG 64 × 64
|
||||
neuf, sans métadonnées**, intégré en base64 aux formulaires (`FeedbackForm.CustomIcon`) : il est donc **couvert par la
|
||||
signature** ; serveur et launchers le revérifient (`CheckCustom` : PNG valide, exactement 64 × 64, 20 Ko au plus,
|
||||
déjà nettoyé).
|
||||
|
||||
**Captures d'écran par type** (`FeedbackForm.AllowAttachments`, vrai par défaut) : faux = section masquée dans la
|
||||
fenêtre « Aide », aucune capture envoyée, et le serveur refuse une demande qui en annonce (`FeedbackRules.Validate`).
|
||||
Mêmes règles qu'avant sinon (3 au plus, conversion, contrôles).
|
||||
|
||||
**Types de champs** (`FeedbackFieldKind`, écrits en toutes lettres) : `Multiline` (zone de texte, 20 à 2 000
|
||||
caractères), `Text` (texte court, 1 à 200), `Choice` (liste déroulante, choix unique), `Checkboxes` (cases à cocher :
|
||||
identifiants séparés par des virgules, remis dans l'ordre du formulaire), `YesNo` (`yes`/`no` ; obligatoire = doit
|
||||
être cochée), `Rating` (3, 5 ou 10 étoiles). `ShowInList` : la valeur d'un champ (liste, note ou oui/non) sert
|
||||
d'aperçu dans la liste du menu (remplace la règle figée « note » / « gravité »).
|
||||
|
||||
**Document** (`Contracts/Feedback/FeedbackFormsDocument`) : `feedback-forms.json` = format, version, **révision**,
|
||||
formulaires, signature ; révision 0 = formulaires d'origine (`FeedbackForms`, jamais publiés). Règles
|
||||
(`FeedbackFormRules.Validate`, communes au menu, au serveur et au launcher) : 1 à 8 types (identifiants
|
||||
`[a-z][a-z0-9_]{0,31}` uniques ; ordre du document = ordre d'affichage), au moins un proposé ; 15 champs au plus ;
|
||||
identifiants de champ uniques ; 2 à 20 choix ; textes déjà propres (une ligne ; aide : 4 lignes, 200 caractères), sans
|
||||
caractère invisible ni **aucun lien** (`FeedbackLinks`) ; icône d'origine parmi les quatre, icône importée contrôlée ;
|
||||
**256 Ko** au plus (icônes comprises : route `PUT v1/feedback/forms` à 264 Ko, bloc nginx dédié). Condensé signé : format, version, révision et formulaires (la date de publication n'en fait
|
||||
pas partie).
|
||||
|
||||
**Signature** : nouveau type de contenu `feedback-forms` (identifiant signé `feedback-forms`) et nouvelle autorisation
|
||||
du trousseau **`forms`** (permission `feedback.forms`, Propriétaire et Administrateur par défaut, pas l'Éditeur).
|
||||
Une autorisation inconnue mais bien formée est désormais **acceptée** dans le trousseau (sans effet : `Verify` exige
|
||||
l'autorisation exacte), pour que les prochaines n'invalident plus le trousseau des launchers ; les launchers 3.1 et
|
||||
avant refusent encore un trousseau contenant `forms` et gardent le précédent (publier la 3.2 avant de re-signer).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
|
||||
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
|
||||
|
||||
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
|
||||
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
|
||||
Formulaires signés obligatoires (pas de mode « non signé »). SQLite écarté : un seul petit document, rarement modifié,
|
||||
publié en fichier de toute façon.
|
||||
|
||||
**Validation des demandes** : `FeedbackSubmission.FormRevision`. Révision 0 (launchers 3.0 / 3.1, ou formulaires
|
||||
publiés illisibles chez le joueur) : formulaires d'origine, toujours acceptés sauf type masqué depuis. Révision en
|
||||
vigueur : son formulaire, **conservé avec la demande** (`FeedbackRecord.Form`, `FeedbackDetail.Form`) pour l'afficher
|
||||
avec ses libellés d'alors. Autre révision : **409 `feedback_form_changed`** (avant le comptage des limites par
|
||||
adresse).
|
||||
|
||||
**Launcher (joueur)** : `FeedbackFormsService` (`Modules.Feedback`) lit `feedback-forms.json` (adresse déduite du
|
||||
trousseau, sinon du catalogue) **à l'ouverture de la fenêtre « Aide » seulement**, 256 Ko au plus (icônes importées comprises), 8 s au plus ;
|
||||
règles contrôlées même si signé ; signature vérifiée par `ContentTrustService.VerifyFeedbackFormsAsync` selon
|
||||
`contentSigning` (clé autorisée `forms`, date de signature non antérieure) ; révision inférieure à la copie gardée
|
||||
refusée ; copie gardée dans `feedback\forms.json` et revérifiée à la lecture. Rien de valide : formulaires d'origine.
|
||||
Fenêtre : « Chargement des formulaires… », types masqués absents, nouveaux champs (texte court, cases à cocher
|
||||
arrondies, case oui/non, 3 à 10 étoiles), aide affichée sous les champs sans zone de saisie. Sur 409 : formulaires
|
||||
relus, type reconstruit, saisie des champs de même identifiant et de même type reprise, message au joueur ; type masqué
|
||||
depuis : retour au choix du type.
|
||||
|
||||
**Menu d'administration** : la fiche d'une demande utilise le formulaire conservé avec elle (libellés, nom du type).
|
||||
|
||||
Page **« Formulaires des demandes »** (`Feedback/FeedbackFormsPanelViewModel`, `FeedbackFormEditors`, permission
|
||||
`feedback.forms`, ordre 16) : trois colonnes, types de demande (proposé / masqué, nouveau / modifié ; « Ajouter un
|
||||
type de demande », 8 au plus, identifiant généré `type_1`… jamais celui d'un type publié ou d'origine, avec un champ
|
||||
« Message ») ; réglages du type (monter, descendre, supprimer avec confirmation — les demandes reçues restent
|
||||
consultables ; nom ; icône d'origine ou « Importer un fichier .ico… » / « Retirer l'icône importée » ; description,
|
||||
exemple de titre, informations techniques cochées par défaut, captures d'écran autorisées, masqué) et champs repliables
|
||||
(monter, descendre, retirer ; libellé, aide, obligatoire, aperçu dans la liste, longueur, nombre d'étoiles, choix
|
||||
ordonnés) ; « Ajouter un champ » par type, identifiant généré et fixe (`texte_1`, `cases_2`…, jamais un identifiant
|
||||
publié) ; **aperçu** rendu avec les modèles de la fenêtre « Aide » (`Themes/FeedbackForms.xaml`, partagé). Contrôle
|
||||
en direct avec `FeedbackFormRules` (publication bloquée tant qu'il reste une erreur) ; avertissements sans refus :
|
||||
libellé qui semble demander une donnée personnelle (`PersonalDataWords` : e-mail, mot de passe, téléphone…), type sans
|
||||
champ. « Publier » : confirmation (types masqués rappelés), contrôle du trousseau publié (`PlayerSigningCheck` :
|
||||
autorisation « formulaires »), signature par la clé de ce PC, requête signée, révision attendue (409 : bandeau
|
||||
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
|
||||
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
|
||||
|
||||
### 11 septies. Mode maintenance du launcher (7f ✅)
|
||||
|
||||
**Besoin** : depuis le menu, un bouton pour mettre **tous les launchers** (tous les jeux) en maintenance : thème rouge /
|
||||
orange, « Maintenance en cours — merci de revenir plus tard » au centre, et **tout bloqué** sauf la **désinstallation
|
||||
des jeux** et le **menu d'administration** ; puis la terminer.
|
||||
|
||||
**Document** (`Contracts/Maintenance/MaintenanceDocument`) : `maintenance.json` = format `gamelauncher-maintenance`,
|
||||
version 1, **révision**, `enabled`, `message` (facultatif), `since` (début, à la seconde ; absent hors maintenance),
|
||||
`updatedAt` (posé par le serveur, hors signature), signature ; révision 0 = jamais publié (inactif). Règles
|
||||
(`MaintenanceRules`, communes au menu, au serveur et au launcher) : format et version, révision ≥ 1 ; message
|
||||
nettoyé comme les textes des demandes (`FeedbackRules.CleanMultiline` : caractères de contrôle, invisibles, de mise en
|
||||
forme, d'inversion du sens d'écriture, d'usage privé refusés), **300 caractères, 6 lignes, aucun lien**
|
||||
(`FeedbackLinks`) ; maintenance active : début présent, à la seconde, pas plus de 10 min dans le futur (serveur) ;
|
||||
inactive : ni début ni message ; **8 Ko** au plus (sous la limite de 64 Ko de l'API : pas de bloc nginx d'API). Titre et
|
||||
sous-titre affichés aux joueurs **fixes** (dans le code) : seul le message est publié. Condensé signé : format, version,
|
||||
révision, état, message, début.
|
||||
|
||||
**Signature** : type de contenu `maintenance` (identifiant signé `maintenance`) et autorisation du trousseau
|
||||
**`maintenance`** (permission `maintenance.manage` : Propriétaire d'office, Administrateur sur une installation neuve,
|
||||
pas l'Éditeur). Les launchers 3.2 acceptent un trousseau qui la contient (autorisation inconnue sans effet) ; ceux
|
||||
d'avant le refusent et gardent le précédent (publier la 3.3 avant de re-signer). Un serveur compromis ne peut donc ni
|
||||
déclencher une fausse maintenance, ni prolonger une maintenance chez les launchers en « require » (date de signature
|
||||
non antérieure à la dernière acceptée, révision non inférieure).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/maintenance` | administrateur connecté | | État en vigueur, publication possible, adresse publique, signataire (tableau de bord) |
|
||||
| `PUT v1/maintenance` | `maintenance.manage` | ✔ | `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 `revision_conflict` sinon), début inchangé pendant une maintenance ; audit `maintenance.enable`, `maintenance.disable` ou `maintenance.update` |
|
||||
|
||||
**Serveur** (`Modules/Maintenance/MaintenanceModule`, `Storage/MaintenanceStore`) : référence privée `maintenance.json`
|
||||
(+ `.bak`, historique de 20 révisions `maintenance-history/`), copie **publique** à côté du trousseau
|
||||
(`MaintenancePublicFileFullPath`, 644, atomique ; public d'abord) ; nom réservé (refus au démarrage si le catalogue ou
|
||||
le trousseau s'appellent ainsi). Document signé obligatoire. Le serveur ne bloque rien lui-même : l'API reste utilisable
|
||||
(c'est elle qui termine la maintenance). `GET v1/signing` renvoie aussi sa signature (`SigningState.Maintenance`).
|
||||
SQLite écarté : un seul petit document, publié en fichier de toute façon. nginx : `location = /catalog/maintenance.json`,
|
||||
sans cache.
|
||||
|
||||
**Launcher (joueur)** : `Core/Maintenance/MaintenanceService` lit `maintenance.json` (adresse déduite du trousseau,
|
||||
sinon du catalogue) **au démarrage**, en parallèle du catalogue et de la mise à jour du launcher, et l'attend **4 s au
|
||||
plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--uninstall`) ; puis toutes les **2 min** (1 min
|
||||
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
|
||||
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
|
||||
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
|
||||
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
|
||||
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
|
||||
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
|
||||
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
|
||||
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
|
||||
vérifié, un 404 termine la maintenance.
|
||||
|
||||
Blocage (`LauncherViewModel.ApplyMaintenance`, `GameViewModel.IsMaintenance`) : commandes désactivées (sauf
|
||||
`UninstallCommand`, l'annulation d'une opération commencée et la fermeture du bandeau) **et** refus à l'entrée de chaque
|
||||
action (installation, mise à jour, réparation, vérification, localisation, oubli, lancement, raccourci du Bureau,
|
||||
paramètres, recherche de mises à jour, `RunOperationAsync` sauf la désinstallation) avec un bandeau d'explication ;
|
||||
recherche automatique et mise à jour automatique suspendues, relancées à la fin de la maintenance ; paramètres du
|
||||
launcher, bouton « Aide » et site officiel masqués ou désactivés. Non interrompus : opération déjà commencée (toujours
|
||||
annulable), jeu déjà lancé, mise à jour du launcher lui-même (signée par la clé racine : moyen de corriger un launcher
|
||||
défectueux). Affichage : `Themes/Maintenance.xaml` (palette rouge / orange, mêmes clés que les thèmes) ajouté aux
|
||||
ressources de la fenêtre principale seulement, carte au centre (cône de chantier `Icon.TrafficCone`, bandes de
|
||||
chantier, titre, sous-titre, message en texte brut sélectionnable, « Depuis le … », rappel « la désinstallation reste
|
||||
possible »), bouton principal « MAINTENANCE » désactivé ; début et fin avec le fondu enchaîné d'un changement de jeu.
|
||||
**Ce n'est pas une barrière de sécurité** : un launcher modifié peut l'ignorer ; les serveurs du jeu doivent refuser
|
||||
eux-mêmes les connexions si nécessaire.
|
||||
|
||||
**Menu d'administration** : module `MaintenanceAdminModule`, page **« Maintenance »** (`MaintenancePanelViewModel`,
|
||||
permission `maintenance.manage`, ordre 5, icône cône) : état publié (en cours depuis…, par…, terminée le…), message
|
||||
(compteur, contrôle en direct, aperçu de la carte), « Activer la maintenance » (confirmation détaillée), « Modifier le
|
||||
message », « Terminer la maintenance » (confirmation), contrôle du trousseau des joueurs avant chaque publication
|
||||
(`PlayerSigningCheck`), 409 : état relu, saisie gardée. Tableau de bord : bandeau « Maintenance en cours » pour tout
|
||||
administrateur (bouton « Gérer » avec la permission). Page « Signature » : ligne « Mode maintenance ». Journal d'audit :
|
||||
catégorie « Mode maintenance ».
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
|
||||
|
||||
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
|
||||
|
||||
**Ancre de confiance** : la ou les clés racine intégrées à l'exe (`Security\update-signing.pub`, celles des
|
||||
mises à jour du launcher). La clé privée racine reste **hors ligne** : le menu d'administration refuse toujours
|
||||
de l'ouvrir (nom de fichier réservé, et toute clé dont l'identifiant ou la clé publique est celle d'une racine
|
||||
intégrée, quel que soit le nom du fichier) ; elle ne sert qu'à `LauncherPublisher` (mises à jour du launcher,
|
||||
`sign-keyring`).
|
||||
|
||||
**Trousseau** (`keyring.json`, `Contracts/Signing/Keyring`) : `sequence` croissante, `issuedAt`, clés
|
||||
(identifiant, clé publique SPKI, nom, autorisations `releases` et/ou `catalog`), signature racine sur le SHA-256
|
||||
du trousseau canonique (message `GameLauncher/Keyring/v1`, clés et autorisations triées). Production : page
|
||||
**Signature** (propriétaires) › « Exporter la demande » (appareils ACTIFS des comptes actifs ; `releases` si le rôle
|
||||
a `releases.publish`, `catalog` s'il a `games.edit` ; noms débarrassés des caractères invisibles, de mise en forme
|
||||
ou d'inversion du sens d'écriture, refusés aussi par `Keyring.Validate`) → `tools\sign-keyring.bat` (compare la
|
||||
demande au trousseau signé précédent `keyring-N.json` du même dossier, vérifié avec `update-signing.pub`, et
|
||||
signale les **nouvelles clés** et autorisations élargies ; la demande vient du serveur et n'est donc pas digne de
|
||||
confiance : ces empreintes se vérifient hors du serveur, avec celle que la page **Mes appareils** calcule sur le
|
||||
PC de chacun — pas celle annoncée par le serveur ; confirmation, phrase de passe ; refuse une racine absente
|
||||
d'`update-signing.pub` ; écrit `keyring-N.json`) → « Publier le trousseau signé » (le launcher vérifie la racine et
|
||||
le numéro avant l'envoi ; l'export reste possible quand le trousseau publié est refusé par ce launcher). Les noms
|
||||
sont publics. Serveur : `PUT v1/keyring` (`admins.manage`,
|
||||
requête signée) contrôle la structure et un numéro strictement croissant, écrit le fichier public (par défaut à
|
||||
côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish` (clés ajoutées/retirées). La liste
|
||||
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
|
||||
permettrait de calculer l'empreinte à dicter).
|
||||
|
||||
**Contenu en ligne** (page Signature, `GET v1/signing` : `SigningState`) : versions en ligne de chaque jeu, catalogue,
|
||||
puis **formulaires des demandes** (`FeedbackFormsSignatureState` : révision, condensé, signature, signataire ;
|
||||
révision 0 = formulaires d'origine intégrés au launcher, rien à signer ; fichier de référence illisible signalé).
|
||||
Chaque contenu est vérifié par le launcher avec le trousseau publié (accepté ou refusé par les joueurs, et pourquoi)
|
||||
et avec le trousseau proposé (« sa clé ne sera plus dans le trousseau proposé » ; l'export de la demande le rappelle).
|
||||
Un serveur antérieur à la 3.2.1 n'envoie pas les formulaires : la ligne n'apparaît pas.
|
||||
|
||||
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
|
||||
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
|
||||
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
|
||||
chacun à partir de ce qu'il a lu (même code dans `Contracts`) :
|
||||
* manifest : jeu, version, exécutable, arguments, archive (adresse, taille, SHA-256), base des objets, fichiers
|
||||
(chemin, taille, SHA-256) triés — **pas** les notes ni la date : les notes restent corrigeables en ligne (texte
|
||||
borné, jamais exécuté) ; le launcher des joueurs calcule le même condensé depuis son modèle `GameManifest` ;
|
||||
* catalogue : tout sauf `revision`, `updatedAt` et `signature` (posés par le serveur), jeux triés par identifiant
|
||||
**tel que publié** (sans mise en minuscules) ; un catalogue dont deux jeux ne diffèrent que par la casse est
|
||||
refusé par les launchers (`HasDuplicateGames`), même signé.
|
||||
|
||||
Les membres absents ou `null` (trousseau, manifest) ne provoquent jamais d'exception : trousseau refusé, condensé
|
||||
calculé sur des valeurs vides.
|
||||
|
||||
**Production des signatures** (clé de la session ; date = heure du serveur) : à la mise en ligne d'une version
|
||||
(`ReleaseUploader`, juste avant le commit : la reprise des envois, fondée sur l'empreinte du manifest, n'est pas
|
||||
touchée ; corps `PublicationCommitRequest`), à chaque publication du catalogue (page Jeux, sur le catalogue
|
||||
contrôlé tel qu'il sera publié), et pour une version déjà en ligne (WinSCP, antérieure) : page « Publier une
|
||||
version » › « Signer la version en ligne » depuis son dossier (`POST v1/games/{id}/signature`, accepté seulement si
|
||||
le condensé en ligne est celui du dossier). Le serveur vérifie chaque signature (clé = celle de la session, date à
|
||||
± 10 min de son heure — une date future bloquerait les mises à jour suivantes chez les joueurs, signature valide)
|
||||
et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire toute `signature` d'un manifest reçu
|
||||
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
|
||||
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
|
||||
|
||||
**Avertissement avant publication** (`Modules/Admin/PlayerSigningCheck`, côté administration uniquement) : avant de
|
||||
publier le catalogue (page Jeux), une version ou de signer la version en ligne (page « Publier une version »), le
|
||||
launcher télécharge le trousseau publié (`keyringUrl` du launcher.json intégré, sans cache, 15 s au plus) et le
|
||||
vérifie comme les launchers des joueurs (structure, racine intégrée, `keyringMinSequence`). Si la clé de ce PC
|
||||
(identifiant calculé localement) en est absente, n'a pas l'autorisation voulue (`catalog` / `releases`), ou si le
|
||||
trousseau est injoignable ou refusé : confirmation « Clé non reconnue par les launchers des joueurs » qui explique la
|
||||
conséquence selon `contentSigning` (« require » : catalogue ignoré / version refusée ; « warn » : alerte au journal)
|
||||
et le remède (page Signature), avec « Publier quand même » / « Signer quand même ». Aucun avertissement en
|
||||
`contentSigning` « off ». **Le serveur n'est pas modifié** : il accepte toujours ces publications (il ne contrôle que
|
||||
la clé de la session, la date et la validité de la signature) ; une erreur imprévue du contrôle ne bloque pas la
|
||||
publication.
|
||||
|
||||
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
|
||||
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
|
||||
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
|
||||
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
|
||||
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
|
||||
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
|
||||
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
|
||||
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
|
||||
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
|
||||
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
|
||||
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
|
||||
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
|
||||
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
|
||||
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
|
||||
sécurité », pas d'installation ni de mise à jour, le jeu installé reste jouable), catalogue ignoré (cache conservé).
|
||||
Le cache du catalogue contient le catalogue tel que publié (signature comprise) : il est revérifié à chaque
|
||||
démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou modifié sur le disque est ignoré en
|
||||
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
|
||||
pas les versions suivantes).
|
||||
|
||||
**Limites assumées** : images du catalogue déposées à la main (nom libre) non signées — celles envoyées depuis la
|
||||
page Jeux le sont par leur empreinte (§ 11 quater) ; un launcher qui n'a jamais vu de trousseau accepte tout
|
||||
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
|
||||
(la page Signature le signale : re-signer d'abord).
|
||||
|
||||
## 13. Phases
|
||||
|
||||
@@ -380,11 +1046,16 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|
||||
| **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 » | à faire |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
|
||||
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
|
||||
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
|
||||
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
|
||||
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
|
||||
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
|
||||
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
|
||||
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
|
||||
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
|
||||
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
|
||||
|
||||
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.
|
||||
@@ -396,5 +1067,5 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
|
||||
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
|
||||
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
|
||||
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) |
|
||||
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 416 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 395 KiB |
@@ -150,14 +150,17 @@ public static class AdminCli
|
||||
if (db.Admins.Count == 0) o.WriteLine("Aucun administrateur.");
|
||||
foreach (var admin in db.Admins.OrderBy(a => a.CreatedAt))
|
||||
{
|
||||
o.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
o.WriteLine($"{admin.DisplayName} [{db.GroupName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
foreach (var key in admin.Keys)
|
||||
o.WriteLine($" clé {key.KeyId} {AdminKeys.ComputeFingerprint(key.PublicKeyBytes())} {key.Status,-8} {key.Label}" +
|
||||
(key.LastUsedAt is { } used ? $" (dernière connexion {used:yyyy-MM-dd HH:mm} UTC)" : ""));
|
||||
}
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var invitation in db.Invitations.Where(i => i.ExpiresAt > now))
|
||||
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.Role}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
|
||||
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({db.GroupName(invitation.Role)}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
|
||||
foreach (var group in db.Groups)
|
||||
o.WriteLine($"Groupe {group.Id} : {group.Name} — " +
|
||||
(Contracts.Security.AdminGroups.IsSystem(group.Id) ? "toutes les permissions" : string.Join(", ", db.PermissionsOf(group.Id))));
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@ public static class ApiModules
|
||||
new Modules.System.SystemModule(),
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule()
|
||||
new Modules.Admins.GroupsModule(),
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule(),
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule(),
|
||||
new Modules.Files.FilesModule(),
|
||||
new Modules.Feedback.FeedbackModule(),
|
||||
new Modules.Maintenance.MaintenanceModule()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,4 +42,13 @@ public static class RateLimitPolicies
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
public const string Enrollment = "enrollment";
|
||||
|
||||
/// <summary>
|
||||
/// Morceaux envoyés pendant une publication (manifest, objets, archive) : limite propre, plus haute,
|
||||
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
|
||||
/// </summary>
|
||||
public const string PublicationUpload = "publication-upload";
|
||||
|
||||
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
|
||||
public const string Feedback = "feedback";
|
||||
}
|
||||
@@ -39,6 +39,26 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public LimitSettings Limits { get; set; } = new();
|
||||
|
||||
public CatalogSettings Catalog { get; set; } = new();
|
||||
|
||||
public KeyringSettings Keyring { get; set; } = new();
|
||||
|
||||
public ContentSigningSettings ContentSigning { get; set; } = new();
|
||||
|
||||
public FilesSettings Files { get; set; } = new();
|
||||
|
||||
/// <summary>Demandes des joueurs (bouton « Aide » du launcher) : réception anonyme, consultation dans le menu.</summary>
|
||||
public FeedbackSettings Feedback { get; set; } = new();
|
||||
|
||||
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
|
||||
public AuditSettings Audit { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
|
||||
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
|
||||
/// </summary>
|
||||
public Dictionary<string, GameSettings> Games { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de validité d'un défi de connexion.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 60;
|
||||
|
||||
@@ -53,6 +73,121 @@ public sealed class LauncherApiOptions
|
||||
public int MaxTotal { get; set; } = 500;
|
||||
}
|
||||
|
||||
/// <summary>Catalogue des jeux (informations modifiables), publié en fichier statique servi par nginx.</summary>
|
||||
public sealed class CatalogSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Fichier public écrit par le service (chemin absolu, ex. « /srv/exovival/www/catalog/catalog.json »).
|
||||
/// Vide : publication désactivée (le catalogue reste consultable dans le menu d'administration).
|
||||
/// Le dossier doit exister, appartenir au compte du service, et être autorisé en écriture par
|
||||
/// systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string PublicFile { get; set; } = "";
|
||||
|
||||
/// <summary>Adresse publique du fichier (information). Vide : « PublicOrigin/catalog/catalog.json ».</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau (phase 7) : clés autorisées à signer le contenu, signé par la clé racine HORS LIGNE puis publié
|
||||
/// depuis la page « Signature » du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed class KeyringSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Fichier public (chemin absolu .json). Vide : « keyring.json » dans le dossier du catalogue (déjà autorisé
|
||||
/// en écriture et servi par nginx), si le catalogue est configuré ; sinon publication désactivée.
|
||||
/// </summary>
|
||||
public string PublicFile { get; set; } = "";
|
||||
|
||||
/// <summary>Adresse publique (information, « keyringUrl » de launcher.json). Vide : à côté du catalogue.</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Signature du contenu servi aux joueurs (phase 7).</summary>
|
||||
public sealed class ContentSigningSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Vrai : catalogue et versions ne sont publiés que signés (refus sinon). À activer une fois le trousseau
|
||||
/// publié et les launchers en mode « require » : évite de mettre en ligne un contenu que ces launchers refuseraient.
|
||||
/// </summary>
|
||||
public bool Require { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Page « Fichiers du serveur » du menu d'administration.</summary>
|
||||
public sealed class FilesSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Racine du site public (chemin absolu, ex. « /srv/exovival/www ») : seul dossier consultable depuis le menu.
|
||||
/// Vide : dossier parent commun des jeux configurés et du catalogue. La suppression n'est possible que là où
|
||||
/// systemd autorise l'écriture (ReadWritePaths : dossiers des jeux et du catalogue).
|
||||
/// </summary>
|
||||
public string Root { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demandes des joueurs (avis, évolutions, bugs…), envoyées SANS compte depuis le bouton « Aide » du launcher.
|
||||
/// Route anonyme : preuve de travail, limites par adresse IP (en mémoire uniquement) et plafonds globaux. Aucune
|
||||
/// adresse IP n'est enregistrée avec une demande ni dans l'audit de son envoi.
|
||||
/// </summary>
|
||||
public sealed class FeedbackSettings
|
||||
{
|
||||
/// <summary>Réception activée (faux par défaut : les routes répondent 503 « not_configured »).</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifiants des jeux acceptés (GameId de launcher.json). Vide : les jeux de <see cref="LauncherApiOptions.Games"/>.
|
||||
/// Une demande pour un autre jeu est refusée.
|
||||
/// </summary>
|
||||
public string[] Games { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Difficulté du défi anti-spam (bits à zéro) : 18 ≈ une fraction de seconde sur un PC. À relever en cas d'attaque.</summary>
|
||||
public int ProofDifficulty { get; set; } = 18;
|
||||
|
||||
/// <summary>Durée de validité d'un défi.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 600;
|
||||
|
||||
/// <summary>Demandes acceptées par adresse IP (IPv6 : par /64) sur 10 minutes, et sur 24 heures.</summary>
|
||||
public int PerIpPer10Minutes { get; set; } = 3;
|
||||
public int PerIpPerDay { get; set; } = 10;
|
||||
|
||||
/// <summary>Défis et envois par minute et par adresse IP (limite de débit ASP.NET Core, avant tout traitement).</summary>
|
||||
public int RequestsPerMinute { get; set; } = 10;
|
||||
|
||||
/// <summary>Plafonds : demandes reçues par jeu sur 24 heures, demandes conservées par jeu, taille totale (Mo).</summary>
|
||||
public int MaxPerGamePerDay { get; set; } = 300;
|
||||
public int MaxPerGame { get; set; } = 5000;
|
||||
public int MaxTotalMegabytes { get; set; } = 1024; // captures d'écran comprises (4 Mo chacune au plus)
|
||||
|
||||
/// <summary>Espace disque libre minimal (Mo) : en dessous, les nouvelles demandes sont refusées.</summary>
|
||||
public int MinFreeDiskMegabytes { get; set; } = 500;
|
||||
|
||||
/// <summary>Conservation : demande close supprimée N mois après sa clôture ; toute demande au plus M mois après réception.</summary>
|
||||
public int RetentionClosedMonths { get; set; } = 12;
|
||||
public int RetentionMaxMonths { get; set; } = 24;
|
||||
}
|
||||
|
||||
/// <summary>Journal d'audit (fichiers audit-AAAA-MM.jsonl).</summary>
|
||||
public sealed class AuditSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Mois conservés : un fichier mensuel est supprimé une fois tous ses jours plus anciens que cette durée
|
||||
/// (CNIL : 6 mois à 1 an pour les journaux de sécurité). Vérifié au démarrage puis chaque jour.
|
||||
/// </summary>
|
||||
public int RetentionMonths { get; set; } = 12;
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
|
||||
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
|
||||
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string ManifestFile { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class LimitSettings
|
||||
{
|
||||
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
|
||||
@@ -61,6 +196,13 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
|
||||
public int AuthPerMinute { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Morceaux envoyés pendant une publication, par minute et par adresse IP (à la place de
|
||||
/// <see cref="RequestsPerMinute"/> pour ces routes). 1500 morceaux de 4 Mo ≈ 100 Mo/s. Ces requêtes
|
||||
/// exigent une session et le droit de publier : le corps n'est lu qu'après ces contrôles.
|
||||
/// </summary>
|
||||
public int PublicationUploadsPerMinute { get; set; } = 1500;
|
||||
|
||||
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
|
||||
public int EnrollmentsPerHour { get; set; } = 10;
|
||||
|
||||
@@ -79,6 +221,52 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public IReadOnlyList<IPAddress> TrustedProxyAddresses { get; private set; } = Array.Empty<IPAddress>();
|
||||
|
||||
/// <summary>Fichier public du catalogue en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? CatalogPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du catalogue, calculée par <see cref="Validate"/>.</summary>
|
||||
public string CatalogPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Hôte de <see cref="CatalogPublicUrl"/> : seul hôte autorisé pour les images du catalogue (le
|
||||
/// launcher n'accepte que des images du même hôte que le catalogue).
|
||||
/// </summary>
|
||||
public string CatalogImageHost { get; private set; } = "";
|
||||
|
||||
/// <summary>Fichier public du trousseau en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? KeyringPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
|
||||
public string KeyringPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Fichier public des formulaires des demandes (feedback-forms.json, à côté du trousseau ; null : publication
|
||||
/// désactivée), calculé par <see cref="Validate"/>. Les launchers le lisent à la même adresse que le trousseau.
|
||||
/// </summary>
|
||||
public string? FeedbackFormsPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
|
||||
public string FeedbackFormsPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
|
||||
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
|
||||
/// </summary>
|
||||
public string? MaintenancePublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
|
||||
public string MaintenancePublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
|
||||
public string? FilesRootFullPath { get; private set; }
|
||||
|
||||
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
@@ -105,15 +293,249 @@ public sealed class LauncherApiOptions
|
||||
}
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
ValidateCatalog();
|
||||
ValidateKeyring();
|
||||
ValidateGames();
|
||||
ValidateFiles();
|
||||
ValidateFeedback();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
|
||||
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
|
||||
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
|
||||
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
|
||||
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1, "Limits");
|
||||
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1 &&
|
||||
Limits.PublicationUploadsPerMinute >= 1, "Limits");
|
||||
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
|
||||
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
|
||||
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
|
||||
}
|
||||
|
||||
private void ValidateFeedback()
|
||||
{
|
||||
var feedback = Feedback;
|
||||
Require(feedback.ProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
|
||||
$"Feedback:ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
|
||||
Require(feedback.ChallengeLifetimeSeconds is >= 60 and <= 3600, "Feedback:ChallengeLifetimeSeconds (60 à 3600)");
|
||||
Require(feedback.PerIpPer10Minutes is >= 1 and <= 1000 && feedback.PerIpPerDay is >= 1 and <= 10_000 &&
|
||||
feedback.RequestsPerMinute is >= 1 and <= 10_000, "Feedback : limites par adresse IP");
|
||||
Require(feedback.MaxPerGamePerDay is >= 1 and <= 100_000, "Feedback:MaxPerGamePerDay");
|
||||
Require(feedback.MaxPerGame is >= 1 and <= 50_000, "Feedback:MaxPerGame (1 à 50000)");
|
||||
Require(feedback.MaxTotalMegabytes is >= 1 and <= 10_000, "Feedback:MaxTotalMegabytes");
|
||||
Require(feedback.MinFreeDiskMegabytes is >= 0 and <= 1_000_000, "Feedback:MinFreeDiskMegabytes");
|
||||
Require(feedback.RetentionClosedMonths is >= 1 and <= 120, "Feedback:RetentionClosedMonths (1 à 120)");
|
||||
Require(feedback.RetentionMaxMonths is >= 1 and <= 120 && feedback.RetentionMaxMonths >= feedback.RetentionClosedMonths,
|
||||
"Feedback:RetentionMaxMonths (1 à 120, au moins RetentionClosedMonths)");
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var id in feedback.Games is { Length: > 0 } configured ? configured : Games.Keys.ToArray())
|
||||
{
|
||||
if (!Contracts.Catalog.CatalogRules.IsValidGameId(id))
|
||||
throw new InvalidOperationException($"{Section}:Feedback:Games : identifiant de jeu invalide « {id} ».");
|
||||
ids.Add(id);
|
||||
}
|
||||
FeedbackGameIds = ids;
|
||||
}
|
||||
|
||||
private void ValidateCatalog()
|
||||
{
|
||||
CatalogPublicFileFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Catalog.PublicFile))
|
||||
{
|
||||
var file = Catalog.PublicFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile doit être un chemin absolu vers un fichier .json.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
|
||||
CatalogPublicFileFullPath = full;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Catalog.PublicUrl))
|
||||
{
|
||||
CatalogPublicUrl = NormalizedOrigin + "/catalog/catalog.json";
|
||||
}
|
||||
else if (Uri.TryCreate(Catalog.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
|
||||
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
|
||||
{
|
||||
CatalogPublicUrl = url.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
|
||||
}
|
||||
|
||||
private void ValidateKeyring()
|
||||
{
|
||||
KeyringPublicFileFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Keyring.PublicFile))
|
||||
{
|
||||
var file = Keyring.PublicFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être un chemin absolu vers un fichier .json.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, "Keyring:PublicFile");
|
||||
KeyringPublicFileFullPath = full;
|
||||
}
|
||||
else if (CatalogPublicFileFullPath is { } catalog)
|
||||
{
|
||||
KeyringPublicFileFullPath = Path.Combine(Path.GetDirectoryName(catalog)!, "keyring.json");
|
||||
}
|
||||
if (KeyringPublicFileFullPath is { } keyring && CatalogPublicFileFullPath is { } catalogFile &&
|
||||
string.Equals(keyring, catalogFile, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être différent du fichier du catalogue.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Keyring.PublicUrl))
|
||||
{
|
||||
KeyringPublicUrl = new Uri(new Uri(CatalogPublicUrl), "keyring.json").AbsoluteUri;
|
||||
}
|
||||
else if (Uri.TryCreate(Keyring.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
|
||||
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
|
||||
{
|
||||
KeyringPublicUrl = url.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
|
||||
// Formulaires des demandes : toujours à côté du trousseau (les launchers en déduisent l'adresse).
|
||||
FeedbackFormsPublicFileFullPath = KeyringPublicFileFullPath is { } keyringFile
|
||||
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
|
||||
: null;
|
||||
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
|
||||
|
||||
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
|
||||
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
|
||||
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
|
||||
: null;
|
||||
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
|
||||
if (MaintenancePublicFileFullPath is { } maintenance &&
|
||||
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
|
||||
}
|
||||
|
||||
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
|
||||
public const string FeedbackFormsFileName = "feedback-forms.json";
|
||||
|
||||
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
|
||||
public const string MaintenanceFileName = "maintenance.json";
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
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;
|
||||
}
|
||||
|
||||
private void ValidateFiles()
|
||||
{
|
||||
FilesRootFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Files.Root))
|
||||
{
|
||||
var root = Files.Root.Trim();
|
||||
if (!Path.IsPathRooted(root))
|
||||
throw new InvalidOperationException($"{Section}:Files:Root doit être un chemin absolu (racine du site, ex. « /srv/exovival/www »).");
|
||||
var full = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
|
||||
if (!Directory.Exists(full))
|
||||
throw new InvalidOperationException($"{Section}:Files:Root : le dossier « {full} » n'existe pas.");
|
||||
if (IsFileSystemRoot(full))
|
||||
throw new InvalidOperationException($"{Section}:Files:Root ne peut pas être la racine du disque.");
|
||||
EnsureSeparatedFromData(full, "Files:Root");
|
||||
FilesRootFullPath = full;
|
||||
return;
|
||||
}
|
||||
|
||||
// Par défaut : dossier parent commun des jeux et du catalogue (ex. /srv/exovival/www). Rien de configuré,
|
||||
// racine du disque ou mélange avec les données privées : page désactivée (sans empêcher le démarrage).
|
||||
var folders = GameManifestFiles.Values.Select(Path.GetDirectoryName)
|
||||
.Concat(new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Where(f => f is not null).Select(Path.GetDirectoryName))
|
||||
.OfType<string>().Select(d => Path.GetFullPath(d).TrimEnd(Path.DirectorySeparatorChar)).Distinct().ToList();
|
||||
if (folders.Count == 0) return;
|
||||
var common = folders.Count == 1 ? folders[0] : CommonParent(folders);
|
||||
if (common is null || IsFileSystemRoot(common) || !Directory.Exists(common)) return;
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
var realCommon = RealPath(common);
|
||||
if (IsSameOrInside(realCommon, dataDirectory) || IsSameOrInside(dataDirectory, realCommon)) return;
|
||||
FilesRootFullPath = common;
|
||||
}
|
||||
|
||||
private static string? CommonParent(IReadOnlyList<string> folders)
|
||||
{
|
||||
string? common = folders[0];
|
||||
foreach (var folder in folders.Skip(1))
|
||||
while (common is not null && !IsSameOrInside(folder, common))
|
||||
common = Path.GetDirectoryName(common);
|
||||
return common;
|
||||
}
|
||||
|
||||
private static bool IsFileSystemRoot(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return string.Equals(Path.GetPathRoot(full)?.TrimEnd(Path.DirectorySeparatorChar), full.TrimEnd(Path.DirectorySeparatorChar),
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ||
|
||||
full == "/";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
|
||||
/// dans l'autre (liens symboliques résolus).
|
||||
/// </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)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Durées de conservation (RGPD, recommandations de la CNIL) appliquées automatiquement, au démarrage du service
|
||||
/// puis toutes les 24 heures :
|
||||
/// • journal d'audit : fichiers mensuels plus anciens que <c>LauncherApi:Audit:RetentionMonths</c> (12 mois) ;
|
||||
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
|
||||
/// <c>Feedback:RetentionMaxMonths</c> (24).
|
||||
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
|
||||
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
|
||||
/// </summary>
|
||||
public sealed class RetentionService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly AuditLog _audit;
|
||||
private readonly FeedbackStore _feedback;
|
||||
private readonly LauncherApiOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<RetentionService> _logger;
|
||||
|
||||
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
|
||||
ILogger<RetentionService> logger)
|
||||
{
|
||||
_audit = audit;
|
||||
_feedback = feedback;
|
||||
_options = options.Value;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
RunOnce();
|
||||
try
|
||||
{
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Une passe de purge (appelée aussi par les tests).</summary>
|
||||
public void RunOnce()
|
||||
{
|
||||
try
|
||||
{
|
||||
var months = _audit.Purge(_options.Audit.RetentionMonths);
|
||||
if (months.Count > 0)
|
||||
_audit.Write("audit.purge", AuditResults.Ok,
|
||||
details: $"{months.Count} mois supprimé(s) ({string.Join(", ", months)}) ; conservation {_options.Audit.RetentionMonths} mois");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Purge du journal d'audit impossible");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var removed = _feedback.Purge(_time.GetUtcNow());
|
||||
if (removed > 0)
|
||||
_audit.Write("feedback.purge", AuditResults.Ok,
|
||||
details: $"{removed} demande(s) supprimée(s) ; conservation {_options.Feedback.RetentionClosedMonths} mois après clôture, " +
|
||||
$"{_options.Feedback.RetentionMaxMonths} mois au plus");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.RateLimiting;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
@@ -5,6 +6,7 @@ using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
@@ -64,14 +66,26 @@ public static class ServerApp
|
||||
builder.Services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = (context, _) => new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
|
||||
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
|
||||
limiter.OnRejected = (context, _) =>
|
||||
{
|
||||
// Délai avant la prochaine fenêtre : le launcher reprend juste à temps au lieu d'attendre à l'aveugle.
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
return new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
|
||||
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
|
||||
};
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
IsPublicationUpload(context)
|
||||
? RateLimitPartition.GetNoLimiter("publication") // limite propre (politique PublicationUpload)
|
||||
: RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.PublicationUpload, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.PublicationUploadsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
});
|
||||
|
||||
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
|
||||
@@ -86,6 +100,8 @@ public static class ServerApp
|
||||
builder.Services.AddSingleton<AdminStore>();
|
||||
builder.Services.AddAdminAuthentication();
|
||||
foreach (var module in ApiModules.All) module.ConfigureServices(builder.Services, options);
|
||||
// Conservation (RGPD) : purge du journal d'audit et des demandes des joueurs, au démarrage puis chaque jour.
|
||||
builder.Services.AddHostedService<RetentionService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -112,6 +128,10 @@ public static class ServerApp
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Route d'envoi de morceaux d'une publication (politique <see cref="RateLimitPolicies.PublicationUpload"/>).</summary>
|
||||
private static bool IsPublicationUpload(HttpContext context) =>
|
||||
context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == RateLimitPolicies.PublicationUpload;
|
||||
|
||||
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
|
||||
{
|
||||
PermitLimit = permits,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Admins;
|
||||
|
||||
/// <summary>
|
||||
/// Règles communes aux modifications des administrateurs et des groupes, appliquées DANS la modification
|
||||
/// (sous le verrou d'admins.json, sur les données relues) : l'auteur et ses droits sont revérifiés au moment
|
||||
/// d'écrire, et personne ne peut se donner (ni donner à un autre) plus de droits qu'il n'en a.
|
||||
/// </summary>
|
||||
internal static class AdminRules
|
||||
{
|
||||
/// <summary>
|
||||
/// L'auteur est toujours actif, sa clé aussi, et il a encore <paramref name="requiredPermission"/> (null : simple
|
||||
/// administrateur connecté). Sinon : 403, rien n'est écrit. Renvoie ses permissions actuelles.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, string? requiredPermission)
|
||||
{
|
||||
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
|
||||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Vos droits ont changé entre-temps : action refusée.");
|
||||
var permissions = db.PermissionsOf(current.Admin.Role);
|
||||
if (requiredPermission is not null && !permissions.Contains(requiredPermission, StringComparer.Ordinal))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/// <summary>L'auteur est propriétaire (groupe système), d'après les données relues.</summary>
|
||||
public static bool IsOwner(AdminDatabase db, AuthenticatedAdmin actor) =>
|
||||
db.FindAdmin(actor.Admin.Id) is { } admin && AdminGroups.IsSystem(admin.Role);
|
||||
|
||||
/// <summary>
|
||||
/// Placer quelqu'un dans ce groupe (invitation, changement de groupe, approbation d'un compte en attente) :
|
||||
/// seul un propriétaire donne le groupe « Propriétaire », et personne ne donne des permissions qu'il n'a pas.
|
||||
/// </summary>
|
||||
public static void EnsureCanAssign(AdminDatabase db, AuthenticatedAdmin actor, string groupId)
|
||||
{
|
||||
if (AdminGroups.IsSystem(groupId) && !IsOwner(db, actor))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Seul un propriétaire peut donner le groupe « Propriétaire ».");
|
||||
EnsureWithinOwnRights(db, actor, db.PermissionsOf(groupId),
|
||||
missing => $"Ce groupe a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas y placer un administrateur.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gérer un autre administrateur (changer son groupe, le révoquer, révoquer un de ses appareils) : seul un
|
||||
/// propriétaire gère un propriétaire, et personne ne gère un administrateur qui a des permissions qu'il n'a pas.
|
||||
/// </summary>
|
||||
public static void EnsureCanManage(AdminDatabase db, AuthenticatedAdmin actor, AdminRecord target)
|
||||
{
|
||||
if (AdminGroups.IsSystem(target.Role) && !IsOwner(db, actor))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Seul un propriétaire peut gérer un autre propriétaire.");
|
||||
EnsureWithinOwnRights(db, actor, db.PermissionsOf(target.Role),
|
||||
missing => $"{target.DisplayName} a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas gérer son compte.");
|
||||
}
|
||||
|
||||
/// <summary>Toutes ces permissions sont aussi celles de l'auteur (sinon 403, en les nommant).</summary>
|
||||
public static void EnsureWithinOwnRights(AdminDatabase db, AuthenticatedAdmin actor, IEnumerable<string> permissions,
|
||||
Func<string, string> message)
|
||||
{
|
||||
var own = db.FindAdmin(actor.Admin.Id) is { } admin ? db.PermissionsOf(admin.Role) : Array.Empty<string>();
|
||||
var missing = permissions.Where(p => !own.Contains(p, StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, message(string.Join(", ", missing.Take(5))));
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,11 @@ namespace GameLauncher.Server.Modules.Admins;
|
||||
/// pourrait sinon révoquer ses clés puis y ajouter la sienne). Compte sans aucun appareil actif :
|
||||
/// restauration sur le VPS (outil en ligne de commande, « add-key »).
|
||||
///
|
||||
/// Garde-fous : on ne change pas son propre rôle, on ne se révoque pas soi-même, on ne révoque pas
|
||||
/// Garde-fous : on ne change pas son propre groupe, on ne se révoque pas soi-même, on ne révoque pas
|
||||
/// la clé de sa propre session, et il reste toujours au moins un propriétaire capable de se connecter.
|
||||
/// Groupes (voir <see cref="AdminRules"/>) : on ne place personne dans un groupe qui a des permissions que
|
||||
/// l'on n'a pas soi-même, on ne gère pas un administrateur plus privilégié que soi, et seul un propriétaire
|
||||
/// donne, retire ou révoque le groupe « Propriétaire ».
|
||||
/// </summary>
|
||||
public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
@@ -33,10 +36,12 @@ public sealed class AdminsModule : IApiModule
|
||||
|
||||
manage.MapGet(ApiRoutes.Admins, List);
|
||||
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
|
||||
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
|
||||
|
||||
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
|
||||
// Ouvertes à tout administrateur connecté : chacun peut voir son compte, ajouter et approuver SES
|
||||
// appareils, et supprimer les invitations « nouvel appareil » qu'il a créées pour lui-même ;
|
||||
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
|
||||
api.MapGet(ApiRoutes.MyAccount, MyAccount).RequireAdmin();
|
||||
api.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireAdmin().RequireSignedRequest();
|
||||
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
|
||||
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
|
||||
@@ -46,14 +51,36 @@ public sealed class AdminsModule : IApiModule
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private static IResult List(AdminStore store) =>
|
||||
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
|
||||
private static IResult List(AdminStore store)
|
||||
{
|
||||
var db = store.Read();
|
||||
return Results.Json(db.Admins.OrderBy(a => a.CreatedAt).Select(a => ToSummary(a, db)).ToList(), ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
|
||||
/// la liste complète : il la lit sur le nouvel appareil) et ses invitations « nouvel appareil » en cours.
|
||||
/// </summary>
|
||||
private static IResult MyAccount(HttpContext context, AdminStore store, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var db = store.Read();
|
||||
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
|
||||
return Results.Json(new MyAccount
|
||||
{
|
||||
Admin = ToSummary(admin, db),
|
||||
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(i => ToSummary(i, db)).ToList()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult ListInvitations(AdminStore store, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
return Results.Json(store.Read().Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(ToSummary).ToList(), ApiJson.Options);
|
||||
var db = store.Read();
|
||||
return Results.Json(db.Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(i => ToSummary(i, db)).ToList(), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Invitations
|
||||
@@ -88,7 +115,8 @@ public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
displayName = ApiText.CleanLine(request.DisplayName, ApiText.MaxDisplayNameLength)
|
||||
?? throw ApiRejection.BadRequest($"Nom obligatoire ({ApiText.MaxDisplayNameLength} caractères au plus).");
|
||||
role = AdminRoles.IsValid(request.Role) ? request.Role! : throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
role = db.FindGroup(request.Role)?.Id ?? throw ApiRejection.BadRequest("Groupe inconnu : rechargez la page.");
|
||||
AdminRules.EnsureCanAssign(db, actor, role);
|
||||
}
|
||||
|
||||
var record = new InvitationRecord
|
||||
@@ -107,20 +135,24 @@ public sealed class AdminsModule : IApiModule
|
||||
return record;
|
||||
});
|
||||
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
|
||||
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
|
||||
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation, store.Read()), Code = code }, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
store.Update(db =>
|
||||
var deleted = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
// Sans admins.manage : seulement une invitation « nouvel appareil » créée par soi-même, pour soi-même.
|
||||
var invitation = db.Invitations.FirstOrDefault(i => i.Id == invitationId);
|
||||
var own = invitation is not null && invitation.ForAdminId == actor.Admin.Id && invitation.CreatedById == actor.Admin.Id;
|
||||
EnsureActor(db, actor, own ? AdminPermission.None : AdminPermission.Manage);
|
||||
if (invitation is null) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
db.Invitations.Remove(invitation);
|
||||
return invitation;
|
||||
});
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
@@ -148,7 +180,13 @@ public sealed class AdminsModule : IApiModule
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
$"Ce nouvel appareil doit être approuvé par {admin.DisplayName} lui-même, depuis un de ses appareils déjà actifs.");
|
||||
}
|
||||
else EnsureActor(db, actor);
|
||||
else
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
// Premier appareil d'un compte en attente : son groupe a été choisi à l'invitation, peut-être par
|
||||
// quelqu'un d'autre ; l'approbateur ne fait entrer personne au-dessus de ses propres droits.
|
||||
if (admin.Status == AdminStatus.Pending) AdminRules.EnsureCanAssign(db, actor, admin.Role);
|
||||
}
|
||||
if (key.Status != AdminStatus.Pending) throw ApiRejection.Conflict("Cette clé n'est pas en attente d'approbation.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
if (!AdminKeys.FingerprintMatches(key.PublicKeyBytes(), request.Fingerprint))
|
||||
@@ -170,7 +208,7 @@ public sealed class AdminsModule : IApiModule
|
||||
return admin.DisplayName;
|
||||
});
|
||||
Audit(audit, context, "admins.key_approve", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
private static IResult RevokeKey(Guid adminId, string keyId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
@@ -184,6 +222,7 @@ public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
if (admin.Id != actor.Admin.Id) AdminRules.EnsureCanManage(db, actor, admin);
|
||||
if (key.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cette clé est déjà révoquée.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = time.GetUtcNow();
|
||||
@@ -193,7 +232,7 @@ public sealed class AdminsModule : IApiModule
|
||||
});
|
||||
sessions.RemoveWhere(s => s.KeyId == keyId);
|
||||
Audit(audit, context, "admins.key_revoke", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
@@ -202,22 +241,24 @@ public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre rôle.");
|
||||
if (!AdminRoles.IsValid(request.Role)) throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre groupe.");
|
||||
|
||||
var (name, previous) = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var group = db.FindGroup(request.Role) ?? throw ApiRejection.BadRequest("Groupe inconnu : rechargez la page.");
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
AdminRules.EnsureCanManage(db, actor, admin);
|
||||
AdminRules.EnsureCanAssign(db, actor, group.Id);
|
||||
var old = admin.Role;
|
||||
admin.Role = request.Role;
|
||||
admin.Role = group.Id;
|
||||
EnsureUsableOwner(db);
|
||||
return (admin.DisplayName, old);
|
||||
});
|
||||
// Les droits sont relus à chaque requête : le nouveau rôle s'applique immédiatement.
|
||||
// Les droits sont relus à chaque requête : le nouveau groupe s'applique immédiatement.
|
||||
Audit(audit, context, "admins.role", name, $"{previous} → {request.Role}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
private static IResult RevokeAdmin(Guid adminId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
@@ -233,6 +274,7 @@ public sealed class AdminsModule : IApiModule
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est déjà révoqué.");
|
||||
AdminRules.EnsureCanManage(db, actor, admin);
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor.Admin.DisplayName;
|
||||
@@ -248,7 +290,13 @@ public sealed class AdminsModule : IApiModule
|
||||
});
|
||||
sessions.RemoveWhere(s => s.AdminId == adminId);
|
||||
Audit(audit, context, "admins.revoke", name);
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
return Summary(store, adminId);
|
||||
}
|
||||
|
||||
private static IResult Summary(AdminStore store, Guid adminId)
|
||||
{
|
||||
var db = store.Read();
|
||||
return Results.Json(ToSummary(db.FindAdmin(adminId)!, db), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
@@ -266,14 +314,8 @@ public sealed class AdminsModule : IApiModule
|
||||
/// l'authentification de sa requête et l'écriture (autre propriétaire, outil en ligne de commande),
|
||||
/// rien n'est écrit.
|
||||
/// </summary>
|
||||
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage)
|
||||
{
|
||||
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
|
||||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active ||
|
||||
(required == AdminPermission.Manage && !AdminRoles.Has(current.Admin.Role, AdminPermissions.AdminsManage)))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
required == AdminPermission.Manage ? "Vos droits ne permettent pas cette action." : "Vos droits ont changé entre-temps : action refusée.");
|
||||
}
|
||||
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage) =>
|
||||
AdminRules.EnsureActor(db, actor, required == AdminPermission.Manage ? AdminPermissions.AdminsManage : null);
|
||||
|
||||
private enum AdminPermission { None, Manage }
|
||||
|
||||
@@ -285,6 +327,9 @@ public sealed class AdminsModule : IApiModule
|
||||
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
|
||||
}
|
||||
|
||||
private static string InvitationDetails(InvitationRecord invitation) =>
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}";
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
|
||||
string result = AuditResults.Ok)
|
||||
{
|
||||
@@ -292,11 +337,13 @@ public sealed class AdminsModule : IApiModule
|
||||
audit.Write(action, result, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
|
||||
internal static AdminSummary ToSummary(AdminRecord admin) => new()
|
||||
internal static AdminSummary ToSummary(AdminRecord admin, AdminDatabase db) => new()
|
||||
{
|
||||
Id = admin.Id,
|
||||
DisplayName = admin.DisplayName,
|
||||
Role = admin.Role,
|
||||
RoleName = db.GroupName(admin.Role),
|
||||
Permissions = admin.Status == AdminStatus.Revoked ? Array.Empty<string>() : db.PermissionsOf(admin.Role),
|
||||
Status = admin.Status,
|
||||
CreatedAt = admin.CreatedAt,
|
||||
Keys = admin.Keys.Select(k => new AdminKeySummary
|
||||
@@ -306,19 +353,22 @@ public sealed class AdminsModule : IApiModule
|
||||
Status = k.Status,
|
||||
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
|
||||
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
|
||||
PublicKey = k.Status == AdminStatus.Active ? Convert.ToBase64String(k.PublicKeyBytes()) : null,
|
||||
AddedAt = k.AddedAt,
|
||||
LastUsedAt = k.LastUsedAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static InvitationSummary ToSummary(InvitationRecord invitation) => new()
|
||||
private static InvitationSummary ToSummary(InvitationRecord invitation, AdminDatabase db) => new()
|
||||
{
|
||||
Id = invitation.Id,
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
RoleName = db.GroupName(invitation.Role),
|
||||
ForAdminId = invitation.ForAdminId,
|
||||
CreatedAt = invitation.CreatedAt,
|
||||
CreatedBy = invitation.CreatedBy,
|
||||
CreatedById = invitation.CreatedById,
|
||||
ExpiresAt = invitation.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Admins;
|
||||
|
||||
/// <summary>
|
||||
/// Groupes d'administrateurs et permissions (permission admins.manage). Toutes les modifications sont des
|
||||
/// requêtes signées, revérifiées sous le verrou d'admins.json, et inscrites au journal d'audit.
|
||||
///
|
||||
/// Garde-fous :
|
||||
/// • le groupe système « Propriétaire » a toujours toutes les permissions : ni modifiable ni supprimable
|
||||
/// (il reste donc toujours un groupe capable de tout réparer) ;
|
||||
/// • on ne modifie pas les permissions de son propre groupe (comme on ne change pas son propre groupe) ;
|
||||
/// • on n'accorde à un groupe que des permissions que l'on a soi-même ;
|
||||
/// • un groupe qui a des membres (actifs ou en attente) ou des invitations en cours ne se supprime pas ;
|
||||
/// • modification préparée sur une révision dépassée (autre administrateur) : refusée (409) ;
|
||||
/// • les permissions INTÉGRÉES (vérifiées par le code) ne se suppriment pas ; une permission personnalisée
|
||||
/// supprimée est retirée de tous les groupes.
|
||||
/// Les droits sont relus à chaque requête : une modification s'applique aussitôt aux sessions ouvertes.
|
||||
/// </summary>
|
||||
public sealed class GroupsModule : IApiModule
|
||||
{
|
||||
public string Name => "Groups";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var manage = api.MapGroup("").RequirePermission(AdminPermissions.AdminsManage);
|
||||
manage.MapGet(ApiRoutes.Groups, List);
|
||||
manage.MapPost(ApiRoutes.Groups, Create).RequireSignedRequest();
|
||||
manage.MapPut("v1/groups/{groupId}", Update).RequireSignedRequest();
|
||||
manage.MapDelete("v1/groups/{groupId}", Delete).RequireSignedRequest();
|
||||
manage.MapPost(ApiRoutes.Permissions, CreatePermission).RequireSignedRequest();
|
||||
manage.MapDelete("v1/permissions/{name}", DeletePermission).RequireSignedRequest();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private static IResult List(AdminStore store, TimeProvider time) => Results.Json(State(store.Read(), time.GetUtcNow()), ApiJson.Options);
|
||||
|
||||
internal static GroupsState State(AdminDatabase db, DateTimeOffset now)
|
||||
{
|
||||
var groups = db.Groups
|
||||
.OrderBy(g => g.Id switch { AdminRoles.Owner => 0, AdminRoles.Admin => 1, AdminRoles.Editor => 2, _ => 3 })
|
||||
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.Select(g => new GroupSummary
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
Description = g.Description,
|
||||
Permissions = db.PermissionsOf(g.Id),
|
||||
System = AdminGroups.IsSystem(g.Id),
|
||||
Revision = g.Revision,
|
||||
MemberCount = Members(db, g.Id),
|
||||
InvitationCount = Invitations(db, g.Id, now),
|
||||
UpdatedAt = g.UpdatedAt ?? g.CreatedAt,
|
||||
UpdatedBy = g.UpdatedBy ?? g.CreatedBy
|
||||
}).ToList();
|
||||
var permissions = AdminPermissions.All.Select(p => new PermissionSummary
|
||||
{
|
||||
Name = p,
|
||||
Label = AdminPermissions.BuiltInLabel(p) ?? p,
|
||||
Description = AdminPermissions.BuiltInDescription(p),
|
||||
BuiltIn = true,
|
||||
GroupCount = groups.Count(g => g.Permissions.Contains(p, StringComparer.Ordinal))
|
||||
})
|
||||
.Concat(db.CustomPermissions.OrderBy(p => p.Name, StringComparer.Ordinal).Select(p => new PermissionSummary
|
||||
{
|
||||
Name = p.Name,
|
||||
Label = p.Label,
|
||||
Description = p.Description,
|
||||
BuiltIn = false,
|
||||
GroupCount = groups.Count(g => g.Permissions.Contains(p.Name, StringComparer.Ordinal)),
|
||||
CreatedAt = p.CreatedAt,
|
||||
CreatedBy = p.CreatedBy
|
||||
})).ToList();
|
||||
return new GroupsState { Groups = groups, Permissions = permissions };
|
||||
}
|
||||
|
||||
private static int Members(AdminDatabase db, string groupId) =>
|
||||
db.Admins.Count(a => a.Status != AdminStatus.Revoked && string.Equals(a.Role, groupId, StringComparison.Ordinal));
|
||||
|
||||
private static int Invitations(AdminDatabase db, string groupId, DateTimeOffset now) =>
|
||||
db.Invitations.Count(i => i.ExpiresAt > now && i.ForAdminId is null && string.Equals(i.Role, groupId, StringComparison.Ordinal));
|
||||
|
||||
// ------------------------------------------------------------------ Groupes
|
||||
|
||||
private static IResult Create(GroupRequest request, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var (name, description) = CheckText(request);
|
||||
var created = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
if (db.Groups.Count >= AdminGroups.MaxGroups)
|
||||
throw ApiRejection.Conflict($"{AdminGroups.MaxGroups} groupes au plus : supprimez-en un avant d'en créer un autre.");
|
||||
EnsureUniqueName(db, name, exceptId: null);
|
||||
var permissions = CheckPermissions(db, request.Permissions);
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, permissions,
|
||||
missing => $"Vous ne pouvez pas accorder des permissions que vous n'avez pas ({missing}).");
|
||||
|
||||
var baseId = AdminGroups.Slug(name);
|
||||
var id = baseId;
|
||||
// Identifiant jamais réutilisé tant qu'un compte (même révoqué) ou une invitation le désigne encore.
|
||||
for (var n = 2; db.FindGroup(id) is not null || db.Admins.Any(a => a.Role == id) || db.Invitations.Any(i => i.Role == id); n++)
|
||||
id = $"{baseId}-{n}";
|
||||
var group = new GroupRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Permissions = permissions,
|
||||
Revision = 1,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor.Admin.DisplayName
|
||||
};
|
||||
db.Groups.Add(group);
|
||||
return group;
|
||||
});
|
||||
Audit(audit, context, "admins.group_create", $"{created.Name} ({created.Id})", Describe(created.Permissions));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Update(string groupId, GroupRequest request, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
if (!AdminGroups.IsValidGroupId(groupId)) throw ApiRejection.NotFound("Groupe introuvable.");
|
||||
if (AdminGroups.IsSystem(groupId))
|
||||
throw ApiRejection.Conflict("Le groupe « Propriétaire » a toujours toutes les permissions : il ne peut pas être modifié.");
|
||||
var (name, description) = CheckText(request);
|
||||
|
||||
var (group, changes) = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
var group = db.FindGroup(groupId) ?? throw ApiRejection.NotFound("Groupe introuvable : il a peut-être été supprimé.");
|
||||
if (group.Revision != request.Revision)
|
||||
throw ApiRejection.Conflict("Ce groupe a été modifié entre-temps par un autre administrateur : rechargez la page.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
EnsureUniqueName(db, name, exceptId: group.Id);
|
||||
var permissions = CheckPermissions(db, request.Permissions);
|
||||
var added = permissions.Except(group.Permissions, StringComparer.Ordinal).ToList();
|
||||
var removed = group.Permissions.Except(permissions, StringComparer.Ordinal).ToList();
|
||||
if ((added.Count > 0 || removed.Count > 0) && db.FindAdmin(actor.Admin.Id)?.Role == group.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction,
|
||||
"Vous ne pouvez pas modifier les permissions de votre propre groupe : demandez-le à un autre administrateur.");
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, added,
|
||||
missing => $"Vous ne pouvez pas accorder des permissions que vous n'avez pas ({missing}).");
|
||||
|
||||
var changes = new List<string>();
|
||||
if (group.Name != name) changes.Add($"nom : {group.Name} → {name}");
|
||||
if (group.Description != description) changes.Add("description modifiée");
|
||||
if (added.Count > 0) changes.Add("+ " + string.Join(", ", added));
|
||||
if (removed.Count > 0) changes.Add("− " + string.Join(", ", removed));
|
||||
group.Name = name;
|
||||
group.Description = description;
|
||||
group.Permissions = permissions;
|
||||
group.Revision++;
|
||||
group.UpdatedAt = now;
|
||||
group.UpdatedBy = actor.Admin.DisplayName;
|
||||
return (group, changes);
|
||||
});
|
||||
Audit(audit, context, "admins.group_update", $"{group.Name} ({group.Id})",
|
||||
changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Delete(string groupId, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
if (!AdminGroups.IsValidGroupId(groupId)) throw ApiRejection.NotFound("Groupe introuvable.");
|
||||
if (AdminGroups.IsSystem(groupId)) throw ApiRejection.Conflict("Le groupe « Propriétaire » ne peut pas être supprimé.");
|
||||
|
||||
var deleted = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
var group = db.FindGroup(groupId) ?? throw ApiRejection.NotFound("Groupe introuvable : il a peut-être déjà été supprimé.");
|
||||
if (db.FindAdmin(actor.Admin.Id)?.Role == group.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas supprimer votre propre groupe.");
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, group.Permissions,
|
||||
missing => $"Ce groupe a des permissions que vous n'avez pas ({missing}) : vous ne pouvez pas le supprimer.");
|
||||
var members = Members(db, group.Id);
|
||||
var invitations = Invitations(db, group.Id, now);
|
||||
if (members > 0 || invitations > 0)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Ce groupe a encore {members} membre(s) et {invitations} invitation(s) en cours : placez ces administrateurs dans " +
|
||||
"un autre groupe (page « Administrateurs ») et supprimez ces invitations avant de le supprimer.");
|
||||
// Invitations expirées vers ce groupe : sans effet, retirées pour ne pas désigner un groupe disparu.
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now && string.Equals(i.Role, group.Id, StringComparison.Ordinal));
|
||||
db.Groups.Remove(group);
|
||||
return group;
|
||||
});
|
||||
Audit(audit, context, "admins.group_delete", $"{deleted.Name} ({deleted.Id})", Describe(deleted.Permissions));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Permissions personnalisées
|
||||
|
||||
private static IResult CreatePermission(CreatePermissionRequest request, HttpContext context, AdminStore store, AuditLog audit,
|
||||
TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var name = request.Name?.Trim() ?? "";
|
||||
if (!AdminGroups.IsValidPermissionName(name))
|
||||
throw ApiRejection.BadRequest("Nom de permission invalide : format « module.action » en minuscules (ex. « news.edit »).");
|
||||
if (AdminPermissions.IsBuiltIn(name)) throw ApiRejection.Conflict("Cette permission existe déjà (permission intégrée).");
|
||||
var label = ApiText.CleanLine(request.Label, AdminGroups.MaxPermissionLabelLength)
|
||||
?? throw ApiRejection.BadRequest($"Libellé obligatoire ({AdminGroups.MaxPermissionLabelLength} caractères au plus, une ligne).");
|
||||
var description = AdminGroups.CleanDescription(request.Description, AdminGroups.MaxDescriptionLength, out var validDescription);
|
||||
if (!validDescription) throw ApiRejection.BadRequest($"Description invalide ({AdminGroups.MaxDescriptionLength} caractères au plus, une ligne).");
|
||||
|
||||
store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
if (db.CustomPermissions.Any(p => string.Equals(p.Name, name, StringComparison.Ordinal)))
|
||||
throw ApiRejection.Conflict("Cette permission existe déjà.");
|
||||
if (db.CustomPermissions.Count >= AdminGroups.MaxCustomPermissions)
|
||||
throw ApiRejection.Conflict($"{AdminGroups.MaxCustomPermissions} permissions personnalisées au plus.");
|
||||
db.CustomPermissions.Add(new PermissionRecord
|
||||
{
|
||||
Name = name,
|
||||
Label = label,
|
||||
Description = description,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor.Admin.DisplayName
|
||||
});
|
||||
});
|
||||
// Le groupe « Propriétaire » la reçoit automatiquement (il a toutes les permissions).
|
||||
Audit(audit, context, "admins.permission_create", name, label);
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeletePermission(string name, HttpContext context, AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
if (AdminPermissions.IsBuiltIn(name))
|
||||
throw ApiRejection.Conflict("Permission intégrée : elle est vérifiée par le serveur et le launcher, elle ne peut pas être supprimée.");
|
||||
if (!AdminGroups.IsValidPermissionName(name)) throw ApiRejection.NotFound("Permission introuvable.");
|
||||
|
||||
var groups = store.Update(db =>
|
||||
{
|
||||
AdminRules.EnsureActor(db, actor, AdminPermissions.AdminsManage);
|
||||
var permission = db.CustomPermissions.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal))
|
||||
?? throw ApiRejection.NotFound("Permission introuvable : elle a peut-être déjà été supprimée.");
|
||||
AdminRules.EnsureWithinOwnRights(db, actor, new[] { name },
|
||||
missing => $"Vous ne pouvez pas supprimer une permission que vous n'avez pas ({missing}).");
|
||||
var affected = new List<string>();
|
||||
foreach (var group in db.Groups.Where(g => g.Permissions.Contains(name, StringComparer.Ordinal)))
|
||||
{
|
||||
group.Permissions.RemoveAll(p => string.Equals(p, name, StringComparison.Ordinal));
|
||||
group.Revision++;
|
||||
group.UpdatedAt = now;
|
||||
group.UpdatedBy = actor.Admin.DisplayName;
|
||||
affected.Add(group.Name);
|
||||
}
|
||||
db.CustomPermissions.Remove(permission);
|
||||
return affected;
|
||||
});
|
||||
Audit(audit, context, "admins.permission_delete", name,
|
||||
groups.Count == 0 ? "attribuée à aucun groupe" : "retirée de : " + string.Join(", ", groups.Take(20)));
|
||||
return Results.Json(State(store.Read(), now), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static (string Name, string? Description) CheckText(GroupRequest request)
|
||||
{
|
||||
var name = AdminGroups.CleanName(request.Name)
|
||||
?? throw ApiRejection.BadRequest($"Nom du groupe obligatoire ({AdminGroups.MaxNameLength} caractères au plus, une ligne).");
|
||||
var description = AdminGroups.CleanDescription(request.Description, AdminGroups.MaxDescriptionLength, out var valid);
|
||||
if (!valid) throw ApiRejection.BadRequest($"Description invalide ({AdminGroups.MaxDescriptionLength} caractères au plus, une ligne).");
|
||||
return (name, description);
|
||||
}
|
||||
|
||||
private static void EnsureUniqueName(AdminDatabase db, string name, string? exceptId)
|
||||
{
|
||||
if (db.Groups.Any(g => g.Id != exceptId && string.Equals(g.Name, name, StringComparison.CurrentCultureIgnoreCase)))
|
||||
throw ApiRejection.Conflict($"Un groupe s'appelle déjà « {name} ».");
|
||||
}
|
||||
|
||||
/// <summary>Permissions demandées : connues du serveur, sans doublon, dans l'ordre des permissions connues.</summary>
|
||||
private static List<string> CheckPermissions(AdminDatabase db, IReadOnlyList<string>? requested)
|
||||
{
|
||||
requested ??= Array.Empty<string>();
|
||||
if (requested.Count > AdminPermissions.All.Count + AdminGroups.MaxCustomPermissions)
|
||||
throw ApiRejection.BadRequest("Trop de permissions.");
|
||||
var unknown = requested.Where(p => p is null || !db.IsKnownPermission(p)).Select(p => p ?? "(vide)").Distinct().Take(5).ToList();
|
||||
if (unknown.Count > 0) throw ApiRejection.BadRequest($"Permission inconnue : {string.Join(", ", unknown)}. Rechargez la page.");
|
||||
var set = requested.ToHashSet(StringComparer.Ordinal);
|
||||
return db.KnownPermissions().Where(set.Contains).ToList();
|
||||
}
|
||||
|
||||
private static string Describe(IReadOnlyCollection<string> permissions) =>
|
||||
permissions.Count == 0 ? "aucune permission" : "permissions : " + string.Join(", ", permissions);
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
@@ -6,18 +9,133 @@ using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Audit;
|
||||
|
||||
/// <summary>Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord.</summary>
|
||||
/// <summary>
|
||||
/// Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord. Chaque consultation est
|
||||
/// elle-même inscrite au journal (« audit.view »), ainsi que chaque export CSV annoncé par le launcher (« audit.export »).
|
||||
/// </summary>
|
||||
public sealed class AuditModule : IApiModule
|
||||
{
|
||||
public string Name => "Audit";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<AuditLog>();
|
||||
services.AddSingleton<AuditConsultations>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api) =>
|
||||
api.MapGet(ApiRoutes.Audit, (int? limit, string? cursor, AuditLog audit) =>
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Audit, Read).RequirePermission(AdminPermissions.AuditRead);
|
||||
api.MapPost(ApiRoutes.AuditExport, RecordExport).RequirePermission(AdminPermissions.AuditRead).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations,
|
||||
AdminStore store)
|
||||
{
|
||||
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
DateTimeOffset? until = null;
|
||||
if (!string.IsNullOrEmpty(before))
|
||||
{
|
||||
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
return Results.Json(audit.Read(limit ?? 50, cursor), ApiJson.Options);
|
||||
}).RequirePermission(AdminPermissions.AuditRead);
|
||||
// Format ISO 8601 uniquement (« 2026-11-07T00:00:00Z »), borné à des dates plausibles.
|
||||
if (before.Length > 40 ||
|
||||
!DateTimeOffset.TryParse(before, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) ||
|
||||
parsed.Year is < 2000 or > 9998)
|
||||
return ApiResults.BadRequest("Date « before » invalide (format ISO 8601 attendu).");
|
||||
until = parsed;
|
||||
}
|
||||
|
||||
var page = WithDeviceNames(audit.Read(limit ?? 50, cursor, until), store);
|
||||
|
||||
// Consultation inscrite APRÈS la lecture (la page renvoyée montre le journal tel qu'il était). Première page
|
||||
// (ouverture, « Actualiser », période changée) : toujours ; pages suivantes (« Charger plus ») : une fois par
|
||||
// clé tant que la consultation date de moins de 10 minutes (pas une ligne par page de 200 entrées).
|
||||
var actor = context.Admin();
|
||||
var continuation = !string.IsNullOrEmpty(cursor);
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, continuation))
|
||||
{
|
||||
var details = new List<string> { $"{page.Entries.Count} entrée(s) lue(s)" };
|
||||
if (continuation) details.Add("entrées plus anciennes");
|
||||
if (until is { } date) details.Add($"antérieures au {date.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC");
|
||||
audit.Write("audit.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
details: string.Join(" ; ", details));
|
||||
}
|
||||
return Results.Json(page, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom de l'appareil de chaque clé (défini à l'enregistrement de la clé, avec l'invitation), ajouté à la lecture :
|
||||
/// les anciennes entrées en profitent aussi, et le journal sur le disque reste inchangé (ajout seul).
|
||||
/// </summary>
|
||||
internal static AuditPage WithDeviceNames(AuditPage page, AdminStore store)
|
||||
{
|
||||
if (page.Entries.All(e => e.KeyId is null && e.KeyLabel is null)) return page;
|
||||
var database = store.Read();
|
||||
var labels = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var entries = page.Entries.Select(entry =>
|
||||
{
|
||||
if (entry.KeyId is not { } keyId) return entry with { KeyLabel = null };
|
||||
if (!labels.TryGetValue(keyId, out var label))
|
||||
labels[keyId] = label = database.FindKey(keyId) is { } found ? Auth.AuthModule.DeviceName(found.Key.Label) : null;
|
||||
return entry with { KeyLabel = label }; // seul le serveur renseigne ce champ (jamais lu depuis le fichier)
|
||||
}).ToList();
|
||||
return page with { Entries = entries };
|
||||
}
|
||||
|
||||
private static IResult RecordExport(HttpContext context, AuditLog audit)
|
||||
{
|
||||
AuditExportRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<AuditExportRequest>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Annonce d'export illisible.");
|
||||
}
|
||||
if (request is null || request.Count is < 0 or > AuditExportLimits.MaxCount ||
|
||||
request.Filters is { Length: > AuditExportLimits.MaxFiltersLength } ||
|
||||
request.From is { Year: < 2000 or > 9998 } || request.To is { Year: < 2000 or > 9998 } ||
|
||||
(request.From is { } from && request.To is { } to && from > to))
|
||||
return ApiResults.BadRequest("Annonce d'export invalide.");
|
||||
|
||||
var period = (request.From, request.To) switch
|
||||
{
|
||||
(null, null) => "toutes les dates",
|
||||
({ } f, null) => $"depuis le {Day(f)}",
|
||||
(null, { } t) => $"jusqu'au {Day(t)}",
|
||||
({ } f, { } t) => $"du {Day(f)} au {Day(t)}"
|
||||
};
|
||||
var actor = context.Admin();
|
||||
audit.Write("audit.export", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
"fichier CSV",
|
||||
$"{request.Count} entrée(s) ; {period}" + (string.IsNullOrWhiteSpace(request.Filters) ? "" : $" ; filtres : {request.Filters}"));
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static string Day(DateOnly day) => day.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Dernière consultation inscrite par clé (évite une ligne par page lors de « Charger plus »).</summary>
|
||||
public sealed class AuditConsultations
|
||||
{
|
||||
/// <summary>Pages suivantes non réinscrites pendant ce délai après la dernière consultation inscrite.</summary>
|
||||
public static readonly TimeSpan ContinuationWindow = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastRecorded = new(StringComparer.Ordinal);
|
||||
|
||||
public AuditConsultations(TimeProvider time) => _time = time;
|
||||
|
||||
public bool ShouldRecord(string keyId, bool continuation)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_lastRecorded)
|
||||
{
|
||||
if (continuation && _lastRecorded.TryGetValue(keyId, out var last) && now - last < ContinuationWindow && now >= last)
|
||||
return false;
|
||||
if (_lastRecorded.Count > 10_000) _lastRecorded.Clear(); // seules les clés enregistrées arrivent ici
|
||||
_lastRecorded[keyId] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public sealed class AuthModule : IApiModule
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
|
||||
var clientName = ClientSoftware(ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength));
|
||||
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
|
||||
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
|
||||
// admins.json à chaque connexion).
|
||||
@@ -117,8 +117,12 @@ public sealed class AuthModule : IApiModule
|
||||
}
|
||||
}
|
||||
|
||||
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
|
||||
var authenticated = new AuthenticatedAdmin(session, admin, key, AdminRoles.PermissionsOf(admin.Role));
|
||||
// Appareil : le nom choisi à l'enregistrement de la clé (invitation), jamais le nom Windows du PC.
|
||||
var device = $"appareil « {DeviceName(key.Label)} »";
|
||||
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip,
|
||||
details: clientName is null ? device : $"{device} · {clientName}");
|
||||
var database = store.Read();
|
||||
var authenticated = new AuthenticatedAdmin(session, admin, key, database.PermissionsOf(admin.Role), database.GroupName(admin.Role));
|
||||
return Results.Json(new LoginResponse
|
||||
{
|
||||
Token = token,
|
||||
@@ -129,6 +133,23 @@ public sealed class AuthModule : IApiModule
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>Nom de l'appareil tel que défini à l'enregistrement de la clé (invitation).</summary>
|
||||
internal static string DeviceName(string? keyLabel) => string.IsNullOrWhiteSpace(keyLabel) ? "sans nom" : keyLabel;
|
||||
|
||||
/// <summary>
|
||||
/// Logiciel client (« GameLauncher 2.5.0 »), pour le journal. Les launchers jusqu'à la version 2.4.1 y ajoutaient le
|
||||
/// nom Windows du PC entre parenthèses (« GameLauncher 2.4.1 (DESKTOP-ABC123) ») : il est retiré, seul le nom de
|
||||
/// l'appareil choisi à l'enregistrement de la clé identifie le poste (et le nom Windows n'est pas conservé).
|
||||
/// </summary>
|
||||
internal static string? ClientSoftware(string? clientName)
|
||||
{
|
||||
if (clientName is null) return null;
|
||||
if (clientName.StartsWith("GameLauncher ", StringComparison.Ordinal) && clientName.EndsWith(')') &&
|
||||
clientName.IndexOf(" (", StringComparison.Ordinal) is var open and > 0)
|
||||
return clientName[..open];
|
||||
return clientName;
|
||||
}
|
||||
|
||||
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux (informations MODIFIABLES : nom affiché, accroche, présentation, liens, thème
|
||||
/// intégré, images, intervalle de mise à jour, ordre et visibilité). Les jeux restent déclarés dans le
|
||||
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
|
||||
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
|
||||
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit ;
|
||||
/// • PUT images/{kind} : envoi d'une icône ou d'une image de fond (games.edit, requête signée : la signature
|
||||
/// couvre l'empreinte du contenu), contrôlée puis rangée dans catalog/images (<see cref="CatalogImageStore"/>),
|
||||
/// audit. L'image n'est visible des joueurs qu'une fois le catalogue qui la désigne publié.
|
||||
/// </summary>
|
||||
public sealed class CatalogModule : IApiModule
|
||||
{
|
||||
public string Name => "Catalog";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<CatalogStore>();
|
||||
services.AddSingleton<CatalogImageStore>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
|
||||
// Seule route du catalogue qui accepte un gros corps (image de 4 Mo au plus) ; voir nginx-api.conf.
|
||||
api.MapPut(ApiRoutes.CatalogImagePattern, UploadImage).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest()
|
||||
.WithBodyLimit(CatalogImages.MaxRequestBytes);
|
||||
}
|
||||
|
||||
private static IResult UploadImage(string kind, HttpContext context, CatalogImageStore images, AuditLog audit,
|
||||
ILogger<CatalogModule> logger)
|
||||
{
|
||||
if (!CatalogImages.IsValidKind(kind)) return ApiResults.BadRequest("Type d'image inconnu (icon ou background).");
|
||||
var actor = context.Admin();
|
||||
var content = context.CapturedBody();
|
||||
CatalogImageUploaded uploaded;
|
||||
try
|
||||
{
|
||||
uploaded = images.Save(kind, content);
|
||||
}
|
||||
catch (ApiRejection rejection) when (rejection.Status == 400)
|
||||
{
|
||||
audit.Write("catalog.image_upload", AuditResults.Denied, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId,
|
||||
context.ClientIp(), CatalogImages.KindLabel(kind), $"{content.Length / 1024} Ko : {rejection.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Envoi d'une image du catalogue");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire l'image : vérifiez le dossier catalog/images et ses droits (server/deploy/LISEZMOI.md, étape 3 bis).");
|
||||
}
|
||||
|
||||
audit.Write("catalog.image_upload", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
CatalogImages.KindLabel(kind),
|
||||
$"{uploaded.FileName} ({uploaded.Width} × {uploaded.Height} px, {uploaded.Bytes / 1024} Ko)" + (uploaded.AlreadyPresent ? " ; déjà présente" : ""));
|
||||
return Results.Json(uploaded, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
|
||||
Results.Json(State(store.Read(), store, options.Value), ApiJson.Options);
|
||||
|
||||
private static IResult Publish(CatalogUpdateRequest request, HttpContext context, CatalogStore store, AuditLog audit,
|
||||
TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<CatalogModule> logger)
|
||||
{
|
||||
if (request.Catalog is null) return ApiResults.BadRequest("Catalogue absent.");
|
||||
if (request.BaseRevision < 0) return ApiResults.BadRequest("Révision invalide.");
|
||||
|
||||
var errors = new List<string>();
|
||||
var catalog = CatalogRules.Normalize(request.Catalog with
|
||||
{
|
||||
Format = CatalogDocument.FormatName,
|
||||
Version = CatalogDocument.CurrentVersion,
|
||||
Revision = 0
|
||||
}, options.Value.CatalogImageHost, errors);
|
||||
if (errors.Count > 0)
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
// Signature (phase 7) : sur le catalogue tel qu'il sera publié (après contrôle), par la clé de cette session.
|
||||
var actor = context.Admin();
|
||||
if (request.Catalog.Signature is { } signature)
|
||||
{
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
ContentSigning.CatalogDigest(catalog), actor, time.GetUtcNow());
|
||||
catalog = catalog with { Signature = signature };
|
||||
}
|
||||
else if (options.Value.ContentSigning.Require)
|
||||
{
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige un catalogue signé (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
}
|
||||
|
||||
CatalogDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(catalog, request.BaseRevision, time.GetUtcNow());
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du catalogue");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}",
|
||||
Describe(previous, published) + (published.Signature is null ? " ; non signé" : $" ; signé (clé {published.Signature.KeyId})"));
|
||||
return Results.Json(State(published, store, options.Value), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static CatalogState State(CatalogDocument catalog, CatalogStore store, LauncherApiOptions options) => new()
|
||||
{
|
||||
Catalog = catalog,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = options.CatalogPublicUrl
|
||||
};
|
||||
|
||||
/// <summary>Résumé pour l'audit : jeux et réglages modifiés.</summary>
|
||||
private static string Describe(CatalogDocument before, CatalogDocument after)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
if (Serialize(before.Launcher) != Serialize(after.Launcher)) changed.Add("launcher");
|
||||
foreach (var id in before.Games.Keys.Union(after.Games.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
before.Games.TryGetValue(id, out var a);
|
||||
after.Games.TryGetValue(id, out var b);
|
||||
if (Serialize(a) != Serialize(b)) changed.Add(id);
|
||||
}
|
||||
return changed.Count == 0 ? "aucun changement" : "modifié : " + string.Join(", ", changed.Take(20));
|
||||
}
|
||||
|
||||
private static string Serialize(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version des jeux (éditeur du menu d'administration) : modification des versions EXISTANTES
|
||||
/// dans le manifest.json publié de chaque jeu (LauncherApi:Games). La création d'une version reste
|
||||
/// l'affaire de ManifestBuilder (et de la publication).
|
||||
/// • GET : tout administrateur connecté (le manifest est public de toute façon) ;
|
||||
/// • PUT (une version) : permission changelog.edit, requête signée, mêmes bornes que le launcher,
|
||||
/// empreinte du manifest attendue (409 si le manifest a changé entre-temps), audit.
|
||||
/// </summary>
|
||||
public sealed class ChangelogModule : IApiModule
|
||||
{
|
||||
public string Name => "Changelog";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<ChangelogStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.GameChangelogPattern, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.GameChangelogEntryPattern, Save).RequirePermission(AdminPermissions.ChangelogEdit).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(string gameId, ChangelogStore store, ILogger<ChangelogModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
try
|
||||
{
|
||||
return Results.Json(store.Read(gameId), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du manifest de {GameId}", gameId);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex is InvalidDataException
|
||||
? $"Le serveur n'a pas pu lire le manifest de « {gameId} » : {ex.Message}"
|
||||
: $"Le serveur n'a pas pu lire le manifest de « {gameId} » : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult Save(string gameId, string version, ChangelogUpdateRequest request, HttpContext context,
|
||||
ChangelogStore store, AuditLog audit, ILogger<ChangelogModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
if (!ChangelogLimits.IsEditableVersion(version)) return ApiResults.BadRequest("Numéro de version invalide.");
|
||||
if (request.Entry is null) return ApiResults.BadRequest("Notes de version absentes.");
|
||||
if (string.IsNullOrWhiteSpace(request.BaseSha256) || request.BaseSha256.Length != 64)
|
||||
return ApiResults.BadRequest("Empreinte du manifest absente ou invalide.");
|
||||
if (!string.Equals(request.Entry.Version, version, StringComparison.Ordinal))
|
||||
return ApiResults.BadRequest("La version des notes ne correspond pas à l'adresse.");
|
||||
|
||||
var errors = ChangelogLimits.Check(request.Entry);
|
||||
if (errors.Count > 0)
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
ChangelogState state;
|
||||
ChangelogEntry previous;
|
||||
try
|
||||
{
|
||||
(state, previous) = store.Save(gameId, version, request.Entry, request.BaseSha256);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Modification des notes de {GameId} {Version}", gameId, version);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex switch
|
||||
{
|
||||
InvalidDataException => $"Le serveur n'a pas pu lire le manifest : {ex.Message}",
|
||||
ManifestWriteRefusedException => $"Modification refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).",
|
||||
_ => "Le serveur n'a pas pu modifier le manifest : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter)."
|
||||
});
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
var after = state.Entries.FirstOrDefault(e => e.Version == version);
|
||||
audit.Write("changelog.edit", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{state.GameId} {version}",
|
||||
$"{Count(previous)} → {Count(after)} élément(s), manifest {state.ManifestSha256[..12]}");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static int Count(ChangelogEntry? entry) => entry is null ? 0 : ChangelogLimits.DisplayedItems(entry);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Demandes des joueurs (bouton « Aide » du launcher : avis, demande d'évolution, bug, autre), rangées par jeu.
|
||||
/// • Joueurs, SANS compte (routes anonymes, seules routes d'écriture ouvertes à tous) : défi anti-spam à résoudre
|
||||
/// (<see cref="FeedbackGuard"/>), limites par adresse IP en mémoire, corps de 16 Ko au plus, contrôle complet
|
||||
/// (<see cref="FeedbackRules"/>), plafonds globaux (<see cref="FeedbackStore"/>). Ni l'adresse IP ni aucun
|
||||
/// identifiant du PC ne sont enregistrés avec la demande, ni dans l'audit de son envoi.
|
||||
/// • Administrateurs : lecture (feedback.read, consultations inscrites au journal), statut et note interne,
|
||||
/// suppression (feedback.manage, requêtes signées). Pas de réponse au joueur (module futur « comptes »).
|
||||
/// • Formulaires (communs à tous les jeux, <see cref="FeedbackFormsStore"/>) : lecture par tout administrateur (contenu
|
||||
/// public), publication par feedback.forms, requête signée ET formulaires signés par la clé de la session
|
||||
/// (autorisation « forms » du trousseau, vérifiée par les launchers). Chaque demande est validée avec le formulaire
|
||||
/// de la révision qu'elle annonce (0 : formulaires d'origine) ; une révision dépassée répond 409.
|
||||
/// </summary>
|
||||
public sealed class FeedbackModule : IApiModule
|
||||
{
|
||||
public string Name => "Feedback";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<FeedbackStore>();
|
||||
services.AddSingleton<FeedbackFormsStore>();
|
||||
services.AddSingleton<FeedbackGuard>();
|
||||
services.AddSingleton<FeedbackConsultations>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapPost(ApiRoutes.FeedbackChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback);
|
||||
api.MapPost(ApiRoutes.Feedback, Submit).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
|
||||
.WithBodyLimit(FeedbackRules.MaxRequestBytes);
|
||||
api.MapPut(ApiRoutes.FeedbackAttachmentPattern, UploadAttachment).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
|
||||
.WithBodyLimit(FeedbackImages.MaxRequestBytes);
|
||||
|
||||
api.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.FeedbackForms, GetForms).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.FeedbackForms, PublishForms).RequirePermission(AdminPermissions.FeedbackForms).RequireSignedRequest()
|
||||
.WithBodyLimit(FeedbackFormRules.MaxRequestBytes); // icônes importées comprises (bloc nginx dédié)
|
||||
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackAttachmentPattern, GetAttachment).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapPut(ApiRoutes.GameFeedbackItemPattern, Update).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
|
||||
api.MapDelete(ApiRoutes.GameFeedbackItemPattern, Delete).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult NotConfigured() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"La réception des demandes n'est pas activée sur le serveur.");
|
||||
|
||||
// ------------------------------------------------------------------ Joueurs (anonymes)
|
||||
|
||||
private static IResult Challenge(FeedbackStore store, FeedbackGuard guard) =>
|
||||
store.Enabled ? Results.Json(guard.CreateChallenge(), ApiJson.Options) : NotConfigured();
|
||||
|
||||
private static IResult Submit(HttpContext context, FeedbackStore store, FeedbackFormsStore forms, FeedbackGuard guard, AuditLog audit,
|
||||
TimeProvider time)
|
||||
{
|
||||
if (!store.Enabled) return NotConfigured();
|
||||
|
||||
FeedbackSubmission? submission;
|
||||
try
|
||||
{
|
||||
submission = JsonSerializer.Deserialize<FeedbackSubmission>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Demande illisible.");
|
||||
}
|
||||
|
||||
// 1. Défi anti-spam (une empreinte à calculer) : avant tout autre traitement.
|
||||
if (submission is null || !guard.TryConsume(submission.ChallengeId, submission.Solution))
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, "défi anti-spam absent, expiré, déjà utilisé ou mal résolu");
|
||||
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-spam échouée : réessayez.");
|
||||
}
|
||||
|
||||
// 2. Jeu accepté par ce serveur (identifiant de la configuration : seul celui-ci sert à construire un chemin).
|
||||
var gameId = store.ResolveGame(submission.GameId);
|
||||
if (gameId is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, "jeu non accepté");
|
||||
return ApiResults.BadRequest("Ce jeu n'accepte pas de demandes sur ce serveur.");
|
||||
}
|
||||
|
||||
// 3. Formulaire de la révision annoncée (409 si les formulaires ont été republiés pendant la saisie), puis contenu
|
||||
// (mêmes règles que le launcher).
|
||||
FeedbackForm form;
|
||||
bool snapshot;
|
||||
try
|
||||
{
|
||||
(form, snapshot) = forms.Resolve(submission.FormRevision, submission.Kind);
|
||||
}
|
||||
catch (ApiRejection ex)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null,
|
||||
ex.Status == 409 ? $"formulaire dépassé (révision {submission.FormRevision})" : "type de demande refusé");
|
||||
throw;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, form, errors);
|
||||
if (clean is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, $"contenu refusé ({errors.Count} erreur(s))");
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
}
|
||||
|
||||
// 4. Limites par adresse IP (en mémoire uniquement), puis plafonds globaux.
|
||||
if (!guard.TryCountSubmission(context.RateLimitPartition(), out var retryAfter))
|
||||
{
|
||||
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
return ApiResults.Error(429, ApiErrorCodes.RateLimited,
|
||||
"Vous avez envoyé plusieurs demandes récemment : patientez un peu avant d'en envoyer une autre.");
|
||||
}
|
||||
var now = time.GetUtcNow();
|
||||
var record = store.Add(gameId, clean, now, snapshot ? form : null);
|
||||
if (record is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, $"plafond atteint ({store.FullReason(gameId, now) ?? "?"})");
|
||||
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull,
|
||||
"Le service des demandes est momentanément saturé. Réessayez plus tard.");
|
||||
}
|
||||
|
||||
// Audit SANS adresse IP : l'audit ne doit pas permettre de relier une demande « anonyme » à une adresse.
|
||||
audit.Write("feedback.submit", AuditResults.Ok, target: record.Reference,
|
||||
details: $"jeu {gameId} ; {KindLabel(record)}" +
|
||||
(record.FormRevision > 0 ? $" ; formulaires révision {record.FormRevision}" : "") + (record.Tech is null ? "" : " ; informations techniques jointes") +
|
||||
(record.AttachmentCount > 0 ? $" ; {record.AttachmentCount} capture(s) annoncée(s)" : ""));
|
||||
return Results.Json(new FeedbackSubmitted
|
||||
{
|
||||
Reference = record.Reference,
|
||||
UploadToken = record.AttachmentCount > 0 ? guard.CreateUploadToken(record.Id, record.AttachmentCount) : null
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture d'écran d'une demande qui vient d'être envoyée (jeton d'envoi remis à ce seul launcher). L'image est
|
||||
/// contrôlée (vraie image PNG ou JPEG, dimensions, structure) puis nettoyée de ses métadonnées avant d'être
|
||||
/// enregistrée ; sans adresse IP dans l'audit, comme la demande.
|
||||
/// </summary>
|
||||
private static IResult UploadAttachment(int index, HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit,
|
||||
TimeProvider time)
|
||||
{
|
||||
if (!store.Enabled) return NotConfigured();
|
||||
var id = guard.CheckUpload(context.Request.Headers[FeedbackUpload.TokenHeader].ToString(), index);
|
||||
if (id is null || store.FindById(id) is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.attachment", null, null, "jeton d'envoi absent, expiré ou invalide");
|
||||
return ApiResults.Error(403, ApiErrorCodes.Forbidden, "Envoi de la capture refusé (délai dépassé ?).");
|
||||
}
|
||||
|
||||
var error = FeedbackImages.Sanitize(context.CapturedBody(), out var info, out var clean);
|
||||
if (error is not null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.attachment", null, null, $"capture refusée ({error})");
|
||||
return ApiResults.BadRequest(error);
|
||||
}
|
||||
var record = store.AddAttachment(id, index, clean!, info!, time.GetUtcNow());
|
||||
if (record is null)
|
||||
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull, "Le service des demandes est momentanément saturé : capture non enregistrée.");
|
||||
audit.Write("feedback.attachment", AuditResults.Ok, target: record.Reference,
|
||||
details: $"jeu {record.GameId} ; capture {index} : {info!.Format.ToUpperInvariant()} {info.Width} × {info.Height}, {clean!.Length / 1024} Ko (métadonnées retirées)");
|
||||
return Results.Json(new FeedbackAttachmentStored { Index = index }, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés), sinon nom d'origine.</summary>
|
||||
private static string KindLabel(FeedbackRecord record) => record.Form?.Label ?? FeedbackForms.KindLabel(record.Kind);
|
||||
|
||||
// ------------------------------------------------------------------ Formulaires (communs à tous les jeux)
|
||||
|
||||
private static IResult GetForms(FeedbackFormsStore forms, AdminStore admins) => Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
|
||||
private static FeedbackFormsState FormsState(FeedbackFormsStore forms, AdminStore admins)
|
||||
{
|
||||
var current = forms.Current;
|
||||
return new FeedbackFormsState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = forms.PublishingEnabled,
|
||||
PublicUrl = forms.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
|
||||
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
|
||||
/// </summary>
|
||||
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Formulaires absents.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Les formulaires doivent être signés.");
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId,
|
||||
FeedbackFormRules.Digest(document), actor, time.GetUtcNow());
|
||||
|
||||
FeedbackFormsDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = forms.Publish(document, request.BaseRevision, time.GetUtcNow());
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication des formulaires des demandes");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
|
||||
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
|
||||
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
|
||||
var changes = FeedbackFormsAudit.Changes(previous, published);
|
||||
foreach (var (action, name, details) in changes
|
||||
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
|
||||
.Reverse())
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"« {name} »", details);
|
||||
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; signé (clé {signature.KeyId})");
|
||||
return Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
|
||||
private static IResult Overview(FeedbackStore store, IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
var counts = store.Counts();
|
||||
return Results.Json(new FeedbackOverview
|
||||
{
|
||||
Enabled = store.Enabled,
|
||||
StorageFull = store.Enabled && counts.Any(c => store.FullReason(c.GameId, now) is not null),
|
||||
Games = counts,
|
||||
RetentionClosedMonths = options.Value.Feedback.RetentionClosedMonths,
|
||||
RetentionMaxMonths = options.Value.Feedback.RetentionMaxMonths
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult List(string gameId, string? kind, string? status, string? cursor, int? limit, HttpContext context,
|
||||
FeedbackStore store, AuditLog audit, FeedbackConsultations consultations)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null) return ApiResults.NotFound("Ce jeu ne reçoit pas de demandes sur ce serveur.");
|
||||
if (kind is not null && !FeedbackFormRules.IsValidId(kind)) return ApiResults.BadRequest("Type de demande inconnu.");
|
||||
if (!FeedbackStore.IsValidStatusFilter(status)) return ApiResults.BadRequest("Statut inconnu.");
|
||||
if (cursor is { Length: > 60 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
|
||||
var page = store.List(game, kind, status, cursor, Math.Clamp(limit ?? 100, 1, 200));
|
||||
var actor = context.Admin();
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, "list:" + game, continuation: !string.IsNullOrEmpty(cursor)))
|
||||
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"jeu {game}", $"liste : {page.Items.Count} demande(s) sur {page.Total}");
|
||||
return Results.Json(page, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Get(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit,
|
||||
FeedbackConsultations consultations)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || store.Find(game, id) is not { } record) return ApiResults.NotFound("Demande introuvable.");
|
||||
var actor = context.Admin();
|
||||
// Lecture d'une demande (texte libre : données personnelles possibles) inscrite au journal, une fois par
|
||||
// demande et par clé toutes les 10 minutes (sans le contenu).
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, "item:" + record.Id, continuation: true))
|
||||
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
record.Reference, $"jeu {game} ; {KindLabel(record)}");
|
||||
return Results.Json(record.ToDetail(), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult GetAttachment(string gameId, string id, int index, HttpContext context, FeedbackStore store, AuditLog audit,
|
||||
FeedbackConsultations consultations)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || store.Find(game, id) is not { } record || store.ReadAttachment(record, index) is not { } bytes)
|
||||
return ApiResults.NotFound("Capture introuvable.");
|
||||
var actor = context.Admin();
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, $"attachment:{record.Id}:{index}", continuation: true))
|
||||
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
record.Reference, $"jeu {game} ; capture {index}");
|
||||
// Type générique : le contenu n'est jamais interprété comme une page par un navigateur.
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
return Results.Bytes(bytes, FeedbackUpload.ContentType);
|
||||
}
|
||||
|
||||
private static IResult Update(string gameId, string id, FeedbackUpdateRequest request, HttpContext context, FeedbackStore store,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
|
||||
if (!FeedbackStatuses.IsValid(request.Status)) return ApiResults.BadRequest("Statut inconnu.");
|
||||
var note = FeedbackRules.CleanAdminNote(request.AdminNote, out var noteValid);
|
||||
if (!noteValid)
|
||||
return ApiResults.BadRequest($"Note interne : {FeedbackRules.MaxAdminNoteLength} caractères au plus, sans caractère invisible.");
|
||||
|
||||
var actor = context.Admin();
|
||||
var updated = store.Update(game, id, request.Revision, request.Status, note, actor.Admin.DisplayName, time.GetUtcNow(), out var previous);
|
||||
|
||||
var changes = new List<string>();
|
||||
if (previous.Status != updated.Status)
|
||||
changes.Add($"statut : {FeedbackStatuses.Label(previous.Status)} → {FeedbackStatuses.Label(updated.Status)}");
|
||||
if (previous.AdminNote != updated.AdminNote) changes.Add(updated.AdminNote is null ? "note interne retirée" : "note interne modifiée");
|
||||
audit.Write("feedback.update", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
updated.Reference, $"jeu {game} ; " + (changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes)));
|
||||
return Results.Json(updated.ToDetail(), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Delete(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit)
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
|
||||
var actor = context.Admin();
|
||||
var deleted = store.Delete(game, id);
|
||||
audit.Write("feedback.delete", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
deleted.Reference, $"jeu {game} ; {KindLabel(deleted)} ; {FeedbackStatuses.Label(deleted.Status)}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dernière consultation inscrite par clé et par élément (liste d'un jeu, demande) : une ligne d'audit par consultation,
|
||||
/// pas une par page ni par réaffichage dans les 10 minutes.
|
||||
/// </summary>
|
||||
public sealed class FeedbackConsultations
|
||||
{
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<string, DateTimeOffset> _last = new(StringComparer.Ordinal);
|
||||
|
||||
public FeedbackConsultations(TimeProvider time) => _time = time;
|
||||
|
||||
/// <param name="continuation">Vrai : pas réinscrit si la dernière consultation date de moins de 10 minutes.</param>
|
||||
public bool ShouldRecord(string keyId, string item, bool continuation)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var key = keyId + "|" + item;
|
||||
lock (_last)
|
||||
{
|
||||
if (continuation && _last.TryGetValue(key, out var last) && now - last < Window && now >= last) return false;
|
||||
if (_last.Count > 20_000) _last.Clear(); // seules les clés enregistrées arrivent ici
|
||||
_last[key] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Fichiers du serveur » (permission files.manage : propriétaires, administrateurs) : consultation du site
|
||||
/// public, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu. Suppressions signées et
|
||||
/// inscrites au journal d'audit ; le serveur revérifie les manifests au moment de supprimer (ServerFilesStore).
|
||||
/// </summary>
|
||||
public sealed class FilesModule : IApiModule
|
||||
{
|
||||
public string Name => "Files";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<ServerFilesStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
const string permission = AdminPermissions.FilesManage;
|
||||
api.MapGet(ApiRoutes.Files, List).RequirePermission(permission);
|
||||
api.MapPost(ApiRoutes.FilesDelete, Delete).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.ObjectsPrunePattern, PreviewPrune).RequirePermission(permission);
|
||||
api.MapPost(ApiRoutes.ObjectsPrunePattern, Prune).RequirePermission(permission).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult List(string? path, ServerFilesStore store, ILogger<FilesModule> logger)
|
||||
{
|
||||
if (path is { Length: > ServerFilesLimits.MaxPathLength }) return ApiResults.BadRequest("Chemin trop long.");
|
||||
return Guard(logger, "Consultation des fichiers", () => Results.Json(store.List(path), ApiJson.Options));
|
||||
}
|
||||
|
||||
private static IResult Delete(HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
|
||||
{
|
||||
ServerFileDeleteRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<ServerFileDeleteRequest>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de suppression illisible.");
|
||||
}
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Path) || request.Path.Length > ServerFilesLimits.MaxPathLength || request.Size < 0)
|
||||
return ApiResults.BadRequest("Requête de suppression invalide.");
|
||||
|
||||
return Guard(logger, "Suppression d'un fichier", () =>
|
||||
{
|
||||
var result = store.Delete(request.Path, request.Size);
|
||||
Audit(audit, context, "files.delete", result.Path, $"{Megabytes(result.FreedBytes)} libéré(s)");
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult PreviewPrune(string gameId, ServerFilesStore store, ILogger<FilesModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
return Guard(logger, "Analyse du dossier objects", () => Results.Json(store.PreviewPrune(gameId), ApiJson.Options));
|
||||
}
|
||||
|
||||
private static IResult Prune(string gameId, HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
ObjectsPruneRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<ObjectsPruneRequest>(context.CapturedBody(), ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de nettoyage illisible.");
|
||||
}
|
||||
var manifestSha = (request?.ManifestSha256 ?? "").ToLowerInvariant();
|
||||
if (!PublishingLimits.IsValidSha256(manifestSha)) return ApiResults.BadRequest("Empreinte du manifest analysé invalide.");
|
||||
|
||||
return Guard(logger, "Nettoyage du dossier objects", () =>
|
||||
{
|
||||
var result = store.Prune(gameId, manifestSha,
|
||||
(file, ex) => logger.LogWarning(ex, "Nettoyage du dossier objects : {File} non supprimé", Path.GetFileName(file)));
|
||||
Audit(audit, context, "files.prune", $"{result.GameId} objects",
|
||||
$"{result.Deleted} fichier(s) supprimé(s), {Megabytes(result.FreedBytes)} libéré(s)" +
|
||||
(result.Failed > 0 ? $" ; {result.Failed} échec(s)" : "") + $" ; manifest {manifestSha[..12]}");
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin du serveur (le détail va dans journalctl).</summary>
|
||||
private static IResult Guard(ILogger logger, string what, Func<IResult> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
logger.LogError(ex, "{What}", what);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas le droit de modifier ce dossier : seuls les dossiers des jeux et du catalogue sont autorisés en écriture " +
|
||||
"(ReadWritePaths, server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "{What}", what);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu accéder au fichier : droits du dossier (ReadWritePaths) ou fichier en cours d'utilisation (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string details)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
|
||||
private static string Megabytes(long bytes) =>
|
||||
bytes >= 1024 * 1024 ? $"{bytes / (1024.0 * 1024):0.#} Mo" : bytes >= 1024 ? $"{bytes / 1024.0:0.#} Ko" : $"{bytes} octet(s)";
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
|
||||
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
|
||||
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
|
||||
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
|
||||
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
|
||||
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
|
||||
/// maintenance (c'est elle qui permet de la terminer).
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModule : IApiModule
|
||||
{
|
||||
public string Name => "Maintenance";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<MaintenanceStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
|
||||
{
|
||||
var current = store.Current;
|
||||
return new MaintenanceState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = store.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
|
||||
var now = time.GetUtcNow();
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
|
||||
MaintenanceRules.Digest(document), actor, now);
|
||||
|
||||
MaintenanceDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(document, request.BaseRevision, now);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
|
||||
"(server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
var action = published.Enabled switch
|
||||
{
|
||||
true when !previous.Enabled => "maintenance.enable",
|
||||
false when previous.Enabled => "maintenance.disable",
|
||||
_ => "maintenance.update"
|
||||
};
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
|
||||
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version depuis le menu d'administration (phase 5) : permission releases.publish,
|
||||
/// TOUTES les requêtes signées (y compris chaque morceau de fichier), audit du début, de la mise en
|
||||
/// ligne et de l'abandon. Les routes d'envoi acceptent des corps jusqu'à 8 Mo (les autres : 64 Ko).
|
||||
/// </summary>
|
||||
public sealed class PublishingModule : IApiModule
|
||||
{
|
||||
public string Name => "Publishing";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<PublicationStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
const string permission = AdminPermissions.ReleasesPublish;
|
||||
api.MapPost(ApiRoutes.PublicationStartPattern, Start).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.PublicationPattern, GetState).RequirePermission(permission);
|
||||
api.MapDelete(ApiRoutes.PublicationPattern, Cancel).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapPut(ApiRoutes.PublicationManifestPattern, Manifest).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPut(ApiRoutes.PublicationArchivePattern, Archive).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPut(ApiRoutes.PublicationObjectPattern, UploadObject).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPost(ApiRoutes.PublicationObjectsPattern, UploadObjects).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPost(ApiRoutes.PublicationCommitPattern, Commit).RequirePermission(permission).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Start(string gameId, PublicationStartRequest request, HttpContext context, PublicationStore store, AuditLog audit,
|
||||
ILogger<PublishingModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
var actor = context.Admin();
|
||||
return Guard(logger, () =>
|
||||
{
|
||||
var (state, replaced) = store.Start(gameId, request, actor.Admin.Id, actor.Admin.DisplayName);
|
||||
if (replaced is not null)
|
||||
Audit(audit, context, "release.cancel", replaced.GameId, replaced.Version, $"remplacée par la version {state.Version}");
|
||||
if (!state.Resumed)
|
||||
Audit(audit, context, "release.start", state.GameId, state.Version, $"version en ligne : {Display(state.PublishedVersion)}");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetState(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.GetState(id, context.Admin().Admin.Id), ApiJson.Options));
|
||||
|
||||
private static IResult Cancel(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
var record = store.Cancel(id, context.Admin().Admin.Id);
|
||||
Audit(audit, context, "release.cancel", record.GameId, record.Version, "abandonnée par l'administrateur");
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
private static IResult Manifest(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AppendManifest(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult Archive(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AppendArchive(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult UploadObject(string id, string sha256, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger)
|
||||
{
|
||||
if (!PublishingLimits.IsValidSha256(sha256)) return ApiResults.BadRequest("Empreinte d'objet invalide.");
|
||||
return Guard(logger, () =>
|
||||
Results.Json(store.AppendObject(id, context.Admin().Admin.Id, sha256, offset, context.CapturedBody()), ApiJson.Options));
|
||||
}
|
||||
|
||||
private static IResult UploadObjects(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AddObjects(id, context.Admin().Admin.Id, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
|
||||
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
// Corps facultatif : signature du contenu du manifest (phase 7). Sans corps : mise en ligne non signée.
|
||||
PublicationCommitRequest? request = null;
|
||||
var body = context.CapturedBody();
|
||||
if (body.Length > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<PublicationCommitRequest>(body, ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de mise en ligne illisible.");
|
||||
}
|
||||
}
|
||||
var signature = request?.Signature;
|
||||
if (signature is null && options.Value.ContentSigning.Require)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige une version signée (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
var actor = context.Admin();
|
||||
var (result, record) = store.Commit(id, actor.Admin.Id, signature,
|
||||
(gameId, digest) => ContentSignatures.RequireFromActor(signature!, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()));
|
||||
if (record is null) return Results.Json(result, ApiJson.Options); // déjà en ligne (réponse précédente perdue)
|
||||
Audit(audit, context, "release.publish", result.GameId, result.Version,
|
||||
$"version précédente : {Display(result.PreviousVersion)} ; {result.ObjectsAdded} fichier(s) ajouté(s)" +
|
||||
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}" +
|
||||
(signature is null ? " ; non signée" : $" ; signée (clé {signature.KeyId})"));
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
|
||||
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin (le détail va dans journalctl).</summary>
|
||||
private static IResult Guard(ILogger logger, Func<IResult> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
catch (ManifestWriteRefusedException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Publication refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Publication d'une version");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire dans le dossier du jeu : vérifiez ses droits et l'espace disque (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string gameId, string version, string details)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{gameId} {version}", details);
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du contenu servi aux joueurs (phase 7) :
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue, des formulaires des demandes
|
||||
/// et du mode maintenance ;
|
||||
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
|
||||
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
|
||||
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
|
||||
/// </summary>
|
||||
public sealed class SigningModule : IApiModule
|
||||
{
|
||||
public string Name => "Signing";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<KeyringStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Signing, State).RequirePermission(AdminPermissions.AdminsManage);
|
||||
api.MapPut(ApiRoutes.Keyring, PublishKeyring).RequirePermission(AdminPermissions.AdminsManage).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.ManifestSignaturePattern, GetManifest).RequirePermission(AdminPermissions.ReleasesPublish);
|
||||
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, MaintenanceStore maintenance,
|
||||
AdminStore admins, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
var manifests = new List<ManifestSignatureState>();
|
||||
foreach (var (gameId, path) in settings.GameManifestFiles.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
manifests.Add(ManifestSignatures.Read(gameId, path, db));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or
|
||||
FormatException or InvalidOperationException)
|
||||
{
|
||||
// Manifest illisible (fichier modifié à la main…) : affiché « non signé », la page reste utilisable.
|
||||
logger.LogWarning(ex, "Signature du manifest de {GameId}", gameId);
|
||||
manifests.Add(new ManifestSignatureState { GameId = gameId });
|
||||
}
|
||||
}
|
||||
|
||||
CatalogSignatureState? catalog = null;
|
||||
if (catalogs.PublishingEnabled)
|
||||
{
|
||||
CatalogDocument? published;
|
||||
try
|
||||
{
|
||||
published = catalogs.ReadPublished();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature du catalogue");
|
||||
published = null;
|
||||
}
|
||||
catalog = published is null
|
||||
? new CatalogSignatureState()
|
||||
: new CatalogSignatureState
|
||||
{
|
||||
Revision = published.Revision,
|
||||
Digest = ContentSigning.CatalogDigest(published),
|
||||
Signature = published.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, published.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
// Formulaires des demandes en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
FeedbackFormsSignatureState? feedbackForms = null;
|
||||
if (forms.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = forms.Current;
|
||||
feedbackForms = current.Revision == 0
|
||||
? new FeedbackFormsSignatureState()
|
||||
: new FeedbackFormsSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Digest = FeedbackFormRules.Digest(current),
|
||||
Signature = current.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, current.Signature)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature des formulaires des demandes");
|
||||
feedbackForms = new FeedbackFormsSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
// Mode maintenance en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
MaintenanceSignatureState? maintenanceState = null;
|
||||
if (maintenance.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = maintenance.Current;
|
||||
maintenanceState = current.Revision == 0
|
||||
? new MaintenanceSignatureState()
|
||||
: new MaintenanceSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Enabled = current.Enabled,
|
||||
Digest = MaintenanceRules.Digest(current),
|
||||
Signature = current.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, current.Signature)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature du mode maintenance");
|
||||
maintenanceState = new MaintenanceSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = keyrings.Read();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence illisible : la page propose d'en publier un nouveau (numéro choisi par le serveur au contrôle).
|
||||
logger.LogWarning(ex, "Trousseau de référence illisible");
|
||||
keyring = null;
|
||||
}
|
||||
|
||||
return Results.Json(new SigningState
|
||||
{
|
||||
Keyring = keyring,
|
||||
KeyringPublishingEnabled = keyrings.PublishingEnabled,
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog,
|
||||
FeedbackForms = feedbackForms,
|
||||
Maintenance = maintenanceState
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult PublishKeyring(HttpContext context, KeyringStore keyrings, AuditLog audit, ILogger<SigningModule> logger)
|
||||
{
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = Keyring.Parse(context.CapturedBody());
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ApiResults.BadRequest($"Trousseau illisible : {ex.Message}");
|
||||
}
|
||||
if (keyring is null) return ApiResults.BadRequest("Trousseau absent.");
|
||||
|
||||
KeyringDocument? previous;
|
||||
try
|
||||
{
|
||||
previous = keyrings.Publish(keyring);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Publication du trousseau");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le trousseau public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence du serveur illisible : refus (le numéro du trousseau publié ne peut pas être contrôlé).
|
||||
logger.LogError(ex, "Trousseau de référence illisible");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le trousseau de référence du serveur est illisible : restaurez-le depuis l'historique (keyring-history/) avant de publier.");
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
var added = keyring.Keys.Select(k => k.KeyId).Except(previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).ToList();
|
||||
var removed = (previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).Except(keyring.Keys.Select(k => k.KeyId)).ToList();
|
||||
audit.Write("keyring.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"trousseau n° {keyring.Sequence}",
|
||||
$"{keyring.Keys.Count} clé(s), racine {keyring.Signature?.RootKeyId}" +
|
||||
(added.Count > 0 ? $" ; ajoutées : {string.Join(", ", added)}" : "") +
|
||||
(removed.Count > 0 ? $" ; retirées : {string.Join(", ", removed)}" : ""));
|
||||
return Results.Json(keyring, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult GetManifest(string gameId, AdminStore admins, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
return Results.Json(ManifestSignatures.Read(gameId, path, admins.Read()), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult SignManifest(string gameId, ManifestSignRequest request, HttpContext context, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
if (request.Signature is null || !PublishingSha(request.Digest)) return ApiResults.BadRequest("Signature ou condensé absent.");
|
||||
var actor = context.Admin();
|
||||
ManifestSignatureState state;
|
||||
try
|
||||
{
|
||||
state = ManifestSignatures.Sign(gameId, path, options.Value.DataDirectoryFullPath, request,
|
||||
digest => ContentSignatures.RequireFromActor(request.Signature, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()),
|
||||
admins.Read());
|
||||
}
|
||||
catch (ManifestWriteRefusedException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Signature refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Signature du manifest de {GameId}", gameId);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu réécrire le manifest : vérifiez les droits du dossier du jeu (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
audit.Write("release.sign", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{gameId} {state.Version}", $"version en ligne signée (contenu {state.Digest[..12]})");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static bool PublishingSha(string? value) => Contracts.Publishing.PublishingLimits.IsValidSha256(value);
|
||||
|
||||
private static string ManifestPath(string gameId, LauncherApiOptions options)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) throw ApiRejection.BadRequest("Identifiant de jeu invalide.");
|
||||
return options.GameManifestFiles.TryGetValue(gameId, out var path)
|
||||
? path
|
||||
: throw new ApiRejection(404, ApiErrorCodes.NotConfigured,
|
||||
"Jeu non configuré sur le serveur (LauncherApi:Games, voir server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ using Microsoft.Extensions.Options;
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
|
||||
/// <param name="Permissions">Permissions de son groupe, lues au moment de l'authentification de la requête.</param>
|
||||
/// <param name="RoleName">Nom affiché de son groupe.</param>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions,
|
||||
string? RoleName = null)
|
||||
{
|
||||
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
@@ -19,6 +22,7 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
|
||||
Id = Admin.Id,
|
||||
DisplayName = Admin.DisplayName,
|
||||
Role = Admin.Role,
|
||||
RoleName = RoleName ?? AdminRoles.DisplayName(Admin.Role),
|
||||
Permissions = Permissions,
|
||||
KeyId = Key.KeyId,
|
||||
KeyLabel = Key.Label
|
||||
@@ -28,8 +32,9 @@ public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord A
|
||||
/// <summary>
|
||||
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
|
||||
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le rôle est relu
|
||||
/// à chaque requête : un changement de rôle s'applique aussitôt.
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le groupe et ses
|
||||
/// permissions sont relus à chaque requête : un changement de groupe, ou des permissions d'un groupe,
|
||||
/// s'applique aussitôt.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
@@ -61,7 +66,8 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var match = _store.Read().FindKey(session.KeyId);
|
||||
var database = _store.Read();
|
||||
var match = database.FindKey(session.KeyId);
|
||||
if (match is not { } found || found.Admin.Id != session.AdminId ||
|
||||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
|
||||
{
|
||||
@@ -69,7 +75,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
|
||||
}
|
||||
|
||||
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
|
||||
var permissions = database.PermissionsOf(found.Admin.Role);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
|
||||
@@ -80,7 +86,7 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
};
|
||||
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
|
||||
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions, database.GroupName(found.Admin.Role));
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle, par le serveur, des signatures de contenu envoyées par le menu d'administration (phase 7). Le
|
||||
/// serveur n'est pas l'ancre de confiance (ce sont les launchers des joueurs, avec le trousseau) : ce contrôle
|
||||
/// évite seulement de mettre en ligne une signature que les launchers refuseraient, et garantit que la clé qui
|
||||
/// signe est celle de la session (rien n'est signé au nom d'un autre).
|
||||
/// </summary>
|
||||
public static class ContentSignatures
|
||||
{
|
||||
/// <exception cref="ApiRejection">400 : signature d'une autre clé, date hors tolérance, ou signature invalide.</exception>
|
||||
public static void RequireFromActor(ContentSignature signature, string kind, string id, string digest,
|
||||
AuthenticatedAdmin actor, DateTimeOffset now)
|
||||
{
|
||||
if (!string.Equals(signature.KeyId, actor.Key.KeyId, StringComparison.Ordinal))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid, "La signature du contenu doit être faite par la clé de cette session.");
|
||||
if (signature.SignedAt < now - ContentSigning.MaxSignedAtSkew || signature.SignedAt > now + ContentSigning.MaxSignedAtSkew)
|
||||
throw new ApiRejection(400, ApiErrorCodes.ClockSkew,
|
||||
"Date de signature du contenu trop éloignée de l'heure du serveur : vérifiez l'heure de Windows, puis recommencez.");
|
||||
if (!ContentSigning.VerifyWithKey(kind, id, digest, signature, actor.Key.PublicKeyBytes()))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid,
|
||||
"Signature du contenu invalide : le contenu signé ne correspond pas à celui envoyé.");
|
||||
}
|
||||
|
||||
/// <summary>« Nicolas (PC fixe) » pour une clé connue du serveur, sinon null.</summary>
|
||||
public static string? SignerName(AdminDatabase db, ContentSignature? signature)
|
||||
{
|
||||
if (signature is null || db.FindKey(signature.KeyId) is not { } found) return null;
|
||||
return string.IsNullOrWhiteSpace(found.Key.Label) ? found.Admin.DisplayName : $"{found.Admin.DisplayName} ({found.Key.Label})";
|
||||
}
|
||||
|
||||
/// <summary>Signature présente dans un manifest publié (null : absente ou illisible).</summary>
|
||||
public static ContentSignature? ReadSignature(JsonObject manifest)
|
||||
{
|
||||
if (PublishedFiles.Property(manifest, "signature") is not JsonObject node) return null;
|
||||
try
|
||||
{
|
||||
return node.Deserialize<ContentSignature>(ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Protections de la route ANONYME d'envoi des demandes des joueurs :
|
||||
/// • défis anti-spam SANS ÉTAT (échéance, difficulté et nonce scellés par HMAC, secret en mémoire) : en demander ne
|
||||
/// coûte rien au serveur ; seuls les défis UTILISÉS sont mémorisés jusqu'à leur échéance (refus du rejeu) ;
|
||||
/// • limites par adresse IP (IPv6 : par /64) sur 10 minutes et sur 24 heures, comptées en mémoire uniquement :
|
||||
/// aucune adresse n'est écrite sur le disque ni rattachée à une demande.
|
||||
/// </summary>
|
||||
public sealed class FeedbackGuard
|
||||
{
|
||||
private const int NonceLength = 16;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 8 + 1 + NonceLength; // échéance + difficulté + nonce
|
||||
private const int MaxTracked = 100_000;
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<DateTimeOffset>> _accepted = new(StringComparer.Ordinal);
|
||||
private readonly LauncherApiOptions.FeedbackSettings _settings;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FeedbackGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_settings = options.Value.Feedback;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public FeedbackChallenge CreateChallenge()
|
||||
{
|
||||
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
|
||||
var payload = new byte[PayloadLength];
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload, expires.ToUnixTimeSeconds());
|
||||
payload[8] = (byte)_settings.ProofDifficulty;
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(9));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new FeedbackChallenge
|
||||
{
|
||||
ChallengeId = Base64Url.Encode(id),
|
||||
Nonce = Base64Url.Encode(payload.AsSpan(9)),
|
||||
Difficulty = payload[8],
|
||||
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le défi (sceau, échéance), puis la solution, puis le marque comme utilisé. Faux : défi absent, forgé,
|
||||
/// expiré, mal résolu ou déjà utilisé.
|
||||
/// </summary>
|
||||
public bool TryConsume(string? challengeId, string? solution)
|
||||
{
|
||||
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) ||
|
||||
id.Length != PayloadLength + MacLength)
|
||||
return false;
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[..8]));
|
||||
if (expires <= now) return false;
|
||||
// Difficulté scellée à la création du défi (un défi obtenu juste avant un relèvement de la difficulté reste
|
||||
// valable jusqu'à son échéance) ; jamais en dessous du minimum.
|
||||
int difficulty = payload[8];
|
||||
if (difficulty < FeedbackProof.MinDifficulty) return false;
|
||||
if (!FeedbackProof.Verify(payload[9..], difficulty, solution)) return false;
|
||||
|
||||
if (_used.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var (key, until) in _used)
|
||||
if (until <= now) _used.TryRemove(key, out _);
|
||||
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de demandes récentes.");
|
||||
}
|
||||
return _used.TryAdd(challengeId, expires);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compte une demande pour cette adresse (partition de limite de débit). Faux si la limite sur 10 minutes ou sur
|
||||
/// 24 heures est atteinte (rien n'est compté alors). <paramref name="retryAfter"/> : délai avant la prochaine place.
|
||||
/// </summary>
|
||||
public bool TryCountSubmission(string partition, out TimeSpan retryAfter)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
retryAfter = TimeSpan.Zero;
|
||||
lock (_accepted)
|
||||
{
|
||||
if (_accepted.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var key in _accepted.Where(p => p.Value.All(t => now - t >= TimeSpan.FromDays(1))).Select(p => p.Key).ToList())
|
||||
_accepted.Remove(key);
|
||||
if (_accepted.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
|
||||
}
|
||||
if (!_accepted.TryGetValue(partition, out var times)) _accepted[partition] = times = new List<DateTimeOffset>();
|
||||
times.RemoveAll(t => now - t >= TimeSpan.FromDays(1));
|
||||
|
||||
var recent = times.Where(t => now - t < TimeSpan.FromMinutes(10)).OrderBy(t => t).ToList();
|
||||
if (recent.Count >= _settings.PerIpPer10Minutes)
|
||||
{
|
||||
retryAfter = recent[recent.Count - _settings.PerIpPer10Minutes] + TimeSpan.FromMinutes(10) - now;
|
||||
return false;
|
||||
}
|
||||
if (times.Count >= _settings.PerIpPerDay)
|
||||
{
|
||||
retryAfter = times.Min() + TimeSpan.FromDays(1) - now;
|
||||
return false;
|
||||
}
|
||||
times.Add(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jetons d'envoi des captures
|
||||
|
||||
private sealed record PendingUpload(byte[] SecretHash, DateTimeOffset Expires, int Count);
|
||||
|
||||
private readonly ConcurrentDictionary<string, PendingUpload> _uploads = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Jeton d'envoi des captures d'une demande qui vient d'être enregistrée : « <id>.<secret 256 bits> ». Seule
|
||||
/// l'empreinte du secret est gardée, en mémoire (un redémarrage du service annule les envois en attente).
|
||||
/// </summary>
|
||||
public string CreateUploadToken(string id, int count)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_uploads.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var (key, pending) in _uploads)
|
||||
if (pending.Expires <= now) _uploads.TryRemove(key, out _);
|
||||
if (_uploads.Count >= MaxTracked) throw new StoreBusyException("Trop d'envois de captures en attente.");
|
||||
}
|
||||
var secret = RandomNumberGenerator.GetBytes(32);
|
||||
_uploads[id] = new PendingUpload(SHA256.HashData(secret), now + TimeSpan.FromMinutes(FeedbackUpload.TokenLifetimeMinutes), count);
|
||||
return id + "." + Base64Url.Encode(secret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de la demande si le jeton est valable pour cette capture (numéro dans le nombre annoncé, délai non
|
||||
/// dépassé), sinon null. Comparaison en temps constant.
|
||||
/// </summary>
|
||||
public string? CheckUpload(string? token, int index)
|
||||
{
|
||||
if (token is not { Length: > 33 and < 120 }) return null;
|
||||
var dot = token.IndexOf('.');
|
||||
if (dot != 32) return null;
|
||||
var id = token[..dot];
|
||||
if (!FeedbackRules.IsValidId(id) || !_uploads.TryGetValue(id, out var pending)) return null;
|
||||
if (pending.Expires <= _time.GetUtcNow())
|
||||
{
|
||||
_uploads.TryRemove(id, out _);
|
||||
return null;
|
||||
}
|
||||
if (!Base64Url.TryDecode(token[(dot + 1)..], out var secret) || secret.Length != 32 ||
|
||||
!CryptographicOperations.FixedTimeEquals(SHA256.HashData(secret), pending.SecretHash))
|
||||
return null;
|
||||
return index >= 1 && index <= pending.Count ? id : null;
|
||||
}
|
||||
|
||||
private byte[] Mac(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var data = new byte[Domain.Length + payload.Length];
|
||||
Domain.CopyTo(data, 0);
|
||||
payload.CopyTo(data.AsSpan(Domain.Length));
|
||||
return HMACSHA256.HashData(_secret, data)[..MacLength];
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,43 @@ public static class SignedRequests
|
||||
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
|
||||
!HttpMethods.IsOptions(context.Request.Method))
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
var body = buffer.ToArray();
|
||||
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
|
||||
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
|
||||
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
|
||||
feature.MaxRequestBodySize = limit.MaxBytes;
|
||||
|
||||
byte[] body;
|
||||
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
|
||||
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
|
||||
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
|
||||
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
|
||||
{
|
||||
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
|
||||
body = new byte[length];
|
||||
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
|
||||
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
|
||||
throw new BadHttpRequestException("Corps plus long que annoncé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
body = buffer.ToArray();
|
||||
}
|
||||
context.Items[BodyKey] = body;
|
||||
context.Request.Body = new MemoryStream(body, writable: false);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
|
||||
public static byte[] CapturedBody(this HttpContext context) =>
|
||||
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
|
||||
|
||||
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
|
||||
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.WithMetadata(new RequestBodyLimit(maxBytes));
|
||||
|
||||
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
|
||||
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.AddEndpointFilter(async (invocation, next) =>
|
||||
@@ -94,3 +122,6 @@ public static class SignedRequests
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
|
||||
public sealed record RequestBodyLimit(long MaxBytes);
|
||||
@@ -5,15 +5,82 @@ namespace GameLauncher.Server.Storage;
|
||||
/// <summary>
|
||||
/// Contenu de admins.json. Ne contient AUCUN secret : clés publiques et empreintes de codes
|
||||
/// d'invitation uniquement.
|
||||
/// Format 2 : groupes d'administrateurs et permissions personnalisées (un fichier au format 1 est migré à la
|
||||
/// lecture : groupes par défaut avec les permissions des anciens rôles ; il est réécrit au format 2 à la
|
||||
/// modification suivante). Un serveur plus ancien refuse de démarrer sur un fichier au format 2.
|
||||
/// </summary>
|
||||
public sealed class AdminDatabase
|
||||
{
|
||||
public int FormatVersion { get; set; } = 1;
|
||||
public const int CurrentFormat = 2;
|
||||
|
||||
public int FormatVersion { get; set; } = CurrentFormat;
|
||||
public List<AdminRecord> Admins { get; set; } = new();
|
||||
public List<InvitationRecord> Invitations { get; set; } = new();
|
||||
|
||||
/// <summary>Groupes (le groupe système « owner » existe toujours, voir <see cref="EnsureDefaults"/>).</summary>
|
||||
public List<GroupRecord> Groups { get; set; } = new();
|
||||
|
||||
/// <summary>Permissions personnalisées (les permissions intégrées sont dans le code : <see cref="AdminPermissions.All"/>).</summary>
|
||||
public List<PermissionRecord> CustomPermissions { get; set; } = new();
|
||||
|
||||
public AdminRecord? FindAdmin(Guid id) => Admins.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
public GroupRecord? FindGroup(string? id) =>
|
||||
id is null ? null : Groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Toutes les permissions connues : intégrées, puis personnalisées.</summary>
|
||||
public IReadOnlyList<string> KnownPermissions() =>
|
||||
AdminPermissions.All.Concat(CustomPermissions.Select(p => p.Name)).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
public bool IsKnownPermission(string permission) =>
|
||||
AdminPermissions.IsBuiltIn(permission) || CustomPermissions.Any(p => string.Equals(p.Name, permission, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Permissions effectives d'un groupe : toutes pour le groupe système, sinon celles du groupe qui existent
|
||||
/// encore ; aucune pour un groupe inconnu (supprimé, ou fichier modifié à la main).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PermissionsOf(string? groupId)
|
||||
{
|
||||
if (AdminGroups.IsSystem(groupId)) return KnownPermissions();
|
||||
if (FindGroup(groupId) is not { } group) return Array.Empty<string>();
|
||||
return group.Permissions.Where(IsKnownPermission).Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
public string GroupName(string? groupId) =>
|
||||
FindGroup(groupId)?.Name ?? (groupId is null ? "Inconnu" : AdminGroups.DefaultName(groupId) ?? $"Groupe supprimé ({groupId})");
|
||||
|
||||
/// <summary>
|
||||
/// Groupes par défaut : le groupe système « Propriétaire » est (re)créé s'il manque ; au premier démarrage
|
||||
/// (ou migration du format 1), les groupes « Administrateur » et « Éditeur » reçoivent les permissions des
|
||||
/// anciens rôles. Faux si rien n'a changé.
|
||||
/// </summary>
|
||||
public bool EnsureDefaults(DateTimeOffset now)
|
||||
{
|
||||
var changed = false;
|
||||
var firstTime = Groups.Count == 0;
|
||||
foreach (var id in firstTime ? AdminRoles.All : new[] { AdminRoles.Owner })
|
||||
{
|
||||
if (FindGroup(id) is not null) continue;
|
||||
Groups.Add(new GroupRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = AdminGroups.DefaultName(id)!,
|
||||
Description = AdminGroups.DefaultDescription(id),
|
||||
Permissions = AdminGroups.IsSystem(id) ? new() : AdminRoles.PermissionsOf(id).ToList(),
|
||||
CreatedAt = now,
|
||||
CreatedBy = "Valeurs par défaut",
|
||||
Revision = 1
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
if (FindGroup(AdminRoles.Owner) is { } owner && owner.Permissions.Count > 0)
|
||||
{
|
||||
owner.Permissions.Clear(); // groupe système : toutes les permissions, jamais une liste
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>Administrateur possédant cette clé (quel que soit l'état de la clé).</summary>
|
||||
public (AdminRecord Admin, KeyRecord Key)? FindKey(string keyId)
|
||||
{
|
||||
@@ -33,7 +100,10 @@ public sealed class AdminRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
/// <summary>Identifiant du groupe (nom historique : « rôle »).</summary>
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
@@ -81,3 +151,36 @@ public sealed class InvitationRecord
|
||||
public Guid? CreatedById { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Groupe d'administrateurs : nom, description, permissions.</summary>
|
||||
public sealed class GroupRecord
|
||||
{
|
||||
/// <summary>Identifiant stable (<see cref="AdminGroups.IsValidGroupId"/>), référencé par les comptes et les invitations.</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Permissions du groupe (vide pour le groupe système, qui les a toutes).</summary>
|
||||
public List<string> Permissions { get; set; } = new();
|
||||
|
||||
/// <summary>Incrémentée à chaque modification (contrôle des modifications concurrentes).</summary>
|
||||
public long Revision { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Permission personnalisée (créée depuis le menu d'administration).</summary>
|
||||
public sealed class PermissionRecord
|
||||
{
|
||||
/// <summary>« module.action » (<see cref="AdminGroups.IsValidPermissionName"/>).</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Label { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
}
|
||||
@@ -84,15 +84,29 @@ public sealed class AdminStore
|
||||
|
||||
private AdminDatabase Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return new AdminDatabase();
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
var empty = new AdminDatabase();
|
||||
empty.EnsureDefaults(DateTimeOffset.UtcNow);
|
||||
return empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
var database = JsonSerializer.Deserialize<AdminDatabase>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException("fichier vide");
|
||||
if (database.FormatVersion != 1) throw new InvalidDataException($"format {database.FormatVersion} inconnu");
|
||||
if (database.FormatVersion is not (1 or AdminDatabase.CurrentFormat))
|
||||
throw new InvalidDataException($"format {database.FormatVersion} inconnu");
|
||||
database.Admins ??= new();
|
||||
database.Invitations ??= new();
|
||||
database.Groups ??= new();
|
||||
database.CustomPermissions ??= new();
|
||||
foreach (var admin in database.Admins) admin.Keys ??= new();
|
||||
foreach (var group in database.Groups) group.Permissions ??= new();
|
||||
CheckGroups(database);
|
||||
// Format 1 (avant les groupes) : groupes par défaut créés avec les permissions des anciens rôles ;
|
||||
// le fichier est réécrit au format 2 à la prochaine modification.
|
||||
database.FormatVersion = AdminDatabase.CurrentFormat;
|
||||
database.EnsureDefaults(DateTimeOffset.UtcNow);
|
||||
return database;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
@@ -102,6 +116,20 @@ public sealed class AdminStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Identifiants de groupes et de permissions : uniques et bien formés (sinon le fichier a été abîmé).</summary>
|
||||
private static void CheckGroups(AdminDatabase database)
|
||||
{
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var group in database.Groups)
|
||||
if (!Contracts.Security.AdminGroups.IsValidGroupId(group.Id) || !ids.Add(group.Id))
|
||||
throw new InvalidDataException($"groupe invalide ou en double : « {group.Id} »");
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var permission in database.CustomPermissions)
|
||||
if (!Contracts.Security.AdminGroups.IsValidPermissionName(permission.Name) ||
|
||||
Contracts.Security.AdminPermissions.IsBuiltIn(permission.Name) || !names.Add(permission.Name))
|
||||
throw new InvalidDataException($"permission invalide ou en double : « {permission.Name} »");
|
||||
}
|
||||
|
||||
private (DateTime, long) Stamp()
|
||||
{
|
||||
var info = new FileInfo(_path);
|
||||
|
||||
@@ -144,11 +144,41 @@ public sealed class AuditLog
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
|
||||
/// précédente (« aaaa-MM:position »). Les fichiers sont lus depuis la fin, par blocs : la mémoire
|
||||
/// utilisée ne dépend pas de leur taille.
|
||||
/// Conservation : supprime les fichiers mensuels dont TOUS les jours sont antérieurs à <paramref name="months"/> mois
|
||||
/// (le mois en cours et les précédents encore dans la fenêtre sont gardés en entier). Renvoie les mois supprimés.
|
||||
/// </summary>
|
||||
public AuditPage Read(int limit, string? cursor)
|
||||
public IReadOnlyList<string> Purge(int months)
|
||||
{
|
||||
if (months < 1) throw new ArgumentOutOfRangeException(nameof(months));
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
// Premier mois conservé : celui qui contient la date limite (ses derniers jours sont encore dans la fenêtre).
|
||||
var cutoff = now.AddMonths(-months);
|
||||
var firstKept = cutoff.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
var removed = new List<string>();
|
||||
if (!Directory.Exists(_directory)) return removed;
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
|
||||
foreach (var file in Directory.GetFiles(_directory, "audit-*.jsonl"))
|
||||
{
|
||||
var month = Path.GetFileName(file)["audit-".Length..^".jsonl".Length];
|
||||
if (!DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) continue;
|
||||
if (string.CompareOrdinal(month, firstKept) >= 0) continue;
|
||||
File.Delete(file);
|
||||
removed.Add(month);
|
||||
}
|
||||
}
|
||||
removed.Sort(StringComparer.Ordinal);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
|
||||
/// précédente (« aaaa-MM:position »). <paramref name="before"/> : seulement les entrées antérieures
|
||||
/// à cet instant (filtre « jusqu'au » du menu ; les mois plus récents ne sont pas lus). Les fichiers
|
||||
/// sont lus depuis la fin, par blocs : la mémoire utilisée ne dépend pas de leur taille.
|
||||
/// </summary>
|
||||
public AuditPage Read(int limit, string? cursor, DateTimeOffset? before = null)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, MaxPageSize);
|
||||
var months = Directory.Exists(_directory)
|
||||
@@ -169,17 +199,23 @@ public sealed class AuditLog
|
||||
startMonth = parts[0];
|
||||
}
|
||||
|
||||
// Mois postérieurs à « before » : sautés sans être lus (une entrée écrite au tout début d'un mois
|
||||
// peut porter l'heure de la fin du mois précédent : le mois de « before » est donc lu en entier).
|
||||
var lastMonth = before?.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
|
||||
var entries = new List<AuditEntry>(limit);
|
||||
for (var m = 0; m < months.Count; m++)
|
||||
{
|
||||
var month = months[m];
|
||||
if (startMonth is not null && string.CompareOrdinal(month, startMonth) > 0) continue;
|
||||
if (lastMonth is not null && string.CompareOrdinal(month, lastMonth) > 0) continue;
|
||||
var end = month == startMonth ? startOffset : long.MaxValue;
|
||||
// Lignes illisibles (écriture interrompue, modification manuelle) ignorées sans rien masquer :
|
||||
// la lecture continue jusqu'à obtenir « limit » entrées lisibles.
|
||||
foreach (var (start, line) in ReadBackward(Path.Combine(_directory, $"audit-{month}.jsonl"), end))
|
||||
{
|
||||
if (TryParse(line) is not { } entry) continue;
|
||||
if (before is { } limitDate && entry.Timestamp >= limitDate) continue;
|
||||
entries.Add(entry);
|
||||
if (entries.Count == limit)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Images du catalogue envoyées depuis le menu d'administration (icône, image de fond), sans client FTP.
|
||||
/// • Contrôle complet AVANT toute écriture (<see cref="CatalogImages.Check"/> : PNG/JPEG reconnu au contenu,
|
||||
/// taille, dimensions, fichier complet sans données ajoutées).
|
||||
/// • Rangées dans le dossier « images » du catalogue public (<c>…/catalog/images</c>, créé à l'installation :
|
||||
/// LISEZMOI.md, étape 3 bis), sous le nom <c><sha256>.png|.jpg</c> : un nom ne désigne jamais deux contenus
|
||||
/// (les launchers gardent les images en cache sous leur adresse) et le launcher des joueurs vérifie l'empreinte.
|
||||
/// • Écriture atomique (fichier temporaire caché du même dossier, puis renommage), lisible par nginx (0644).
|
||||
/// • Nombre et taille totale des images bornés ; espace disque vérifié. Une image envoyée n'est pas publiée
|
||||
/// tant que le catalogue qui la désigne ne l'est pas (page « Jeux » › « Publier ») ; les images inutilisées se
|
||||
/// suppriment depuis la page « Fichiers du serveur ».
|
||||
/// • Le dossier ne doit pas être un lien symbolique ; un fichier existant de même nom qui n'est pas un fichier
|
||||
/// ordinaire est refusé.
|
||||
/// </summary>
|
||||
public sealed class CatalogImageStore
|
||||
{
|
||||
/// <summary>Marge d'espace disque laissée libre après l'écriture.</summary>
|
||||
private const long FreeSpaceMargin = 64L * 1024 * 1024;
|
||||
|
||||
private readonly string? _directory;
|
||||
private readonly Uri? _baseUrl;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public CatalogImageStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.CatalogPublicFileFullPath, options.Value.CatalogPublicUrl) { }
|
||||
|
||||
public CatalogImageStore(string? catalogPublicFile, string catalogPublicUrl)
|
||||
{
|
||||
if (catalogPublicFile is null) return;
|
||||
_directory = Path.Combine(Path.GetDirectoryName(catalogPublicFile)!, CatalogImages.FolderName);
|
||||
_baseUrl = new Uri(new Uri(catalogPublicUrl), CatalogImages.FolderName + "/");
|
||||
}
|
||||
|
||||
public bool Enabled => _directory is not null;
|
||||
|
||||
/// <summary>Contrôle puis publie une image ; renvoie son adresse publique.</summary>
|
||||
/// <exception cref="ApiRejection">Image refusée (400), non configuré ou dossier inutilisable (503), place (409/507).</exception>
|
||||
/// <exception cref="IOException">Écriture impossible (droits du dossier) : rien n'a été publié.</exception>
|
||||
public CatalogImageUploaded Save(string kind, byte[] content)
|
||||
{
|
||||
if (_directory is null || _baseUrl is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile) : impossible d'y envoyer une image.");
|
||||
if (CatalogImages.Check(kind, content, out var info) is { } error) throw ApiRejection.BadRequest(error);
|
||||
|
||||
var sha256 = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
|
||||
var name = $"{sha256}.{info!.Format}";
|
||||
var path = Path.Combine(_directory, name);
|
||||
|
||||
lock (_writeLock)
|
||||
{
|
||||
EnsureDirectory();
|
||||
var existing = new FileInfo(path);
|
||||
if (existing.Exists || existing.LinkTarget is not null)
|
||||
{
|
||||
if (existing.LinkTarget is not null || existing.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Une image de même nom existe sur le serveur mais n'est pas un fichier ordinaire : supprimez-la sur le VPS.");
|
||||
if (existing.Length == content.Length && SameContent(path, sha256))
|
||||
return Result(name, sha256, info, content.Length, alreadyPresent: true);
|
||||
// Contenu différent sous un nom d'empreinte : fichier abîmé ou remplacé à la main, il est réécrit.
|
||||
}
|
||||
|
||||
var files = new DirectoryInfo(_directory).EnumerateFiles().Where(f => !f.Name.StartsWith('.')).ToList();
|
||||
if (files.Count >= CatalogImages.MaxStoredFiles || files.Sum(f => f.Length) + content.Length > CatalogImages.MaxStoredBytes)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Trop d'images sur le serveur ({files.Count} fichiers, {files.Sum(f => f.Length) / 1024 / 1024} Mo ; au plus " +
|
||||
$"{CatalogImages.MaxStoredFiles} fichiers et {CatalogImages.MaxStoredBytes / 1024 / 1024} Mo) : supprimez les images " +
|
||||
"inutilisées depuis la page « Fichiers du serveur » (dossier catalog/images).");
|
||||
try
|
||||
{
|
||||
if (new DriveInfo(_directory).AvailableFreeSpace < content.Length + FreeSpaceMargin)
|
||||
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage, "Espace disque insuffisant sur le serveur.");
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// lecteur non identifiable (tests, système de fichiers particulier) : l'écriture échouera proprement si besoin
|
||||
}
|
||||
|
||||
PublicFileWriter.Write(path, content, "fichier image");
|
||||
return Result(name, sha256, info, content.Length, alreadyPresent: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDirectory()
|
||||
{
|
||||
var directory = new DirectoryInfo(_directory!);
|
||||
if (!directory.Exists)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
$"Le dossier des images du catalogue n'existe pas sur le serveur : créez « {CatalogImages.FolderName} » à côté de " +
|
||||
"catalog.json (server/deploy/LISEZMOI.md, étape 3 bis).");
|
||||
if (directory.LinkTarget is not null || directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Le dossier des images du catalogue est un lien symbolique : refusé (remplacez-le par un vrai dossier).");
|
||||
}
|
||||
|
||||
private static bool SameContent(string path, string sha256)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return string.Equals(Convert.ToHexString(SHA256.HashData(stream)), sha256, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private CatalogImageUploaded Result(string name, string sha256, CatalogImages.ImageInfo info, long bytes, bool alreadyPresent) => new()
|
||||
{
|
||||
Url = new Uri(_baseUrl!, name).AbsoluteUri,
|
||||
FileName = name,
|
||||
Sha256 = sha256,
|
||||
Width = info.Width,
|
||||
Height = info.Height,
|
||||
Bytes = bytes,
|
||||
AlreadyPresent = alreadyPresent
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux (informations modifiables).
|
||||
/// • Référence : catalog.json dans le dossier des données privées (+ historique des 20 dernières révisions).
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Catalog:PublicFile),
|
||||
/// écrite atomiquement (fichier temporaire du même dossier puis renommage), lisible par tous (0644).
|
||||
/// • Contrôle des modifications concurrentes : une publication précise la révision sur laquelle elle
|
||||
/// a été préparée ; si quelqu'un a publié entre-temps, elle est refusée (409).
|
||||
/// </summary>
|
||||
public sealed class CatalogStore
|
||||
{
|
||||
public const string FileName = "catalog.json";
|
||||
private const string HistoryFolder = "catalog-history";
|
||||
private const int HistoryLength = 20;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public CatalogStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.CatalogPublicFileFullPath) { }
|
||||
|
||||
public CatalogStore(string dataDirectory, string? publicFile)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
/// <summary>Catalogue actuel (vide, révision 0, s'il n'a jamais été publié).</summary>
|
||||
public CatalogDocument Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return new CatalogDocument();
|
||||
var text = File.ReadAllText(_path);
|
||||
var document = JsonSerializer.Deserialize<CatalogDocument>(text, Json)
|
||||
?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
var errors = new List<string>();
|
||||
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
|
||||
}
|
||||
|
||||
/// <summary>Catalogue publié tel qu'écrit (sans normalisation : signature comprise), ou null s'il n'a jamais été publié.</summary>
|
||||
public CatalogDocument? ReadPublished()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
|
||||
/// fichier public, puis référence privée et historique.
|
||||
/// </summary>
|
||||
/// <returns>Le catalogue publié et le précédent.</returns>
|
||||
public (CatalogDocument Published, CatalogDocument Previous) Publish(CatalogDocument catalog, long baseRevision, DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile).");
|
||||
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var current = Read();
|
||||
if (current.Revision != baseRevision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le catalogue a été publié entre-temps (révision {current.Revision}) : rechargez-le, puis refaites vos modifications.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
|
||||
var published = catalog with
|
||||
{
|
||||
Format = CatalogDocument.FormatName,
|
||||
Version = CatalogDocument.CurrentVersion,
|
||||
Revision = current.Revision + 1,
|
||||
UpdatedAt = now
|
||||
};
|
||||
var json = JsonSerializer.Serialize(published, Json);
|
||||
if (Encoding.UTF8.GetByteCount(json) > CatalogRules.MaxFileBytes)
|
||||
throw ApiRejection.BadRequest($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko).");
|
||||
|
||||
// 1. Référence privée (+ .bak) : c'est elle qui porte la révision.
|
||||
var previousText = File.Exists(_path) ? File.ReadAllText(_path) : null;
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
|
||||
// 2. Fichier public. En cas d'échec, la référence privée est remise dans son état précédent :
|
||||
// les joueurs et le serveur restent sur la même révision (rien n'est publié à moitié).
|
||||
try
|
||||
{
|
||||
WritePublic(json);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
RestorePrivate(previousText);
|
||||
throw;
|
||||
}
|
||||
|
||||
// 3. Historique (aide uniquement).
|
||||
WriteHistory(published.Revision, json);
|
||||
return (published, current);
|
||||
}
|
||||
}
|
||||
|
||||
private void WritePublic(string json) => PublicFileWriter.Write(_publicFile!, json, "catalogue public");
|
||||
|
||||
private void RestorePrivate(string? previousText)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (previousText is null) File.Delete(_path);
|
||||
else SecureFiles.WriteAllTextAtomic(_path, previousText, keepBackup: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Très improbable (le dossier privé vient d'être écrit) : la révision suivante sera alors
|
||||
// refusée (409) jusqu'au rechargement de la page, sans rien écraser.
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"catalog-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "catalog-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version des jeux, dans le manifest.json PUBLIÉ de chaque jeu (LauncherApi:Games:<id>:ManifestFile).
|
||||
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
|
||||
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
|
||||
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
|
||||
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
|
||||
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
|
||||
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
|
||||
/// </summary>
|
||||
public sealed class ChangelogStore
|
||||
{
|
||||
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
|
||||
public const long MaxManifestBytes = PublishedFiles.MaxManifestBytes;
|
||||
|
||||
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
|
||||
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public ChangelogStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
|
||||
|
||||
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
/// <summary>Notes de version publiées du jeu.</summary>
|
||||
public ChangelogState Read(string gameId)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
var bytes = ReadManifest(path);
|
||||
var root = Parse(bytes, key);
|
||||
return State(key, root, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
|
||||
/// </summary>
|
||||
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
|
||||
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
lock (_writeLock)
|
||||
using (PublishedFiles.LockGame(_dataDirectory, key))
|
||||
{
|
||||
var bytes = ReadManifest(path);
|
||||
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw Conflict();
|
||||
|
||||
var root = Parse(bytes, key);
|
||||
var changelog = ChangelogArray(root)
|
||||
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
|
||||
var index = -1;
|
||||
for (var i = 0; i < changelog.Count; i++)
|
||||
if (changelog[i] is JsonObject candidate && string.Equals(PublishedFiles.StringValue(candidate, "version"), version, StringComparison.Ordinal))
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
if (index < 0)
|
||||
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
|
||||
|
||||
var old = (JsonObject)changelog[index]!;
|
||||
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
|
||||
|
||||
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
|
||||
// anciens launchers ; numéro et date de la version repris du manifest.
|
||||
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
|
||||
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
|
||||
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
|
||||
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
|
||||
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
|
||||
var replacement = old.DeepClone().AsObject();
|
||||
var edited = JsonSerializer.SerializeToNode(clean, PublishedFiles.ManifestJson)!.AsObject();
|
||||
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.DeepClone());
|
||||
changelog[index] = replacement;
|
||||
|
||||
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
|
||||
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
|
||||
if (total > ChangelogLimits.MaxTotalItems)
|
||||
throw ApiRejection.BadRequest(
|
||||
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
|
||||
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(_dataDirectory, key, bytes);
|
||||
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
|
||||
return (State(key, root, newBytes), previous);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw PublishedFiles.NotConfigured(gameId, "Modification des notes de version");
|
||||
}
|
||||
|
||||
private static byte[] ReadManifest(string path) =>
|
||||
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
|
||||
|
||||
internal static JsonObject Parse(byte[] bytes, string gameId)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
|
||||
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
|
||||
var manifestGameId = PublishedFiles.StringValue(root, "gameId");
|
||||
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
|
||||
{
|
||||
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
|
||||
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
|
||||
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
|
||||
return new ChangelogState
|
||||
{
|
||||
GameId = gameId,
|
||||
ManifestVersion = PublishedFiles.StringValue(root, "version") ?? "",
|
||||
ManifestSha256 = Sha256(bytes),
|
||||
Entries = entries
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
|
||||
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
|
||||
{
|
||||
try
|
||||
{
|
||||
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.ManifestJson) ?? new List<ChangelogEntry?>();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
|
||||
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
|
||||
|
||||
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
|
||||
|
||||
private static ApiRejection Conflict() => ApiRejection.Conflict(
|
||||
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
|
||||
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
|
||||
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
|
||||
/// <param name="Details">Détail de chaque changement, en clair.</param>
|
||||
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
|
||||
|
||||
/// <summary>
|
||||
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
|
||||
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
|
||||
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
|
||||
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
|
||||
/// numérotées (1/2, 2/2…) plutôt que tronqué.
|
||||
/// </summary>
|
||||
public static class FeedbackFormsAudit
|
||||
{
|
||||
public const string Published = "feedback.forms";
|
||||
public const string Created = "feedback.form_create";
|
||||
public const string Updated = "feedback.form_update";
|
||||
public const string Deleted = "feedback.form_delete";
|
||||
|
||||
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
|
||||
internal const int MaxDetailsLength = 480;
|
||||
|
||||
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
|
||||
private const int MaxShownValueLength = 80;
|
||||
|
||||
private const string Separator = " ; ";
|
||||
|
||||
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
|
||||
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<FeedbackFormChange>();
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
|
||||
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
|
||||
}
|
||||
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
|
||||
changes.Add(new FeedbackFormChange(Deleted, form.Label,
|
||||
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
|
||||
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
|
||||
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
|
||||
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
|
||||
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
|
||||
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) parts.Add("ordre des types modifié");
|
||||
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
|
||||
}
|
||||
|
||||
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
|
||||
{
|
||||
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
|
||||
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
|
||||
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
|
||||
{
|
||||
// Place réservée au numéro « (12/12) ».
|
||||
var room = Math.Max(20, maxLength - 8);
|
||||
var lines = new List<string>();
|
||||
var current = "";
|
||||
foreach (var detail in details.SelectMany(d => Split(d, room)))
|
||||
{
|
||||
if (current.Length == 0) current = detail;
|
||||
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
|
||||
else
|
||||
{
|
||||
lines.Add(current);
|
||||
current = detail;
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current);
|
||||
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Split(string text, int room)
|
||||
{
|
||||
for (var i = 0; i < text.Length; i += room)
|
||||
yield return text.Substring(i, Math.Min(room, text.Length - i));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Types de demande
|
||||
|
||||
private static List<string> CreatedDetails(FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
|
||||
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
|
||||
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
|
||||
details.Add($"{form.Fields.Count} champ(s)");
|
||||
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
|
||||
return details;
|
||||
}
|
||||
|
||||
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
|
||||
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
|
||||
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
|
||||
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
|
||||
if (old.TechByDefault != form.TechByDefault)
|
||||
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
|
||||
if (old.CustomIcon != form.CustomIcon)
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
|
||||
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
|
||||
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
|
||||
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
|
||||
details.Add($"champ supprimé : « {field.Label} »");
|
||||
foreach (var field in form.Fields)
|
||||
{
|
||||
if (old.Field(field.Id) is not { } before)
|
||||
{
|
||||
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
|
||||
continue;
|
||||
}
|
||||
var parts = FieldDetails(before, field);
|
||||
if (parts.Count == 0) continue;
|
||||
var prefix = $"champ « {field.Label} » modifié : ";
|
||||
var combined = prefix + string.Join(", ", parts);
|
||||
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
|
||||
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
|
||||
else details.AddRange(parts.Select(p => prefix + p));
|
||||
}
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
details.Add("ordre des champs modifié");
|
||||
return details;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Champs
|
||||
|
||||
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
|
||||
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
|
||||
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
|
||||
if ((old.Hint ?? "") != (field.Hint ?? ""))
|
||||
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
|
||||
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
|
||||
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
|
||||
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
|
||||
if (old.ShowInList != field.ShowInList)
|
||||
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
|
||||
parts.AddRange(OptionDetails(old.Choices, field.Choices));
|
||||
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
|
||||
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
|
||||
{
|
||||
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
|
||||
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
|
||||
foreach (var option in now)
|
||||
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
|
||||
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
|
||||
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
|
||||
yield return "ordre des choix modifié";
|
||||
}
|
||||
|
||||
private static string FieldSummary(FeedbackField field)
|
||||
{
|
||||
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
|
||||
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
|
||||
if (field.ShowInList) parts.Add("en aperçu dans la liste");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string KindText(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "zone de texte",
|
||||
FeedbackFieldKind.Text => "texte court",
|
||||
FeedbackFieldKind.Choice => "liste de choix",
|
||||
FeedbackFieldKind.Checkboxes => "cases à cocher",
|
||||
FeedbackFieldKind.Rating => "note en étoiles",
|
||||
FeedbackFieldKind.YesNo => "case oui/non",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Texte
|
||||
|
||||
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
|
||||
private static string ValueChange(string name, bool feminine, string? before, string? after)
|
||||
{
|
||||
var e = feminine ? "e" : "";
|
||||
before = OneLine(before);
|
||||
after = OneLine(after);
|
||||
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
|
||||
if (after.Length == 0) return $"{name} retiré{e}";
|
||||
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
|
||||
? $"{name} : {Quote(before)} → {Quote(after)}"
|
||||
: $"{name} modifié{e}";
|
||||
}
|
||||
|
||||
private static string OneLine(string? value) =>
|
||||
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
private static string Quote(string? value) => $"« {OneLine(value)} »";
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes des joueurs (communs à tous les jeux), publiés depuis le menu d'administration.
|
||||
/// • Référence : feedback-forms.json dans les données privées (+ historique des 20 dernières révisions) ;
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
|
||||
/// l'adresse), écrite atomiquement, lisible par tous ;
|
||||
/// • Toujours signés (autorisation « forms » du trousseau) : le serveur vérifie que la signature est celle de la
|
||||
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
|
||||
/// Rien de publié : les formulaires d'origine (révision 0) s'appliquent.
|
||||
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormsStore
|
||||
{
|
||||
public const string FileName = "feedback-forms.json";
|
||||
private const string HistoryFolder = "feedback-forms-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _lock = new();
|
||||
private FeedbackFormsDocument? _current;
|
||||
|
||||
public FeedbackFormsStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.FeedbackFormsPublicFileFullPath, options.Value.FeedbackFormsPublicUrl) { }
|
||||
|
||||
public FeedbackFormsStore(string dataDirectory, string? publicFile, string? publicUrl = null)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
PublicUrl = publicUrl;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
public string? PublicUrl { get; }
|
||||
|
||||
/// <summary>Formulaires en vigueur : ceux publiés, sinon ceux d'origine (révision 0).</summary>
|
||||
public FeedbackFormsDocument Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _current ??= Read() ?? FeedbackFormRules.Defaults();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaire à utiliser pour valider une demande, ou la raison du refus. Révision 0 (formulaires d'origine) :
|
||||
/// toujours acceptée (launchers 3.0 / 3.1, formulaires publiés illisibles chez le joueur), sauf pour un type
|
||||
/// masqué ou supprimé depuis. Autre révision : seule celle en vigueur est acceptée (409 : le launcher recharge les
|
||||
/// formulaires).
|
||||
/// </summary>
|
||||
/// <returns>Le formulaire du type, et s'il faut le conserver avec la demande (formulaires publiés).</returns>
|
||||
public (FeedbackForm Form, bool Snapshot) Resolve(long revision, string? kind)
|
||||
{
|
||||
var current = Current;
|
||||
if (current.Find(kind) is { Hidden: true } ||
|
||||
(current.Revision > 0 && current.Find(kind) is null && FeedbackForms.Find(kind) is not null))
|
||||
throw ApiRejection.BadRequest("Ce type de demande n'est plus proposé.");
|
||||
if (revision == 0)
|
||||
return (FeedbackForms.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), false);
|
||||
if (revision != current.Revision)
|
||||
throw new ApiRejection(409, ApiErrorCodes.FeedbackFormChanged,
|
||||
"Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis envoyez de nouveau.");
|
||||
return (current.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), current.Revision > 0);
|
||||
}
|
||||
|
||||
/// <summary>Formulaires publiés (null : aucun).</summary>
|
||||
private FeedbackFormsDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return FeedbackFormRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie des formulaires signés (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue
|
||||
/// (409 si une autre publication a eu lieu), révision suivante obligatoire.
|
||||
/// </summary>
|
||||
/// <returns>Formulaires publiés (avec la date) et ceux d'avant.</returns>
|
||||
public (FeedbackFormsDocument Published, FeedbackFormsDocument Previous) Publish(FeedbackFormsDocument document, long baseRevision,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication des formulaires non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
|
||||
"LauncherApi:Catalog:PublicFile) est nécessaire, les formulaires sont publiés à côté.");
|
||||
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Formulaires non signés.");
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors))
|
||||
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
lock (_lock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var previous = Read() ?? FeedbackFormRules.Defaults();
|
||||
if (baseRevision != previous.Revision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Les formulaires ont été publiés entre-temps (révision {previous.Revision}) : rechargez la page, puis refaites vos modifications.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (document.Revision != previous.Revision + 1)
|
||||
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
|
||||
|
||||
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
|
||||
var json = FeedbackFormRules.Serialize(published);
|
||||
if (Encoding.UTF8.GetByteCount(json) > FeedbackFormRules.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Formulaires trop volumineux (au plus {FeedbackFormRules.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "formulaires des demandes");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(published.Revision, json);
|
||||
_current = published;
|
||||
return (published, previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"feedback-forms-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "feedback-forms-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
|
||||
.Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>Demande enregistrée (un fichier JSON par demande, dans les données privées).</summary>
|
||||
public sealed record FeedbackRecord
|
||||
{
|
||||
public const string FormatName = "gamelauncher-feedback";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
public string Id { get; init; } = "";
|
||||
public string Reference { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public int FormVersion { get; init; }
|
||||
|
||||
/// <summary>Révision des formulaires utilisés (0 : formulaires d'origine).</summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
/// <summary>Formulaire tel qu'à l'envoi (formulaires publiés depuis le menu) ; null : formulaires d'origine.</summary>
|
||||
public FeedbackForm? Form { get; init; }
|
||||
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public string Title { get; init; } = "";
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
|
||||
/// <summary>Captures annoncées à l'envoi (celles réellement reçues : <see cref="Attachments"/>).</summary>
|
||||
public int AttachmentCount { get; init; }
|
||||
|
||||
/// <summary>Captures reçues, nettoyées (fichiers <c><id>-<n>.png|jpg</c> à côté de la demande).</summary>
|
||||
public List<FeedbackAttachmentInfo> Attachments { get; init; } = new();
|
||||
public string Status { get; init; } = FeedbackStatuses.New;
|
||||
public long Revision { get; init; } = 1;
|
||||
public string? AdminNote { get; init; }
|
||||
public DateTimeOffset? StatusChangedAt { get; init; }
|
||||
public string? StatusChangedBy { get; init; }
|
||||
|
||||
public FeedbackSummary ToSummary() => new()
|
||||
{
|
||||
Id = Id,
|
||||
Reference = Reference,
|
||||
GameId = GameId,
|
||||
Kind = Kind,
|
||||
Title = Title,
|
||||
Status = Status,
|
||||
ReceivedAt = ReceivedAt,
|
||||
StatusChangedAt = StatusChangedAt,
|
||||
Badge = (Form ?? FeedbackForms.Find(Kind))?.Badge(Fields),
|
||||
HasNote = !string.IsNullOrEmpty(AdminNote),
|
||||
HasLinks = FeedbackLinks.Contains(Title + "\n" + string.Join("\n", Fields.Values)),
|
||||
AttachmentCount = Attachments.Count,
|
||||
Revision = Revision
|
||||
};
|
||||
|
||||
public FeedbackDetail ToDetail() => new()
|
||||
{
|
||||
Summary = ToSummary(),
|
||||
FormVersion = FormVersion,
|
||||
FormRevision = FormRevision,
|
||||
Form = Form,
|
||||
Fields = Fields,
|
||||
Tech = Tech,
|
||||
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
|
||||
AdminNote = AdminNote,
|
||||
StatusChangedBy = StatusChangedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demandes des joueurs : <c>feedback/<jeu>/<AAAA-MM>/<id>.json</c> dans le dossier des données privées
|
||||
/// (0700, fichiers 0600, jamais servi par nginx). Un fichier par demande : écriture atomique (fichier temporaire puis
|
||||
/// renommage) d'une seule demande à la fois, suppression et purge par simple suppression de fichiers. Un index en
|
||||
/// mémoire (résumés) est construit au premier accès : les listes ne relisent pas le disque. Seul le service écrit
|
||||
/// ici (l'outil en ligne de commande n'y touche pas) : un verrou en mémoire suffit.
|
||||
///
|
||||
/// Choix (au lieu de SQLite) : volumes bornés (<see cref="LauncherApiOptions.FeedbackSettings.MaxPerGame"/>), aucune
|
||||
/// relation entre données, pas de dépendance native à maintenir sur le VPS, sauvegarde par simple copie de dossier,
|
||||
/// cohérent avec les autres stores du serveur. À réévaluer avec le futur module « comptes + échanges ».
|
||||
/// </summary>
|
||||
public sealed class FeedbackStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
private readonly string _root;
|
||||
private readonly LauncherApiOptions.FeedbackSettings _settings;
|
||||
private readonly IReadOnlySet<string> _games;
|
||||
private readonly ILogger<FeedbackStore>? _logger;
|
||||
private readonly object _lock = new();
|
||||
private Dictionary<string, Dictionary<string, FeedbackRecord>>? _index;
|
||||
private long _totalBytes;
|
||||
|
||||
public FeedbackStore(IOptions<LauncherApiOptions> options, ILogger<FeedbackStore> logger)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.Feedback, options.Value.FeedbackGameIds, logger) { }
|
||||
|
||||
public FeedbackStore(string dataDirectory, LauncherApiOptions.FeedbackSettings settings, IReadOnlySet<string> games,
|
||||
ILogger<FeedbackStore>? logger = null)
|
||||
{
|
||||
_root = Path.Combine(dataDirectory, "feedback");
|
||||
_settings = settings;
|
||||
_games = games;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool Enabled => _settings.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de jeu tel que configuré (casse du réglage), ou null si ce jeu n'accepte pas de demandes.
|
||||
/// Seul ce résultat sert à construire un chemin (jamais la valeur reçue).
|
||||
/// </summary>
|
||||
public string? ResolveGame(string? gameId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gameId) || gameId.StartsWith('.')) return null;
|
||||
return _games.FirstOrDefault(g => string.Equals(g, gameId, StringComparison.OrdinalIgnoreCase) && !g.StartsWith('.'));
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Games => _games.Where(g => !g.StartsWith('.')).Order(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
// ------------------------------------------------------------------ Envoi
|
||||
|
||||
/// <summary>Raison d'un refus d'enregistrement pour cause de plafond (null : place disponible).</summary>
|
||||
public string? FullReason(string gameId, DateTimeOffset now, long extraBytes = 0)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = Index();
|
||||
var game = index.TryGetValue(gameId, out var records) ? records : null;
|
||||
if (game is not null)
|
||||
{
|
||||
if (game.Count >= _settings.MaxPerGame) return $"{game.Count} demandes conservées pour ce jeu (plafond {_settings.MaxPerGame})";
|
||||
var lastDay = game.Values.Count(r => r.ReceivedAt > now - TimeSpan.FromDays(1));
|
||||
if (lastDay >= _settings.MaxPerGamePerDay) return $"{lastDay} demandes reçues en 24 h pour ce jeu (plafond {_settings.MaxPerGamePerDay})";
|
||||
}
|
||||
if (_totalBytes + extraBytes >= (long)_settings.MaxTotalMegabytes * 1024 * 1024)
|
||||
return $"taille totale des demandes {_totalBytes / (1024 * 1024)} Mo (plafond {_settings.MaxTotalMegabytes} Mo)";
|
||||
if (FreeDiskBytes() is { } free && free < (long)_settings.MinFreeDiskMegabytes * 1024 * 1024)
|
||||
return $"espace disque libre {free / (1024 * 1024)} Mo (minimum {_settings.MinFreeDiskMegabytes} Mo)";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enregistre une demande déjà validée (<see cref="FeedbackRules.Validate"/>). Null : plafond atteint.</summary>
|
||||
/// <param name="form">Formulaire utilisé, conservé avec la demande (null : formulaires d'origine).</param>
|
||||
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now, FeedbackForm? form = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (FullReason(gameId, now) is not null) return null;
|
||||
var index = Index();
|
||||
if (!index.TryGetValue(gameId, out var game)) index[gameId] = game = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
|
||||
|
||||
var existingReferences = index.Values.SelectMany(g => g.Values).Select(r => r.Reference).ToHashSet(StringComparer.Ordinal);
|
||||
string reference;
|
||||
do reference = FeedbackRules.NewReference(); while (existingReferences.Contains(reference));
|
||||
|
||||
var record = new FeedbackRecord
|
||||
{
|
||||
Id = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
Reference = reference,
|
||||
GameId = gameId,
|
||||
Kind = submission.Kind,
|
||||
FormVersion = submission.FormVersion,
|
||||
FormRevision = form is null ? 0 : submission.FormRevision,
|
||||
Form = form,
|
||||
ReceivedAt = now,
|
||||
Title = submission.Title,
|
||||
Fields = new Dictionary<string, string>(submission.Fields, StringComparer.Ordinal),
|
||||
Tech = submission.Tech,
|
||||
AttachmentCount = submission.AttachmentCount,
|
||||
Status = FeedbackStatuses.New,
|
||||
Revision = 1
|
||||
};
|
||||
Write(record);
|
||||
game[record.Id] = record;
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Captures d'écran
|
||||
|
||||
/// <summary>Demande (tous jeux confondus) d'après son identifiant interne.</summary>
|
||||
public FeedbackRecord? FindById(string id)
|
||||
{
|
||||
if (!FeedbackRules.IsValidId(id)) return null;
|
||||
lock (_lock)
|
||||
foreach (var game in Index().Values)
|
||||
if (game.TryGetValue(id, out var record)) return record;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre une capture DÉJÀ contrôlée et nettoyée (<see cref="FeedbackImages.Sanitize"/>). Null : plafond atteint.
|
||||
/// Une capture de même numéro est remplacée (nouvel essai après une coupure).
|
||||
/// </summary>
|
||||
public FeedbackRecord? AddAttachment(string id, int index, byte[] clean, FeedbackImages.ImageInfo info, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var record = FindById(id) ?? throw ApiRejection.NotFound("Demande introuvable.");
|
||||
if (index < 1 || index > record.AttachmentCount) throw ApiRejection.BadRequest("Numéro de capture invalide.");
|
||||
var previous = record.Attachments.FirstOrDefault(a => a.Index == index);
|
||||
if (FullReason(record.GameId, now, clean.Length - (previous?.Bytes ?? 0)) is not null) return null;
|
||||
|
||||
var path = AttachmentPath(record, index, info.Format);
|
||||
if (previous is not null && previous.Format != info.Format) DeleteAttachmentFile(record, previous);
|
||||
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
SecureFiles.WriteAllBytesAtomic(path, clean);
|
||||
_totalBytes += clean.Length - oldSize;
|
||||
|
||||
var attachments = record.Attachments.Where(a => a.Index != index)
|
||||
.Append(new FeedbackAttachmentInfo { Index = index, Format = info.Format, Width = info.Width, Height = info.Height, Bytes = clean.Length })
|
||||
.OrderBy(a => a.Index).ToList();
|
||||
var updated = record with { Attachments = attachments };
|
||||
Write(updated);
|
||||
Index()[record.GameId][id] = updated;
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'une capture (null : absente).</summary>
|
||||
public byte[]? ReadAttachment(FeedbackRecord record, int index)
|
||||
{
|
||||
var info = record.Attachments.FirstOrDefault(a => a.Index == index);
|
||||
if (info is null) return null;
|
||||
var path = AttachmentPath(record, index, info.Format);
|
||||
if (!File.Exists(path) || IsLink(path) || new FileInfo(path).Length > FeedbackImages.MaxBytes) return null;
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Consultation
|
||||
|
||||
public IReadOnlyList<FeedbackGameCounts> Counts()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = Index();
|
||||
return Games.Select(id =>
|
||||
{
|
||||
var records = index.TryGetValue(id, out var game) ? game.Values : (IEnumerable<FeedbackRecord>)Array.Empty<FeedbackRecord>();
|
||||
var list = records.ToList();
|
||||
return new FeedbackGameCounts
|
||||
{
|
||||
GameId = id,
|
||||
New = list.Count(r => r.Status == FeedbackStatuses.New),
|
||||
Open = list.Count(r => !FeedbackStatuses.IsClosed(r.Status)),
|
||||
Total = list.Count
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demandes d'un jeu, les plus récentes d'abord. <paramref name="cursor"/> : « ticks-id » de la dernière demande
|
||||
/// de la page précédente (stable même si des demandes arrivent entre deux pages).
|
||||
/// </summary>
|
||||
public FeedbackPage List(string gameId, string? kind, string? status, string? cursor, int limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var records = Index().TryGetValue(gameId, out var game) ? game.Values : Enumerable.Empty<FeedbackRecord>();
|
||||
var filtered = records.Where(r => (kind is null || r.Kind == kind) && (status is null || MatchesStatus(r.Status, status)))
|
||||
.OrderByDescending(r => r.ReceivedAt).ThenByDescending(r => r.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var start = 0;
|
||||
if (TryParseCursor(cursor, out var ticks, out var id))
|
||||
{
|
||||
start = filtered.FindIndex(r => r.ReceivedAt.UtcTicks < ticks ||
|
||||
(r.ReceivedAt.UtcTicks == ticks && string.CompareOrdinal(r.Id, id) < 0));
|
||||
if (start < 0) start = filtered.Count;
|
||||
}
|
||||
var page = filtered.Skip(start).Take(limit).ToList();
|
||||
var more = start + page.Count < filtered.Count;
|
||||
return new FeedbackPage
|
||||
{
|
||||
Items = page.Select(r => r.ToSummary()).ToList(),
|
||||
NextCursor = more && page.Count > 0 ? $"{page[^1].ReceivedAt.UtcTicks}-{page[^1].Id}" : null,
|
||||
Total = filtered.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Filtre de statut : un statut précis, ou « open » (non close) / « closed ».</summary>
|
||||
public const string OpenFilter = "open";
|
||||
public const string ClosedFilter = "closed";
|
||||
|
||||
public static bool IsValidStatusFilter(string? status) => status is null or OpenFilter or ClosedFilter || FeedbackStatuses.IsValid(status);
|
||||
|
||||
private static bool MatchesStatus(string status, string filter) => filter switch
|
||||
{
|
||||
OpenFilter => !FeedbackStatuses.IsClosed(status),
|
||||
ClosedFilter => FeedbackStatuses.IsClosed(status),
|
||||
_ => status == filter
|
||||
};
|
||||
|
||||
private static bool TryParseCursor(string? cursor, out long ticks, out string id)
|
||||
{
|
||||
ticks = 0;
|
||||
id = "";
|
||||
if (string.IsNullOrEmpty(cursor) || cursor.Length > 60) return false;
|
||||
var dash = cursor.IndexOf('-');
|
||||
if (dash <= 0 || !long.TryParse(cursor[..dash], NumberStyles.None, CultureInfo.InvariantCulture, out ticks)) return false;
|
||||
id = cursor[(dash + 1)..];
|
||||
return FeedbackRules.IsValidId(id);
|
||||
}
|
||||
|
||||
public FeedbackRecord? Find(string gameId, string id)
|
||||
{
|
||||
if (!FeedbackRules.IsValidId(id)) return null;
|
||||
lock (_lock)
|
||||
return Index().TryGetValue(gameId, out var game) && game.TryGetValue(id, out var record) ? record : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Traitement
|
||||
|
||||
/// <summary>Statut et note interne ; révision attendue (409 sinon), refus si la demande n'existe plus (404).</summary>
|
||||
public FeedbackRecord Update(string gameId, string id, long revision, string status, string? note, string actorName, DateTimeOffset now,
|
||||
out FeedbackRecord previous)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
previous = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (supprimée entre-temps ?).");
|
||||
if (previous.Revision != revision)
|
||||
throw ApiRejection.Conflict("La demande a été modifiée par un autre administrateur : rechargez-la.", ApiErrorCodes.RevisionConflict);
|
||||
var updated = previous with
|
||||
{
|
||||
Status = status,
|
||||
AdminNote = note,
|
||||
Revision = previous.Revision + 1,
|
||||
StatusChangedAt = now,
|
||||
StatusChangedBy = actorName
|
||||
};
|
||||
Write(updated);
|
||||
Index()[gameId][id] = updated;
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
public FeedbackRecord Delete(string gameId, string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var record = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (déjà supprimée ?).");
|
||||
DeleteFile(record);
|
||||
Index()[gameId].Remove(id);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge (conservation RGPD) : demandes closes depuis plus de <c>RetentionClosedMonths</c>, et toutes celles reçues
|
||||
/// depuis plus de <c>RetentionMaxMonths</c>. Renvoie le nombre de demandes supprimées.
|
||||
/// </summary>
|
||||
public int Purge(DateTimeOffset now)
|
||||
{
|
||||
var closedLimit = now.AddMonths(-_settings.RetentionClosedMonths);
|
||||
var maxLimit = now.AddMonths(-_settings.RetentionMaxMonths);
|
||||
var removed = 0;
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var game in Index().Values)
|
||||
{
|
||||
var expired = game.Values.Where(r => r.ReceivedAt < maxLimit ||
|
||||
(FeedbackStatuses.IsClosed(r.Status) && (r.StatusChangedAt ?? r.ReceivedAt) < closedLimit))
|
||||
.ToList();
|
||||
foreach (var record in expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteFile(record);
|
||||
game.Remove(record.Id);
|
||||
removed++;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger?.LogError(ex, "Purge de la demande {Reference} impossible", record.Reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
RemoveEmptyFolders();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Fichiers
|
||||
|
||||
private string GameFolder(string gameId) => Path.Combine(_root, gameId);
|
||||
|
||||
private string PathOf(FeedbackRecord record) =>
|
||||
Path.Combine(GameFolder(record.GameId), record.ReceivedAt.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture), record.Id + ".json");
|
||||
|
||||
private void Write(FeedbackRecord record)
|
||||
{
|
||||
if (ResolveGame(record.GameId) != record.GameId || !FeedbackRules.IsValidId(record.Id))
|
||||
throw new InvalidOperationException("Demande invalide : écriture refusée.");
|
||||
var path = PathOf(record);
|
||||
SecureFiles.EnsureDirectory(_root);
|
||||
SecureFiles.EnsureDirectory(GameFolder(record.GameId));
|
||||
SecureFiles.EnsureDirectory(Path.GetDirectoryName(path)!);
|
||||
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
SecureFiles.WriteAllTextAtomic(path, JsonSerializer.Serialize(record, Json), keepBackup: false);
|
||||
_totalBytes += new FileInfo(path).Length - oldSize;
|
||||
}
|
||||
|
||||
private string AttachmentPath(FeedbackRecord record, int index, string format)
|
||||
{
|
||||
if (format is not (FeedbackImages.Png or FeedbackImages.Jpeg) || index is < 1 or > FeedbackImages.MaxAttachments)
|
||||
throw new InvalidOperationException("Capture invalide.");
|
||||
return Path.Combine(Path.GetDirectoryName(PathOf(record))!, $"{record.Id}-{index}.{format}");
|
||||
}
|
||||
|
||||
private void DeleteAttachmentFile(FeedbackRecord record, FeedbackAttachmentInfo attachment)
|
||||
{
|
||||
var path = AttachmentPath(record, attachment.Index, attachment.Format);
|
||||
if (!File.Exists(path)) return;
|
||||
var size = new FileInfo(path).Length;
|
||||
File.Delete(path);
|
||||
_totalBytes -= size;
|
||||
}
|
||||
|
||||
private void DeleteFile(FeedbackRecord record)
|
||||
{
|
||||
foreach (var attachment in record.Attachments) DeleteAttachmentFile(record, attachment);
|
||||
var path = PathOf(record);
|
||||
if (!File.Exists(path)) return;
|
||||
var size = new FileInfo(path).Length;
|
||||
File.Delete(path);
|
||||
_totalBytes -= size;
|
||||
}
|
||||
|
||||
private void RemoveEmptyFolders()
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
foreach (var game in Directory.GetDirectories(_root))
|
||||
foreach (var month in Directory.GetDirectories(game))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(month).Any()) Directory.Delete(month);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// dossier en cours d'utilisation : nettoyé à la purge suivante
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long? FreeDiskBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = Directory.Exists(_root) ? _root : Path.GetDirectoryName(_root)!;
|
||||
return new DriveInfo(Path.GetFullPath(folder)).AvailableFreeSpace; // même usage que les autres stores
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ArgumentException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index construit au premier accès : lecture de tous les fichiers des jeux configurés (dossiers et fichiers aux noms
|
||||
/// attendus uniquement, liens symboliques ignorés). Un fichier illisible est signalé et ignoré : il n'empêche ni le
|
||||
/// démarrage ni la réception.
|
||||
/// </summary>
|
||||
private Dictionary<string, Dictionary<string, FeedbackRecord>> Index()
|
||||
{
|
||||
if (_index is not null) return _index;
|
||||
var index = new Dictionary<string, Dictionary<string, FeedbackRecord>>(StringComparer.Ordinal);
|
||||
long total = 0;
|
||||
foreach (var gameId in Games)
|
||||
{
|
||||
var records = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
|
||||
index[gameId] = records;
|
||||
var folder = GameFolder(gameId);
|
||||
if (!Directory.Exists(folder) || IsLink(folder)) continue;
|
||||
foreach (var month in Directory.GetDirectories(folder))
|
||||
{
|
||||
if (IsLink(month) || !DateTime.TryParseExact(Path.GetFileName(month), "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
continue;
|
||||
foreach (var file in Directory.GetFiles(month, "*.json"))
|
||||
{
|
||||
var id = Path.GetFileNameWithoutExtension(file);
|
||||
if (!FeedbackRules.IsValidId(id) || IsLink(file)) continue;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length > 256 * 1024) throw new InvalidDataException("fichier trop volumineux");
|
||||
var record = JsonSerializer.Deserialize<FeedbackRecord>(File.ReadAllText(file), Json)
|
||||
?? throw new InvalidDataException("fichier vide");
|
||||
if (record.Format != FeedbackRecord.FormatName || record.Id != id ||
|
||||
!string.Equals(record.GameId, gameId, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("contenu inattendu");
|
||||
var attachments = (record.Attachments ?? new List<FeedbackAttachmentInfo>())
|
||||
.Where(a => a.Format is FeedbackImages.Png or FeedbackImages.Jpeg && a.Index is >= 1 and <= FeedbackImages.MaxAttachments)
|
||||
.ToList();
|
||||
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>(), Attachments = attachments };
|
||||
total += info.Length;
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var attachmentPath = Path.Combine(month, $"{id}-{attachment.Index}.{attachment.Format}");
|
||||
if (File.Exists(attachmentPath)) total += new FileInfo(attachmentPath).Length;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger?.LogWarning("Demande ignorée (fichier illisible) : {File} ({Message})", file, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_totalBytes = total;
|
||||
return _index = index;
|
||||
}
|
||||
|
||||
private static bool IsLink(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileInfo(path).LinkTarget is not null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau publié (phase 7).
|
||||
/// • Référence : keyring.json dans le dossier des données privées (+ historique).
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Keyring:PublicFile, par défaut à côté
|
||||
/// du catalogue), écrite atomiquement, lisible par tous.
|
||||
/// • Le serveur ne détient pas la clé racine et ne vérifie que la structure et le numéro (toujours croissant) :
|
||||
/// c'est le launcher qui vérifie la signature racine (avant l'envoi dans le menu d'administration, puis chez
|
||||
/// chaque joueur). Un trousseau falsifié serait refusé par tous les launchers.
|
||||
/// </summary>
|
||||
public sealed class KeyringStore
|
||||
{
|
||||
public const string FileName = "keyring.json";
|
||||
private const string HistoryFolder = "keyring-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public KeyringStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.KeyringPublicFileFullPath) { }
|
||||
|
||||
public KeyringStore(string dataDirectory, string? publicFile)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
/// <summary>Trousseau publié, ou null.</summary>
|
||||
public KeyringDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return Keyring.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>Publie un trousseau signé (structure contrôlée, numéro supérieur au trousseau actuel).</summary>
|
||||
/// <returns>Le trousseau précédent (null : premier).</returns>
|
||||
public KeyringDocument? Publish(KeyringDocument keyring)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou LauncherApi:Catalog:PublicFile).");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
if (keyring.Signature is null || string.IsNullOrEmpty(keyring.Signature.Value))
|
||||
throw ApiRejection.BadRequest("Trousseau non signé : signez-le d'abord avec la clé racine (tools\\sign-keyring.bat).");
|
||||
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var current = Read();
|
||||
if (current is not null && keyring.Sequence <= current.Sequence)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Trousseau n° {keyring.Sequence} refusé : le trousseau publié porte déjà le n° {current.Sequence} " +
|
||||
"(exportez une nouvelle demande, qui prend le numéro suivant).", ApiErrorCodes.RevisionConflict);
|
||||
var json = Keyring.Serialize(keyring);
|
||||
if (System.Text.Encoding.UTF8.GetByteCount(json) > Keyring.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Trousseau trop volumineux (au plus {Keyring.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte le numéro) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "trousseau public");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(keyring.Sequence, json);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long sequence, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"keyring-{sequence:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "keyring-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux), activé ou désactivé depuis le menu d'administration.
|
||||
/// • Référence : maintenance.json dans les données privées (+ historique des 20 dernières révisions) ;
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
|
||||
/// l'adresse), écrite atomiquement, lisible par tous ;
|
||||
/// • Toujours signé (autorisation « maintenance » du trousseau) : le serveur vérifie que la signature est celle de la
|
||||
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
|
||||
/// Rien de publié : maintenance inactive (révision 0).
|
||||
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceStore
|
||||
{
|
||||
public const string FileName = "maintenance.json";
|
||||
private const string HistoryFolder = "maintenance-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _lock = new();
|
||||
private MaintenanceDocument? _current;
|
||||
|
||||
public MaintenanceStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.MaintenancePublicFileFullPath, options.Value.MaintenancePublicUrl) { }
|
||||
|
||||
public MaintenanceStore(string dataDirectory, string? publicFile, string? publicUrl = null)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
PublicUrl = publicUrl;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
public string? PublicUrl { get; }
|
||||
|
||||
/// <summary>État en vigueur : celui publié, sinon maintenance inactive (révision 0).</summary>
|
||||
/// <exception cref="InvalidDataException">Fichier de référence illisible.</exception>
|
||||
public MaintenanceDocument Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _current ??= Read() ?? MaintenanceRules.Defaults();
|
||||
}
|
||||
}
|
||||
|
||||
private MaintenanceDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
try
|
||||
{
|
||||
return MaintenanceRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"{FileName} illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un document signé (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue (409 si
|
||||
/// une autre publication a eu lieu), révision suivante obligatoire. Pendant une maintenance, le début annoncé ne
|
||||
/// change pas (modification du message seulement).
|
||||
/// </summary>
|
||||
/// <returns>Document publié (avec la date) et celui d'avant.</returns>
|
||||
public (MaintenanceDocument Published, MaintenanceDocument Previous) Publish(MaintenanceDocument document, long baseRevision, DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du mode maintenance non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
|
||||
"LauncherApi:Catalog:PublicFile) est nécessaire, le mode maintenance est publié à côté.");
|
||||
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Mode maintenance non signé.");
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors, now))
|
||||
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
|
||||
lock (_lock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var previous = Read() ?? MaintenanceRules.Defaults();
|
||||
if (baseRevision != previous.Revision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le mode maintenance a été modifié entre-temps (révision {previous.Revision}) : rechargez la page, puis recommencez.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (document.Revision != previous.Revision + 1)
|
||||
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
|
||||
if (document.Enabled && previous.Enabled && document.Since != previous.Since)
|
||||
throw ApiRejection.BadRequest("Maintenance déjà en cours : sa date de début ne change pas (seul le message peut être modifié).");
|
||||
|
||||
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
|
||||
var json = MaintenanceRules.Serialize(published);
|
||||
if (Encoding.UTF8.GetByteCount(json) > MaintenanceRules.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Mode maintenance trop volumineux (au plus {MaintenanceRules.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "fichier public du mode maintenance");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(published.Revision, json);
|
||||
_current = published;
|
||||
return (published, previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"maintenance-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "maintenance-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
|
||||
.Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du manifest déjà en ligne d'un jeu (version publiée avant la phase 7, ou avec WinSCP) : le launcher
|
||||
/// envoie la signature du contenu de SON manifest.json (dossier de publication) ; le serveur ne l'ajoute que si la
|
||||
/// version en ligne a exactement ce contenu, puis réécrit le manifest atomiquement (mêmes règles que les notes).
|
||||
/// </summary>
|
||||
public static class ManifestSignatures
|
||||
{
|
||||
public static ManifestSignatureState Read(string gameId, string manifestPath, AdminDatabase admins)
|
||||
{
|
||||
if (PublishedFiles.ReadManifest(manifestPath) is not { } bytes) return new ManifestSignatureState { GameId = gameId };
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
var signature = ContentSignatures.ReadSignature(root);
|
||||
return new ManifestSignatureState
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = PublishedFiles.StringValue(root, "version") ?? "",
|
||||
Digest = Digest(bytes),
|
||||
Signature = signature,
|
||||
SignerName = ContentSignatures.SignerName(admins, signature)
|
||||
};
|
||||
}
|
||||
|
||||
/// <param name="verify">Contrôle de la signature sur le condensé de la version en ligne (lève une ApiRejection).</param>
|
||||
public static ManifestSignatureState Sign(string gameId, string manifestPath, string dataDirectory, ManifestSignRequest request,
|
||||
Action<string> verify, AdminDatabase admins)
|
||||
{
|
||||
using (PublishedFiles.LockGame(dataDirectory, gameId))
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath)
|
||||
?? throw ApiRejection.NotFound("Aucune version en ligne pour ce jeu.");
|
||||
var digest = Digest(bytes);
|
||||
if (!string.Equals(digest, request.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
throw ApiRejection.Conflict(
|
||||
"La version en ligne ne correspond pas au manifest.json choisi (fichiers, archive ou exécutable différents) : " +
|
||||
"choisissez le dossier de publication de la version en ligne.", ApiErrorCodes.RevisionConflict);
|
||||
verify(digest);
|
||||
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "signature", JsonSerializer.SerializeToNode(request.Signature, ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(dataDirectory, gameId, bytes);
|
||||
PublishedFiles.WriteManifest(manifestPath, newBytes, PublishedFiles.Sha256(bytes),
|
||||
() => ApiRejection.Conflict("Le manifest a été modifié pendant la signature : recommencez.", ApiErrorCodes.RevisionConflict));
|
||||
return Read(gameId, manifestPath, admins);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Digest(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ContentSigning.ManifestDigest(PublishedFiles.StripBom(bytes));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest en ligne illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichier PUBLIC servi par nginx (catalogue, trousseau, images du catalogue) : écrit atomiquement (fichier temporaire caché du
|
||||
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
|
||||
/// </summary>
|
||||
internal static class PublicFileWriter
|
||||
{
|
||||
public const UnixFileMode Mode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
|
||||
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
|
||||
public static void Write(string path, string text, string what) =>
|
||||
Write(path, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text), what);
|
||||
|
||||
/// <summary>Contenu binaire (image du catalogue) : mêmes garanties que pour un texte.</summary>
|
||||
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
|
||||
public static void Write(string path, byte[] content, string what)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(content);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
|
||||
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, Mode);
|
||||
File.Move(temp, path, overwrite: true); // renommage atomique dans le même dossier
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fichier temporaire laissé : sans conséquence (nom unique)
|
||||
}
|
||||
throw new IOException($"Écriture du {what} impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version depuis le menu d'administration (phase 5).
|
||||
/// • Dépôt : dossier CACHÉ « .publish-<id> » dans le dossier du jeu (même disque : la mise en ligne
|
||||
/// finale n'est qu'une suite de renommages ; nginx refuse les chemins commençant par un point), droits
|
||||
/// 0700 ; description (version, plan) dans les données privées.
|
||||
/// • Envoi : manifest, puis objets absents du serveur (par lots, ou par morceaux pour les gros), puis
|
||||
/// archive par morceaux ; chaque fichier est reçu à la suite (position vérifiée : reprise possible
|
||||
/// après une coupure) puis vérifié (SHA-256) avant d'être accepté.
|
||||
/// • Mise en ligne (commit) : tout est revérifié AVANT de déplacer quoi que ce soit ; objets puis archive
|
||||
/// installés, manifest en DERNIER (comme avec WinSCP), historique des notes du serveur conservé ; si
|
||||
/// l'écriture du manifest échoue, l'archive remplacée est remise en place. Jamais si les fichiers en
|
||||
/// ligne ont changé pendant l'envoi (autre publication, WinSCP).
|
||||
/// • Une publication par jeu ; abandonnée au bout de 48 h sans envoi ; résultat conservé 24 h (une
|
||||
/// réponse perdue n'empêche pas de constater que la version est en ligne).
|
||||
/// </summary>
|
||||
public sealed class PublicationStore
|
||||
{
|
||||
private const string RecordsFolder = "publications";
|
||||
private const string StagingPrefix = ".publish-";
|
||||
private const string DoneSuffix = ".done";
|
||||
private const long FreeSpaceMargin = 256L * 1024 * 1024;
|
||||
private static readonly TimeSpan DoneLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan CleanUpEvery = TimeSpan.FromMinutes(10);
|
||||
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string _recordsDirectory;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _startLock = new();
|
||||
private readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, HashProgress> _hashes = new(StringComparer.Ordinal);
|
||||
private DateTimeOffset _lastCleanUp = DateTimeOffset.MinValue;
|
||||
|
||||
public PublicationStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath, time) { }
|
||||
|
||||
public PublicationStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory, TimeProvider time)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
_recordsDirectory = Path.Combine(dataDirectory, RecordsFolder);
|
||||
_time = time;
|
||||
}
|
||||
|
||||
// ================================================================== Début, état, abandon
|
||||
|
||||
/// <summary>
|
||||
/// Début d'une publication, ou reprise si le même manifest était en cours d'envoi. Une publication
|
||||
/// d'un autre administrateur, active depuis moins de 30 min, n'est pas remplacée (409).
|
||||
/// </summary>
|
||||
/// <returns>L'état, et la publication remplacée (audit) le cas échéant.</returns>
|
||||
public (PublicationState State, PublicationRecord? Replaced) Start(string gameId, PublicationStartRequest request, Guid adminId, string adminName)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
if (!ManifestCheck.IsValidVersion(request.Version)) throw ApiRejection.BadRequest("Numéro de version invalide.");
|
||||
if (!PublishingLimits.IsValidSha256(request.ManifestSha256)) throw ApiRejection.BadRequest("Empreinte du manifest invalide.");
|
||||
if (request.ManifestSize is <= 0 or > PublishingLimits.MaxManifestBytes) throw ApiRejection.BadRequest("Taille du manifest invalide.");
|
||||
|
||||
lock (_startLock)
|
||||
{
|
||||
CleanUp(force: true);
|
||||
PublicationRecord? replaced = null;
|
||||
foreach (var existing in Records().Where(r => string.Equals(r.GameId, key, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
{
|
||||
if (existing.AdminId == adminId && existing.Version == request.Version &&
|
||||
string.Equals(existing.ManifestSha256, request.ManifestSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Touch(existing.Id);
|
||||
return (Lock(existing.Id, () => State(Current(existing.Id), resumed: true)), null);
|
||||
}
|
||||
var idle = _time.GetUtcNow() - LastActivity(existing.Id);
|
||||
if (existing.AdminId != adminId && idle < PublishingLimits.TakeoverAfter)
|
||||
throw ApiRejection.Conflict(
|
||||
$"{existing.AdminName} publie la version {existing.Version} de ce jeu (dernier envoi il y a " +
|
||||
$"{Math.Max(1, (int)idle.TotalMinutes)} min). Réessayez plus tard.", ApiErrorCodes.PublicationInProgress);
|
||||
Lock(existing.Id, () =>
|
||||
{
|
||||
Delete(existing);
|
||||
return 0;
|
||||
});
|
||||
replaced = existing;
|
||||
}
|
||||
|
||||
var current = PublishedFiles.ReadManifest(manifestPath);
|
||||
var currentRoot = current is null ? null : ChangelogStore.Parse(current, key);
|
||||
var record = new PublicationRecord
|
||||
{
|
||||
Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
GameId = key,
|
||||
Version = request.Version,
|
||||
ManifestSha256 = request.ManifestSha256.ToLowerInvariant(),
|
||||
ManifestSize = request.ManifestSize,
|
||||
AdminId = adminId,
|
||||
AdminName = adminName,
|
||||
CreatedAt = _time.GetUtcNow(),
|
||||
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
|
||||
PublishedContentSha256 = OnlineContentHash(currentRoot)
|
||||
};
|
||||
CreateStaging(record);
|
||||
Save(record);
|
||||
return (Lock(record.Id, () => State(record, resumed: false)), replaced);
|
||||
}
|
||||
}
|
||||
|
||||
public PublicationState GetState(string id, Guid adminId)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
EnsurePlan(Current(id));
|
||||
return State(Current(id), resumed: false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Abandon : dépôt supprimé, rien n'est publié.</summary>
|
||||
public PublicationRecord Cancel(string id, Guid adminId)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
Delete(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================== Envoi
|
||||
|
||||
public ChunkResult AppendManifest(string id, Guid adminId, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var staging = Staging(record);
|
||||
var result = Append(Path.Combine(staging, "manifest.part"), Path.Combine(staging, "manifest.json"), offset, data,
|
||||
record.ManifestSize, record.ManifestSha256, "manifest");
|
||||
Touch(id);
|
||||
if (result.Complete) EnsurePlan(record);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public ChunkResult AppendArchive(string id, Guid adminId, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
if (!plan.ArchiveNeeded) return new ChunkResult { Received = plan.ArchiveSize, Complete = true };
|
||||
var staging = Staging(record);
|
||||
var result = Append(Path.Combine(staging, "archive.part"), Path.Combine(staging, "archive.zip"), offset, data,
|
||||
plan.ArchiveSize, plan.ArchiveSha256, "archive");
|
||||
Touch(id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Gros objet, par morceaux.</summary>
|
||||
public ChunkResult AppendObject(string id, Guid adminId, string sha256, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
var sha = sha256.ToLowerInvariant();
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
if (!plan.Pending.TryGetValue(sha, out var size))
|
||||
throw ApiRejection.BadRequest("Cet objet n'est pas attendu par cette publication.");
|
||||
var objects = StagingObjects(record);
|
||||
var result = Append(Path.Combine(objects, sha + ".part"), Path.Combine(objects, sha + ".bin"), offset, data, size, sha, "objet");
|
||||
Touch(id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Lot de petits objets (chacun vérifié) ; renvoie le nombre d'objets enregistrés.</summary>
|
||||
public ChunkResult AddObjects(string id, Guid adminId, byte[] body)
|
||||
{
|
||||
Find(id, adminId);
|
||||
List<(string Sha256, ReadOnlyMemory<byte> Data)> batch;
|
||||
try
|
||||
{
|
||||
batch = ObjectBatch.Read(body, PublishingLimits.MaxObjectsPerBatch);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
throw ApiRejection.BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
var objects = StagingObjects(record);
|
||||
var saved = 0;
|
||||
foreach (var (sha, data) in batch)
|
||||
{
|
||||
if (!plan.Pending.TryGetValue(sha, out var size))
|
||||
throw ApiRejection.BadRequest("Un objet du lot n'est pas attendu par cette publication.");
|
||||
if (data.Length != size) throw ApiRejection.BadRequest($"Objet {sha[..12]}… : taille différente de celle du manifest.");
|
||||
if (PublishedFiles.Sha256(data.Span) != sha)
|
||||
throw ApiRejection.BadRequest($"Objet {sha[..12]}… : contenu différent de son empreinte (fichier modifié ou abîmé).");
|
||||
var done = Path.Combine(objects, sha + ".bin");
|
||||
if (!File.Exists(done))
|
||||
{
|
||||
var temp = Path.Combine(objects, $"{sha}.{Guid.NewGuid():N}.tmp");
|
||||
PublishedFiles.WriteNew(temp, data.Span, PrivateFile);
|
||||
File.Move(temp, done, overwrite: true);
|
||||
}
|
||||
saved++;
|
||||
}
|
||||
Touch(id);
|
||||
return new ChunkResult { Received = saved, Complete = true };
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================== Mise en ligne
|
||||
|
||||
/// <summary>
|
||||
/// Met la version en ligne : objets, archive, puis manifest (historique des notes du serveur conservé).
|
||||
/// Déjà faite (réponse précédente perdue) : le même résultat est renvoyé.
|
||||
/// </summary>
|
||||
/// <returns>Le résultat, et l'enregistrement (null si la mise en ligne avait déjà eu lieu).</returns>
|
||||
/// <param name="signature">Signature du contenu du manifest par l'administrateur (phase 7), ou null.</param>
|
||||
/// <param name="verifySignature">Contrôle de la signature sur le condensé du manifest reçu (lève une ApiRejection).</param>
|
||||
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId, ContentSignature? signature = null,
|
||||
Action<string, string>? verifySignature = null)
|
||||
{
|
||||
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
|
||||
if (Load(id) is null && LoadDone(id) is { } done && done.AdminId == adminId) return (done.Result, null);
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
if (Load(id) is null && LoadDone(id) is { } finished && finished.AdminId == adminId) return (finished.Result, (PublicationRecord?)null);
|
||||
var record = Current(id);
|
||||
var (manifestPath, gameId) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var plan = RequirePlan(record);
|
||||
var state = State(record, resumed: false);
|
||||
if (state.PendingObjects.Count > 0 || !state.ArchiveComplete)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Envoi incomplet : {state.PendingObjects.Count} fichier(s) et {(state.ArchiveComplete ? "aucune archive" : "l'archive")} restent à envoyer.");
|
||||
|
||||
var staging = Staging(record);
|
||||
var manifestBytes = File.ReadAllBytes(Path.Combine(staging, "manifest.json"));
|
||||
var errors = new List<string>();
|
||||
var summary = ManifestCheck.Check(manifestBytes, gameId, record.Version, errors)
|
||||
?? throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
|
||||
using (PublishedFiles.LockGame(_dataDirectory, gameId))
|
||||
{
|
||||
var current = PublishedFiles.ReadManifest(manifestPath);
|
||||
var currentRoot = current is null ? null : ChangelogStore.Parse(current, gameId);
|
||||
var currentVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "";
|
||||
// Fichiers en ligne changés pendant l'envoi (autre publication, WinSCP) : jamais écrasés. Une
|
||||
// correction des notes (page Notes de version) ne change que « changelog » : elle est permise.
|
||||
if (OnlineContentHash(currentRoot) != record.PublishedContentSha256)
|
||||
throw ApiRejection.Conflict(
|
||||
$"La version en ligne a changé pendant l'envoi ({Display(record.PublishedVersion)} → {Display(currentVersion)}) : " +
|
||||
"vérifiez avant de publier la vôtre, puis recommencez.", ApiErrorCodes.RevisionConflict);
|
||||
|
||||
// Signature (phase 7) : sur le contenu reçu (fichiers, archive, exécutable…), que la fusion des notes ne touche pas.
|
||||
if (signature is not null)
|
||||
verifySignature?.Invoke(gameId, ContentSigning.ManifestDigest(PublishedFiles.StripBom(manifestBytes)));
|
||||
|
||||
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne) et la
|
||||
// signature de cette mise en ligne (une signature éventuellement présente dans le fichier reçu est retirée).
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
|
||||
PublishedFiles.SetProperty(root, "signature",
|
||||
signature is null ? null : JsonSerializer.SerializeToNode(signature, Contracts.ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
|
||||
// 0. Contrôles AVANT tout déplacement : droits, liens symboliques, dossiers objects/xx.
|
||||
if (new FileInfo(manifestPath).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
|
||||
var archiveTarget = Path.Combine(gameDirectory, summary.ArchiveName);
|
||||
if (new FileInfo(archiveTarget).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("l'archive publiée est un lien symbolique : la remplacer romprait le lien.");
|
||||
var mode = PublishedFiles.PublicMode(gameDirectory, current is null ? null : manifestPath);
|
||||
var objectsDirectory = Path.Combine(gameDirectory, "objects");
|
||||
PublishedFiles.EnsurePublicDirectory(objectsDirectory, gameDirectory, keepSetGid: true);
|
||||
foreach (var prefix in plan.Pending.Keys.Select(sha => sha[..2]).Distinct())
|
||||
PublishedFiles.EnsurePublicDirectory(Path.Combine(objectsDirectory, prefix), gameDirectory, keepSetGid: false);
|
||||
|
||||
// 1. Objets (contenu adressé par l'empreinte : un objet présent est le bon s'il a la bonne taille).
|
||||
var added = 0;
|
||||
foreach (var (sha, size) in plan.Pending)
|
||||
{
|
||||
var staged = Path.Combine(StagingObjects(record), sha + ".bin");
|
||||
var target = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha));
|
||||
if (ObjectPresent(target, size))
|
||||
{
|
||||
PublishedFiles.TryDelete(staged);
|
||||
continue;
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, mode);
|
||||
File.Move(staged, target, overwrite: true);
|
||||
added++;
|
||||
}
|
||||
foreach (var (sha, size) in summary.Objects)
|
||||
if (!ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le fichier {ManifestCheck.ObjectPath(sha)} a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
|
||||
|
||||
// 2. Archive : l'ancienne (même nom : republication) est gardée de côté jusqu'à l'écriture du manifest.
|
||||
var stagedArchive = Path.Combine(staging, "archive.zip");
|
||||
string? previousArchive = null;
|
||||
var archiveAdded = false;
|
||||
if (File.Exists(stagedArchive))
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(stagedArchive, mode);
|
||||
if (File.Exists(archiveTarget))
|
||||
{
|
||||
previousArchive = PublishedFiles.TempPath(archiveTarget);
|
||||
File.Move(archiveTarget, previousArchive);
|
||||
}
|
||||
File.Move(stagedArchive, archiveTarget);
|
||||
archiveAdded = true;
|
||||
}
|
||||
else if (!File.Exists(archiveTarget) || new FileInfo(archiveTarget).Length != summary.ArchiveSize)
|
||||
{
|
||||
throw ApiRejection.Conflict("L'archive a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
|
||||
}
|
||||
|
||||
// 3. Manifest en dernier : les launchers ne voient jamais une version incomplète.
|
||||
try
|
||||
{
|
||||
if (current is not null) PublishedFiles.Backup(_dataDirectory, gameId, current);
|
||||
PublishedFiles.WriteManifest(manifestPath, newBytes, current is null ? null : PublishedFiles.Sha256(current),
|
||||
() => ApiRejection.Conflict("Le manifest a été modifié pendant la mise en ligne : recommencez.", ApiErrorCodes.RevisionConflict));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Archive remise dans le dépôt (pas besoin de la renvoyer), l'ancienne remise en ligne.
|
||||
if (archiveAdded)
|
||||
{
|
||||
File.Move(archiveTarget, stagedArchive, overwrite: true);
|
||||
if (previousArchive is not null) File.Move(previousArchive, archiveTarget);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (previousArchive is not null) PublishedFiles.TryDelete(previousArchive);
|
||||
|
||||
var result = new PublicationResult
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = record.Version,
|
||||
PreviousVersion = currentVersion,
|
||||
ManifestSha256 = PublishedFiles.Sha256(newBytes),
|
||||
ObjectsAdded = added,
|
||||
ArchiveAdded = archiveAdded
|
||||
};
|
||||
SaveDone(new PublicationDone { Id = id, AdminId = record.AdminId, Result = result, At = _time.GetUtcNow() });
|
||||
Delete(record);
|
||||
return (result, (PublicationRecord?)record);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes : celles de la version publiée (du manifest reçu ; à défaut, celles déjà en ligne), puis
|
||||
/// l'historique du SERVEUR (qui contient les corrections faites en ligne), dans les bornes du launcher
|
||||
/// (30 versions ; les plus anciennes retirées si l'ensemble est trop long).
|
||||
/// Sans manifest en ligne : les notes du manifest reçu.
|
||||
/// </summary>
|
||||
private static JsonArray? MergeChangelog(JsonObject uploaded, JsonObject? current, string version)
|
||||
{
|
||||
var received = ChangelogStore.ChangelogArray(uploaded);
|
||||
if (current is null) return received?.DeepClone().AsArray();
|
||||
|
||||
static bool Is(JsonNode? node, string version) =>
|
||||
node is JsonObject entry && PublishedFiles.StringValue(entry, "version") == version;
|
||||
|
||||
var history = ChangelogStore.ChangelogArray(current) ?? new JsonArray();
|
||||
var merged = new JsonArray();
|
||||
var head = received?.FirstOrDefault(n => Is(n, version)) ?? history.FirstOrDefault(n => Is(n, version));
|
||||
if (head is not null) merged.Add(head.DeepClone());
|
||||
foreach (var entry in history)
|
||||
{
|
||||
if (merged.Count >= ChangelogLimits.MaxEntries) break;
|
||||
if (entry is JsonObject && !Is(entry, version)) merged.Add(entry.DeepClone());
|
||||
}
|
||||
// Budget de tout l'historique (contrôle de lecture en même temps : une entrée illisible empêche la publication).
|
||||
while (merged.Count > 1 &&
|
||||
ChangelogStore.Entries(merged).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems) > ChangelogLimits.MaxTotalItems)
|
||||
merged.RemoveAt(merged.Count - 1);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>Empreinte de la version en ligne hors notes et hors signature (une correction de notes ou une signature reste permise pendant l'envoi) ; vide si elle n'existe pas.</summary>
|
||||
private static string OnlineContentHash(JsonObject? root)
|
||||
{
|
||||
if (root is null) return "";
|
||||
var copy = root.DeepClone().AsObject();
|
||||
PublishedFiles.SetProperty(copy, "changelog", null);
|
||||
PublishedFiles.SetProperty(copy, "signature", null);
|
||||
return PublishedFiles.Sha256(System.Text.Encoding.UTF8.GetBytes(copy.ToJsonString()));
|
||||
}
|
||||
|
||||
// ================================================================== Plan (après réception du manifest)
|
||||
|
||||
/// <summary>Manifest complet : contrôle, puis liste des objets et de l'archive à envoyer.</summary>
|
||||
private void EnsurePlan(PublicationRecord record)
|
||||
{
|
||||
if (record.Plan is not null) return;
|
||||
var staging = Staging(record);
|
||||
var manifest = Path.Combine(staging, "manifest.json");
|
||||
if (!File.Exists(manifest)) return;
|
||||
|
||||
var (manifestPath, gameId) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var errors = new List<string>();
|
||||
var summary = ManifestCheck.Check(File.ReadAllBytes(manifest), gameId, record.Version, errors);
|
||||
if (summary is null)
|
||||
{
|
||||
Delete(record); // manifest refusé : la publication est abandonnée (à refaire avec un manifest corrigé)
|
||||
throw ApiRejection.BadRequest("Manifest refusé : " + string.Join(" ", errors.Take(5)) +
|
||||
(errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
}
|
||||
|
||||
var pending = summary.Objects
|
||||
.Where(o => !ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(o.Key)), o.Value))
|
||||
.ToDictionary(o => o.Key, o => o.Value, StringComparer.Ordinal);
|
||||
// Archive déjà en ligne (même nom, même taille, même empreinte DANS le manifest en ligne) : pas renvoyée.
|
||||
// Aucune lecture du fichier (plusieurs Go) pendant la requête.
|
||||
var archiveTarget = new FileInfo(Path.Combine(gameDirectory, summary.ArchiveName));
|
||||
var archiveNeeded = !(archiveTarget.Exists && archiveTarget.LinkTarget is null && archiveTarget.Length == summary.ArchiveSize &&
|
||||
PublishedArchive(manifestPath, gameId) is { } online &&
|
||||
string.Equals(online.Name, summary.ArchiveName, StringComparison.Ordinal) && online.Sha256 == summary.ArchiveSha256);
|
||||
|
||||
var needed = pending.Values.Sum() + (archiveNeeded ? summary.ArchiveSize : 0);
|
||||
var free = new DriveInfo(gameDirectory).AvailableFreeSpace;
|
||||
if (free < needed + FreeSpaceMargin)
|
||||
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage,
|
||||
$"Espace disque insuffisant sur le serveur : {needed / (1024 * 1024)} Mo à recevoir, {free / (1024 * 1024)} Mo libres.");
|
||||
|
||||
Save(record with
|
||||
{
|
||||
Plan = new PublicationPlan
|
||||
{
|
||||
ArchiveName = summary.ArchiveName,
|
||||
ArchiveSize = summary.ArchiveSize,
|
||||
ArchiveSha256 = summary.ArchiveSha256,
|
||||
ArchiveNeeded = archiveNeeded,
|
||||
Pending = pending,
|
||||
ObjectCount = summary.Objects.Count,
|
||||
ObjectsOnServer = summary.Objects.Count - pending.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Archive référencée par le manifest en ligne (nom, empreinte), sans la lire.</summary>
|
||||
private static (string Name, string Sha256)? PublishedArchive(string manifestPath, string gameId)
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath);
|
||||
if (bytes is null) return null;
|
||||
var root = ChangelogStore.Parse(bytes, gameId);
|
||||
if (PublishedFiles.Property(root, "archive") is not JsonObject archive) return null;
|
||||
var name = PublishedFiles.StringValue(archive, "url");
|
||||
var sha = PublishedFiles.StringValue(archive, "sha256");
|
||||
return name is null || sha is null ? null : (name, sha.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private PublicationPlan RequirePlan(PublicationRecord record)
|
||||
{
|
||||
EnsurePlan(record);
|
||||
return Current(record.Id).Plan ?? throw ApiRejection.Conflict("Envoyez d'abord le manifest.");
|
||||
}
|
||||
|
||||
private PublicationState State(PublicationRecord record, bool resumed)
|
||||
{
|
||||
var staging = Staging(record);
|
||||
var manifestDone = File.Exists(Path.Combine(staging, "manifest.json"));
|
||||
var plan = record.Plan;
|
||||
var pending = new List<string>();
|
||||
var partial = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
long archiveReceived = 0;
|
||||
var archiveComplete = false;
|
||||
if (plan is not null)
|
||||
{
|
||||
var (manifestPath, _) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var objects = Path.Combine(staging, "objects");
|
||||
foreach (var (sha, size) in plan.Pending)
|
||||
{
|
||||
if (File.Exists(Path.Combine(objects, sha + ".bin")) ||
|
||||
ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
|
||||
continue;
|
||||
pending.Add(sha);
|
||||
var part = new FileInfo(Path.Combine(objects, sha + ".part"));
|
||||
if (part.Exists && part.Length > 0) partial[sha] = part.Length;
|
||||
}
|
||||
archiveComplete = !plan.ArchiveNeeded || File.Exists(Path.Combine(staging, "archive.zip"));
|
||||
archiveReceived = archiveComplete ? plan.ArchiveSize : Length(Path.Combine(staging, "archive.part"));
|
||||
}
|
||||
|
||||
return new PublicationState
|
||||
{
|
||||
PublicationId = record.Id,
|
||||
GameId = record.GameId,
|
||||
Version = record.Version,
|
||||
PublishedVersion = record.PublishedVersion,
|
||||
Resumed = resumed,
|
||||
ManifestSize = record.ManifestSize,
|
||||
ManifestReceived = manifestDone ? record.ManifestSize : Length(Path.Combine(staging, "manifest.part")),
|
||||
ManifestAccepted = plan is not null,
|
||||
ObjectCount = plan?.ObjectCount ?? 0,
|
||||
ObjectsOnServer = plan?.ObjectsOnServer ?? 0,
|
||||
PendingObjects = pending,
|
||||
PartialObjects = partial,
|
||||
ArchiveName = plan?.ArchiveName,
|
||||
ArchiveSize = plan?.ArchiveSize ?? 0,
|
||||
ArchiveReceived = archiveReceived,
|
||||
ArchiveComplete = archiveComplete,
|
||||
ExpiresAt = LastActivity(record.Id) + PublishingLimits.IdleExpiry
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================== Réception d'un fichier
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute un morceau à la suite de ce qui a été reçu (position vérifiée), puis vérifie l'empreinte
|
||||
/// quand le fichier est complet (calculée au fil des morceaux ; recalculée après un redémarrage).
|
||||
/// </summary>
|
||||
private ChunkResult Append(string partPath, string donePath, long offset, byte[] data, long size, string sha256, string what)
|
||||
{
|
||||
if (File.Exists(donePath)) return new ChunkResult { Received = size, Complete = true };
|
||||
var received = Length(partPath);
|
||||
if (offset != received)
|
||||
throw ApiRejection.Conflict($"Position d'envoi {offset} inattendue ({what} : {received} octets déjà reçus).", ApiErrorCodes.OffsetMismatch);
|
||||
if (received + data.LongLength > size) throw ApiRejection.BadRequest($"Données en trop ({what} : {size} octets attendus).");
|
||||
if (data.Length == 0 && size > 0) throw ApiRejection.BadRequest("Morceau vide.");
|
||||
|
||||
var options = new FileStreamOptions { Mode = FileMode.Append, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
|
||||
using (var stream = new FileStream(partPath, options))
|
||||
{
|
||||
stream.Write(data);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
HashProgress? progress;
|
||||
if (received == 0)
|
||||
{
|
||||
RemoveHash(partPath);
|
||||
progress = _hashes[partPath] = new HashProgress();
|
||||
}
|
||||
else if (!_hashes.TryGetValue(partPath, out progress) || progress.Offset != received)
|
||||
{
|
||||
RemoveHash(partPath);
|
||||
progress = null;
|
||||
}
|
||||
progress?.Add(data);
|
||||
received += data.Length;
|
||||
if (received < size) return new ChunkResult { Received = received };
|
||||
|
||||
var actual = progress is not null && progress.Offset == size ? progress.Finish() : HashFile(partPath);
|
||||
RemoveHash(partPath);
|
||||
if (actual != sha256)
|
||||
{
|
||||
PublishedFiles.TryDelete(partPath);
|
||||
throw ApiRejection.BadRequest($"Empreinte différente pour {what} : fichier abîmé ou modifié pendant l'envoi ; il sera renvoyé.");
|
||||
}
|
||||
File.Move(partPath, donePath, overwrite: true);
|
||||
return new ChunkResult { Received = size, Complete = true };
|
||||
}
|
||||
|
||||
private void RemoveHash(string key)
|
||||
{
|
||||
if (_hashes.TryRemove(key, out var progress)) progress.Dispose();
|
||||
}
|
||||
|
||||
private sealed class HashProgress : IDisposable
|
||||
{
|
||||
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
public long Offset { get; private set; }
|
||||
|
||||
public void Add(byte[] data)
|
||||
{
|
||||
_hash.AppendData(data);
|
||||
Offset += data.Length;
|
||||
}
|
||||
|
||||
public string Finish() => Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
|
||||
|
||||
public void Dispose() => _hash.Dispose();
|
||||
}
|
||||
|
||||
// ================================================================== Enregistrements, dépôt
|
||||
|
||||
public sealed record PublicationRecord
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
public long ManifestSize { get; init; }
|
||||
public Guid AdminId { get; init; }
|
||||
public string AdminName { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Version en ligne au début (vide : aucune).</summary>
|
||||
public string PublishedVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest en ligne SANS les notes, au début : la mise en ligne est refusée si elle a changé.</summary>
|
||||
public string PublishedContentSha256 { get; init; } = "";
|
||||
|
||||
public PublicationPlan? Plan { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PublicationPlan
|
||||
{
|
||||
public string ArchiveName { get; init; } = "";
|
||||
public long ArchiveSize { get; init; }
|
||||
public string ArchiveSha256 { get; init; } = "";
|
||||
public bool ArchiveNeeded { get; init; }
|
||||
|
||||
/// <summary>Objets absents du serveur quand le manifest a été reçu (empreinte → taille).</summary>
|
||||
public Dictionary<string, long> Pending { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
public int ObjectCount { get; init; }
|
||||
public int ObjectsOnServer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication terminée : résultat gardé 24 h (réponse perdue, nouvel essai).</summary>
|
||||
public sealed record PublicationDone
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public Guid AdminId { get; init; }
|
||||
public PublicationResult Result { get; init; } = new();
|
||||
public DateTimeOffset At { get; init; }
|
||||
}
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw PublishedFiles.NotConfigured(gameId, "Publication");
|
||||
}
|
||||
|
||||
/// <summary>Publication de cet administrateur, encore valable (sinon 404/403).</summary>
|
||||
private PublicationRecord Find(string id, Guid adminId)
|
||||
{
|
||||
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
|
||||
CleanUp(force: false);
|
||||
var record = Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
|
||||
if (record.AdminId != adminId)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, $"Cette publication a été lancée par {record.AdminName}.");
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>Relue sous le verrou : abandonnée entre-temps → 404.</summary>
|
||||
private PublicationRecord Current(string id) =>
|
||||
Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
|
||||
|
||||
private string RecordPath(string id) => Path.Combine(_recordsDirectory, id + ".json");
|
||||
|
||||
private string DonePath(string id) => Path.Combine(_recordsDirectory, id + DoneSuffix + ".json");
|
||||
|
||||
private PublicationRecord? Load(string id) => Read<PublicationRecord>(RecordPath(id));
|
||||
|
||||
private PublicationDone? LoadDone(string id) => Read<PublicationDone>(DonePath(id));
|
||||
|
||||
private static T? Read<T>(string path) where T : class
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<PublicationRecord> Records()
|
||||
{
|
||||
if (!Directory.Exists(_recordsDirectory)) yield break;
|
||||
foreach (var file in Directory.GetFiles(_recordsDirectory, "*.json"))
|
||||
{
|
||||
var id = Path.GetFileNameWithoutExtension(file);
|
||||
if (PublishingLimits.IsValidPublicationId(id) && Load(id) is { } record) yield return record;
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(PublicationRecord record)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_recordsDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(RecordPath(record.Id), JsonSerializer.Serialize(record, ApiJson.Options), keepBackup: false);
|
||||
}
|
||||
|
||||
private void SaveDone(PublicationDone done)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_recordsDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(DonePath(done.Id), JsonSerializer.Serialize(done, ApiJson.Options), keepBackup: false);
|
||||
}
|
||||
|
||||
/// <summary>Dernier envoi : date de modification de l'enregistrement (mise à jour à chaque morceau).</summary>
|
||||
private DateTimeOffset LastActivity(string id)
|
||||
{
|
||||
var path = RecordPath(id);
|
||||
return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero) : DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private void Touch(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetLastWriteTimeUtc(RecordPath(id), _time.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// simple indication d'activité
|
||||
}
|
||||
}
|
||||
|
||||
private string Staging(PublicationRecord record) =>
|
||||
Path.Combine(Path.GetDirectoryName(Locate(record.GameId).Path)!, StagingPrefix + record.Id);
|
||||
|
||||
private string StagingObjects(PublicationRecord record)
|
||||
{
|
||||
var objects = Path.Combine(Staging(record), "objects");
|
||||
if (!Directory.Exists(objects)) CreatePrivateDirectory(objects);
|
||||
return objects;
|
||||
}
|
||||
|
||||
private void CreateStaging(PublicationRecord record)
|
||||
{
|
||||
var staging = Staging(record);
|
||||
if (new DirectoryInfo(Path.GetDirectoryName(staging)!).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le dossier du jeu est un lien symbolique.");
|
||||
CreatePrivateDirectory(staging);
|
||||
}
|
||||
|
||||
private static void CreatePrivateDirectory(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
|
||||
else Directory.CreateDirectory(path, PrivateDirectory);
|
||||
}
|
||||
|
||||
private void Delete(PublicationRecord record)
|
||||
{
|
||||
foreach (var key in _hashes.Keys.Where(k => k.Contains(StagingPrefix + record.Id, StringComparison.Ordinal)).ToList())
|
||||
RemoveHash(key);
|
||||
try
|
||||
{
|
||||
var staging = Staging(record);
|
||||
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
// dépôt laissé : supprimé au prochain nettoyage
|
||||
}
|
||||
PublishedFiles.TryDelete(RecordPath(record.Id));
|
||||
_locks.TryRemove(record.Id, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publications sans envoi depuis 48 h, résultats de plus de 24 h et dépôts orphelins : supprimés
|
||||
/// (au début d'une publication, et au plus toutes les 10 minutes lors des envois).
|
||||
/// </summary>
|
||||
private void CleanUp(bool force)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_locks)
|
||||
{
|
||||
if (!force && now - _lastCleanUp < CleanUpEvery) return;
|
||||
_lastCleanUp = now;
|
||||
}
|
||||
var known = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var record in Records().ToList())
|
||||
{
|
||||
if (now - LastActivity(record.Id) > PublishingLimits.IdleExpiry)
|
||||
Lock(record.Id, () =>
|
||||
{
|
||||
Delete(record);
|
||||
return 0;
|
||||
});
|
||||
else known.Add(record.Id);
|
||||
}
|
||||
if (Directory.Exists(_recordsDirectory))
|
||||
foreach (var done in Directory.GetFiles(_recordsDirectory, "*" + DoneSuffix + ".json"))
|
||||
if (now - new DateTimeOffset(File.GetLastWriteTimeUtc(done), TimeSpan.Zero) > DoneLifetime)
|
||||
PublishedFiles.TryDelete(done);
|
||||
foreach (var manifest in _manifests.Values)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(manifest)!;
|
||||
if (!Directory.Exists(directory)) continue;
|
||||
foreach (var staging in Directory.GetDirectories(directory, StagingPrefix + "*"))
|
||||
{
|
||||
var id = Path.GetFileName(staging)[StagingPrefix.Length..];
|
||||
if (known.Contains(id) || now - Directory.GetLastWriteTimeUtc(staging) <= PublishingLimits.IdleExpiry) continue;
|
||||
try
|
||||
{
|
||||
Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// nouvel essai au prochain nettoyage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private T Lock<T>(string id, Func<T> action)
|
||||
{
|
||||
lock (_locks.GetOrAdd(id, _ => new object())) return action();
|
||||
}
|
||||
|
||||
private static bool ObjectPresent(string path, long size)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return info.Exists && info.LinkTarget is null && info.Length == size;
|
||||
}
|
||||
|
||||
private static long Length(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return info.Exists ? info.Length : 0;
|
||||
}
|
||||
|
||||
private static string HashFile(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers PUBLICS d'un jeu (dossier servi par nginx : manifest.json, archive, objects/), communs aux
|
||||
/// notes de version (phase 4) et à la publication d'une version (phase 5) :
|
||||
/// • même verrou par jeu (une seule écriture du manifest à la fois, service et outils confondus) ;
|
||||
/// • même présentation JSON que ManifestBuilder ;
|
||||
/// • écriture atomique : fichier temporaire caché (refusé par nginx) dans le même dossier, puis renommage ;
|
||||
/// • droits : ceux du manifest existant (sinon 0640 avec le groupe du dossier setgid, ou 0644) ;
|
||||
/// refus si le dossier n'a pas le bit setgid alors que nginx lit les fichiers par leur groupe,
|
||||
/// ou si le manifest est un lien symbolique.
|
||||
/// </summary>
|
||||
internal static class PublishedFiles
|
||||
{
|
||||
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
|
||||
public static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private const string HistoryFolder = "manifest-history";
|
||||
private const int HistoryLength = 20;
|
||||
|
||||
private const UnixFileMode GroupReadable = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
|
||||
private const UnixFileMode AllReadable = GroupReadable | UnixFileMode.OtherRead;
|
||||
|
||||
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
|
||||
public static readonly JsonSerializerOptions ManifestJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
public static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
/// <summary>Verrou d'écriture du manifest d'un jeu (fichier dans les données privées).</summary>
|
||||
public static FileStream LockGame(string dataDirectory, string gameId) =>
|
||||
SecureFiles.AcquireLock(Path.Combine(dataDirectory, $"manifest-{gameId.ToLowerInvariant()}.lock"), LockTimeout);
|
||||
|
||||
public static string Sha256(ReadOnlySpan<byte> bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
public static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
|
||||
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
|
||||
|
||||
/// <summary>Manifest publié (null s'il n'existe pas encore).</summary>
|
||||
public static byte[]? ReadManifest(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) return null;
|
||||
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
public static JsonObject ParseObject(ReadOnlySpan<byte> bytes, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(bytes, documentOptions: ReadOptions) as JsonObject
|
||||
?? throw new InvalidDataException($"{what} : objet JSON attendu.");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or ArgumentException)
|
||||
{
|
||||
throw new InvalidDataException($"{what} illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Propriété d'un objet JSON, nom insensible à la casse (comme pour le launcher).</summary>
|
||||
public static JsonNode? Property(JsonObject node, string name)
|
||||
{
|
||||
foreach (var (key, value) in node)
|
||||
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? StringValue(JsonObject node, string name) =>
|
||||
Property(node, name) is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
|
||||
|
||||
/// <summary>Remplace une propriété (toutes casses confondues) ; null la retire.</summary>
|
||||
public static void SetProperty(JsonObject node, string name, JsonNode? value)
|
||||
{
|
||||
foreach (var existing in node.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
node.Remove(existing);
|
||||
if (value is not null) node[name] = value;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(JsonObject root)
|
||||
{
|
||||
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(root.ToJsonString(ManifestJson));
|
||||
if (bytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération ; 20 dernières).</summary>
|
||||
public static void Backup(string dataDirectory, string gameId, byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.Combine(dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
|
||||
SecureFiles.EnsureDirectory(Path.Combine(dataDirectory, HistoryFolder));
|
||||
SecureFiles.EnsureDirectory(directory);
|
||||
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// La copie est une aide : son échec n'empêche pas l'écriture.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Droits d'un nouveau fichier public du dossier : ceux du manifest existant, sinon lisible par le
|
||||
/// groupe du dossier (setgid) ou par tous. Refus si nginx risque de perdre l'accès.
|
||||
/// </summary>
|
||||
public static UnixFileMode PublicMode(string directory, string? referenceFile)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return AllReadable;
|
||||
var directoryMode = File.GetUnixFileMode(directory);
|
||||
var setgid = directoryMode.HasFlag(UnixFileMode.SetGroup);
|
||||
var mode = referenceFile is not null && File.Exists(referenceFile)
|
||||
? File.GetUnixFileMode(referenceFile)
|
||||
: setgid ? GroupReadable : AllReadable;
|
||||
// Le fichier créé appartient au compte du service, avec le groupe du dossier SEULEMENT si celui-ci
|
||||
// a le bit setgid : sinon nginx (groupe www-data) ne pourrait plus lire un fichier lisible par le groupe.
|
||||
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) && !setgid)
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : les fichiers écrits ne seraient plus lisibles par nginx.");
|
||||
// Fichier existant : ses droits sont repris tels quels (rw des comptes compris) ; nouveau fichier : lecture.
|
||||
return mode & (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sous-dossier public (objects, objects/ab) : droits du dossier du jeu, fixés explicitement (le masque
|
||||
/// du service, UMask=0077, retirerait l'accès de nginx). Refus s'il s'agit d'un lien symbolique.
|
||||
/// <paramref name="keepSetGid"/> : dossier qui en contiendra d'autres (objects) ; son bit setgid doit être
|
||||
/// conservé pour que les sous-dossiers créés ensuite aient le groupe lu par nginx. (Un dossier
|
||||
/// « feuille » garde de toute façon le groupe hérité à sa création, comme les fichiers qui y sont déplacés.)
|
||||
/// </summary>
|
||||
public static void EnsurePublicDirectory(string path, string gameDirectory, bool keepSetGid)
|
||||
{
|
||||
var info = new DirectoryInfo(path);
|
||||
if (info.LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException($"« {Path.GetFileName(path)} » est un lien symbolique dans le dossier du jeu.");
|
||||
if (info.Exists) return;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
return;
|
||||
}
|
||||
var gameMode = File.GetUnixFileMode(gameDirectory);
|
||||
var permissions = gameMode & PermissionBits;
|
||||
var needSetGid = keepSetGid && gameMode.HasFlag(UnixFileMode.SetGroup);
|
||||
// Jamais de bit spécial demandé : systemd l'interdit au service (RestrictSUIDSGID=yes, refus EPERM).
|
||||
// Dans un dossier setgid, le noyau transmet de lui-même le bit setgid et le groupe (www-data).
|
||||
Directory.CreateDirectory(path, permissions);
|
||||
if ((File.GetUnixFileMode(path) & PermissionBits) != permissions) // réduits par le masque du service (UMask=0077)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fixer les droits retire le setgid hérité : sans conséquence pour un dossier « feuille » (son
|
||||
// groupe reste celui hérité) ; « objects » doit le redemander, ce que systemd peut refuser.
|
||||
File.SetUnixFileMode(path, needSetGid ? permissions | UnixFileMode.SetGroup : permissions);
|
||||
}
|
||||
catch (UnauthorizedAccessException) when (needSetGid)
|
||||
{
|
||||
// traité ci-dessous
|
||||
}
|
||||
}
|
||||
// Sans setgid (ou si le compte du service n'appartient pas au groupe du dossier, Linux le retire), les
|
||||
// sous-dossiers créés ensuite n'auraient plus le groupe lu par nginx.
|
||||
if (needSetGid && File.GetUnixFileMode(path) != (permissions | UnixFileMode.SetGroup))
|
||||
{
|
||||
TryDeleteDirectory(path);
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier « objects » n'existe pas encore et le service ne peut pas le créer avec le bit setgid et l'accès de nginx : " +
|
||||
"créez-le sur le VPS (server/deploy/LISEZMOI.md, étape 3 quater).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Droits rwx des trois classes, sans setuid, setgid ni sticky.</summary>
|
||||
private const UnixFileMode PermissionBits =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé : vide, sans conséquence
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace (ou crée) le manifest publié. <paramref name="expectedSha256"/> : empreinte du manifest
|
||||
/// sur lequel l'écriture a été préparée (null : il ne devait pas exister) ; revérifiée juste avant le
|
||||
/// renommage, pour ne jamais écraser un envoi (WinSCP…) fait entre-temps.
|
||||
/// </summary>
|
||||
public static void WriteManifest(string path, byte[] bytes, string? expectedSha256, Func<ApiRejection> conflict)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
if (new FileInfo(path).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
|
||||
var mode = PublicMode(directory, path);
|
||||
var temp = TempPath(path);
|
||||
try
|
||||
{
|
||||
WriteNew(temp, bytes, mode);
|
||||
var current = ReadManifest(path);
|
||||
if (current is null ? expectedSha256 is not null : !string.Equals(Sha256(current), expectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw conflict();
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
TryDelete(temp);
|
||||
if (ex is ApiRejection or ManifestWriteRefusedException) throw;
|
||||
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier temporaire caché du même dossier (nginx refuse les noms commençant par un point).</summary>
|
||||
public static string TempPath(string path) =>
|
||||
Path.Combine(Path.GetDirectoryName(path)!, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
|
||||
public static void WriteNew(string path, ReadOnlySpan<byte> bytes, UnixFileMode mode)
|
||||
{
|
||||
using (var stream = new FileStream(path, new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, mode);
|
||||
}
|
||||
|
||||
public static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé en place : sans conséquence (nom unique, caché)
|
||||
}
|
||||
}
|
||||
|
||||
public static ApiRejection NotConfigured(string gameId, string what) => new(503, ApiErrorCodes.NotConfigured,
|
||||
$"{what} impossible pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
|
||||
}
|
||||
|
||||
/// <summary>Écriture refusée par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
|
||||
public sealed class ManifestWriteRefusedException : IOException
|
||||
{
|
||||
public ManifestWriteRefusedException(string message) : base(message) { }
|
||||
}
|
||||
@@ -33,6 +33,18 @@ internal static class SecureFiles
|
||||
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
|
||||
}
|
||||
|
||||
/// <summary>Écrit un fichier binaire de façon atomique (fichier temporaire 0600 puis renommage).</summary>
|
||||
public static void WriteAllBytesAtomic(string path, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var temp = path + ".tmp";
|
||||
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
|
||||
public static bool TryCreateNew(string path, string content)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Fichiers du serveur » : consultation du site public (racine LauncherApi:Files:Root), suppression d'un
|
||||
/// fichier inutilisé et nettoyage du dossier « objects » d'un jeu.
|
||||
///
|
||||
/// Règles (le serveur ne fait confiance qu'à ce qu'il lit lui-même, au moment de supprimer) :
|
||||
/// • chemins relatifs à la racine uniquement : ni « .. », ni chemin absolu, ni élément caché (« .publish-… »),
|
||||
/// ni lien symbolique sur le chemin ; un dossier n'est jamais supprimé ;
|
||||
/// • fichiers .json (manifest, catalogue, trousseau, mise à jour du launcher) : JAMAIS supprimés ;
|
||||
/// • un fichier référencé par un fichier de description d'un dossier parent (archive et fichiers d'un
|
||||
/// manifest.json, exécutable de launcher-update.json, images du catalogue) n'est pas supprimable ;
|
||||
/// un fichier de description illisible rend tout son dossier non supprimable (impossible de vérifier) ;
|
||||
/// • vérification refaite sous le verrou d'écriture du manifest du jeu (celui de la publication et des notes),
|
||||
/// et refus pendant une publication de ce jeu (dossier « .publish-… » présent) ;
|
||||
/// • la suppression n'est possible que là où systemd autorise l'écriture (ReadWritePaths).
|
||||
/// </summary>
|
||||
public sealed partial class ServerFilesStore
|
||||
{
|
||||
private const string ManifestName = "manifest.json";
|
||||
private const string LauncherUpdateName = "launcher-update.json";
|
||||
private const string StagingPrefix = ".publish-";
|
||||
|
||||
/// <summary>Fichier de description lu au plus (même borne que le manifest).</summary>
|
||||
private const long MaxDescriptionBytes = PublishedFiles.MaxManifestBytes;
|
||||
|
||||
private static readonly StringComparison PathComparison =
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
private static readonly StringComparer PathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{2}\z")]
|
||||
private static partial Regex PrefixRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{64}\.bin\z")]
|
||||
private static partial Regex ObjectNameRegex();
|
||||
|
||||
/// <summary>Les dépôts de publication (« .publish-… ») sont « cachés » sous Linux : énumération explicite.</summary>
|
||||
private static readonly EnumerationOptions IncludingHidden = new() { AttributesToSkip = 0, IgnoreInaccessible = true };
|
||||
|
||||
private readonly string? _root;
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string? _catalogFile;
|
||||
private readonly string _catalogUrl;
|
||||
|
||||
public ServerFilesStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.FilesRootFullPath, options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath,
|
||||
options.Value.CatalogPublicFileFullPath, options.Value.CatalogPublicUrl) { }
|
||||
|
||||
public ServerFilesStore(string? root, IReadOnlyDictionary<string, string> manifests, string dataDirectory,
|
||||
string? catalogFile, string catalogUrl)
|
||||
{
|
||||
_root = root is null ? null : Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
_catalogFile = catalogFile is null ? null : Path.GetFullPath(catalogFile);
|
||||
_catalogUrl = catalogUrl;
|
||||
}
|
||||
|
||||
public bool Enabled => _root is not null;
|
||||
|
||||
// ================================================================== Consultation
|
||||
|
||||
public ServerFolder List(string? relativePath)
|
||||
{
|
||||
var root = RequireRoot();
|
||||
var (directory, relative) = Resolve(relativePath ?? "");
|
||||
if (!Directory.Exists(directory)) throw ApiRejection.NotFound("Dossier introuvable (supprimé ou renommé entre-temps ?).");
|
||||
|
||||
var references = ScanReferences();
|
||||
var infos = new List<FileSystemInfo>();
|
||||
var truncated = false;
|
||||
foreach (var info in new DirectoryInfo(directory).EnumerateFileSystemInfos("*", new EnumerationOptions
|
||||
{
|
||||
AttributesToSkip = 0,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = false
|
||||
}))
|
||||
{
|
||||
if (info.Name.StartsWith('.')) continue; // dépôts de publication, fichiers cachés : jamais affichés
|
||||
if (infos.Count >= ServerFilesLimits.MaxEntries)
|
||||
{
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
infos.Add(info);
|
||||
}
|
||||
|
||||
var budget = new ScanBudget(ServerFilesLimits.MaxScannedFiles);
|
||||
var entries = infos
|
||||
.OrderBy(i => i is DirectoryInfo ? 0 : 1)
|
||||
.ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(info => Describe(info, references, budget))
|
||||
.ToList();
|
||||
|
||||
long free = -1, total = -1;
|
||||
try
|
||||
{
|
||||
var drive = new DriveInfo(root);
|
||||
free = drive.AvailableFreeSpace;
|
||||
total = drive.TotalSize;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
// information facultative
|
||||
}
|
||||
|
||||
return new ServerFolder
|
||||
{
|
||||
Path = relative,
|
||||
RootName = Path.GetFileName(root) is { Length: > 0 } name ? name : root,
|
||||
Entries = entries,
|
||||
Truncated = truncated,
|
||||
FreeBytes = free,
|
||||
TotalBytes = total,
|
||||
PrunableGames = _manifests.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private ServerFileEntry Describe(FileSystemInfo info, References references, ScanBudget budget)
|
||||
{
|
||||
var modified = new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero);
|
||||
if (info.LinkTarget is not null)
|
||||
return new ServerFileEntry
|
||||
{
|
||||
Name = info.Name,
|
||||
IsDirectory = info is DirectoryInfo,
|
||||
Size = -1,
|
||||
FileCount = -1,
|
||||
Modified = modified,
|
||||
Status = ServerFileStatus.Link,
|
||||
Reason = "Lien symbolique : ni suivi ni supprimé depuis le menu."
|
||||
};
|
||||
|
||||
if (info is DirectoryInfo directory)
|
||||
{
|
||||
var (size, count) = budget.Measure(directory);
|
||||
return new ServerFileEntry
|
||||
{
|
||||
Name = info.Name,
|
||||
IsDirectory = true,
|
||||
Size = size,
|
||||
FileCount = count,
|
||||
Modified = modified,
|
||||
Status = ServerFileStatus.Directory
|
||||
};
|
||||
}
|
||||
|
||||
var (status, reason) = references.StatusOf(info.FullName);
|
||||
return new ServerFileEntry
|
||||
{
|
||||
Name = info.Name,
|
||||
Size = ((FileInfo)info).Length,
|
||||
FileCount = 1,
|
||||
Modified = modified,
|
||||
Status = status,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Taille des sous-dossiers, dans la limite d'un nombre de fichiers parcourus par requête.</summary>
|
||||
private sealed class ScanBudget
|
||||
{
|
||||
private long _remaining;
|
||||
|
||||
public ScanBudget(long files) => _remaining = files;
|
||||
|
||||
public (long Size, long Count) Measure(DirectoryInfo directory)
|
||||
{
|
||||
if (_remaining <= 0) return (-1, -1);
|
||||
long size = 0, count = 0;
|
||||
try
|
||||
{
|
||||
// Fichiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
|
||||
foreach (var file in directory.EnumerateFiles("*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
|
||||
}))
|
||||
{
|
||||
if (--_remaining < 0) return (-1, -1);
|
||||
size += file.Length;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return (-1, -1);
|
||||
}
|
||||
return (size, count);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== Suppression d'un fichier
|
||||
|
||||
public ServerFileDeleteResult Delete(string relativePath, long expectedSize)
|
||||
{
|
||||
RequireRoot();
|
||||
var (path, relative) = Resolve(relativePath);
|
||||
if (relative.Length == 0 || Directory.Exists(path))
|
||||
throw ApiRejection.BadRequest("Seul un fichier peut être supprimé (jamais un dossier).");
|
||||
var file = new FileInfo(path);
|
||||
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
|
||||
|
||||
// Même verrou que la publication et les notes de version pour chaque jeu dont le dossier contient ce fichier :
|
||||
// le manifest ne peut pas changer entre la vérification et la suppression.
|
||||
var locks = new List<FileStream>();
|
||||
try
|
||||
{
|
||||
foreach (var gameId in GamesContaining(path)) locks.Add(PublishedFiles.LockGame(_dataDirectory, gameId));
|
||||
|
||||
file.Refresh();
|
||||
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
|
||||
if (file.LinkTarget is not null) throw ApiRejection.Conflict("Lien symbolique : jamais supprimé depuis le menu.");
|
||||
if (file.Length != expectedSize)
|
||||
throw ApiRejection.Conflict("Le fichier a changé depuis l'affichage de la liste : actualisez avant de le supprimer.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
|
||||
if (PublicationFolderInProgress(path) is { } game)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Une publication est en cours dans « {RelativeDisplay(game)} » : réessayez une fois la version en ligne ou la publication abandonnée.",
|
||||
ApiErrorCodes.PublicationInProgress);
|
||||
|
||||
// Vérification refaite maintenant (manifests relus) : seul un fichier inutilisé est supprimé.
|
||||
var (status, reason) = ScanReferences().StatusOf(path);
|
||||
if (status != ServerFileStatus.Unused)
|
||||
throw ApiRejection.Conflict(status == ServerFileStatus.Protected || status == ServerFileStatus.InUse
|
||||
? $"Suppression refusée : {reason}"
|
||||
: $"Suppression refusée : {reason ?? "impossible de vérifier que ce fichier est inutilisé."}");
|
||||
|
||||
var size = file.Length;
|
||||
File.Delete(path);
|
||||
return new ServerFileDeleteResult { Path = relative, FreedBytes = size };
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var handle in locks) handle.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== Nettoyage du dossier « objects » d'un jeu
|
||||
|
||||
public ObjectsPrunePreview PreviewPrune(string gameId)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
return Analyze(manifestPath, key).Preview;
|
||||
}
|
||||
|
||||
public ObjectsPruneResult Prune(string gameId, string manifestSha256, Action<string, Exception> logFailure)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
using (PublishedFiles.LockGame(_dataDirectory, key))
|
||||
{
|
||||
var analysis = Analyze(manifestPath, key);
|
||||
if (!string.Equals(analysis.Preview.ManifestSha256, manifestSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw ApiRejection.Conflict("Le manifest en ligne a changé depuis l'analyse : relancez l'analyse avant de nettoyer.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (analysis.Preview.PublicationInProgress)
|
||||
throw ApiRejection.Conflict(
|
||||
"Une publication de ce jeu est en cours : nettoyez une fois la version en ligne (ou la publication abandonnée).",
|
||||
ApiErrorCodes.PublicationInProgress);
|
||||
|
||||
int deleted = 0, failed = 0;
|
||||
long freed = 0;
|
||||
foreach (var orphan in analysis.Orphans)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(orphan);
|
||||
if (!info.Exists || info.LinkTarget is not null) continue;
|
||||
var size = info.Length;
|
||||
File.Delete(orphan);
|
||||
deleted++;
|
||||
freed += size;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
failed++;
|
||||
logFailure(orphan, ex);
|
||||
}
|
||||
}
|
||||
return new ObjectsPruneResult { GameId = key, Deleted = deleted, FreedBytes = freed, Failed = failed };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PruneAnalysis(ObjectsPrunePreview Preview, IReadOnlyList<string> Orphans);
|
||||
|
||||
private PruneAnalysis Analyze(string manifestPath, string gameId)
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath)
|
||||
?? throw ApiRejection.NotFound($"Aucun manifest en ligne pour « {gameId} » : rien à nettoyer.");
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var manifest = ManifestReferences.Parse(bytes);
|
||||
if (!manifest.LocalObjects)
|
||||
throw ApiRejection.Conflict("Le manifest en ligne sert ses fichiers depuis une autre adresse (« objectsBaseUrl ») : " +
|
||||
"nettoyage impossible depuis le menu.");
|
||||
|
||||
var objectsDirectory = Path.Combine(gameDirectory, "objects");
|
||||
if (new DirectoryInfo(gameDirectory).LinkTarget is not null || new DirectoryInfo(objectsDirectory).LinkTarget is not null)
|
||||
throw ApiRejection.Conflict("Le dossier du jeu ou son dossier « objects » est un lien symbolique : nettoyage impossible depuis le menu.");
|
||||
|
||||
// Autres fichiers de description du site (un autre manifest peut désigner ces objets par une adresse) :
|
||||
// un objet qu'ils utilisent, ou qu'ils rendent invérifiable, est conservé.
|
||||
References? site = null;
|
||||
if (_root is not null && IsInside(gameDirectory, _root))
|
||||
{
|
||||
site = ScanReferences();
|
||||
if (site.SiteWide is { } reason) throw ApiRejection.Conflict($"Nettoyage impossible : {reason}");
|
||||
}
|
||||
|
||||
var referenced = new HashSet<string>(manifest.Objects, StringComparer.Ordinal);
|
||||
var present = new HashSet<string>(StringComparer.Ordinal);
|
||||
var orphans = new List<string>();
|
||||
long orphanBytes = 0;
|
||||
if (Directory.Exists(objectsDirectory))
|
||||
{
|
||||
foreach (var prefix in new DirectoryInfo(objectsDirectory).EnumerateDirectories())
|
||||
{
|
||||
if (!PrefixRegex().IsMatch(prefix.Name) || prefix.LinkTarget is not null) continue;
|
||||
foreach (var file in prefix.EnumerateFiles())
|
||||
{
|
||||
// Seuls les objets au nom exact (« <empreinte>.bin » dans le bon sous-dossier) sont concernés :
|
||||
// tout autre fichier est laissé tel quel.
|
||||
if (!ObjectNameRegex().IsMatch(file.Name) || !file.Name.StartsWith(prefix.Name, StringComparison.Ordinal) ||
|
||||
file.LinkTarget is not null)
|
||||
continue;
|
||||
var sha = file.Name[..64];
|
||||
if (referenced.Contains(sha))
|
||||
{
|
||||
present.Add(sha);
|
||||
continue;
|
||||
}
|
||||
if (site is not null && site.StatusOf(file.FullName).Status != ServerFileStatus.Unused) continue;
|
||||
orphans.Add(file.FullName);
|
||||
orphanBytes += file.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var inProgress = Directory.Exists(gameDirectory) &&
|
||||
new DirectoryInfo(gameDirectory).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any();
|
||||
orphans.Sort(StringComparer.Ordinal);
|
||||
return new PruneAnalysis(new ObjectsPrunePreview
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = manifest.Version ?? "",
|
||||
ManifestSha256 = PublishedFiles.Sha256(bytes),
|
||||
ReferencedCount = referenced.Count,
|
||||
MissingCount = referenced.Count - present.Count,
|
||||
OrphanCount = orphans.Count,
|
||||
OrphanBytes = orphanBytes,
|
||||
Examples = orphans.Take(10).Select(o => Path.GetRelativePath(gameDirectory, o).Replace('\\', '/')).ToList(),
|
||||
PublicationInProgress = inProgress
|
||||
}, orphans);
|
||||
}
|
||||
|
||||
// ================================================================== Références (fichiers de description)
|
||||
|
||||
/// <summary>Dossiers parcourus au plus pour trouver les fichiers de description du site.</summary>
|
||||
private const int MaxScannedFolders = 50_000;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers désignés par TOUS les fichiers de description du site (manifest.json et launcher-update.json de
|
||||
/// n'importe quel dossier, catalogue configuré), relus à chaque requête. Les adresses sont résolues comme le fait
|
||||
/// le launcher (relatives au fichier de description, « .. » compris ; adresses absolues du même serveur rattachées
|
||||
/// aux fichiers du site). Ce qui ne peut pas être vérifié n'est jamais supprimable.
|
||||
/// </summary>
|
||||
private sealed class References
|
||||
{
|
||||
public Dictionary<string, string> Files { get; } = new(PathComparer);
|
||||
|
||||
/// <summary>Dossiers dont le contenu ne peut pas être vérifié (fichier de description illisible…).</summary>
|
||||
public List<(string Folder, string Reason)> UnknownFolders { get; } = new();
|
||||
|
||||
/// <summary>Vérification impossible pour tout le site (adresse du même serveur non rattachable, site trop grand…).</summary>
|
||||
public string? SiteWide { get; set; }
|
||||
|
||||
public void Add(string path, string reason) => Files.TryAdd(path, reason);
|
||||
|
||||
public (string Status, string? Reason) StatusOf(string path)
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
if (name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
return (ServerFileStatus.Protected, name.Equals(ManifestName, StringComparison.OrdinalIgnoreCase)
|
||||
? "manifest d'un jeu : jamais supprimé."
|
||||
: "fichier de description (.json : catalogue, trousseau, mise à jour du launcher…) : jamais supprimé depuis le menu.");
|
||||
if (Files.TryGetValue(path, out var reason)) return (ServerFileStatus.InUse, reason);
|
||||
if (SiteWide is not null) return (ServerFileStatus.Unknown, SiteWide);
|
||||
foreach (var (folder, why) in UnknownFolders)
|
||||
if (IsInside(path, folder))
|
||||
return (ServerFileStatus.Unknown, why);
|
||||
return (ServerFileStatus.Unused, null);
|
||||
}
|
||||
}
|
||||
|
||||
private References ScanReferences()
|
||||
{
|
||||
var root = RequireRoot();
|
||||
var references = new References();
|
||||
var site = new SiteAddresses(root, _catalogFile, _catalogUrl);
|
||||
|
||||
var folders = new List<string> { root };
|
||||
try
|
||||
{
|
||||
// Dossiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
|
||||
foreach (var directory in new DirectoryInfo(root).EnumerateDirectories("*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
|
||||
}))
|
||||
{
|
||||
if (folders.Count >= MaxScannedFolders)
|
||||
{
|
||||
references.SiteWide = $"impossible de vérifier : le site contient plus de {MaxScannedFolders} dossiers.";
|
||||
return references;
|
||||
}
|
||||
folders.Add(directory.FullName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
references.SiteWide = "impossible de vérifier : le site n'a pas pu être parcouru entièrement.";
|
||||
return references;
|
||||
}
|
||||
|
||||
foreach (var folder in folders) ReadFolderReferences(folder, site, references);
|
||||
if (_catalogFile is not null && IsInside(_catalogFile, root) && File.Exists(_catalogFile)) ReadCatalogReferences(site, references);
|
||||
return references;
|
||||
}
|
||||
|
||||
private void ReadFolderReferences(string folder, SiteAddresses site, References references)
|
||||
{
|
||||
var display = RelativeDisplay(folder);
|
||||
var problems = new List<string>();
|
||||
|
||||
var manifest = Path.Combine(folder, ManifestName);
|
||||
if (File.Exists(manifest))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = ManifestReferences.Parse(ReadDescription(manifest));
|
||||
var version = parsed.Version is { Length: > 0 } v ? $"version {v}" : "version en ligne";
|
||||
var reason = $"manifest de {display}";
|
||||
if (parsed.Archive is { } archive &&
|
||||
Resolve(site, folder, archive, $"archive « {archive} » du {reason}", problems, references) is { } archivePath)
|
||||
references.Add(archivePath, $"archive de la {version} ({reason}).");
|
||||
var objectsBase = string.IsNullOrEmpty(parsed.ObjectsBase) ? "objects/" : parsed.ObjectsBase;
|
||||
if (!objectsBase.EndsWith('/')) objectsBase += "/";
|
||||
if (parsed.Objects.Count > 0 &&
|
||||
Resolve(site, folder, objectsBase, $"dossier des fichiers « {objectsBase} » du {reason}", problems, references) is { } objects)
|
||||
foreach (var sha in parsed.Objects)
|
||||
references.Add(Path.Combine(objects, sha[..2], sha + ".bin"), $"fichier de la {version} ({reason}).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
problems.Add($"manifest de {display} illisible ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
var update = Path.Combine(folder, LauncherUpdateName);
|
||||
if (File.Exists(update))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (version, executable) = LauncherUpdateReference(ReadDescription(update));
|
||||
if (Resolve(site, folder, executable, $"exécutable « {executable} » de launcher-update.json de {display}", problems, references) is { } target)
|
||||
references.Add(target, $"exécutable du launcher {version} (launcher-update.json de {display}).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
problems.Add($"launcher-update.json de {display} illisible ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.Count > 0)
|
||||
references.UnknownFolders.Add((folder,
|
||||
$"impossible de vérifier : {string.Join(" ; ", problems)}. Rien n'est supprimé dans {display} tant que ce n'est pas corrigé."));
|
||||
}
|
||||
|
||||
private void ReadCatalogReferences(SiteAddresses site, References references)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(_catalogFile!)!;
|
||||
try
|
||||
{
|
||||
foreach (var (gameId, kind, url) in CatalogImages(ReadDescription(_catalogFile!)))
|
||||
{
|
||||
switch (site.ResolveCatalogImage(url))
|
||||
{
|
||||
case { Path: { } path }:
|
||||
references.Add(path, $"{kind} du jeu {gameId} dans le catalogue.");
|
||||
break;
|
||||
case { Unmappable: true }:
|
||||
references.SiteWide ??= $"impossible de vérifier : {kind} du jeu {gameId} du catalogue (« {url} ») non rattachée aux fichiers du site.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
references.UnknownFolders.Add((folder, $"impossible de vérifier : catalogue illisible ({ex.Message})."));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fichier (ou dossier) du site désigné par une adresse d'un fichier de description de <paramref name="folder"/>.
|
||||
/// Null : hors du site (autre serveur, en dehors de la racine), ou non vérifiable (problème noté).
|
||||
/// </summary>
|
||||
private static string? Resolve(SiteAddresses site, string folder, string address, string what, List<string> problems, References references)
|
||||
{
|
||||
var resolved = site.Resolve(folder, address);
|
||||
if (resolved.Invalid) problems.Add($"{what} : adresse non reconnue");
|
||||
else if (resolved.Unmappable)
|
||||
references.SiteWide ??= $"impossible de vérifier : {what} désigne ce serveur par une adresse complète qui n'a pas pu être rattachée aux fichiers du site.";
|
||||
return resolved.Path;
|
||||
}
|
||||
|
||||
/// <summary>Adresse résolue : chemin du site, adresse extérieure (rien), invalide, ou du même serveur mais non rattachable.</summary>
|
||||
private readonly record struct ResolvedAddress(string? Path, bool Invalid = false, bool Unmappable = false);
|
||||
|
||||
/// <summary>
|
||||
/// Correspondance entre adresses et fichiers du site. Hôte du site : celui du catalogue ; préfixe des adresses :
|
||||
/// déduit de l'adresse publique et de l'emplacement du fichier du catalogue (« /catalog/catalog.json » ↔
|
||||
/// « www/catalog/catalog.json » : la racine du site est servie sous « / »).
|
||||
/// </summary>
|
||||
private sealed class SiteAddresses
|
||||
{
|
||||
private const string LocalHost = "site.invalid";
|
||||
private readonly string _root;
|
||||
private readonly Uri? _catalogUri;
|
||||
private readonly string? _catalogFile;
|
||||
private readonly string? _sitePrefix;
|
||||
|
||||
public SiteAddresses(string root, string? catalogFile, string catalogUrl)
|
||||
{
|
||||
_root = root;
|
||||
_catalogFile = catalogFile;
|
||||
if (Uri.TryCreate(catalogUrl, UriKind.Absolute, out var uri)) _catalogUri = uri;
|
||||
if (_catalogUri is not null && catalogFile is not null && IsInside(catalogFile, root))
|
||||
{
|
||||
var relative = "/" + System.IO.Path.GetRelativePath(root, catalogFile).Replace('\\', '/');
|
||||
var path = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
|
||||
if (path.EndsWith(relative, StringComparison.Ordinal)) _sitePrefix = path[..^(relative.Length - 1)]; // se termine par « / »
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Résolution comme le launcher : adresse relative au fichier de description (adresse fictive du dossier).</summary>
|
||||
public ResolvedAddress Resolve(string folder, string address)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address) || address.Any(char.IsControl) || address.Contains('\\')) return new(null, Invalid: true);
|
||||
var relativeFolder = System.IO.Path.GetRelativePath(_root, folder).Replace('\\', '/');
|
||||
var baseUri = new Uri($"http://{LocalHost}/" + (relativeFolder == "." ? "" : string.Join('/', relativeFolder.Split('/').Select(Uri.EscapeDataString)) + "/"));
|
||||
if (!Uri.TryCreate(baseUri, address, out var target) || target.Scheme is not ("http" or "https")) return new(null, Invalid: true);
|
||||
if (target.Host == LocalHost) return Map(Uri.UnescapeDataString(target.AbsolutePath));
|
||||
return Absolute(target);
|
||||
}
|
||||
|
||||
/// <summary>Image du catalogue : relative à l'adresse du catalogue.</summary>
|
||||
public ResolvedAddress ResolveCatalogImage(string url)
|
||||
{
|
||||
if (_catalogUri is null || _catalogFile is null) return new(null, Unmappable: true);
|
||||
if (!Uri.TryCreate(_catalogUri, url, out var image) || image.Scheme is not ("http" or "https")) return new(null, Invalid: true);
|
||||
if (!string.Equals(image.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase)) return new(null);
|
||||
if (_sitePrefix is not null) return Absolute(image);
|
||||
// Préfixe inconnu : chemin relatif au dossier du catalogue.
|
||||
var catalogPath = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
|
||||
var directory = catalogPath[..(catalogPath.LastIndexOf('/') + 1)];
|
||||
var imagePath = Uri.UnescapeDataString(image.AbsolutePath);
|
||||
if (!imagePath.StartsWith(directory, StringComparison.Ordinal)) return new(null, Unmappable: true);
|
||||
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(_catalogFile)!,
|
||||
imagePath[directory.Length..].Replace('/', System.IO.Path.DirectorySeparatorChar)));
|
||||
return IsInside(full, _root) ? new(full) : new(null);
|
||||
}
|
||||
|
||||
private ResolvedAddress Absolute(Uri target)
|
||||
{
|
||||
if (_catalogUri is not null && !string.Equals(target.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase))
|
||||
return new(null); // autre serveur : rien à protéger ici
|
||||
if (_sitePrefix is null) return new(null, Unmappable: true); // même serveur (ou inconnu) : impossible de rattacher
|
||||
var path = Uri.UnescapeDataString(target.AbsolutePath);
|
||||
return path.StartsWith(_sitePrefix, StringComparison.Ordinal) ? Map("/" + path[_sitePrefix.Length..]) : new(null);
|
||||
}
|
||||
|
||||
/// <summary>Chemin d'adresse (« /jeu/objects/ab/… ») → chemin du site (null : en dehors de la racine).</summary>
|
||||
private ResolvedAddress Map(string urlPath)
|
||||
{
|
||||
var relative = urlPath.TrimStart('/');
|
||||
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(_root, relative.Replace('/', System.IO.Path.DirectorySeparatorChar)));
|
||||
if (urlPath.EndsWith('/')) full = full.TrimEnd(System.IO.Path.DirectorySeparatorChar);
|
||||
return IsInside(full, _root) ? new(full) : new(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ReadDescription(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length > MaxDescriptionBytes) throw new InvalidDataException("fichier trop volumineux");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
private static (string Version, string Executable) LauncherUpdateReference(byte[] bytes)
|
||||
{
|
||||
var envelope = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "launcher-update.json");
|
||||
var payload = PublishedFiles.StringValue(envelope, "payload") ?? throw new InvalidDataException("contenu signé absent");
|
||||
byte[] decoded;
|
||||
try
|
||||
{
|
||||
decoded = Convert.FromBase64String(payload);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new InvalidDataException("contenu signé illisible");
|
||||
}
|
||||
var release = PublishedFiles.ParseObject(decoded, "Description du launcher");
|
||||
var file = PublishedFiles.StringValue(release, "file");
|
||||
if (string.IsNullOrWhiteSpace(file)) throw new InvalidDataException("exécutable absent");
|
||||
return (PublishedFiles.StringValue(release, "version") ?? "", file);
|
||||
}
|
||||
|
||||
private static IEnumerable<(string GameId, string Kind, string Url)> CatalogImages(byte[] bytes)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Catalogue");
|
||||
if (PublishedFiles.Property(root, "games") is not JsonObject games) yield break;
|
||||
foreach (var (gameId, node) in games)
|
||||
{
|
||||
if (node is not JsonObject game) continue;
|
||||
if (PublishedFiles.StringValue(game, "icon") is { Length: > 0 } icon) yield return (gameId, "icône", icon);
|
||||
if (PublishedFiles.StringValue(game, "background") is { Length: > 0 } background) yield return (gameId, "image de fond", background);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== Chemins
|
||||
|
||||
private string RequireRoot() => _root ?? throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Racine du site non configurée sur le serveur (LauncherApi:Files:Root, ou un jeu / le catalogue configuré).");
|
||||
|
||||
/// <summary>
|
||||
/// Chemin relatif (« / ») → chemin complet sous la racine. Refus : « .. », chemin absolu, « \ », « : »,
|
||||
/// élément caché, caractère de contrôle, lien symbolique sur le chemin.
|
||||
/// </summary>
|
||||
private (string FullPath, string Relative) Resolve(string relativePath)
|
||||
{
|
||||
var root = RequireRoot();
|
||||
var relative = relativePath.Trim().Trim('/');
|
||||
if (relative.Length == 0) return (root, "");
|
||||
if (relative.Length > ServerFilesLimits.MaxPathLength || relative.Any(char.IsControl) || relative.Contains('\\') ||
|
||||
relative.Contains(':'))
|
||||
throw ApiRejection.BadRequest("Chemin invalide.");
|
||||
var segments = relative.Split('/');
|
||||
if (segments.Any(s => s.Length == 0 || s == "." || s == ".." || s.StartsWith('.')))
|
||||
throw ApiRejection.BadRequest("Chemin invalide (élément vide, caché ou « .. »).");
|
||||
|
||||
var full = Path.GetFullPath(Path.Combine(new[] { root }.Concat(segments).ToArray()));
|
||||
if (!IsInside(full, root)) throw ApiRejection.BadRequest("Chemin en dehors du site.");
|
||||
|
||||
// Aucun lien symbolique entre la racine et l'élément (il pourrait mener en dehors du site).
|
||||
var current = root;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
|
||||
if (info.Exists && info.LinkTarget is not null)
|
||||
throw ApiRejection.BadRequest("Chemin refusé : il passe par un lien symbolique.");
|
||||
}
|
||||
return (full, string.Join('/', segments));
|
||||
}
|
||||
|
||||
/// <summary>Dossiers de la racine jusqu'à <paramref name="folder"/> inclus (fichiers de description pris en compte).</summary>
|
||||
private IEnumerable<string> FoldersFromRootTo(string folder)
|
||||
{
|
||||
var root = _root;
|
||||
var chain = new List<string>();
|
||||
for (var current = Path.GetFullPath(folder).TrimEnd(Path.DirectorySeparatorChar);
|
||||
current is not null && (root is null || IsInside(current, root));
|
||||
current = Path.GetDirectoryName(current))
|
||||
chain.Add(current);
|
||||
chain.Reverse();
|
||||
return chain;
|
||||
}
|
||||
|
||||
/// <summary>Jeux configurés dont le dossier contient ce chemin (verrous à prendre avant de supprimer).</summary>
|
||||
private IEnumerable<string> GamesContaining(string path) =>
|
||||
_manifests.Where(m => IsInside(path, Path.GetDirectoryName(m.Value)!))
|
||||
.Select(m => m.Key).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
|
||||
/// <summary>Dossier d'un jeu (contenant un manifest.json) où une publication est en cours, parmi les parents du chemin.</summary>
|
||||
private string? PublicationFolderInProgress(string path)
|
||||
{
|
||||
foreach (var folder in FoldersFromRootTo(Path.GetDirectoryName(path)!))
|
||||
if (File.Exists(Path.Combine(folder, ManifestName)) || _manifests.Values.Any(m => string.Equals(Path.GetDirectoryName(m), folder, PathComparison)))
|
||||
if (new DirectoryInfo(folder).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any())
|
||||
return folder;
|
||||
return null;
|
||||
}
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw PublishedFiles.NotConfigured(gameId, "Nettoyage des fichiers");
|
||||
}
|
||||
|
||||
private string RelativeDisplay(string folder)
|
||||
{
|
||||
if (_root is null) return Path.GetFileName(folder) + "/";
|
||||
var relative = Path.GetRelativePath(_root, folder).Replace('\\', '/');
|
||||
return relative == "." ? "la racine du site" : relative + "/";
|
||||
}
|
||||
|
||||
private static bool IsInside(string path, string parent)
|
||||
{
|
||||
var p = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar);
|
||||
var r = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar);
|
||||
return string.Equals(p, r, PathComparison) || p.StartsWith(r + Path.DirectorySeparatorChar, PathComparison);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ce qu'un manifest.json désigne sur le disque : nom de l'archive, empreintes des fichiers. Lecture tolérante (tout
|
||||
/// manifest du site, pas seulement ceux des jeux configurés) mais stricte sur ce qui sert à décider : toute empreinte
|
||||
/// invalide rend le manifest « illisible », donc rien n'est supprimé à côté.
|
||||
/// </summary>
|
||||
internal sealed record ManifestReferences(string? Version, string? Archive, string? ObjectsBase, bool LocalObjects, IReadOnlyCollection<string> Objects)
|
||||
{
|
||||
public static ManifestReferences Parse(byte[] bytes)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
var version = PublishedFiles.StringValue(root, "version");
|
||||
|
||||
string? archive = null;
|
||||
if (PublishedFiles.Property(root, "archive") is JsonObject archiveNode)
|
||||
archive = PublishedFiles.StringValue(archiveNode, "url") is { Length: > 0 } url ? url : throw new InvalidDataException("archive sans adresse");
|
||||
|
||||
var objectsBase = PublishedFiles.StringValue(root, "objectsBaseUrl");
|
||||
var local = string.IsNullOrEmpty(objectsBase) || objectsBase is "objects/" or "objects";
|
||||
|
||||
var objects = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (PublishedFiles.Property(root, "files") is JsonArray files)
|
||||
{
|
||||
foreach (var node in files)
|
||||
{
|
||||
if (node is not JsonObject file) throw new InvalidDataException("fichier invalide dans la liste");
|
||||
var sha = PublishedFiles.StringValue(file, "sha256")?.ToLowerInvariant();
|
||||
if (!PublishingLimits.IsValidSha256(sha)) throw new InvalidDataException("empreinte de fichier invalide");
|
||||
objects.Add(sha!);
|
||||
}
|
||||
}
|
||||
else if (PublishedFiles.Property(root, "files") is not null)
|
||||
{
|
||||
throw new InvalidDataException("liste des fichiers invalide");
|
||||
}
|
||||
return new ManifestReferences(version, archive, objectsBase, local, objects);
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,58 @@
|
||||
},
|
||||
"ChallengeLifetimeSeconds": 60,
|
||||
"InvitationLifetimeHours": 24,
|
||||
// Catalogue des jeux (phase 3) : fichier public écrit par le service, servi par nginx.
|
||||
// Vide : publication désactivée. À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « /srv/exovival/www/catalog/catalog.json ».
|
||||
"Catalog": {
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Trousseau (phase 7) : vide = « keyring.json » à côté du catalogue (même dossier, déjà autorisé en écriture).
|
||||
"Keyring": {
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Signature du contenu : true = catalogue et versions refusés s'ils ne sont pas signés (une fois les
|
||||
// launchers en mode « require », voir server/deploy/LISEZMOI.md, étape 3 quinquies).
|
||||
"ContentSigning": {
|
||||
"Require": false
|
||||
},
|
||||
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
|
||||
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
|
||||
"Games": {},
|
||||
// Page « Fichiers du serveur » : racine du site public consultable depuis le menu. Vide = dossier parent
|
||||
// commun des jeux et du catalogue (ex. « /srv/exovival/www »). La suppression n'est possible que dans les
|
||||
// dossiers autorisés en écriture par systemd (ReadWritePaths : jeux, catalogue). Voir server/deploy/LISEZMOI.md.
|
||||
"Files": {
|
||||
"Root": ""
|
||||
},
|
||||
// Demandes des joueurs (bouton « Aide » du launcher) : désactivé par défaut. Activer dans appsettings.Production.json
|
||||
// (voir server/deploy/LISEZMOI.md, étape 3 octies). Games vide = jeux de « Games » ci-dessus.
|
||||
// Aucune adresse IP n'est enregistrée avec une demande ; les limites par adresse sont comptées en mémoire.
|
||||
"Feedback": {
|
||||
"Enabled": false,
|
||||
"Games": [],
|
||||
"ProofDifficulty": 18,
|
||||
"PerIpPer10Minutes": 3,
|
||||
"PerIpPerDay": 10,
|
||||
"RequestsPerMinute": 10,
|
||||
"MaxPerGamePerDay": 300,
|
||||
"MaxPerGame": 5000,
|
||||
"MaxTotalMegabytes": 1024,
|
||||
"MinFreeDiskMegabytes": 500,
|
||||
"RetentionClosedMonths": 12,
|
||||
"RetentionMaxMonths": 24
|
||||
},
|
||||
// Journal d'audit : mois conservés (purge automatique au démarrage puis chaque jour ; CNIL : 6 mois à 1 an).
|
||||
"Audit": {
|
||||
"RetentionMonths": 12
|
||||
},
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
|
||||
"PublicationUploadsPerMinute": 1500,
|
||||
"AuthPerMinute": 20,
|
||||
"EnrollmentsPerHour": 10,
|
||||
"MaxActiveInvitations": 20,
|
||||
|
||||
+476
-16
@@ -65,6 +65,442 @@ Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), l
|
||||
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
|
||||
signatures seront refusées.
|
||||
|
||||
## 3 bis. Catalogue des jeux (VPS)
|
||||
|
||||
La page **Jeux** du menu d'administration publie `catalog.json` (nom affiché, accroche, description,
|
||||
liens, thème, images, ordre des jeux), que les launchers des joueurs lisent au démarrage à l'adresse
|
||||
`catalogUrl` de `launcher.json`. Le serveur l'écrit dans **un seul** dossier, servi par nginx, séparé
|
||||
de ses données privées. Exemple ci-dessous avec le site de « Serveur de téléchargement Exovival »
|
||||
(`/srv/exovival/www`) : adapte le chemin à la racine réelle de ton site.
|
||||
|
||||
1. Crée le dossier public (propriétaire `deploy`, groupe `www-data` pour nginx) :
|
||||
|
||||
```
|
||||
sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog /srv/exovival/www/catalog/images
|
||||
```
|
||||
2. Indique le fichier au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
|
||||
avec tes autres réglages) :
|
||||
|
||||
```
|
||||
{ "LauncherApi": { "Catalog": { "PublicFile": "/srv/exovival/www/catalog/catalog.json" } } }
|
||||
```
|
||||
|
||||
L'adresse publique est déduite de `PublicOrigin` (`https://launcher.lanisys.fr/catalog/catalog.json`) ;
|
||||
sinon, ajoute `"PublicUrl"`. Elle doit être **identique** à `catalogUrl` dans `launcher.json`.
|
||||
Le serveur refuse de démarrer si le dossier n'existe pas, ou si le dossier public et
|
||||
`/var/lib/launcher-api` sont l'un dans l'autre.
|
||||
3. Autorise le service à écrire dans ce dossier, et seulement lui (le reste du système reste en
|
||||
lecture seule) :
|
||||
|
||||
```
|
||||
sudo mkdir -p /etc/systemd/system/launcher-api.service.d
|
||||
sudo nano /etc/systemd/system/launcher-api.service.d/catalog.conf
|
||||
```
|
||||
|
||||
Colle le contenu de `server/deploy/launcher-api-catalog.conf` (ligne `ReadWritePaths=` adaptée),
|
||||
puis :
|
||||
|
||||
```
|
||||
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
```
|
||||
|
||||
Ce fichier est conservé lors des mises à jour du serveur.
|
||||
4. nginx : colle le bloc `location = /catalog/catalog.json { … }` de `server/deploy/nginx-catalog.conf`
|
||||
dans le bloc HTTPS du site, avant `location / {`, puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
5. Publie depuis la page **Jeux**, puis vérifie depuis ton PC :
|
||||
|
||||
```
|
||||
PS> curl.exe -sI https://launcher.lanisys.fr/catalog/catalog.json
|
||||
```
|
||||
|
||||
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
|
||||
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
|
||||
|
||||
**Images** (icône, fond) : page **Jeux** › « Parcourir… » à côté du champ (explorateur Windows). L'image
|
||||
est contrôlée sur le PC puis par le serveur (PNG ou JPEG de 4 Mo au plus, dimensions bornées, fichier
|
||||
complet), rangée dans `www/catalog/images/` sous le nom `<empreinte SHA-256>.png|.jpg`, et son adresse
|
||||
remplit le champ ; « Publier » l'applique aux joueurs. Prérequis : étape 3 septies (bloc nginx). L'envoi
|
||||
en SFTP (compte `deploy`) reste possible : saisis alors l'adresse à la main, par ex.
|
||||
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
|
||||
en cache : pour changer une image déposée à la main, envoie-la sous un **nouveau nom** (automatique
|
||||
avec « Parcourir… »). Les images d'un autre serveur sont refusées.
|
||||
|
||||
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
|
||||
|
||||
## 3 ter. Notes de version modifiables en ligne (VPS)
|
||||
|
||||
La page **Notes de version** du menu d'administration corrige les notes des versions déjà publiées,
|
||||
directement dans le `manifest.json` du jeu (rien d'autre n'y est modifié). Exemple ci-dessous avec le
|
||||
dossier du jeu de « Serveur de téléchargement Exovival » (`/srv/exovival/www/exovivalzombie`) : adapte
|
||||
le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
|
||||
|
||||
> **Compromis de sécurité** : le service pourra écrire dans le dossier du jeu (il remplace le manifest
|
||||
> par renommage dans ce dossier). Un service compromis pourrait donc modifier les fichiers du jeu de ce
|
||||
> dossier. L'autorisation se limite à ce seul dossier ; sans cette étape, rien n'est modifiable en ligne.
|
||||
|
||||
1. Vérifie que le dossier du jeu a le **bit setgid** (le `s` de `drwxr-s---`), pour que le manifest
|
||||
remplacé garde le groupe `www-data` lu par nginx :
|
||||
|
||||
```
|
||||
stat -c '%A %U:%G' /srv/exovival/www/exovivalzombie
|
||||
sudo chmod g+s /srv/exovival/www/exovivalzombie # seulement s'il manque
|
||||
```
|
||||
|
||||
Sans lui, le serveur refuse la modification plutôt que de rendre le manifest illisible.
|
||||
2. Indique le manifest au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
|
||||
avec tes autres réglages ; la clé est le `gameId` de `launcher.json`) :
|
||||
|
||||
```
|
||||
{ "LauncherApi": { "Games": { "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } } } }
|
||||
```
|
||||
3. Autorise le service à écrire dans ce dossier, et seulement lui :
|
||||
|
||||
```
|
||||
sudo nano /etc/systemd/system/launcher-api.service.d/games.conf
|
||||
```
|
||||
|
||||
Colle le contenu de `server/deploy/launcher-api-games.conf` (ligne `ReadWritePaths=` adaptée,
|
||||
une par jeu), puis :
|
||||
|
||||
```
|
||||
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
```
|
||||
|
||||
Ce fichier est conservé lors des mises à jour du serveur.
|
||||
4. Sur ton PC, ajoute `--remote-manifest <adresse du manifest>` à ton script de publication
|
||||
(déjà dans `tools\publish-example.bat`) : sans lui, la publication suivante écraserait les notes
|
||||
corrigées en ligne avec celles du `manifest.json` local.
|
||||
5. Teste : menu d'administration › **Notes de version**, corrige une faute, **Enregistrer**. L'ancien
|
||||
manifest est copié dans `/var/lib/launcher-api/manifest-history/`.
|
||||
|
||||
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
|
||||
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
|
||||
|
||||
## 3 quater. Publier une version depuis le launcher (VPS)
|
||||
|
||||
La page **Publier une version** du menu d'administration remplace l'envoi avec WinSCP : seuls les
|
||||
fichiers absents du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est mis
|
||||
en ligne en dernier. Prérequis : l'étape 3 ter (dossier du jeu déclaré dans `LauncherApi:Games`,
|
||||
complément `games.conf`, bit setgid).
|
||||
|
||||
1. nginx : ajoute la zone de limite des envois dans `/etc/nginx/conf.d/securite.conf`, à côté de
|
||||
`api_par_ip` (un envoi enchaîne plusieurs morceaux de 4 Mo par seconde ; avec la limite générale de
|
||||
l'API, il serait bridé et ferait des pauses) :
|
||||
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
|
||||
```
|
||||
|
||||
Puis colle le bloc `location ^~ /api/v1/publications/ { … }` de `server/deploy/nginx-publications.conf`
|
||||
dans le bloc HTTPS du site, à côté du bloc `/api/`, et recharge :
|
||||
|
||||
```
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Seules ces routes acceptent des envois de 8 Mo ; le reste de l'API reste limité à 64 Ko. Vérifie
|
||||
aussi que le site contient toujours la règle `location ~ /\. { deny all; }` : les fichiers en cours
|
||||
d'envoi sont rangés dans un dossier caché (`.publish-…`) du dossier du jeu.
|
||||
2. Crée le dossier `objects` du jeu **et ses 256 sous-dossiers** (`00` à `ff`), avec le groupe et le bit
|
||||
setgid du dossier du jeu (sans risque s'ils existent déjà : leurs droits sont simplement remis en état) :
|
||||
|
||||
```
|
||||
JEU=/srv/exovival/www/exovivalzombie
|
||||
sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects"
|
||||
for i in $(seq 0 255); do sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects/$(printf %02x $i)"; done
|
||||
```
|
||||
|
||||
Pourquoi : systemd interdit au service de demander le bit setgid (`RestrictSUIDSGID=yes`, gardé). Un
|
||||
sous-dossier qui manquerait serait créé par le service en `drwxr-x---` (groupe `www-data`, lisible par
|
||||
nginx) mais **sans setgid** : un fichier déposé ensuite dedans **avec WinSCP** prendrait le groupe
|
||||
`deploy` et ne serait plus lisible par nginx. Relance la boucle si `stat` montre un `drwxr-x---`.
|
||||
3. Espace disque : l'envoi est stocké sur le même disque que le jeu jusqu'à la mise en ligne. Prévois la
|
||||
taille de l'archive et des nouveaux fichiers (le serveur refuse l'envoi s'il manque de place).
|
||||
4. Teste : lance le .bat de publication (ManifestBuilder) sur ton PC, puis menu d'administration ›
|
||||
**Publier une version** › « Choisir le manifest.json… » (dossier `--output`) › **Publier**.
|
||||
|
||||
> **Déjà fait avant la correction des pauses ?** Ajoute la ligne `limit_req_zone … publication_par_ip …`
|
||||
> ci-dessus, remplace dans le bloc `/api/v1/publications/` du site `limit_req zone=api_par_ip burst=20 nodelay;`
|
||||
> par `limit_req zone=publication_par_ip burst=50 nodelay;`, recharge nginx, puis mets à jour le serveur
|
||||
> (étape 8) : il a désormais sa propre limite pour les envois (`LauncherApi:Limits:PublicationUploadsPerMinute`,
|
||||
> 1500 par minute par défaut, rien à ajouter dans `appsettings.Production.json`).
|
||||
|
||||
Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cliques de nouveau sur
|
||||
« Publier » avec le même dossier ; sans reprise, elle est supprimée au bout de 48 h. WinSCP reste
|
||||
possible, mais n'envoie pas une version avec les deux en même temps : le serveur refuse alors de
|
||||
mettre la sienne en ligne plutôt que d'écraser l'autre.
|
||||
|
||||
## 3 quinquies. Signer ce que reçoivent les joueurs (PC et VPS)
|
||||
|
||||
Sans signature, quelqu'un qui prendrait le contrôle du VPS pourrait distribuer un faux jeu. Avec la
|
||||
signature, chaque version mise en ligne (fichiers, archive, exécutable) et le catalogue portent la
|
||||
signature de l'administrateur qui les a publiés. Les launchers ne font confiance qu'aux clés du
|
||||
**trousseau**, lui-même signé **hors ligne** par la clé racine du launcher (`launcher-signing.key`, celle des
|
||||
mises à jour du launcher). Le VPS ne détient aucune de ces clés privées.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue : le trousseau est écrit dans le même dossier) et 3 quater.
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le trousseau
|
||||
est écrit à côté du catalogue (`/srv/exovival/www/catalog/keyring.json`).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/keyring.json { … }` de `server/deploy/nginx-catalog.conf` à côté
|
||||
de celui du catalogue, puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. **Trousseau** (PC) :
|
||||
1. menu d'administration › **Signature** › « Exporter la demande… » : enregistre `keyring-request-1.json`
|
||||
(les appareils actifs des administrateurs autorisés à publier) ;
|
||||
2. glisse ce fichier sur `tools\sign-keyring.bat` : la demande est comparée au trousseau signé précédent
|
||||
(`keyring-N.json` du même dossier) et chaque **NOUVELLE CLÉ** (ou autorisation élargie) est signalée.
|
||||
**Vérifie ces empreintes hors du serveur** : la demande vient du VPS, un serveur compromis pourrait y glisser
|
||||
sa propre clé. Fais lire à chaque personne l'empreinte de son appareil (page **Mes appareils**, calculée sur
|
||||
son PC) de vive voix ou par téléphone, et compare. Puis la phrase de passe de la clé racine → `keyring-N.json`
|
||||
(garde ce fichier dans le même dossier : il servira de référence la fois suivante) ;
|
||||
3. page **Signature** › « Publier le trousseau signé… » : le launcher vérifie la signature racine avant de l'envoyer.
|
||||
|
||||
Les noms des appareils (« Nicolas — PC fixe ») sont **publics** : le trousseau est lisible par tous.
|
||||
4. **Contenu déjà en ligne** :
|
||||
* version du jeu : page **Publier une version** › « Choisir le manifest.json… » du dossier de **la version
|
||||
en ligne** › « Signer la version en ligne » (le serveur vérifie que les fichiers sont identiques) ;
|
||||
* catalogue : page **Jeux** › « Publier » (il est signé à chaque publication).
|
||||
|
||||
La page **Signature** doit alors tout indiquer « valide pour les launchers ».
|
||||
5. **Launcher** : dans `src\GameLauncher\launcher.json`, décommente `"keyringUrl"` (adresse affichée par la page
|
||||
Signature) et `"contentSigning": "warn"`, puis publie le launcher (mise à jour signée habituelle). En « warn »,
|
||||
les problèmes sont seulement inscrits au journal du launcher (`%AppData%\<éditeur>\GameLauncher\logs\launcher.log`) : vérifie qu'il
|
||||
indique « Manifest de EZR … signé par … » et « Trousseau n° 1 vérifié ».
|
||||
6. **Protection complète** : passe à `"contentSigning": "require"` et publie le launcher : un contenu non signé,
|
||||
mal signé ou plus ancien que le dernier vu est alors refusé (le jeu installé reste jouable). Optionnel, sur le
|
||||
VPS : `"ContentSigning": { "Require": true }` dans la section `LauncherApi` d'`appsettings.Production.json`
|
||||
(puis `sudo systemctl restart launcher-api`) pour que le serveur refuse toute publication non signée.
|
||||
|
||||
Ensuite, les nouvelles versions et le catalogue sont signés automatiquement à leur publication. **À refaire
|
||||
(étape 3)** : à chaque nouvel administrateur ou nouvel appareil qui doit publier (avant sa première publication),
|
||||
et à chaque révocation (la page Signature signale le contenu signé par une clé qui va disparaître : re-signe-le
|
||||
d'abord). Une version envoyée avec WinSCP doit être signée ensuite (étape 4).
|
||||
|
||||
**Clé compromise** (appareil volé…) : révoque l'appareil, signe et publie un nouveau trousseau (étape 3), puis
|
||||
mets son numéro dans `"keyringMinSequence"` de `launcher.json` et publie le launcher. Sans cela, un launcher qui
|
||||
n'a encore jamais vu le nouveau trousseau (installation neuve, cache effacé) accepterait encore l'ancien,
|
||||
toujours signé par la clé racine, si un serveur compromis le lui présentait.
|
||||
|
||||
Limites :
|
||||
* les images du catalogue ne sont pas signées (un serveur compromis pourrait les remplacer, pas les liens ni les
|
||||
textes) ;
|
||||
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
|
||||
et au moins celui du dernier trousseau qu'il a vu ;
|
||||
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
|
||||
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
|
||||
fois par minute) ;
|
||||
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
|
||||
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
|
||||
|
||||
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
|
||||
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
|
||||
à la fois les mises à jour du launcher et le trousseau.
|
||||
|
||||
## 3 sexies. Fichiers du serveur : libérer de la place depuis le launcher (VPS)
|
||||
|
||||
La page **« Fichiers du serveur »** du menu d'administration (propriétaires et administrateurs, permission
|
||||
`files.manage`) affiche le contenu du site public et permet de supprimer ce qui ne sert plus : anciennes archives
|
||||
`.zip`, anciens exécutables du launcher, images retirées du catalogue, et les fichiers du dossier `objects` d'un jeu
|
||||
que la version en ligne n'utilise plus (bouton **« Nettoyer le dossier objects »**, avec analyse préalable).
|
||||
|
||||
1. Racine du site : par défaut, le dossier parent commun des jeux et du catalogue configurés (ici
|
||||
`/srv/exovival/www`). Pour un autre dossier, dans `appsettings.Production.json` :
|
||||
```json
|
||||
{ "LauncherApi": { "Files": { "Root": "/srv/exovival/www" } } }
|
||||
```
|
||||
Le dossier doit être séparé des données privées (`/var/lib/launcher-api`). Les éléments cachés (`.publish-…`)
|
||||
et les liens symboliques ne sont ni affichés comme fichiers ordinaires, ni suivis, ni supprimés.
|
||||
2. Écriture : rien à ajouter pour les dossiers des jeux et du catalogue (déjà dans `games.conf` et `catalog.conf`).
|
||||
Pour supprimer aussi dans un autre dossier (ex. `/srv/exovival/www/launcher`, exécutables du launcher), ajoute
|
||||
une ligne `ReadWritePaths=` à un complément systemd, puis `sudo systemctl daemon-reload && sudo systemctl restart launcher-api`.
|
||||
Sans cela, la page affiche ces fichiers mais la suppression est refusée (« le serveur n'a pas le droit de modifier ce dossier »).
|
||||
|
||||
Règles appliquées par le serveur (le launcher ne fait qu'afficher) :
|
||||
* les fichiers `.json` (manifest, catalogue, trousseau, `launcher-update.json`) ne sont **jamais** supprimés ;
|
||||
* avant chaque suppression, le serveur relit **tous** les fichiers de description du site (chaque `manifest.json`
|
||||
et `launcher-update.json`, le catalogue) : un fichier encore désigné (archive, fichier d'une version, exécutable,
|
||||
image) est refusé, y compris par une adresse relative (`../`) ou complète vers ce serveur ;
|
||||
* un fichier de description illisible rend son dossier non supprimable ; une adresse de ce serveur impossible à
|
||||
rattacher aux fichiers du site bloque toute suppression (impossible de vérifier) ;
|
||||
* vérification refaite sous le verrou d'écriture du manifest du jeu, et refus pendant une publication de ce jeu ;
|
||||
* le nettoyage du dossier `objects` ne touche que les fichiers `<empreinte>.bin` que plus aucun manifest du site
|
||||
n'utilise, et seulement si le manifest analysé n'a pas changé entre l'analyse et le nettoyage ;
|
||||
* chaque suppression et chaque nettoyage sont inscrits au journal d'audit (`files.delete`, `files.prune`).
|
||||
|
||||
À savoir : après un nettoyage, une version précédente ne peut plus être republiée sans renvoyer ses fichiers
|
||||
(la publication les renvoie automatiquement). Un joueur en train de télécharger l'ancienne version reprend
|
||||
avec la nouvelle à sa prochaine vérification.
|
||||
|
||||
## 3 septies. Groupes d'administrateurs et envoi d'images depuis le launcher (PC et VPS)
|
||||
|
||||
**Groupes et permissions** : les rôles fixes (Propriétaire, Administrateur, Éditeur) deviennent des **groupes**
|
||||
modifiables depuis la page **« Groupes et permissions »** du menu d'administration (permission `admins.manage`).
|
||||
Au premier démarrage, le serveur crée les groupes par défaut avec les droits des anciens rôles : rien ne change
|
||||
pour les administrateurs existants. Le groupe **Propriétaire** a toujours toutes les permissions et ne peut être
|
||||
ni modifié ni supprimé.
|
||||
|
||||
**Envoi des images du catalogue** sans client FTP : page **Jeux** › « Parcourir… ».
|
||||
|
||||
1. **Avant** la mise à jour, garde une copie de la liste des administrateurs au format actuel (le serveur la
|
||||
réécrit au format 2 à la première modification ; une ancienne version du serveur refuserait de démarrer avec) :
|
||||
```
|
||||
sudo -u deploy cp /var/lib/launcher-api/admins.json /var/lib/launcher-api/admins.format1.json
|
||||
```
|
||||
2. Mets le serveur à jour (étape 8), puis vérifie : `sudo -u deploy /opt/launcher-api/GameLauncher.Server list`
|
||||
affiche maintenant aussi les groupes et leurs permissions.
|
||||
3. **nginx** : ajoute le second bloc de `server/deploy/nginx-api.conf` (`location ^~ /api/v1/catalog/images/ { … }`)
|
||||
à côté du bloc `/api/`, puis `sudo nginx -t && sudo systemctl reload nginx`. Sans lui, nginx refuse les images
|
||||
de plus de 64 Ko (« Requête trop volumineuse »).
|
||||
4. Le dossier des images doit exister (étape 3 bis, il appartient à `deploy`, groupe `www-data`) :
|
||||
```
|
||||
ls -ld /srv/exovival/www/catalog/images
|
||||
```
|
||||
Sinon : `sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog/images`. Le service ne le crée
|
||||
jamais lui-même ; il n'écrit que dans ce dossier (déjà couvert par `catalog.conf`), en `-rw-r--r--`.
|
||||
5. Test : page **Jeux** › un jeu › Icône › « Parcourir… » › une image PNG : l'adresse
|
||||
`https://launcher.lanisys.fr/catalog/images/<empreinte>.png` remplit le champ ; « Publier ».
|
||||
|
||||
Règles appliquées par le serveur : image reconnue à son **contenu** (PNG/JPEG), fichier complet sans données
|
||||
ajoutées après l'image, icône de 32 à 2048 px, fond de 640 × 360 à 7680 × 4320 px, 300 images et 300 Mo au plus
|
||||
dans le dossier (les images inutilisées se suppriment dans « Fichiers du serveur »), requête signée, permission
|
||||
`games.edit`, audit `catalog.image_upload`. Le launcher des joueurs vérifie que l'image téléchargée a bien
|
||||
l'empreinte de son nom : l'adresse figurant dans le catalogue signé, l'image est couverte par cette signature.
|
||||
|
||||
## 3 octies. Demandes des joueurs (bouton « Aide ») et purge automatique (VPS)
|
||||
|
||||
Le bouton **Aide** du launcher permet aux joueurs d'envoyer, **sans compte**, un avis, une demande d'évolution, un
|
||||
bug ou autre chose, rattaché au jeu sélectionné. Les demandes se lisent dans le menu d'administration, page
|
||||
**« Demandes des joueurs »** (permissions `feedback.read` et `feedback.manage`). C'est la **première route
|
||||
d'écriture ouverte à tous** : elle est désactivée tant que tu ne l'actives pas.
|
||||
|
||||
Protections en place : défi anti-spam à résoudre par le launcher (preuve de calcul, une fraction de seconde), 3
|
||||
demandes par 10 minutes et 10 par jour par adresse IP (comptées **en mémoire seulement**), 300 demandes par jour et
|
||||
par jeu, 5 000 demandes et 1 Go au plus (captures comprises), 500 Mo d'espace disque libre minimum, corps de 16 Ko,
|
||||
contrôle complet du contenu (caractères invisibles refusés). **Captures d'écran** : 3 par demande, PNG ou JPEG de
|
||||
4 Mo au plus, envoyées avec un jeton remis au seul launcher qui a envoyé la demande (15 min) ; le serveur vérifie
|
||||
que ce sont de vraies images (structure complète) et **retire leurs métadonnées** (EXIF, position GPS…). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
|
||||
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
|
||||
|
||||
1. Active la réception dans `/opt/launcher-api/appsettings.Production.json` (section `LauncherApi`) :
|
||||
```json
|
||||
"Feedback": {
|
||||
"Enabled": true,
|
||||
"Games": [ "EZR" ]
|
||||
}
|
||||
```
|
||||
`Games` : identifiants (`gameId` de `launcher.json`) des jeux qui acceptent des demandes ; vide = les jeux de la
|
||||
section `Games`. Les autres réglages (limites, plafonds, durées de conservation) sont décrits dans
|
||||
`appsettings.json`. En cas d'afflux, relève `ProofDifficulty` (18 par défaut, 26 au plus).
|
||||
2. **nginx** : ajoute dans `/etc/nginx/conf.d/securite.conf` (à côté de `api_par_ip`) :
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m;
|
||||
```
|
||||
puis colle les **trois** blocs `location … /api/v1/feedback…` de `server/deploy/nginx-api.conf` (défi, envoi,
|
||||
captures d'écran à 5 Mo) à côté du bloc `/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. Redémarre le service : `sudo systemctl restart launcher-api`.
|
||||
4. **Droits des administrateurs existants** : le groupe **Propriétaire** reçoit automatiquement les nouvelles
|
||||
permissions. Pour un serveur déjà installé, les autres groupes ne changent pas tout seuls (aucune élévation de droits
|
||||
sans action d'un propriétaire) : page **« Groupes et permissions »** › groupe « Administrateur » › coche
|
||||
« Demandes des joueurs (lecture) » et « (traitement) ». Le groupe « Éditeur » n'y a pas accès par défaut (le texte
|
||||
libre peut contenir des données personnelles).
|
||||
5. Test : launcher › bouton **Aide** (point d'interrogation, en haut à droite) › Bug › envoyer ; la référence
|
||||
`FB-XXXX-XXXX` s'affiche. Menu d'administration › « Demandes des joueurs » › le jeu › la demande est là.
|
||||
|
||||
**Conservation (RGPD) — purge automatique**, au démarrage du service puis toutes les 24 heures :
|
||||
|
||||
| Données | Durée (réglage) | Journal |
|
||||
|---|---|---|
|
||||
| Journal d'audit `audit-AAAA-MM.jsonl` | 12 mois (`Audit:RetentionMonths`, 1 à 120) | `audit.purge` |
|
||||
| Demande close (traitée, rejetée, doublon) | 12 mois après la clôture (`Feedback:RetentionClosedMonths`) | `feedback.purge` |
|
||||
| Toute demande | 24 mois après réception (`Feedback:RetentionMaxMonths`) | `feedback.purge` |
|
||||
|
||||
Un fichier d'audit mensuel est supprimé quand tous ses jours dépassent la durée (le mois le plus ancien gardé peut
|
||||
donc compter quelques semaines de plus). Pense aussi à borner les journaux du système (voir la fiche RGPD) :
|
||||
`journald` (`MaxRetentionSec=1year` dans `/etc/systemd/journald.conf`) et nginx (`logrotate`).
|
||||
|
||||
## 3 nonies. Formulaires des demandes modifiables depuis le menu (PC et VPS)
|
||||
|
||||
Les types de demande du bouton **Aide** (avis, demande d'évolution, bug, autre au départ ; 1 à 8) se gèrent depuis le
|
||||
menu d'administration : ajout, suppression, ordre d'affichage, nom, description, icône (l'une des quatre d'origine ou
|
||||
un fichier **.ico** contenant une image 64 × 64), captures d'écran autorisées ou non, champs (zone de texte, texte
|
||||
court, liste déroulante, cases à cocher, case oui/non, étoiles), types masqués. Les formulaires sont **communs à tous les jeux** et **signés** : un serveur
|
||||
compromis ne peut pas faire afficher aux joueurs un faux formulaire (« entrez votre mot de passe »). Les libellés et
|
||||
aides ne peuvent contenir **aucun lien**.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue), 3 quinquies (trousseau) et 3 octies (demandes).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : les formulaires sont
|
||||
écrits à côté du trousseau (`/srv/exovival/www/catalog/feedback-forms.json`, 644).
|
||||
2. **nginx** : ajoute
|
||||
- le bloc `location = /catalog/feedback-forms.json { … }` de `server/deploy/nginx-catalog.conf`, à côté de celui
|
||||
du trousseau (lecture par les launchers) ;
|
||||
- le bloc `location = /api/v1/feedback/forms { … }` de `server/deploy/nginx-api.conf` (publication depuis le
|
||||
menu : formulaires et icônes importées, 300 Ko au plus ; sans lui, une publication de plus de 64 Ko est refusée) ;
|
||||
|
||||
puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. **Launcher 3.2** : publie-le (mise à jour signée habituelle) **avant** l'étape 4, et laisse aux joueurs le temps de
|
||||
le recevoir. Les launchers plus anciens ne connaissent pas la nouvelle autorisation « formulaires » du trousseau :
|
||||
ils refusent le nouveau trousseau et gardent le précédent (aucune gêne pour jouer, mais un appareil ajouté au
|
||||
trousseau en même temps ne serait pas reconnu par eux).
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `feedback.forms` (« Formulaires des demandes ») au groupe qui doit modifier
|
||||
les formulaires (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
|
||||
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« formulaires des demandes »** des
|
||||
appareils concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Publier** depuis le menu, page **« Formulaires des demandes »** (modifier, masquer, aperçu, « Publier »). Les launchers lisent le fichier à l'ouverture de
|
||||
la fenêtre « Aide » et vérifient la signature comme pour le catalogue (`contentSigning`) ; s'ils ne peuvent pas
|
||||
(hors ligne, signature refusée), ils proposent les formulaires d'origine, toujours acceptés par le serveur.
|
||||
|
||||
Compatibilité : chaque demande annonce la révision des formulaires utilisés. Les launchers 3.0 / 3.1 (révision 0,
|
||||
formulaires d'origine) restent acceptés, sauf pour un type masqué. Si les formulaires sont republiés pendant qu'un
|
||||
joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formulaires et garde la saisie des champs
|
||||
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
|
||||
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
|
||||
|
||||
## 3 decies. Mode maintenance du launcher (PC et VPS)
|
||||
|
||||
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
|
||||
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
|
||||
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
|
||||
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
|
||||
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
|
||||
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
|
||||
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
|
||||
(`catalog.conf`, étape 3 bis).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
|
||||
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
|
||||
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
|
||||
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
|
||||
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
|
||||
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
|
||||
l'activer (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
|
||||
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« mode maintenance »** des appareils
|
||||
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
|
||||
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
|
||||
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
|
||||
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
|
||||
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
|
||||
|
||||
Comportement des launchers :
|
||||
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
|
||||
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
|
||||
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
|
||||
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
|
||||
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
|
||||
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
|
||||
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
|
||||
du jeu doivent refuser les connexions, c'est à eux de le faire.
|
||||
|
||||
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
|
||||
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
|
||||
|
||||
## 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 :
|
||||
@@ -108,31 +544,35 @@ Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste d
|
||||
|
||||
## 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) :
|
||||
Tout se fait dans le launcher (menu d'administration), sans ligne de commande :
|
||||
|
||||
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
|
||||
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
|
||||
2. **Paul** : 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
|
||||
1. **Toi** : page **Administrateurs** › « Inviter un administrateur » (nom, rôle : propriétaire,
|
||||
administrateur ou éditeur, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable
|
||||
24 h, usage unique, affiché une seule fois).
|
||||
2. **Paul** : launcher › menu d'administration › « J'ai une invitation », code collé ; il te communique
|
||||
**par téléphone ou message** l'empreinte affichée.
|
||||
3. **Toi** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
|
||||
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
|
||||
3. **Toi** : page **Administrateurs** › « Actualiser » : le compte de Paul apparaît en premier, en
|
||||
attente ; saisis l'empreinte dictée sous son appareil, puis « Approuver ».
|
||||
|
||||
En ligne de commande, `tools\admin-key.bat` fait la même chose : `invite --api https://launcher.lanisys.fr/api/
|
||||
--name "Paul" --role editor`, puis `admins --api …` et `approve --api … --admin <id> --key-id <clé>
|
||||
--fingerprint "<empreinte>"`.
|
||||
|
||||
Le serveur n'affiche pas l'empreinte des clés en attente : l'approbation n'est possible qu'avec
|
||||
celle que Paul te donne. Si elle ne correspond pas, **n'insiste pas** : quelqu'un d'autre a pu
|
||||
utiliser le code. Révoque (`revoke-admin`) et recommence.
|
||||
utiliser le code. Révoque ce compte (page Administrateurs, ou `revoke-admin`) et recommence.
|
||||
|
||||
Nouveau PC pour un administrateur existant : **lui-même** lance `invite --api … --for <son id>`
|
||||
depuis un PC déjà enregistré, `enroll` sur le nouveau PC, puis `approve` depuis l'ancien. Personne
|
||||
d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
|
||||
Nouveau PC pour un administrateur existant : **lui-même**, depuis un PC déjà enregistré, page **Mes
|
||||
appareils** › « Ajouter un appareil » ; sur le nouveau PC, « J'ai une invitation » ; puis, de retour sur
|
||||
l'ancien, approbation de l'appareil en attente avec l'empreinte affichée par le nouveau (en ligne de
|
||||
commande : `invite --for <son id>`, `enroll`, `approve`). Personne d'autre ne peut approuver une clé à
|
||||
son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
|
||||
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
conservés ; les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
conservés, ainsi que les compléments systemd (`launcher-api.service.d/catalog.conf` et `games.conf`) ;
|
||||
les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
pas, le script remet la précédente en place.
|
||||
|
||||
## 9. Récupération (VPS)
|
||||
@@ -145,6 +585,7 @@ pas, le script remet la précédente en place.
|
||||
| Retirer un administrateur | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-admin --id <identifiant>` |
|
||||
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
|
||||
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
|
||||
| Plus personne ne peut gérer les groupes | Tout propriétaire le peut toujours (groupe système, toutes les permissions) ; sinon `add-owner` (étape 5) |
|
||||
|
||||
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
|
||||
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
|
||||
@@ -163,6 +604,7 @@ Le service n'a pas besoin d'être redémarré : une révocation ferme immédiate
|
||||
|---|---|
|
||||
| Journal du service (connexions, refus, erreurs) | `journalctl -u launcher-api -f` |
|
||||
| Journal d'audit (une ligne JSON par action) | `sudo tail -n 20 /var/lib/launcher-api/audit/audit-$(date +%Y-%m).jsonl` |
|
||||
| Demandes des joueurs reçues (nombre par jeu) | `sudo find /var/lib/launcher-api/feedback -name '*.json' \| cut -d/ -f6 \| sort \| uniq -c` |
|
||||
| Niveau de durcissement du service | `systemd-analyze security launcher-api` |
|
||||
| État | `systemctl status launcher-api` |
|
||||
|
||||
@@ -180,7 +622,25 @@ recréer les administrateurs.
|
||||
/var/lib/launcher-api/ données privées (deploy, 700)
|
||||
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
|
||||
├─ server-id identifiant aléatoire du serveur
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit (12 mois, purge automatique)
|
||||
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
|
||||
├─ catalog-history/ 20 dernières versions publiées
|
||||
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
|
||||
├─ publications/ publications en cours (version, fichiers restant à envoyer)
|
||||
├─ feedback/<jeu>/AAAA-MM/<id>.json demandes des joueurs (une par fichier, 600 ; purge automatique)
|
||||
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
|
||||
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
|
||||
├─ feedback-forms-history/ 20 dernières révisions publiées
|
||||
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
|
||||
├─ maintenance-history/ 20 dernières révisions publiées
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
|
||||
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
|
||||
├─ keyring.json trousseau publié (écrit par le service, 644)
|
||||
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
|
||||
├─ maintenance.json mode maintenance signé (écrit par le service, 644)
|
||||
└─ images/ images du catalogue (envoyées en SFTP)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
|
||||
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# =============================================================================
|
||||
# Launcher API : autorisation d'écrire le catalogue des jeux (LISEZMOI.md, étape 3 bis).
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf
|
||||
#
|
||||
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
|
||||
# écrire dans UN SEUL dossier, celui du fichier public du catalogue (LauncherApi:Catalog:PublicFile).
|
||||
# Fichier séparé : il est conservé lors des mises à jour du serveur (installer-api.sh remplace
|
||||
# launcher-api.service, pas ce dossier).
|
||||
#
|
||||
# Adapter le chemin au dossier réellement servi par nginx (exemple de la documentation ci-dessous),
|
||||
# puis : sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
# =============================================================================
|
||||
[Service]
|
||||
ReadWritePaths=/srv/exovival/www/catalog
|
||||
@@ -0,0 +1,17 @@
|
||||
# =============================================================================
|
||||
# Launcher API : autorisation de modifier le manifest.json des jeux (LISEZMOI.md, étape 3 ter).
|
||||
# /etc/systemd/system/launcher-api.service.d/games.conf
|
||||
#
|
||||
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
|
||||
# écrire dans le dossier de chaque jeu configuré (LauncherApi:Games:<id>:ManifestFile), et
|
||||
# seulement là. Le remplacement du manifest se fait par renommage dans ce dossier : c'est le dossier
|
||||
# entier qui doit être autorisé (pas le seul fichier).
|
||||
# Compromis : un service compromis pourrait alors modifier les fichiers du jeu de ce dossier.
|
||||
# Sans ce complément (et sans LauncherApi:Games), les notes ne sont pas modifiables en ligne.
|
||||
#
|
||||
# Fichier séparé : conservé lors des mises à jour du serveur. Adapter le chemin au dossier du jeu
|
||||
# réellement servi par nginx (exemple de la documentation ci-dessous ; une ligne par jeu), puis :
|
||||
# sudo systemctl daemon-reload && sudo systemctl restart launcher-api
|
||||
# =============================================================================
|
||||
[Service]
|
||||
ReadWritePaths=/srv/exovival/www/exovivalzombie
|
||||
@@ -6,6 +6,10 @@
|
||||
# terminal), enfermé par systemd : système en lecture seule, pas d'accès à /home, aucun privilège.
|
||||
# Ses binaires (/opt/launcher-api) appartiennent à root : il ne peut pas se modifier lui-même.
|
||||
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
|
||||
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
|
||||
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
|
||||
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
|
||||
#
|
||||
# Vérifier le durcissement : systemd-analyze security launcher-api
|
||||
# Journal : journalctl -u launcher-api -f
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (le bloc ci-dessous)
|
||||
# (les blocs ci-dessous ; le deuxième, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies ;
|
||||
# les suivants, demandes des joueurs, LISEZMOI.md étape 3 octies ; le dernier, formulaires des demandes,
|
||||
# LISEZMOI.md étape 3 nonies)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
@@ -32,3 +34,110 @@ location ^~ /api/ {
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# Envoi d'une image du catalogue (icône, fond) depuis la page « Jeux » : seule route du catalogue qui accepte un
|
||||
# corps de plus de 64 Ko (image de 4 Mo au plus). Le préfixe le plus long l'emporte sur « /api/ ». Mêmes limites
|
||||
# de débit ; la route exige une session d'administrateur, la permission games.edit et une requête signée.
|
||||
location ^~ /api/v1/catalog/images/ {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 5m;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# Demandes des joueurs (bouton « Aide » du launcher) : seules routes d'ÉCRITURE ouvertes à tous (sans compte).
|
||||
# • corps de 16 Ko au plus ; limite de débit propre, plus stricte (zone feedback_par_ip, à ajouter dans
|
||||
# /etc/nginx/conf.d/securite.conf : « limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m; ») ;
|
||||
# • access_log off : l'heure d'envoi d'une demande ne peut pas être rapprochée d'une adresse IP dans les journaux
|
||||
# nginx (la demande est anonyme ; le serveur ne garde les adresses qu'en mémoire, pour ses limites).
|
||||
# Correspondance EXACTE (« = ») : prioritaire sur « ^~ /api/ », et limitée aux deux routes des joueurs (les routes
|
||||
# des administrateurs, /api/v1/feedback/overview et /api/v1/games/…/feedback, restent sous le bloc « /api/ »).
|
||||
location = /api/v1/feedback/challenge {
|
||||
limit_req zone=feedback_par_ip burst=6 nodelay;
|
||||
limit_req_status 429;
|
||||
access_log off;
|
||||
|
||||
client_max_body_size 16k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
location = /api/v1/feedback {
|
||||
limit_req zone=feedback_par_ip burst=6 nodelay;
|
||||
limit_req_status 429;
|
||||
access_log off;
|
||||
|
||||
client_max_body_size 16k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# Captures d'écran d'une demande (jeton d'envoi remis au seul launcher qui a envoyé la demande) : 4 Mo par image.
|
||||
location ^~ /api/v1/feedback/attachments/ {
|
||||
limit_req zone=feedback_par_ip burst=6 nodelay;
|
||||
limit_req_status 429;
|
||||
access_log off;
|
||||
|
||||
client_max_body_size 5m;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# Publication des formulaires des demandes (page « Formulaires des demandes », administrateurs) : formulaires signés
|
||||
# avec leurs icônes importées (256 Ko au plus). Correspondance EXACTE : la lecture (GET) passe aussi par ce bloc,
|
||||
# avec les limites de débit des administrateurs ; la route exige une session, feedback.forms et une requête signée.
|
||||
location = /api/v1/feedback/forms {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 300k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux, trousseau, formulaires des demandes et mode maintenance (écrits par la Launcher API) —
|
||||
# voir LISEZMOI.md, étapes 3 bis, 3 quinquies, 3 nonies et 3 decies :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Les images du catalogue (catalog/images/*.png|jpg) sont servies par « location / » ; les
|
||||
# fichiers temporaires de la Launcher API (.catalog.json.….tmp) par la règle des fichiers cachés
|
||||
# (refusés).
|
||||
# =============================================================================
|
||||
|
||||
# Catalogue : toujours relu sur le serveur (les launchers le lisent au démarrage).
|
||||
location = /catalog/catalog.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Trousseau (phase 7) : clés autorisées à signer versions et catalogue, signé par la clé racine du launcher.
|
||||
location = /catalog/keyring.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Formulaires des demandes (bouton « Aide ») : publiés depuis le menu, signés (autorisation « forms » du trousseau),
|
||||
# lus par les launchers à l'ouverture de la fenêtre « Aide ».
|
||||
location = /catalog/feedback-forms.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Mode maintenance (launcher 3.3) : activé depuis le menu, signé (autorisation « maintenance » du trousseau), relu par les
|
||||
# launchers au démarrage puis toutes les 1 à 2 minutes. Jamais mis en cache : la fin de la maintenance doit être vue tout de suite.
|
||||
location = /catalog/maintenance.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# Publication d'une version depuis le launcher — deux ajouts (voir LISEZMOI.md, étape 3 quater) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf, à côté de la zone « api_par_ip » :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
|
||||
#
|
||||
# Un envoi enchaîne plusieurs morceaux de 4 Mo par seconde : la zone « api_par_ip » (5 par seconde)
|
||||
# briderait l'envoi vers 20 Mo/s et provoquerait des pauses. 25 par seconde ≈ 100 Mo/s ; ces routes
|
||||
# exigent une session d'administrateur et le droit de publier.
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival, à côté du bloc
|
||||
# « location ^~ /api/ { … } » (nginx-api.conf) : (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Seules ces routes acceptent de gros envois (morceaux de 4 Mo, 8 Mo au plus) ; toutes les autres
|
||||
# routes de l'API restent limitées à 64 Ko. Le préfixe le plus long l'emporte sur « /api/ ».
|
||||
# Les fichiers en cours d'envoi sont rangés dans un dossier caché (.publish-…) du dossier du jeu :
|
||||
# la règle « location ~ /\. { deny all; } » du site doit être présente (elle l'est dans le guide).
|
||||
# =============================================================================
|
||||
|
||||
location ^~ /api/v1/publications/ {
|
||||
limit_req zone=publication_par_ip burst=50 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 9m;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 180s;
|
||||
proxy_read_timeout 180s;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
@@ -41,6 +49,12 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public DateTimeOffset IdleExpiresAt { get; private set; }
|
||||
public bool IsOpen => _token is not null;
|
||||
|
||||
/// <summary>Clé publique de CET appareil (SPKI), lue sur le PC : son empreinte ne dépend pas du serveur.</summary>
|
||||
public byte[] PublicKey => _signer.PublicKey;
|
||||
|
||||
/// <summary>Empreinte de la clé de cet appareil, calculée sur le PC (à dicter pour vérifier le trousseau).</summary>
|
||||
public string LocalFingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
|
||||
|
||||
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
// ------------------------------------------------------------------ Session
|
||||
@@ -79,6 +93,9 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
|
||||
public Task<MyAccount> GetMyAccountAsync(CancellationToken ct = default) => GetAsync<MyAccount>(ApiRoutes.MyAccount, ct);
|
||||
|
||||
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
|
||||
|
||||
@@ -100,13 +117,250 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
|
||||
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, CancellationToken ct = default)
|
||||
// ------------------------------------------------------------------ Groupes et permissions (admins.manage)
|
||||
|
||||
/// <summary>Groupes (permissions, membres) et permissions connues du serveur.</summary>
|
||||
public Task<GroupsState> GetGroupsAsync(CancellationToken ct = default) => GetAsync<GroupsState>(ApiRoutes.Groups, ct);
|
||||
|
||||
public Task<GroupsState> CreateGroupAsync(GroupRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Groups, request, ct);
|
||||
|
||||
/// <summary>Modifie un groupe (409 « revision_conflict » s'il a changé depuis <see cref="GroupRequest.Revision"/>).</summary>
|
||||
public Task<GroupsState> UpdateGroupAsync(string groupId, GroupRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Put, ApiRoutes.Group(CheckGroupId(groupId)), request, ct);
|
||||
|
||||
/// <summary>Supprime un groupe sans membre ni invitation en cours.</summary>
|
||||
public Task<GroupsState> DeleteGroupAsync(string groupId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Group(CheckGroupId(groupId)), (object?)null, ct);
|
||||
|
||||
public Task<GroupsState> CreatePermissionAsync(CreatePermissionRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Post, ApiRoutes.Permissions, request, ct);
|
||||
|
||||
/// <summary>Supprime une permission personnalisée (elle est retirée de tous les groupes).</summary>
|
||||
public Task<GroupsState> DeletePermissionAsync(string name, CancellationToken ct = default) =>
|
||||
SendSignedAsync<GroupsState>(HttpMethod.Delete, ApiRoutes.Permission(
|
||||
AdminGroups.IsValidPermissionName(name) ? name : throw new ArgumentException($"Permission invalide : « {name} ».", nameof(name))),
|
||||
(object?)null, ct);
|
||||
|
||||
private static string CheckGroupId(string groupId) =>
|
||||
AdminGroups.IsValidGroupId(groupId) ? groupId : throw new ArgumentException($"Identifiant de groupe invalide : « {groupId} ».", nameof(groupId));
|
||||
|
||||
/// <param name="before">Facultatif : seulement les entrées antérieures à cet instant (filtre « jusqu'au »).</param>
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, DateTimeOffset? before = null, CancellationToken ct = default) =>
|
||||
GetAsync<AuditPage>(ApiRoutes.AuditPage(limit, cursor, before), ct);
|
||||
|
||||
/// <summary>Inscrit un export CSV du journal au journal (à faire AVANT d'écrire le fichier).</summary>
|
||||
public Task RecordAuditExportAsync(AuditExportRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuditExport, request, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Fichiers du site public (www)
|
||||
|
||||
/// <summary>Contenu d'un dossier du site (chemin relatif à sa racine, « / » comme séparateur ; « » : racine).</summary>
|
||||
public Task<ServerFolder> GetServerFolderAsync(string path, CancellationToken ct = default) =>
|
||||
GetAsync<ServerFolder>(ApiRoutes.FilesFolder(path), ct);
|
||||
|
||||
/// <summary>Supprime un fichier que le serveur juge inutilisé (refus s'il est référencé par un manifest).</summary>
|
||||
public Task<ServerFileDeleteResult> DeleteServerFileAsync(string path, long size, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ServerFileDeleteResult>(HttpMethod.Post, ApiRoutes.FilesDelete, new ServerFileDeleteRequest { Path = path, Size = size }, ct);
|
||||
|
||||
public Task<ObjectsPrunePreview> GetObjectsPruneAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ObjectsPrunePreview>(ApiRoutes.ObjectsPrune(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>Supprime les fichiers d'« objects » que le manifest analysé n'utilise plus (refus s'il a changé).</summary>
|
||||
public Task<ObjectsPruneResult> PruneObjectsAsync(string gameId, string manifestSha256, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ObjectsPruneResult>(HttpMethod.Post, ApiRoutes.ObjectsPrune(CheckGameId(gameId)),
|
||||
new ObjectsPruneRequest { ManifestSha256 = manifestSha256 }, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Envoie une image du catalogue (icône ou fond, contrôlée avant l'envoi avec <see cref="CatalogImages.Check"/>) :
|
||||
/// requête signée (la signature couvre l'empreinte du contenu). Renvoie l'adresse à mettre dans le catalogue.
|
||||
/// </summary>
|
||||
public Task<CatalogImageUploaded> UploadCatalogImageAsync(string kind, byte[] content, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<CatalogImageUploaded>(HttpMethod.Put,
|
||||
ApiRoutes.CatalogImage(CatalogImages.IsValidKind(kind) ? kind : throw new ArgumentException($"Type d'image inconnu : « {kind} ».", nameof(kind))),
|
||||
content, CatalogImages.ContentType, UploadTimeout, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
|
||||
|
||||
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
|
||||
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)
|
||||
{
|
||||
var route = $"{ApiRoutes.Audit}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += "&cursor=" + Uri.EscapeDataString(cursor);
|
||||
return GetAsync<AuditPage>(route, ct);
|
||||
if (!ChangelogLimits.IsEditableVersion(entry.Version))
|
||||
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
|
||||
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
|
||||
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
|
||||
/// <summary>Délai d'une requête d'envoi de fichier (morceau de 4 Mo sur une connexion lente).</summary>
|
||||
public static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(3);
|
||||
|
||||
public Task<PublicationState> StartPublicationAsync(string gameId, PublicationStartRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<PublicationState>(HttpMethod.Post, ApiRoutes.PublicationStart(CheckGameId(gameId)), request, ct);
|
||||
|
||||
public Task<PublicationState> GetPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
GetAsync<PublicationState>(ApiRoutes.Publication(CheckPublicationId(id)), ct);
|
||||
|
||||
public Task CancelPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Publication(CheckPublicationId(id)), null, ct);
|
||||
|
||||
public Task<ChunkResult> UploadManifestAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationManifest(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadArchiveAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationArchive(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectAsync(string id, string sha256, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put,
|
||||
ApiRoutes.PublicationObject(CheckPublicationId(id), PublishingLimits.IsValidSha256(sha256) ? sha256.ToLowerInvariant()
|
||||
: throw new ArgumentException("Empreinte invalide.", nameof(sha256)), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectBatchAsync(string id, byte[] batch, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Post, ApiRoutes.PublicationObjects(CheckPublicationId(id)), batch,
|
||||
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
|
||||
|
||||
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
|
||||
/// <param name="signature">Signature du contenu du manifest (phase 7), ou null (version non signée).</param>
|
||||
public Task<PublicationResult> CommitPublicationAsync(string id, ContentSignature? signature = null, CancellationToken ct = default) =>
|
||||
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)),
|
||||
signature is null ? null : JsonSerializer.SerializeToUtf8Bytes(new PublicationCommitRequest { Signature = signature }, ApiJson.Options),
|
||||
signed: true, ct, timeout: UploadTimeout);
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
|
||||
/// <summary>
|
||||
/// Signe un contenu servi aux joueurs avec la clé de cette session (date : heure du serveur, à la seconde).
|
||||
/// La clé privée ne quitte jamais le PC (ni la puce TPM) : seul le condensé calculé ici est signé.
|
||||
/// </summary>
|
||||
public async Task<ContentSignature> SignContentAsync(string kind, string id, string digest, CancellationToken ct = default)
|
||||
{
|
||||
var signedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset);
|
||||
var message = ContentSigning.Message(kind, id, signedAt, _signer.KeyId, digest);
|
||||
var signature = await _signer.SignAsync(message, ct).ConfigureAwait(false);
|
||||
return new ContentSignature { KeyId = _signer.KeyId, SignedAt = signedAt, Value = Convert.ToBase64String(signature) };
|
||||
}
|
||||
|
||||
public Task<SigningState> GetSigningStateAsync(CancellationToken ct = default) => GetAsync<SigningState>(ApiRoutes.Signing, ct);
|
||||
|
||||
/// <summary>Publie un trousseau signé par la clé racine (le serveur contrôle la structure et le numéro).</summary>
|
||||
public Task<KeyringDocument> PublishKeyringAsync(KeyringDocument keyring, CancellationToken ct = default) =>
|
||||
SendSignedAsync<KeyringDocument>(HttpMethod.Put, ApiRoutes.Keyring, keyring, ct);
|
||||
|
||||
public Task<ManifestSignatureState> GetManifestSignatureAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ManifestSignatureState>(ApiRoutes.ManifestSignature(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>Signe la version en ligne (le serveur vérifie qu'elle a exactement le contenu signé).</summary>
|
||||
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs
|
||||
|
||||
public Task<FeedbackOverview> GetFeedbackOverviewAsync(CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackOverview>(ApiRoutes.FeedbackOverview, ct);
|
||||
|
||||
/// <param name="status">Statut précis, « open » (non closes) ou « closed » ; null : toutes.</param>
|
||||
public Task<FeedbackPage> GetFeedbackPageAsync(string gameId, string? kind = null, string? status = null, string? cursor = null,
|
||||
int limit = 100, CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackPage>(ApiRoutes.GameFeedback(CheckGameId(gameId), kind, status, cursor, limit), ct);
|
||||
|
||||
public Task<FeedbackDetail> GetFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackDetail>(ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), ct);
|
||||
|
||||
/// <summary>Statut et note interne (409 « revision_conflict » si la demande a changé depuis <paramref name="revision"/>).</summary>
|
||||
public Task<FeedbackDetail> UpdateFeedbackAsync(string gameId, string id, long revision, string status, string? adminNote,
|
||||
CancellationToken ct = default) =>
|
||||
SendSignedAsync<FeedbackDetail>(HttpMethod.Put, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)),
|
||||
new FeedbackUpdateRequest { Revision = revision, Status = status, AdminNote = adminNote }, ct);
|
||||
|
||||
public Task DeleteFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), null, ct);
|
||||
|
||||
/// <summary>Formulaires des demandes en vigueur (communs à tous les jeux ; révision 0 : formulaires d'origine).</summary>
|
||||
public Task<FeedbackFormsState> GetFeedbackFormsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackFormsState>(ApiRoutes.FeedbackForms, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Publie les formulaires : révision <paramref name="baseRevision"/> + 1, signés par la clé de cette session (autorisation
|
||||
/// « forms » du trousseau requise chez les joueurs), puis envoyés en requête signée.
|
||||
/// </summary>
|
||||
public async Task<FeedbackFormsState> PublishFeedbackFormsAsync(IReadOnlyList<FeedbackForm> forms, long baseRevision,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var document = new FeedbackFormsDocument { Revision = baseRevision + 1, Forms = forms.ToList() };
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(forms));
|
||||
var signature = await SignContentAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(document), ct)
|
||||
.ConfigureAwait(false);
|
||||
return await SendSignedAsync<FeedbackFormsState>(HttpMethod.Put, ApiRoutes.FeedbackForms,
|
||||
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Mode maintenance en vigueur (révision 0 : jamais publié, inactif).</summary>
|
||||
public Task<MaintenanceState> GetMaintenanceAsync(CancellationToken ct = default) =>
|
||||
GetAsync<MaintenanceState>(ApiRoutes.Maintenance, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Active, désactive ou modifie le mode maintenance : révision <paramref name="baseRevision"/> + 1, signé par la clé de
|
||||
/// cette session (autorisation « maintenance » du trousseau requise chez les joueurs), puis envoyé en requête signée.
|
||||
/// </summary>
|
||||
/// <param name="message">Précision affichée aux joueurs, déjà nettoyée (<see cref="MaintenanceRules.CleanMessage"/>) ; ignorée à la désactivation.</param>
|
||||
/// <param name="since">
|
||||
/// Début de la maintenance : celui de la maintenance en cours pour un nouveau message, null pour en commencer une
|
||||
/// (l'heure du serveur, à la seconde) ; ignoré à la désactivation.
|
||||
/// </param>
|
||||
public async Task<MaintenanceState> PublishMaintenanceAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var document = new MaintenanceDocument
|
||||
{
|
||||
Revision = baseRevision + 1,
|
||||
Enabled = enabled,
|
||||
Message = enabled ? message : null,
|
||||
Since = enabled ? since ?? ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset) : null
|
||||
};
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(message));
|
||||
var signature = await SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), ct)
|
||||
.ConfigureAwait(false);
|
||||
return await SendSignedAsync<MaintenanceState>(HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
|
||||
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
|
||||
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
|
||||
signed: false, ct, maxResponseBytes: FeedbackImages.MaxBytes);
|
||||
|
||||
private static string CheckFeedbackId(string id) =>
|
||||
FeedbackRules.IsValidId(id) ? id : throw new ArgumentException("Identifiant de demande invalide.", nameof(id));
|
||||
|
||||
private static string CheckPublicationId(string id) =>
|
||||
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
|
||||
|
||||
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) =>
|
||||
@@ -116,7 +370,14 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct)
|
||||
/// <summary>Requête qui modifie avec un corps binaire (morceau de fichier), signée par la clé.</summary>
|
||||
public Task<T> SendSignedBytesAsync<T>(HttpMethod method, string route, byte[] body, string contentType, TimeSpan timeout,
|
||||
CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
|
||||
string contentType = "application/json", TimeSpan? timeout = null,
|
||||
long maxResponseBytes = ApiJson.MaxResponseBytes)
|
||||
{
|
||||
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
|
||||
|
||||
@@ -142,7 +403,7 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}, ct, contentType, timeout, maxResponseBytes).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
|
||||
@@ -17,5 +17,8 @@ public sealed class ApiException : Exception
|
||||
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
|
||||
public string Code { get; }
|
||||
|
||||
/// <summary>Délai annoncé par le serveur avant un nouvel essai (en-tête Retry-After), s'il y en a un.</summary>
|
||||
public TimeSpan? RetryAfter { get; init; }
|
||||
|
||||
public bool IsUnauthorized => Status == 401;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
@@ -41,7 +42,8 @@ public sealed class LauncherApiClient : IDisposable
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}, disposeHandler: handler is null)
|
||||
{
|
||||
Timeout = RequestTimeout
|
||||
// Délai propre à chaque requête (SendAsync) : 30 s par défaut, plus pour l'envoi de fichiers.
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
@@ -105,6 +107,65 @@ public sealed class LauncherApiClient : IDisposable
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs (routes anonymes)
|
||||
|
||||
/// <summary>
|
||||
/// Envoie une demande de joueur (bouton « Aide ») : défi anti-spam demandé au serveur, résolu sur ce PC (une fraction de
|
||||
/// seconde, hors du fil de l'interface), puis envoi. Aucune session, aucune clé, aucun identifiant du PC.
|
||||
/// </summary>
|
||||
public async Task<FeedbackSubmitted> SendFeedbackAsync(FeedbackSubmission submission, CancellationToken ct = default) =>
|
||||
(await SendFeedbackAsync(submission, Array.Empty<byte[]>(), ct).ConfigureAwait(false)).Submitted;
|
||||
|
||||
/// <summary>
|
||||
/// Envoie une demande puis ses captures d'écran (déjà nettoyées par <see cref="FeedbackImages.Sanitize"/>), une par
|
||||
/// une, avec le jeton remis par le serveur. Une capture refusée ou perdue n'annule pas la demande : le résultat
|
||||
/// indique combien ont été reçues et pourquoi les autres ne l'ont pas été.
|
||||
/// </summary>
|
||||
public async Task<FeedbackSendResult> SendFeedbackAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (attachments.Count > FeedbackImages.MaxAttachments) throw new ArgumentException("Trop de captures.", nameof(attachments));
|
||||
var submitted = await SendSubmissionAsync(submission with { AttachmentCount = attachments.Count }, ct).ConfigureAwait(false);
|
||||
var sent = 0;
|
||||
string? error = null;
|
||||
for (var i = 0; i < attachments.Count && submitted.UploadToken is { } token; i++)
|
||||
{
|
||||
var index = i + 1;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendAsync<FeedbackAttachmentStored>(HttpMethod.Put, ApiRoutes.FeedbackAttachment(index), attachments[i],
|
||||
request => request.Headers.Add(FeedbackUpload.TokenHeader, token), ct, FeedbackUpload.ContentType,
|
||||
TimeSpan.FromMinutes(2)).ConfigureAwait(false);
|
||||
sent++;
|
||||
break;
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 0 && attempt < 2)
|
||||
{
|
||||
// coupure : un nouvel essai (même numéro : la capture est remplacée si elle était arrivée)
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new FeedbackSendResult(submitted, sent, error);
|
||||
}
|
||||
|
||||
private async Task<FeedbackSubmitted> SendSubmissionAsync(FeedbackSubmission submission, CancellationToken ct)
|
||||
{
|
||||
var challenge = await SendAsync<FeedbackChallenge>(HttpMethod.Post, ApiRoutes.FeedbackChallenge, null, null, ct).ConfigureAwait(false);
|
||||
if (challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty || !Base64Url.TryDecode(challenge.Nonce, out var nonce) ||
|
||||
nonce.Length is 0 or > 64)
|
||||
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
|
||||
var solution = await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
|
||||
return await SendAsync<FeedbackSubmitted>(HttpMethod.Post, ApiRoutes.Feedback,
|
||||
Serialize(submission with { ChallengeId = challenge.ChallengeId, Solution = solution }), null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Transport
|
||||
|
||||
internal static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options);
|
||||
@@ -129,35 +190,54 @@ public sealed class LauncherApiClient : IDisposable
|
||||
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
|
||||
|
||||
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
|
||||
Action<HttpRequestMessage>? prepare, CancellationToken ct)
|
||||
Action<HttpRequestMessage>? prepare, CancellationToken ct,
|
||||
string contentType = "application/json", TimeSpan? timeout = null,
|
||||
long maxResponseBytes = ApiJson.MaxResponseBytes)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, Resolve(route));
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(body);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
request.Content.Headers.ContentType = contentType == "application/json"
|
||||
? new MediaTypeHeaderValue(contentType) { CharSet = "utf-8" }
|
||||
: new MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
prepare?.Invoke(request);
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(timeout ?? RequestTimeout);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var bytes = await ReadBoundedAsync(response, ct).ConfigureAwait(false);
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await ReadBoundedAsync(response, deadline.Token, maxResponseBytes).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Connexion au serveur d'administration interrompue ({ex.Message}).", ex);
|
||||
}
|
||||
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
|
||||
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
|
||||
if (typeof(T) == typeof(byte[])) return (T)(object)bytes; // contenu binaire (capture d'écran…)
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
|
||||
@@ -171,9 +251,9 @@ public sealed class LauncherApiClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct, long maxBytes)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
|
||||
if (response.Content.Headers.ContentLength > maxBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
@@ -181,7 +261,7 @@ public sealed class LauncherApiClient : IDisposable
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > ApiJson.MaxResponseBytes)
|
||||
if (buffer.Length + read > maxBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
@@ -191,25 +271,36 @@ public sealed class LauncherApiClient : IDisposable
|
||||
private static ApiException ToException(HttpResponseMessage response, byte[] body)
|
||||
{
|
||||
var status = (int)response.StatusCode;
|
||||
var retryAfter = RetryAfterOf(response);
|
||||
try
|
||||
{
|
||||
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
|
||||
return new ApiException(status, error.Code, error.Message);
|
||||
return new ApiException(status, error.Code, error.Message) { RetryAfter = retryAfter };
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// réponse d'erreur non JSON (nginx, proxy…)
|
||||
}
|
||||
return status switch
|
||||
var (code, message) = status switch
|
||||
{
|
||||
401 => new ApiException(status, ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
|
||||
403 => new ApiException(status, ApiErrorCodes.Forbidden, "Action non autorisée."),
|
||||
404 => new ApiException(status, ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
|
||||
413 => new ApiException(status, ApiErrorCodes.InvalidRequest, "Requête trop volumineuse."),
|
||||
429 => new ApiException(status, ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
|
||||
>= 500 => new ApiException(status, ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
|
||||
_ => new ApiException(status, ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
|
||||
401 => (ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
|
||||
403 => (ApiErrorCodes.Forbidden, "Action non autorisée."),
|
||||
404 => (ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
|
||||
413 => (ApiErrorCodes.InvalidRequest,
|
||||
"Requête trop volumineuse pour le serveur (envoi de fichiers : vérifiez nginx-publications.conf sur le VPS)."),
|
||||
429 => (ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
|
||||
>= 500 => (ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
|
||||
_ => (ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
|
||||
};
|
||||
return new ApiException(status, code, message) { RetryAfter = retryAfter };
|
||||
}
|
||||
|
||||
/// <summary>En-tête Retry-After (en secondes ou en date) ; absent ou incohérent : null.</summary>
|
||||
private static TimeSpan? RetryAfterOf(HttpResponseMessage response)
|
||||
{
|
||||
var header = response.Headers.RetryAfter;
|
||||
var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null);
|
||||
return delay is { } value && value >= TimeSpan.Zero ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
|
||||
@@ -232,3 +323,8 @@ public sealed class NoContent
|
||||
public static readonly NoContent Value = new();
|
||||
private NoContent() { }
|
||||
}
|
||||
|
||||
/// <summary>Résultat de l'envoi d'une demande et de ses captures d'écran.</summary>
|
||||
/// <param name="AttachmentsSent">Captures reçues par le serveur.</param>
|
||||
/// <param name="AttachmentError">Raison de l'échec de la première capture non reçue (null : toutes reçues).</param>
|
||||
public sealed record FeedbackSendResult(FeedbackSubmitted Submitted, int AttachmentsSent, string? AttachmentError);
|
||||
@@ -28,12 +28,146 @@ public static class ApiRoutes
|
||||
public const string Admins = "v1/admins";
|
||||
public const string Invitations = "v1/admins/invitations";
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
|
||||
public const string MyAccount = "v1/admins/me";
|
||||
|
||||
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
|
||||
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
|
||||
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
|
||||
public static string KeyApprove(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/approve";
|
||||
public static string KeyRevoke(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/revoke";
|
||||
|
||||
// ------------------------------------------------------------------ Groupes et permissions (admins.manage)
|
||||
public const string Groups = "v1/groups";
|
||||
public const string Permissions = "v1/permissions";
|
||||
|
||||
/// <summary>Un groupe (identifiant : minuscules, chiffres, tirets — rien à encoder).</summary>
|
||||
public static string Group(string groupId) => $"v1/groups/{groupId}";
|
||||
|
||||
/// <summary>Une permission personnalisée (« module.action » : rien à encoder).</summary>
|
||||
public static string Permission(string name) => $"v1/permissions/{name}";
|
||||
|
||||
// ------------------------------------------------------------------ Audit
|
||||
public const string Audit = "v1/audit";
|
||||
|
||||
/// <summary>Annonce d'un export CSV du journal (requête signée, inscrite au journal avant l'écriture du fichier).</summary>
|
||||
public const string AuditExport = "v1/audit/export";
|
||||
|
||||
/// <summary>
|
||||
/// Page du journal : <paramref name="before"/> (facultatif) = entrées antérieures à cet instant (filtre « jusqu'au »),
|
||||
/// <paramref name="cursor"/> = suite de la page précédente.
|
||||
/// </summary>
|
||||
public static string AuditPage(int limit, string? cursor, DateTimeOffset? before)
|
||||
{
|
||||
var route = $"{Audit}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
|
||||
if (before is { } date)
|
||||
route += $"&before={Uri.EscapeDataString(date.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture))}";
|
||||
return route;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Fichiers du site public (www)
|
||||
public const string Files = "v1/files";
|
||||
public const string FilesDelete = "v1/files/delete";
|
||||
public const string ObjectsPrunePattern = "v1/games/{gameId}/objects/prune";
|
||||
|
||||
/// <summary>Contenu d'un dossier (chemin relatif à la racine du site, « » : racine).</summary>
|
||||
public static string FilesFolder(string path) =>
|
||||
string.IsNullOrEmpty(path) ? Files : $"{Files}?path={Uri.EscapeDataString(path)}";
|
||||
|
||||
public static string ObjectsPrune(string gameId) => $"v1/games/{gameId}/objects/prune";
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
public const string Catalog = "v1/catalog";
|
||||
|
||||
/// <summary>Envoi d'une image du catalogue (icône ou fond) : corps binaire, requête signée, games.edit.</summary>
|
||||
public const string CatalogImagePattern = "v1/catalog/images/{kind}";
|
||||
|
||||
/// <summary>Préfixe des routes d'envoi d'image (limite de taille propre, voir nginx-api.conf).</summary>
|
||||
public const string CatalogImagesPrefix = "v1/catalog/images/";
|
||||
|
||||
public static string CatalogImage(string kind) => $"v1/catalog/images/{kind}";
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
|
||||
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
|
||||
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
|
||||
|
||||
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
|
||||
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
|
||||
|
||||
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
/// <summary>État des signatures (trousseau publié, manifests, catalogue) : propriétaires.</summary>
|
||||
public const string Signing = "v1/signing";
|
||||
|
||||
/// <summary>Publication d'un trousseau signé par la clé racine : propriétaires.</summary>
|
||||
public const string Keyring = "v1/keyring";
|
||||
|
||||
public const string ManifestSignaturePattern = "v1/games/{gameId}/signature";
|
||||
|
||||
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs (aide, avis, bugs…)
|
||||
/// <summary>Défi anti-spam (anonyme) à résoudre avant l'envoi d'une demande.</summary>
|
||||
public const string FeedbackChallenge = "v1/feedback/challenge";
|
||||
|
||||
/// <summary>Envoi d'une demande par un joueur (anonyme, corps de 16 Ko au plus).</summary>
|
||||
public const string Feedback = "v1/feedback";
|
||||
|
||||
/// <summary>Capture d'écran d'une demande (anonyme, jeton d'envoi ; corps : l'image, 4 Mo au plus).</summary>
|
||||
public const string FeedbackAttachmentPattern = "v1/feedback/attachments/{index:int}";
|
||||
|
||||
public static string FeedbackAttachment(int index) => $"v1/feedback/attachments/{index}";
|
||||
|
||||
/// <summary>Compteurs par jeu (feedback.read).</summary>
|
||||
public const string FeedbackOverview = "v1/feedback/overview";
|
||||
|
||||
/// <summary>Formulaires des demandes : lecture (feedback.forms ou feedback.read), publication signée (PUT, feedback.forms).</summary>
|
||||
public const string FeedbackForms = "v1/feedback/forms";
|
||||
|
||||
public const string GameFeedbackPattern = "v1/games/{gameId}/feedback";
|
||||
public const string GameFeedbackItemPattern = "v1/games/{gameId}/feedback/{id}";
|
||||
|
||||
/// <summary>Demandes d'un jeu (identifiant : caractères sûrs ; filtres et curseur encodés).</summary>
|
||||
public static string GameFeedback(string gameId, string? kind = null, string? status = null, string? cursor = null, int limit = 100)
|
||||
{
|
||||
var route = $"v1/games/{gameId}/feedback?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(kind)) route += $"&kind={Uri.EscapeDataString(kind)}";
|
||||
if (!string.IsNullOrEmpty(status)) route += $"&status={Uri.EscapeDataString(status)}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
|
||||
return route;
|
||||
}
|
||||
|
||||
/// <summary>Une demande (identifiant : 32 caractères hexadécimaux, rien à encoder).</summary>
|
||||
public static string GameFeedbackItem(string gameId, string id) => $"v1/games/{gameId}/feedback/{id}";
|
||||
|
||||
public const string GameFeedbackAttachmentPattern = "v1/games/{gameId}/feedback/{id}/attachments/{index:int}";
|
||||
|
||||
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
|
||||
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance du launcher
|
||||
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
|
||||
public const string Maintenance = "v1/maintenance";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
public const string PublicationManifestPattern = "v1/publications/{id}/manifest";
|
||||
public const string PublicationArchivePattern = "v1/publications/{id}/archive";
|
||||
public const string PublicationObjectsPattern = "v1/publications/{id}/objects";
|
||||
public const string PublicationObjectPattern = "v1/publications/{id}/objects/{sha256}";
|
||||
public const string PublicationCommitPattern = "v1/publications/{id}/commit";
|
||||
|
||||
/// <summary>Préfixe des routes d'envoi (limite de taille propre, voir nginx-publications.conf).</summary>
|
||||
public const string PublicationsPrefix = "v1/publications/";
|
||||
|
||||
public static string PublicationStart(string gameId) => $"v1/games/{gameId}/publications";
|
||||
public static string Publication(string id) => $"v1/publications/{id}";
|
||||
public static string PublicationManifest(string id, long offset) => $"v1/publications/{id}/manifest?offset={offset}";
|
||||
public static string PublicationArchive(string id, long offset) => $"v1/publications/{id}/archive?offset={offset}";
|
||||
public static string PublicationObjects(string id) => $"v1/publications/{id}/objects";
|
||||
public static string PublicationObject(string id, string sha256, long offset) => $"v1/publications/{id}/objects/{sha256}?offset={offset}";
|
||||
public static string PublicationCommit(string id) => $"v1/publications/{id}/commit";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Informations MODIFIABLES des jeux (catalog.json, publié par le serveur et lu par les launchers).
|
||||
/// Les jeux restent déclarés dans launcher.json (intégré à l'exe) : une entrée pour un jeu non déclaré
|
||||
/// est ignorée par le launcher. Toute valeur absente (null) = valeur de launcher.json.
|
||||
/// Ce fichier est PUBLIC : il ne contient que de l'affichage (aucun nom d'administrateur).
|
||||
/// </summary>
|
||||
public sealed record CatalogDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-catalog";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Numéro de version du contenu, augmenté à chaque publication (contrôle des modifications concurrentes).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
public CatalogLauncher? Launcher { get; init; }
|
||||
|
||||
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
|
||||
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Signature de l'administrateur qui a publié (phase 7) : couvre tout sauf la révision et la date
|
||||
/// (<see cref="ContentSigning.CatalogDigest"/>). Ignorée par les launchers antérieurs.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
|
||||
public sealed record CatalogLauncher
|
||||
{
|
||||
public string? LauncherName { get; init; }
|
||||
|
||||
/// <summary>Jeu affiché au premier lancement.</summary>
|
||||
public string? DefaultGame { get; init; }
|
||||
|
||||
/// <summary>Ordre des jeux dans la liste (les jeux non cités suivent, dans l'ordre de launcher.json).</summary>
|
||||
public List<string>? GameOrder { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
|
||||
public sealed record CatalogGame
|
||||
{
|
||||
/// <summary>Nom AFFICHÉ, repris par le raccourci du Bureau (le nom de launcher.json reste celui 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,175 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Images du catalogue (icône, image de fond) envoyées depuis le menu d'administration : contrôles communs
|
||||
/// au launcher (avant l'envoi) et au serveur (refus de tout fichier non conforme).
|
||||
/// • PNG ou JPEG uniquement, reconnus à leur contenu (pas à leur extension), <see cref="MaxBytes"/> au plus
|
||||
/// (la limite de téléchargement des launchers) ;
|
||||
/// • dimensions lues dans l'en-tête et bornées (pas d'image démesurée à décoder chez les joueurs) ;
|
||||
/// • fichier complet, sans données ajoutées après la fin de l'image (PNG : bloc IEND ; JPEG : marqueur EOI) ;
|
||||
/// • nom du fichier publié = empreinte SHA-256 du contenu (<c><64 hex>.png|.jpg</c>) : une image publiée ne
|
||||
/// change jamais de contenu (les launchers la gardent en cache sous son adresse), et le launcher des joueurs
|
||||
/// vérifie que l'image téléchargée a bien cette empreinte. Comme l'adresse figure dans le catalogue signé,
|
||||
/// l'image est ainsi protégée par la même signature.
|
||||
/// </summary>
|
||||
public static partial class CatalogImages
|
||||
{
|
||||
/// <summary>Taille maximale d'une image (celle acceptée par les launchers des joueurs).</summary>
|
||||
public const long MaxBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Taille maximale d'une requête d'envoi (image + marge) ; voir nginx-api.conf.</summary>
|
||||
public const long MaxRequestBytes = MaxBytes + 64 * 1024;
|
||||
|
||||
public const string Icon = "icon";
|
||||
public const string Background = "background";
|
||||
|
||||
public const string ContentType = "application/octet-stream";
|
||||
|
||||
/// <summary>Dossier des images, à côté du fichier public du catalogue.</summary>
|
||||
public const string FolderName = "images";
|
||||
|
||||
/// <summary>Nombre et taille totale des images du dossier (les images inutilisées se suppriment dans « Fichiers du serveur »).</summary>
|
||||
public const int MaxStoredFiles = 300;
|
||||
public const long MaxStoredBytes = 300L * 1024 * 1024;
|
||||
|
||||
/// <summary>Nombre maximal de pixels (≈ 8K) : borne la mémoire nécessaire au décodage chez les joueurs.</summary>
|
||||
public const long MaxPixels = 40_000_000;
|
||||
|
||||
public static bool IsValidKind(string? kind) => kind is Icon or Background;
|
||||
|
||||
public static string KindLabel(string kind) => kind == Icon ? "icône" : "image de fond";
|
||||
|
||||
/// <summary>Dimensions acceptées (largeur et hauteur, en pixels) selon le type d'image.</summary>
|
||||
public static (int MinWidth, int MinHeight, int MaxWidth, int MaxHeight) Bounds(string kind) =>
|
||||
kind == Icon ? (32, 32, 2048, 2048) : (640, 360, 7680, 4320);
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{64}\.(png|jpg)\z")]
|
||||
private static partial Regex StoredNameRegex();
|
||||
|
||||
/// <summary>Nom d'une image publiée par le serveur (empreinte SHA-256 du contenu + extension).</summary>
|
||||
public static bool IsStoredName(string? name) => name is not null && StoredNameRegex().IsMatch(name);
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte attendue d'une image dont l'adresse se termine par un nom publié par le serveur
|
||||
/// (« …/catalog/images/<sha256>.png ») ; null pour une autre adresse (image déposée à la main).
|
||||
/// </summary>
|
||||
public static string? ExpectedSha256(Uri url)
|
||||
{
|
||||
var name = url.Segments.Length == 0 ? "" : Uri.UnescapeDataString(url.Segments[^1]);
|
||||
return IsStoredName(name) ? name[..64] : null;
|
||||
}
|
||||
|
||||
/// <summary>Format et dimensions lus dans le contenu.</summary>
|
||||
/// <param name="Format">« png » ou « jpg » (extension du fichier publié).</param>
|
||||
public sealed record ImageInfo(string Format, int Width, int Height);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'une image pour un type donné : null si elle est acceptable, sinon la raison du refus
|
||||
/// (en français, affichable). <paramref name="info"/> est renseigné dès que le format est reconnu.
|
||||
/// </summary>
|
||||
public static string? Check(string kind, ReadOnlySpan<byte> data, out ImageInfo? info)
|
||||
{
|
||||
info = null;
|
||||
if (!IsValidKind(kind)) return "Type d'image inconnu.";
|
||||
if (data.Length == 0) return "Fichier vide.";
|
||||
if (data.Length > MaxBytes) return $"Image trop volumineuse ({data.Length / 1024 / 1024.0:0.0} Mo) : {MaxBytes / 1024 / 1024} Mo au plus.";
|
||||
|
||||
string? error;
|
||||
if (data.StartsWith(PngSignature)) info = ReadPng(data, out error);
|
||||
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) info = ReadJpeg(data, out error);
|
||||
else return "Ce fichier n'est pas une image PNG ou JPEG.";
|
||||
if (info is null) return error;
|
||||
|
||||
var (minW, minH, maxW, maxH) = Bounds(kind);
|
||||
if (info.Width < minW || info.Height < minH || info.Width > maxW || info.Height > maxH ||
|
||||
(long)info.Width * info.Height > MaxPixels)
|
||||
return $"Dimensions refusées pour une {KindLabel(kind)} : {info.Width} × {info.Height} px " +
|
||||
$"(de {minW} × {minH} à {maxW} × {maxH} px).";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
/// <summary>Dernier bloc d'un PNG : longueur 0, « IEND », CRC fixe.</summary>
|
||||
private static ReadOnlySpan<byte> PngEnd => new byte[] { 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 };
|
||||
|
||||
private static ImageInfo? ReadPng(ReadOnlySpan<byte> data, out string? error)
|
||||
{
|
||||
error = "Image PNG invalide ou incomplète.";
|
||||
// Signature (8) + bloc IHDR : longueur (4) = 13, type (4), largeur (4), hauteur (4)…
|
||||
if (data.Length < 8 + 8 + 13 + 4 + PngEnd.Length) return null;
|
||||
if (BinaryPrimitives.ReadUInt32BigEndian(data[8..]) != 13 || !data.Slice(12, 4).SequenceEqual("IHDR"u8)) return null;
|
||||
var width = BinaryPrimitives.ReadUInt32BigEndian(data[16..]);
|
||||
var height = BinaryPrimitives.ReadUInt32BigEndian(data[20..]);
|
||||
if (width is 0 or > int.MaxValue || height is 0 or > int.MaxValue) return null;
|
||||
if (!data[^PngEnd.Length..].SequenceEqual(PngEnd))
|
||||
{
|
||||
error = "Image PNG incomplète, ou suivie de données étrangères à l'image.";
|
||||
return null;
|
||||
}
|
||||
error = null;
|
||||
return new ImageInfo("png", (int)width, (int)height);
|
||||
}
|
||||
|
||||
private static ImageInfo? ReadJpeg(ReadOnlySpan<byte> data, out string? error)
|
||||
{
|
||||
error = "Image JPEG invalide ou incomplète.";
|
||||
if (data.Length < 4 || data[^2] != 0xFF || data[^1] != 0xD9)
|
||||
{
|
||||
error = "Image JPEG incomplète, ou suivie de données étrangères à l'image.";
|
||||
return null;
|
||||
}
|
||||
var i = 2;
|
||||
while (i + 4 <= data.Length)
|
||||
{
|
||||
if (data[i] != 0xFF) return null;
|
||||
var marker = data[i + 1];
|
||||
if (marker == 0xFF)
|
||||
{
|
||||
i++; // octet de remplissage
|
||||
continue;
|
||||
}
|
||||
if (marker is 0x01 or (>= 0xD0 and <= 0xD7))
|
||||
{
|
||||
i += 2; // marqueur sans longueur
|
||||
continue;
|
||||
}
|
||||
if (marker is 0xD8 or 0xD9 or 0xDA) return null; // début d'image, fin ou données avant les dimensions
|
||||
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 2)..]);
|
||||
if (length < 2 || i + 2 + length > data.Length) return null;
|
||||
// SOF0 à SOF15, sauf DHT (C4), JPG (C8) et DAC (CC) : précision (1), hauteur (2), largeur (2).
|
||||
if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC)
|
||||
{
|
||||
if (length < 7) return null;
|
||||
var height = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 5)..]);
|
||||
var width = BinaryPrimitives.ReadUInt16BigEndian(data[(i + 7)..]);
|
||||
if (width == 0 || height == 0) return null;
|
||||
error = null;
|
||||
return new ImageInfo("jpg", width, height);
|
||||
}
|
||||
i += 2 + length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Image envoyée (PUT v1/catalog/images/{kind}) : adresse publique à mettre dans le catalogue.</summary>
|
||||
public sealed record CatalogImageUploaded
|
||||
{
|
||||
/// <summary>Adresse https de l'image, sur le serveur du catalogue.</summary>
|
||||
public string Url { get; init; } = "";
|
||||
|
||||
/// <summary>Nom du fichier publié (empreinte SHA-256 du contenu + extension).</summary>
|
||||
public string FileName { get; init; } = "";
|
||||
|
||||
public string Sha256 { get; init; } = "";
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public long Bytes { get; init; }
|
||||
|
||||
/// <summary>Le serveur avait déjà cette image (même contenu) : rien n'a été écrit.</summary>
|
||||
public bool AlreadyPresent { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle et nettoyage du catalogue, communs au serveur (refus de toute valeur invalide) et au
|
||||
/// launcher des joueurs (valeur invalide ignorée : la valeur de launcher.json s'applique).
|
||||
/// Tout est borné : textes, nombre de jeux et de liens, adresses (https uniquement), images (même
|
||||
/// serveur que le catalogue, PNG ou JPEG), thème (identifiant d'un thème intégré, jamais un fichier).
|
||||
/// </summary>
|
||||
public static partial class CatalogRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Taille maximale de catalog.json. Inférieure à la taille maximale d'une requête de l'API (64 Ko :
|
||||
/// Limits:MaxRequestBodyBytes et client_max_body_size de nginx), qui n'est donc pas à augmenter.
|
||||
/// </summary>
|
||||
public const int MaxFileBytes = 48 * 1024;
|
||||
|
||||
/// <summary>Format du fichier publié (JSON indenté, écrit par le serveur).</summary>
|
||||
public static readonly JsonSerializerOptions PublishedJson = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
/// <summary>Taille (octets) du fichier publié pour ce catalogue.</summary>
|
||||
public static int PublishedSize(CatalogDocument catalog) =>
|
||||
Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(catalog, PublishedJson));
|
||||
public const int MaxGames = 50;
|
||||
public const int MaxGameNameLength = 80;
|
||||
public const int MaxLauncherNameLength = 80;
|
||||
public const int MaxTaglineLength = 200;
|
||||
public const int MaxDescriptionLength = 2000;
|
||||
public const int MaxDescriptionLines = 40;
|
||||
public const int MaxLinks = 8;
|
||||
public const int MaxLinkLabelLength = 40;
|
||||
public const int MaxUrlLength = 500;
|
||||
public const int MinUpdateInterval = 5;
|
||||
public const int MaxUpdateInterval = 1440;
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9_.\-]{1,64}\z")]
|
||||
private static partial Regex GameIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[a-z0-9][a-z0-9\-]{0,31}\z")]
|
||||
private static partial Regex ThemeRegex();
|
||||
|
||||
public static bool IsValidGameId(string? id) => id is not null && GameIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidTheme(string? theme) => theme is not null && ThemeRegex().IsMatch(theme);
|
||||
|
||||
/// <summary>
|
||||
/// Nettoie le catalogue. Chaque valeur invalide est retirée (null) et décrite dans <paramref name="errors"/> :
|
||||
/// le serveur refuse la publication s'il y en a, le launcher ignore simplement ces valeurs.
|
||||
/// </summary>
|
||||
/// <param name="imageHost">Hôte autorisé pour les images (celui du catalogue) ; null : https suffit.</param>
|
||||
public static CatalogDocument Normalize(CatalogDocument input, string? imageHost, List<string> errors)
|
||||
{
|
||||
if (input.Format != CatalogDocument.FormatName) errors.Add("Format de catalogue inconnu.");
|
||||
if (input.Version != CatalogDocument.CurrentVersion) errors.Add($"Version de catalogue non prise en charge ({input.Version}).");
|
||||
if (input.Revision < 0) errors.Add("Révision invalide.");
|
||||
|
||||
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (id, game) in input.Games ?? new Dictionary<string, CatalogGame>())
|
||||
{
|
||||
if (!IsValidGameId(id))
|
||||
{
|
||||
errors.Add($"Identifiant de jeu invalide : « {Truncate(id)} ».");
|
||||
continue;
|
||||
}
|
||||
if (games.ContainsKey(id))
|
||||
{
|
||||
errors.Add($"Jeu « {id} » présent plusieurs fois.");
|
||||
continue;
|
||||
}
|
||||
if (games.Count >= MaxGames)
|
||||
{
|
||||
errors.Add($"Trop de jeux (au plus {MaxGames}).");
|
||||
break;
|
||||
}
|
||||
if (game is null) continue;
|
||||
var cleaned = NormalizeGame(id, game, imageHost, errors);
|
||||
if (!IsEmpty(cleaned)) games[id] = cleaned;
|
||||
}
|
||||
|
||||
return new CatalogDocument
|
||||
{
|
||||
Revision = Math.Max(0, input.Revision),
|
||||
UpdatedAt = input.UpdatedAt,
|
||||
Launcher = NormalizeLauncher(input.Launcher, errors),
|
||||
Games = games
|
||||
};
|
||||
}
|
||||
|
||||
private static CatalogLauncher? NormalizeLauncher(CatalogLauncher? launcher, List<string> errors)
|
||||
{
|
||||
if (launcher is null) return null;
|
||||
var name = Line(launcher.LauncherName, MaxLauncherNameLength, "Nom du launcher", errors);
|
||||
string? defaultGame = null;
|
||||
if (!string.IsNullOrWhiteSpace(launcher.DefaultGame))
|
||||
{
|
||||
if (IsValidGameId(launcher.DefaultGame.Trim())) defaultGame = launcher.DefaultGame.Trim();
|
||||
else errors.Add("Jeu par défaut : identifiant invalide.");
|
||||
}
|
||||
|
||||
List<string>? order = null;
|
||||
if (launcher.GameOrder is { Count: > 0 })
|
||||
{
|
||||
order = new List<string>();
|
||||
foreach (var id in launcher.GameOrder)
|
||||
{
|
||||
if (!IsValidGameId(id))
|
||||
{
|
||||
errors.Add($"Ordre des jeux : identifiant invalide « {Truncate(id)} ».");
|
||||
continue;
|
||||
}
|
||||
if (order.Count >= MaxGames)
|
||||
{
|
||||
errors.Add($"Ordre des jeux : au plus {MaxGames} jeux.");
|
||||
break;
|
||||
}
|
||||
if (!order.Contains(id, StringComparer.OrdinalIgnoreCase)) order.Add(id);
|
||||
}
|
||||
if (order.Count == 0) order = null;
|
||||
}
|
||||
|
||||
return name is null && defaultGame is null && order is null
|
||||
? null
|
||||
: new CatalogLauncher { LauncherName = name, DefaultGame = defaultGame, GameOrder = order };
|
||||
}
|
||||
|
||||
private static CatalogGame NormalizeGame(string id, CatalogGame game, string? imageHost, List<string> errors)
|
||||
{
|
||||
var prefix = $"Jeu « {id} »";
|
||||
int? interval = null;
|
||||
if (game.UpdateCheckIntervalMinutes is { } minutes)
|
||||
{
|
||||
if (minutes == 0 || minutes is >= MinUpdateInterval and <= MaxUpdateInterval) interval = minutes;
|
||||
else errors.Add($"{prefix} : intervalle de recherche des mises à jour invalide (0, ou {MinUpdateInterval} à {MaxUpdateInterval} minutes).");
|
||||
}
|
||||
|
||||
string? theme = null;
|
||||
if (!string.IsNullOrWhiteSpace(game.Theme))
|
||||
{
|
||||
var value = game.Theme.Trim();
|
||||
if (IsValidTheme(value)) theme = value;
|
||||
else errors.Add($"{prefix} : thème invalide (identifiant d'un thème intégré au launcher).");
|
||||
}
|
||||
|
||||
return new CatalogGame
|
||||
{
|
||||
GameName = Line(game.GameName, MaxGameNameLength, $"{prefix} : nom", errors),
|
||||
Tagline = Line(game.Tagline, MaxTaglineLength, $"{prefix} : accroche", errors),
|
||||
Description = Text(game.Description, $"{prefix} : présentation", errors),
|
||||
WebsiteUrl = Url(game.WebsiteUrl, $"{prefix} : site web", errors),
|
||||
Links = Links(game.Links, prefix, errors),
|
||||
Theme = theme,
|
||||
Icon = Image(game.Icon, imageHost, $"{prefix} : icône", errors),
|
||||
Background = Image(game.Background, imageHost, $"{prefix} : image de fond", errors),
|
||||
UpdateCheckIntervalMinutes = interval,
|
||||
Hidden = game.Hidden == true ? true : null
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CatalogLink>? Links(List<CatalogLink>? links, string prefix, List<string> errors)
|
||||
{
|
||||
if (links is not { Count: > 0 }) return null;
|
||||
var result = new List<CatalogLink>();
|
||||
foreach (var link in links)
|
||||
{
|
||||
if (link is null) continue;
|
||||
if (result.Count >= MaxLinks)
|
||||
{
|
||||
errors.Add($"{prefix} : au plus {MaxLinks} liens.");
|
||||
break;
|
||||
}
|
||||
var kind = CatalogLinkKinds.All.Contains(link.Kind) ? link.Kind : null;
|
||||
if (kind is null)
|
||||
{
|
||||
errors.Add($"{prefix} : type de lien inconnu « {Truncate(link.Kind)} ».");
|
||||
continue;
|
||||
}
|
||||
var url = Url(link.Url, $"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)}", errors);
|
||||
if (url is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(link.Url)) errors.Add($"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)} sans adresse.");
|
||||
continue;
|
||||
}
|
||||
var label = Line(link.Label, MaxLinkLabelLength, $"{prefix} : libellé de lien", errors);
|
||||
result.Add(new CatalogLink { Kind = kind, Label = label, Url = url });
|
||||
}
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Valeurs
|
||||
|
||||
private static string? Line(string? value, int maxLength, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var cleaned = ApiText.CleanLine(value, maxLength);
|
||||
if (cleaned is null) errors.Add($"{field} : {maxLength} caractères au plus, sans caractère spécial invisible.");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// <summary>Texte de plusieurs lignes : fins de ligne normalisées, sans caractère de contrôle ou invisible.</summary>
|
||||
private static string? Text(string? value, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var lines = value.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||
var sb = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append('\n');
|
||||
var line = lines[i].TrimEnd();
|
||||
if (line.Length == 0) continue;
|
||||
// Indentation conservée (espaces de tête), le reste nettoyé comme une ligne simple.
|
||||
var indent = line.Length - line.TrimStart(' ').Length;
|
||||
var cleaned = ApiText.CleanLine(line, MaxDescriptionLength);
|
||||
if (cleaned is null)
|
||||
{
|
||||
errors.Add($"{field} : caractère spécial invisible ou de contrôle interdit.");
|
||||
return null;
|
||||
}
|
||||
sb.Append(' ', Math.Min(indent, 8)).Append(cleaned);
|
||||
}
|
||||
var text = sb.ToString().Trim('\n');
|
||||
if (text.Length > MaxDescriptionLength || text.Count(c => c == '\n') + 1 > MaxDescriptionLines)
|
||||
{
|
||||
errors.Add($"{field} : {MaxDescriptionLength} caractères et {MaxDescriptionLines} lignes au plus.");
|
||||
return null;
|
||||
}
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
|
||||
/// <summary>Adresse https absolue, sans identifiants, bornée.</summary>
|
||||
public static string? Url(string? value, string field, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var text = value.Trim();
|
||||
if (text.Length > MaxUrlLength || !Uri.TryCreate(text, UriKind.Absolute, out var uri) ||
|
||||
uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrEmpty(uri.Host) ||
|
||||
text.Any(char.IsWhiteSpace) || text.Any(char.IsControl))
|
||||
{
|
||||
errors.Add($"{field} : adresse https:// complète attendue ({MaxUrlLength} caractères au plus).");
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri;
|
||||
}
|
||||
|
||||
/// <summary>Image https PNG ou JPEG, sur l'hôte du catalogue.</summary>
|
||||
private static string? Image(string? value, string? imageHost, string field, List<string> errors)
|
||||
{
|
||||
var url = Url(value, field, errors);
|
||||
if (url is null) return null;
|
||||
var uri = new Uri(url);
|
||||
var path = uri.AbsolutePath;
|
||||
if (!(path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) || !string.IsNullOrEmpty(uri.Query))
|
||||
{
|
||||
errors.Add($"{field} : image .png ou .jpg attendue (sans paramètres).");
|
||||
return null;
|
||||
}
|
||||
if (imageHost is not null && !string.Equals(uri.Host, imageHost, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"{field} : l'image doit être sur le serveur du catalogue ({imageHost}).");
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private static bool IsEmpty(CatalogGame game) =>
|
||||
game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
|
||||
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null };
|
||||
|
||||
private static string Truncate(string? value) =>
|
||||
value is null ? "" : value.Length <= 40 ? value : value[..40] + "…";
|
||||
}
|
||||
+73
-4
@@ -1,11 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Core.Utils;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version du jeu (manifest.json → "changelog").
|
||||
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
|
||||
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
|
||||
///
|
||||
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
|
||||
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
|
||||
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
|
||||
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
|
||||
public List<string> Changes { get; set; } = new();
|
||||
|
||||
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
|
||||
|
||||
[JsonIgnore]
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
|
||||
@@ -142,6 +147,69 @@ public static class ChangelogLimits
|
||||
public const int MaxTitleLength = 200;
|
||||
public const int MaxTagLength = 24;
|
||||
public const int MaxTextLength = 2000;
|
||||
public const int MaxVersionLength = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
|
||||
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
|
||||
/// </summary>
|
||||
public const int MaxEditRequestBytes = 60 * 1024;
|
||||
|
||||
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
|
||||
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
|
||||
|
||||
/// <summary>
|
||||
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
|
||||
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
|
||||
/// </summary>
|
||||
public static List<string> Check(ChangelogEntry entry)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
void Length(string? value, int max, string what)
|
||||
{
|
||||
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
|
||||
}
|
||||
|
||||
Length(entry.Title, MaxTitleLength, "Titre");
|
||||
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
|
||||
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
|
||||
if (introduction.Count > MaxIntroductionParagraphs)
|
||||
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
|
||||
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
|
||||
|
||||
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
|
||||
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
|
||||
var items = 0;
|
||||
for (var s = 0; s < sections.Count; s++)
|
||||
{
|
||||
var section = sections[s];
|
||||
var where = $"Section {s + 1}";
|
||||
Length(section.Title, MaxTitleLength, $"{where} (titre)");
|
||||
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
|
||||
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
|
||||
items += sectionItems.Count;
|
||||
for (var i = 0; i < sectionItems.Count; i++)
|
||||
{
|
||||
var item = sectionItems[i];
|
||||
var at = $"{where}, élément {i + 1}";
|
||||
Length(item.Text, MaxTextLength, at);
|
||||
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
|
||||
Length(item.Note, MaxTextLength, $"{at} (note)");
|
||||
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
|
||||
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
|
||||
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
|
||||
}
|
||||
}
|
||||
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
|
||||
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
|
||||
errors.Add($"{MaxFlatChanges} modifications au plus.");
|
||||
return errors.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
|
||||
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
|
||||
|
||||
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
|
||||
{
|
||||
@@ -229,7 +297,8 @@ public static class ChangelogLimits
|
||||
|
||||
var text = sb.ToString().Trim();
|
||||
if (text.Length == 0) return null;
|
||||
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
|
||||
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
|
||||
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
|
||||
/// l'éditeur du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed record ChangelogState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
|
||||
public string ManifestVersion { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
|
||||
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// </summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
|
||||
public List<ChangelogEntry> Entries { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
|
||||
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
|
||||
/// </summary>
|
||||
public sealed record ChangelogUpdateRequest
|
||||
{
|
||||
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
|
||||
public string BaseSha256 { get; init; } = "";
|
||||
|
||||
public ChangelogEntry? Entry { get; init; }
|
||||
}
|
||||
@@ -12,6 +12,12 @@ public sealed record AdminKeySummary
|
||||
/// </summary>
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Clé publique (SPKI, Base64) des clés ACTIVES seulement (trousseau de la phase 7). Absente pour une clé en
|
||||
/// attente (on pourrait en déduire l'empreinte) ou révoquée.
|
||||
/// </summary>
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
}
|
||||
@@ -20,18 +26,34 @@ public sealed record AdminSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
|
||||
/// <summary>Identifiant du groupe de l'administrateur (historiquement « rôle »).</summary>
|
||||
public string Role { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché du groupe (null : ancien serveur, ou groupe supprimé d'un compte révoqué).</summary>
|
||||
public string? RoleName { get; init; }
|
||||
|
||||
/// <summary>Permissions effectives (celles du groupe, relues à chaque requête par le serveur).</summary>
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
public string Status { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
|
||||
}
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté : ses appareils (clés) et les invitations « nouvel appareil » en cours.</summary>
|
||||
public sealed record MyAccount
|
||||
{
|
||||
public AdminSummary Admin { get; init; } = new();
|
||||
public IReadOnlyList<InvitationSummary> Invitations { get; init; } = Array.Empty<InvitationSummary>();
|
||||
}
|
||||
|
||||
public sealed record CreateInvitationRequest
|
||||
{
|
||||
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>Rôle du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
/// <summary>Groupe du nouvel administrateur (identifiant ; ignoré pour un nouvel appareil).</summary>
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>Renseigné : invitation « nouvel appareil » pour cet administrateur existant.</summary>
|
||||
@@ -43,9 +65,16 @@ public sealed record InvitationSummary
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché du groupe (null : ancien serveur).</summary>
|
||||
public string? RoleName { get; init; }
|
||||
public Guid? ForAdminId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Administrateur qui a créé l'invitation (null : auteur inconnu).</summary>
|
||||
public Guid? CreatedById { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -65,5 +94,6 @@ public sealed record ApproveKeyRequest
|
||||
|
||||
public sealed record ChangeRoleRequest
|
||||
{
|
||||
/// <summary>Identifiant du nouveau groupe.</summary>
|
||||
public string Role { get; init; } = "";
|
||||
}
|
||||
@@ -13,6 +13,14 @@ public sealed record AuditEntry
|
||||
public Guid? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Nom de l'appareil de la clé <see cref="KeyId"/>, tel que défini à l'enregistrement de la clé (invitation).
|
||||
/// Jamais écrit dans les fichiers du journal : ajouté par le serveur à la lecture (null : clé inconnue, ou serveur
|
||||
/// plus ancien).
|
||||
/// </summary>
|
||||
public string? KeyLabel { get; init; }
|
||||
|
||||
public string? Ip { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? Details { get; init; }
|
||||
@@ -26,6 +34,30 @@ public sealed record AuditPage
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export CSV du journal par le menu d'administration (requête signée) : le launcher l'annonce au serveur AVANT
|
||||
/// d'écrire le fichier, et n'écrit rien si le serveur ne l'a pas inscrit au journal (action « audit.export »).
|
||||
/// </summary>
|
||||
public sealed record AuditExportRequest
|
||||
{
|
||||
/// <summary>Nombre d'entrées exportées.</summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>Période exportée (jours inclus, heure du PC), null si non bornée.</summary>
|
||||
public DateOnly? From { get; init; }
|
||||
|
||||
public DateOnly? To { get; init; }
|
||||
|
||||
/// <summary>Autres filtres appliqués (catégorie, résultat, recherche), en clair ; null si aucun.</summary>
|
||||
public string? Filters { get; init; }
|
||||
}
|
||||
|
||||
public static class AuditExportLimits
|
||||
{
|
||||
public const int MaxCount = 1_000_000;
|
||||
public const int MaxFiltersLength = 300;
|
||||
}
|
||||
|
||||
public static class AuditResults
|
||||
{
|
||||
public const string Ok = "ok";
|
||||
|
||||
@@ -34,7 +34,13 @@ public sealed record AdminProfile
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
|
||||
/// <summary>Identifiant du groupe (historiquement « rôle »).</summary>
|
||||
public string Role { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché du groupe (null : ancien serveur).</summary>
|
||||
public string? RoleName { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? KeyLabel { get; init; }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
/// <summary>Groupe d'administrateurs (page « Groupes et permissions »).</summary>
|
||||
public sealed record GroupSummary
|
||||
{
|
||||
/// <summary>Identifiant stable (« owner », « admin », « editor », « moderateurs »…), utilisé par les comptes et les invitations.</summary>
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Permissions effectives (groupe système : toutes, y compris les permissions personnalisées).</summary>
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Groupe système (« Propriétaire ») : toutes les permissions, ni modifiable ni supprimable.</summary>
|
||||
public bool System { get; init; }
|
||||
|
||||
/// <summary>Révision : une modification préparée sur une révision dépassée est refusée (409 revision_conflict).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Comptes actifs ou en attente du groupe (les comptes révoqués ne comptent pas).</summary>
|
||||
public int MemberCount { get; init; }
|
||||
|
||||
/// <summary>Invitations en cours vers ce groupe.</summary>
|
||||
public int InvitationCount { get; init; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Permission connue du serveur : intégrée (vérifiée par le code) ou personnalisée.</summary>
|
||||
public sealed record PermissionSummary
|
||||
{
|
||||
/// <summary>« module.action ».</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Label { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Intégrée : vérifiée par le serveur et le launcher, jamais supprimable.</summary>
|
||||
public bool BuiltIn { get; init; }
|
||||
|
||||
/// <summary>Groupes qui ont cette permission (le groupe système compte).</summary>
|
||||
public int GroupCount { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>GET v1/groups : groupes et permissions connues.</summary>
|
||||
public sealed record GroupsState
|
||||
{
|
||||
public IReadOnlyList<GroupSummary> Groups { get; init; } = Array.Empty<GroupSummary>();
|
||||
public IReadOnlyList<PermissionSummary> Permissions { get; init; } = Array.Empty<PermissionSummary>();
|
||||
}
|
||||
|
||||
/// <summary>Création (POST v1/groups) ou modification (PUT v1/groups/{id}) d'un groupe.</summary>
|
||||
public sealed record GroupRequest
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Modification : révision sur laquelle elle a été préparée (ignorée à la création).</summary>
|
||||
public long Revision { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Création d'une permission personnalisée (POST v1/permissions).</summary>
|
||||
public sealed record CreatePermissionRequest
|
||||
{
|
||||
/// <summary>« module.action », ex. « news.edit ».</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Label { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -53,4 +53,28 @@ public static class ApiErrorCodes
|
||||
public const string SelfAction = "self_action";
|
||||
public const string Busy = "busy";
|
||||
public const string HttpsRequired = "https_required";
|
||||
|
||||
/// <summary>Quelqu'un a modifié la même donnée entre-temps (révision dépassée) : recharger avant de recommencer.</summary>
|
||||
public const string RevisionConflict = "revision_conflict";
|
||||
|
||||
/// <summary>Fonction non configurée sur le serveur.</summary>
|
||||
public const string NotConfigured = "not_configured";
|
||||
|
||||
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
|
||||
public const string OffsetMismatch = "offset_mismatch";
|
||||
|
||||
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
|
||||
public const string PublicationInProgress = "publication_in_progress";
|
||||
|
||||
/// <summary>Espace disque insuffisant sur le serveur.</summary>
|
||||
public const string InsufficientStorage = "insufficient_storage";
|
||||
|
||||
/// <summary>Demandes des joueurs : plafond atteint (nombre, taille, espace disque), envoi refusé pour l'instant.</summary>
|
||||
public const string FeedbackFull = "feedback_full";
|
||||
|
||||
/// <summary>Les formulaires des demandes ont été republiés pendant la saisie : le launcher les recharge.</summary>
|
||||
public const string FeedbackFormChanged = "feedback_form_changed";
|
||||
|
||||
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
|
||||
public const string ProofInvalid = "proof_invalid";
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
// ------------------------------------------------------------------ Joueur (routes anonymes)
|
||||
|
||||
/// <summary>Défi anti-spam (POST v1/feedback/challenge) : à résoudre avant l'envoi, voir <see cref="FeedbackProof"/>.</summary>
|
||||
public sealed record FeedbackChallenge
|
||||
{
|
||||
/// <summary>Identifiant scellé par le serveur (sans état), à renvoyer avec la demande.</summary>
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
/// <summary>Valeur aléatoire (base64url) à combiner avec la solution.</summary>
|
||||
public string Nonce { get; init; } = "";
|
||||
|
||||
/// <summary>Nombre de bits à zéro exigés en tête de l'empreinte.</summary>
|
||||
public int Difficulty { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Informations techniques jointes à une demande (facultatives, choisies par le joueur).</summary>
|
||||
public sealed record FeedbackTechInfo
|
||||
{
|
||||
public string? LauncherVersion { get; init; }
|
||||
|
||||
/// <summary>Version du jeu installée (absente si le jeu n'est pas installé).</summary>
|
||||
public string? GameVersion { get; init; }
|
||||
|
||||
/// <summary>« Windows 11 64 bits (10.0.26100) ».</summary>
|
||||
public string? Os { get; init; }
|
||||
|
||||
/// <summary>Langue de Windows (« fr-FR »).</summary>
|
||||
public string? Language { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demande envoyée par un joueur (POST v1/feedback), SANS compte : aucun identifiant du joueur ni de son PC.
|
||||
/// Le serveur ne fait confiance à rien : il revalide tout avec <see cref="FeedbackRules"/> et pose lui-même la date,
|
||||
/// l'identifiant, la référence et le statut.
|
||||
/// </summary>
|
||||
public sealed record FeedbackSubmission
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public int FormVersion { get; init; } = FeedbackForms.CurrentVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Révision des formulaires utilisés (<see cref="FeedbackFormsDocument.Revision"/>) ; 0 : formulaires d'origine
|
||||
/// (launchers 3.0 / 3.1, ou formulaires publiés illisibles). Une autre révision que celle en vigueur : 409.
|
||||
/// </summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
/// <summary>Valeurs des champs du formulaire (identifiant du champ → valeur).</summary>
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
|
||||
/// <summary>Captures d'écran annoncées (0 à <see cref="FeedbackImages.MaxAttachments"/>), envoyées ensuite une par une.</summary>
|
||||
public int AttachmentCount { get; init; }
|
||||
|
||||
/// <summary>Défi résolu (<see cref="FeedbackChallenge.ChallengeId"/>) et sa solution.</summary>
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
public string Solution { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Demande enregistrée : référence à citer (Discord, e-mail…).</summary>
|
||||
public sealed record FeedbackSubmitted
|
||||
{
|
||||
public string Reference { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Jeton d'envoi des captures annoncées (null sans capture) : à joindre à chaque envoi (en-tête
|
||||
/// <see cref="FeedbackUpload.TokenHeader"/>). Valable <see cref="FeedbackUpload.TokenLifetimeMinutes"/> minutes,
|
||||
/// seulement pour cette demande et le nombre de captures annoncé ; connu du seul launcher qui a envoyé la demande.
|
||||
/// </summary>
|
||||
public string? UploadToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Envoi des captures d'écran d'une demande (PUT v1/feedback/attachments/{index}, corps : l'image).</summary>
|
||||
public static class FeedbackUpload
|
||||
{
|
||||
public const string TokenHeader = "X-GL-Feedback-Upload";
|
||||
public const int TokenLifetimeMinutes = 15;
|
||||
public const string ContentType = "application/octet-stream";
|
||||
}
|
||||
|
||||
/// <summary>Capture d'écran enregistrée (nettoyée de ses métadonnées par le serveur).</summary>
|
||||
public sealed record FeedbackAttachmentInfo
|
||||
{
|
||||
/// <summary>Numéro (1 à <see cref="FeedbackImages.MaxAttachments"/>).</summary>
|
||||
public int Index { get; init; }
|
||||
|
||||
/// <summary>« png » ou « jpg ».</summary>
|
||||
public string Format { get; init; } = "";
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public long Bytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Capture reçue (réponse du serveur).</summary>
|
||||
public sealed record FeedbackAttachmentStored
|
||||
{
|
||||
public int Index { get; init; }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Menu d'administration
|
||||
|
||||
/// <summary>Statuts d'une demande (traitement par les administrateurs).</summary>
|
||||
public static class FeedbackStatuses
|
||||
{
|
||||
public const string New = "new";
|
||||
public const string InProgress = "in_progress";
|
||||
public const string Done = "done";
|
||||
public const string Rejected = "rejected";
|
||||
public const string Duplicate = "duplicate";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { New, InProgress, Done, Rejected, Duplicate };
|
||||
|
||||
public static bool IsValid(string? status) => status is not null && All.Contains(status, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Demande close (traitée, rejetée, doublon) : comptée pour la durée de conservation après clôture.</summary>
|
||||
public static bool IsClosed(string? status) => status is Done or Rejected or Duplicate;
|
||||
|
||||
public static string Label(string? status) => status switch
|
||||
{
|
||||
New => "Nouvelle",
|
||||
InProgress => "En cours",
|
||||
Done => "Traitée",
|
||||
Rejected => "Rejetée",
|
||||
Duplicate => "Doublon",
|
||||
_ => status ?? "?"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Résumé d'une demande (listes).</summary>
|
||||
public sealed record FeedbackSummary
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Reference { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public string Title { get; init; } = "";
|
||||
public string Status { get; init; } = FeedbackStatuses.New;
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public DateTimeOffset? StatusChangedAt { get; init; }
|
||||
|
||||
/// <summary>Note (avis) ou gravité (bug), pour l'aperçu de la liste.</summary>
|
||||
public string? Badge { get; init; }
|
||||
|
||||
public bool HasNote { get; init; }
|
||||
|
||||
/// <summary>Le titre ou un champ contient un lien ou une adresse (<see cref="FeedbackLinks"/>) : prudence.</summary>
|
||||
public bool HasLinks { get; init; }
|
||||
|
||||
/// <summary>Captures d'écran reçues.</summary>
|
||||
public int AttachmentCount { get; init; }
|
||||
|
||||
public long Revision { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Page de demandes d'un jeu (GET v1/games/{gameId}/feedback), les plus récentes d'abord.</summary>
|
||||
public sealed record FeedbackPage
|
||||
{
|
||||
public IReadOnlyList<FeedbackSummary> Items { get; init; } = Array.Empty<FeedbackSummary>();
|
||||
|
||||
/// <summary>Suite de la liste (null : fin).</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
/// <summary>Nombre de demandes correspondant aux filtres.</summary>
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Demande complète (GET v1/games/{gameId}/feedback/{id}).</summary>
|
||||
public sealed record FeedbackDetail
|
||||
{
|
||||
public FeedbackSummary Summary { get; init; } = new();
|
||||
public int FormVersion { get; init; }
|
||||
|
||||
/// <summary>Révision des formulaires avec laquelle la demande a été envoyée (0 : formulaires d'origine).</summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Formulaire tel qu'il était à l'envoi (libellés, types, choix) : une demande reste lisible même si le formulaire a
|
||||
/// changé depuis. Null : demande reçue avant la 3.2 (formulaires d'origine).
|
||||
/// </summary>
|
||||
public FeedbackForm? Form { get; init; }
|
||||
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
|
||||
/// <summary>Captures d'écran reçues (contenu : GET …/attachments/{index}).</summary>
|
||||
public IReadOnlyList<FeedbackAttachmentInfo> Attachments { get; init; } = Array.Empty<FeedbackAttachmentInfo>();
|
||||
|
||||
/// <summary>Note interne des administrateurs (jamais visible du joueur).</summary>
|
||||
public string? AdminNote { get; init; }
|
||||
|
||||
/// <summary>Nom de l'administrateur qui a changé le statut ou la note en dernier.</summary>
|
||||
public string? StatusChangedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Changement de statut et/ou de note interne (PUT, requête signée).</summary>
|
||||
public sealed record FeedbackUpdateRequest
|
||||
{
|
||||
/// <summary>Révision lue : 409 « revision_conflict » si la demande a changé entre-temps.</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
public string Status { get; init; } = FeedbackStatuses.New;
|
||||
public string? AdminNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Compteurs d'un jeu (tableau de bord, tuiles du choix du jeu).</summary>
|
||||
public sealed record FeedbackGameCounts
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public int New { get; init; }
|
||||
public int Open { get; init; }
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Vue d'ensemble (GET v1/feedback/overview).</summary>
|
||||
public sealed record FeedbackOverview
|
||||
{
|
||||
/// <summary>Réception des demandes activée sur le serveur (LauncherApi:Feedback:Enabled).</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Plafond atteint (nombre, taille ou espace disque) : les nouvelles demandes sont refusées.</summary>
|
||||
public bool StorageFull { get; init; }
|
||||
|
||||
public IReadOnlyList<FeedbackGameCounts> Games { get; init; } = Array.Empty<FeedbackGameCounts>();
|
||||
|
||||
/// <summary>Durées de conservation appliquées (mois).</summary>
|
||||
public int RetentionClosedMonths { get; init; }
|
||||
|
||||
public int RetentionMaxMonths { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>Type d'un champ de formulaire (écrit en toutes lettres dans le fichier des formulaires).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FeedbackFieldKind>))]
|
||||
public enum FeedbackFieldKind
|
||||
{
|
||||
/// <summary>Zone de texte sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
Multiline,
|
||||
|
||||
/// <summary>Liste déroulante : un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
|
||||
Choice,
|
||||
|
||||
/// <summary>Note en étoiles, de 1 à <see cref="FeedbackField.RatingMax"/> (valeur : « 1 », « 2 »…).</summary>
|
||||
Rating,
|
||||
|
||||
/// <summary>Texte court sur une ligne (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
Text,
|
||||
|
||||
/// <summary>Cases à cocher : plusieurs choix (valeur : identifiants des options cochées, séparés par des virgules).</summary>
|
||||
Checkboxes,
|
||||
|
||||
/// <summary>Case oui/non (valeur : « yes » ou « no » ; obligatoire = doit être cochée).</summary>
|
||||
YesNo
|
||||
}
|
||||
|
||||
/// <summary>Option d'un champ à choix : identifiant stable (envoyé au serveur) et libellé affiché.</summary>
|
||||
public sealed record FeedbackOption(string Id, string Label);
|
||||
|
||||
/// <summary>Champ prédéfini d'un formulaire.</summary>
|
||||
/// <param name="Id">Identifiant stable (clé dans <see cref="FeedbackSubmission.Fields"/>).</param>
|
||||
/// <param name="Label">Libellé affiché (joueur et menu d'administration).</param>
|
||||
/// <param name="Hint">Aide sous le champ, ou texte indicatif dans la zone de saisie.</param>
|
||||
/// <param name="RatingMax">Nombre d'étoiles d'une note (3, 5 ou 10).</param>
|
||||
/// <param name="ShowInList">Valeur affichée en aperçu dans la liste des demandes du menu (un champ par formulaire au plus).</param>
|
||||
public sealed record FeedbackField(
|
||||
string Id,
|
||||
string Label,
|
||||
FeedbackFieldKind Kind,
|
||||
bool Required,
|
||||
string? Hint = null,
|
||||
int MaxLength = FeedbackRules.MaxMultilineLength,
|
||||
IReadOnlyList<FeedbackOption>? Options = null,
|
||||
int RatingMax = 5,
|
||||
bool ShowInList = false)
|
||||
{
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<FeedbackOption> Choices => Options ?? Array.Empty<FeedbackOption>();
|
||||
|
||||
/// <summary>Champ à saisir au clavier (zone de texte ou texte court).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsText => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
|
||||
|
||||
/// <summary>Libellé d'une valeur (option(s) choisie(s), « 4/5 » pour une note, « Oui »/« Non », texte sinon).</summary>
|
||||
public string DisplayValue(string? value) => Kind switch
|
||||
{
|
||||
_ when string.IsNullOrEmpty(value) => "",
|
||||
FeedbackFieldKind.Choice => Choices.FirstOrDefault(o => o.Id == value)?.Label ?? value!,
|
||||
FeedbackFieldKind.Checkboxes => string.Join(", ", value!.Split(',').Select(id => Choices.FirstOrDefault(o => o.Id == id)?.Label ?? id)),
|
||||
FeedbackFieldKind.Rating => $"{value}/{RatingMax}",
|
||||
FeedbackFieldKind.YesNo => value == FeedbackRules.Yes ? "Oui" : "Non",
|
||||
_ => value!
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Formulaire d'un type de demande.</summary>
|
||||
/// <param name="Kind">Identifiant stable (<see cref="FeedbackKinds"/>).</param>
|
||||
/// <param name="IconKey">Icône du launcher (ressource « Icon.<clé> »).</param>
|
||||
/// <param name="TitleHint">Texte indicatif du titre de la demande.</param>
|
||||
/// <param name="TechByDefault">« Joindre les informations techniques » coché à l'ouverture du formulaire.</param>
|
||||
/// <param name="Hidden">Type masqué : n'est plus proposé aux joueurs (les demandes déjà reçues restent consultables).</param>
|
||||
/// <param name="AllowAttachments">Captures d'écran proposées pour ce type (3 au plus, contrôlées et nettoyées).</param>
|
||||
/// <param name="CustomIcon">Icône importée (PNG 64 × 64 en base64, <see cref="FeedbackIcons"/>) ; null : <paramref name="IconKey"/>.</param>
|
||||
public sealed record FeedbackForm(
|
||||
string Kind,
|
||||
string Label,
|
||||
string Description,
|
||||
string IconKey,
|
||||
string TitleHint,
|
||||
bool TechByDefault,
|
||||
IReadOnlyList<FeedbackField> Fields,
|
||||
bool Hidden = false,
|
||||
bool AllowAttachments = true,
|
||||
string? CustomIcon = null)
|
||||
{
|
||||
/// <summary>Octets de l'icône importée (null : icône d'origine <see cref="IconKey"/>).</summary>
|
||||
public byte[]? CustomIconBytes() => FeedbackIcons.Decode(CustomIcon);
|
||||
|
||||
public FeedbackField? Field(string id) => (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.Id == id);
|
||||
|
||||
/// <summary>Valeur montrée en aperçu dans la liste du menu (champ « afficher en aperçu »), ou null.</summary>
|
||||
public string? Badge(IReadOnlyDictionary<string, string>? values)
|
||||
{
|
||||
var field = (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.ShowInList);
|
||||
return field is not null && values is not null && values.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value)
|
||||
? field.DisplayValue(value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Types de demande (identifiants stables, jamais traduits).</summary>
|
||||
public static class FeedbackKinds
|
||||
{
|
||||
public const string Review = "review";
|
||||
public const string Feature = "feature";
|
||||
public const string Bug = "bug";
|
||||
public const string Other = "other";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires D'ORIGINE des demandes des joueurs (révision 0). Ils servent tant qu'aucun formulaire n'a été publié
|
||||
/// depuis le menu d'administration (<see cref="FeedbackFormsDocument"/>), aux launchers qui n'ont pas pu lire les
|
||||
/// formulaires publiés, aux launchers 3.0 / 3.1, et à l'affichage des demandes reçues avant la 3.2.
|
||||
/// Ne pas les modifier : les formulaires se changent désormais depuis le menu (page « Formulaires des demandes »).
|
||||
/// </summary>
|
||||
public static class FeedbackForms
|
||||
{
|
||||
/// <summary>Version du format des demandes (enregistrée avec chaque demande).</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private static FeedbackOption O(string id, string label) => new(id, label);
|
||||
|
||||
public static readonly IReadOnlyList<FeedbackForm> All = new[]
|
||||
{
|
||||
new FeedbackForm(FeedbackKinds.Review, "Avis", "Dites-nous ce que vous pensez du jeu.", "Star",
|
||||
"Votre avis en une phrase", TechByDefault: false, new[]
|
||||
{
|
||||
new FeedbackField("rating", "Note", FeedbackFieldKind.Rating, Required: true, ShowInList: true),
|
||||
new FeedbackField("topic", "Sujet", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("overall", "Le jeu dans son ensemble"), O("gameplay", "Gameplay"), O("graphics", "Graphismes"),
|
||||
O("sound", "Son et musique"), O("performance", "Performances"), O("story", "Histoire et ambiance"),
|
||||
O("launcher", "Launcher"), O("other", "Autre")
|
||||
}),
|
||||
new FeedbackField("liked", "Ce que vous aimez", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Ce qui vous plaît le plus.", MaxLength: 1500),
|
||||
new FeedbackField("improve", "Ce qui pourrait être amélioré", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Ce qui vous gêne ou vous manque.", MaxLength: 1500),
|
||||
new FeedbackField("recommend", "Recommanderiez-vous le jeu ?", FeedbackFieldKind.Choice, Required: false, Options: new[]
|
||||
{
|
||||
O("yes", "Oui"), O("maybe", "Peut-être"), O("no", "Non")
|
||||
})
|
||||
}),
|
||||
|
||||
new FeedbackForm(FeedbackKinds.Feature, "Demande d'évolution", "Proposez une idée ou une amélioration.", "Lightbulb",
|
||||
"Votre idée en une phrase", TechByDefault: false, new[]
|
||||
{
|
||||
new FeedbackField("area", "Domaine", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("gameplay", "Gameplay"), O("content", "Contenu (niveaux, objets, ennemis…)"), O("interface", "Interface et menus"),
|
||||
O("accessibility", "Accessibilité"), O("launcher", "Launcher"), O("other", "Autre")
|
||||
}),
|
||||
new FeedbackField("idea", "Description de l'idée", FeedbackFieldKind.Multiline, Required: true,
|
||||
"Décrivez ce que vous aimeriez voir dans le jeu."),
|
||||
new FeedbackField("problem", "Quel problème cela résoudrait-il ?", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Facultatif : pourquoi cette idée vous semble utile.", MaxLength: 1500),
|
||||
new FeedbackField("importance", "Importance pour vous", FeedbackFieldKind.Choice, Required: false, Options: new[]
|
||||
{
|
||||
O("bonus", "Un petit plus"), O("useful", "Utile"), O("important", "Importante")
|
||||
})
|
||||
}),
|
||||
|
||||
new FeedbackForm(FeedbackKinds.Bug, "Bug", "Signalez un problème rencontré dans le jeu ou le launcher.", "Bug",
|
||||
"Le problème en une phrase", TechByDefault: true, new[]
|
||||
{
|
||||
new FeedbackField("category", "Catégorie", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("crash", "Plantage ou fermeture du jeu"), O("graphics", "Graphismes"), O("sound", "Son"), O("gameplay", "Gameplay"),
|
||||
O("performance", "Performances (lenteurs, saccades)"), O("install", "Installation ou mise à jour"),
|
||||
O("launcher", "Launcher"), O("other", "Autre")
|
||||
}),
|
||||
new FeedbackField("severity", "Gravité", FeedbackFieldKind.Choice, Required: true, ShowInList: true, Options: new[]
|
||||
{
|
||||
O("minor", "Mineur"), O("annoying", "Gênant"), O("blocking", "Bloquant"), O("crash", "Plantage")
|
||||
}),
|
||||
new FeedbackField("frequency", "Fréquence", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("always", "À chaque fois"), O("often", "Souvent"), O("sometimes", "Parfois"), O("once", "Une seule fois")
|
||||
}),
|
||||
new FeedbackField("steps", "Étapes pour reproduire", FeedbackFieldKind.Multiline, Required: true,
|
||||
"1. Je lance une partie…\n2. J'ouvre l'inventaire…"),
|
||||
new FeedbackField("expected", "Résultat attendu", FeedbackFieldKind.Multiline, Required: false,
|
||||
"Ce qui aurait dû se passer.", MaxLength: 1000),
|
||||
new FeedbackField("actual", "Résultat obtenu", FeedbackFieldKind.Multiline, Required: true,
|
||||
"Ce qui s'est passé à la place (message d'erreur…).", MaxLength: 1500)
|
||||
}),
|
||||
|
||||
new FeedbackForm(FeedbackKinds.Other, "Autre", "Une question, une remarque, autre chose.", "Forum",
|
||||
"Le sujet de votre message", TechByDefault: false, new[]
|
||||
{
|
||||
new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true, "Votre message.")
|
||||
})
|
||||
};
|
||||
|
||||
public static FeedbackForm? Find(string? kind) => All.FirstOrDefault(f => f.Kind == kind);
|
||||
|
||||
public static string KindLabel(string? kind) => Find(kind)?.Label ?? kind ?? "?";
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, communs à tous les jeux).
|
||||
/// Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée « forms » dans le
|
||||
/// trousseau : le launcher des joueurs le vérifie comme le catalogue avant d'afficher quoi que ce soit.
|
||||
/// Révision 0 = formulaires d'origine (<see cref="FeedbackForms"/>), jamais publiés.
|
||||
/// </summary>
|
||||
public sealed record FeedbackFormsDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-feedback-forms";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Révision : +1 à chaque publication (couverte par la signature). Envoyée avec chaque demande.</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Types de demande (1 à 8), dans l'ordre d'affichage aux joueurs, masqués ou non.</summary>
|
||||
public List<FeedbackForm> Forms { get; init; } = new();
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
|
||||
public FeedbackForm? Find(string? kind) => (Forms ?? new List<FeedbackForm>()).FirstOrDefault(f => f.Kind == kind);
|
||||
|
||||
/// <summary>Types proposés aux joueurs (non masqués), dans l'ordre du document.</summary>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<FeedbackForm> Visible => (Forms ?? new List<FeedbackForm>()).Where(f => f is not null && !f.Hidden);
|
||||
}
|
||||
|
||||
/// <summary>État des formulaires pour le menu d'administration (GET v1/feedback/forms).</summary>
|
||||
public sealed record FeedbackFormsState
|
||||
{
|
||||
/// <summary>Formulaires en vigueur (ceux d'origine, révision 0, si rien n'a été publié).</summary>
|
||||
public FeedbackFormsDocument Document { get; init; } = new();
|
||||
|
||||
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
|
||||
public bool PublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
|
||||
public string? PublicUrl { get; init; }
|
||||
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication des formulaires (PUT v1/feedback/forms, requête signée).</summary>
|
||||
public sealed record FeedbackFormsPublishRequest
|
||||
{
|
||||
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
/// <summary>Formulaires signés, révision <see cref="BaseRevision"/> + 1.</summary>
|
||||
public FeedbackFormsDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles des formulaires publiés, communes au menu (contrôle avant de signer), au serveur (refus avant publication)
|
||||
/// et au launcher (refus d'un fichier hors limites, même signé). Les textes saisis par l'administrateur doivent être
|
||||
/// déjà propres (une ligne, sans caractère invisible) et ne contiennent AUCUN lien : rien ne doit pouvoir renvoyer le
|
||||
/// joueur vers un site depuis un formulaire.
|
||||
/// </summary>
|
||||
public static partial class FeedbackFormRules
|
||||
{
|
||||
/// <summary>Taille du fichier (icônes importées comprises). Publication : route dédiée de nginx et de l'API.</summary>
|
||||
public const int MaxBytes = 256 * 1024;
|
||||
|
||||
/// <summary>Corps de la requête de publication (formulaires + révision de base).</summary>
|
||||
public const int MaxRequestBytes = MaxBytes + 8 * 1024;
|
||||
|
||||
/// <summary>Nombre de types de demande (au moins un proposé aux joueurs).</summary>
|
||||
public const int MaxForms = 8;
|
||||
|
||||
public const int MaxFields = 15;
|
||||
public const int MinOptions = 2;
|
||||
public const int MaxOptions = 20;
|
||||
public const int MaxFormLabelLength = 40;
|
||||
public const int MaxDescriptionLength = 120;
|
||||
public const int MaxTitleHintLength = 80;
|
||||
public const int MaxFieldLabelLength = 80;
|
||||
public const int MaxHintLength = 200;
|
||||
public const int MaxHintLines = 4;
|
||||
public const int MaxOptionLabelLength = 60;
|
||||
public const int MaxTextLength = 200;
|
||||
public const int DefaultTextLength = 120;
|
||||
public const int MinMultilineLength = 20;
|
||||
|
||||
/// <summary>Nombres d'étoiles proposés pour une note.</summary>
|
||||
public static readonly IReadOnlyList<int> RatingSizes = new[] { 3, 5, 10 };
|
||||
|
||||
/// <summary>Icônes proposées pour un type de demande : les quatre d'origine (sinon icône importée, <see cref="FeedbackIcons"/>).</summary>
|
||||
public static IReadOnlyList<string> Icons => FeedbackIcons.DefaultIcons;
|
||||
|
||||
/// <summary>Identifiant d'un champ ou d'un choix : stable, jamais affiché (clé des valeurs envoyées).</summary>
|
||||
[GeneratedRegex(@"\A[a-z][a-z0-9_]{0,31}\z")]
|
||||
private static partial Regex IdRegex();
|
||||
|
||||
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
|
||||
|
||||
/// <summary>Formulaires d'origine (révision 0).</summary>
|
||||
public static FeedbackFormsDocument Defaults() => new() { Revision = 0, Forms = FeedbackForms.All.ToList() };
|
||||
|
||||
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Lit feedback-forms.json (null : vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static FeedbackFormsDocument? Parse(ReadOnlySpan<byte> json) => JsonSerializer.Deserialize<FeedbackFormsDocument>(json, ApiJson.Options);
|
||||
|
||||
public static string Serialize(FeedbackFormsDocument document) => JsonSerializer.Serialize(document, FileOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Condensé signé : format, version, révision et formulaires (tels quels, dans l'ordre ; valeurs vides ramenées à
|
||||
/// une forme unique). La date de publication et la signature n'en font pas partie.
|
||||
/// </summary>
|
||||
public static string Digest(FeedbackFormsDocument document)
|
||||
{
|
||||
var forms = (document.Forms ?? new List<FeedbackForm>()).Where(f => f is not null).Select(f => f with
|
||||
{
|
||||
CustomIcon = string.IsNullOrEmpty(f.CustomIcon) ? null : f.CustomIcon,
|
||||
Fields = (f.Fields ?? Array.Empty<FeedbackField>()).Where(x => x is not null).Select(x => x with
|
||||
{
|
||||
Hint = string.IsNullOrEmpty(x.Hint) ? null : x.Hint,
|
||||
Options = x.Options is { Count: > 0 } options ? options.ToList() : null
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
var canonical = new CanonicalForms(document.Format ?? "", document.Version, document.Revision, forms);
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
|
||||
}
|
||||
|
||||
private sealed record CanonicalForms(string Format, int Version, long Revision, List<FeedbackForm> Forms);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'un document publié (révision 1 au moins). Les erreurs, en français, désignent le type et le
|
||||
/// champ concernés ; elles sont ajoutées à <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
public static bool Validate(FeedbackFormsDocument? document, List<string> errors)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (document is null)
|
||||
{
|
||||
errors.Add("Formulaires absents.");
|
||||
return false;
|
||||
}
|
||||
if (document.Format != FeedbackFormsDocument.FormatName || document.Version != FeedbackFormsDocument.CurrentVersion)
|
||||
errors.Add($"Format de formulaires non pris en charge ({document.Format} v{document.Version}).");
|
||||
if (document.Revision < 1) errors.Add("Révision des formulaires invalide (1 au moins).");
|
||||
|
||||
var forms = document.Forms ?? new List<FeedbackForm>();
|
||||
if (forms.Count is 0 or > MaxForms || forms.Any(f => f is null))
|
||||
{
|
||||
errors.Add($"Les formulaires doivent comprendre de 1 à {MaxForms} types de demande.");
|
||||
return false;
|
||||
}
|
||||
var kinds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var form in forms)
|
||||
{
|
||||
if (!IsValidId(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant invalide (minuscules, chiffres, « _ »).");
|
||||
else if (!kinds.Add(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant « {form.Kind} » en double.");
|
||||
}
|
||||
if (forms.All(f => f.Hidden)) errors.Add("Au moins un type de demande doit rester proposé aux joueurs.");
|
||||
|
||||
foreach (var form in forms) ValidateForm(form, errors);
|
||||
if (errors.Count == count && Encoding.UTF8.GetByteCount(Serialize(document)) > MaxBytes)
|
||||
errors.Add($"Formulaires trop volumineux (au plus {MaxBytes / 1024} Ko).");
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
private static void ValidateForm(FeedbackForm form, List<string> errors)
|
||||
{
|
||||
var where = $"Type « {form.Label} »";
|
||||
CheckLine(form.Label, MaxFormLabelLength, required: true, $"{where} : nom", errors);
|
||||
CheckLine(form.Description, MaxDescriptionLength, required: true, $"{where} : description", errors);
|
||||
CheckLine(form.TitleHint, MaxTitleHintLength, required: false, $"{where} : exemple de titre", errors);
|
||||
if (!Icons.Contains(form.IconKey, StringComparer.Ordinal)) errors.Add($"{where} : icône inconnue.");
|
||||
if (form.CustomIcon is not null && FeedbackIcons.CheckCustom(form.CustomIcon) is { } iconError) errors.Add($"{where} : {iconError}");
|
||||
|
||||
var fields = form.Fields ?? Array.Empty<FeedbackField>();
|
||||
if (fields.Count > MaxFields) errors.Add($"{where} : {MaxFields} champs au plus.");
|
||||
if (fields.Any(f => f is null))
|
||||
{
|
||||
errors.Add($"{where} : champ vide.");
|
||||
return;
|
||||
}
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var label = $"{where}, champ « {field.Label} »";
|
||||
if (!IsValidId(field.Id)) errors.Add($"{label} : identifiant invalide (minuscules, chiffres, « _ », 32 caractères au plus).");
|
||||
else if (!ids.Add(field.Id)) errors.Add($"{label} : identifiant « {field.Id} » en double.");
|
||||
CheckLine(field.Label, MaxFieldLabelLength, required: true, $"{label} : libellé", errors);
|
||||
CheckHint(field.Hint, $"{label} : aide", errors);
|
||||
if (!Enum.IsDefined(field.Kind)) errors.Add($"{label} : type de champ inconnu.");
|
||||
|
||||
var minLength = field.Kind == FeedbackFieldKind.Multiline ? MinMultilineLength : 1;
|
||||
var maxLength = field.Kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => FeedbackRules.MaxMultilineLength,
|
||||
FeedbackFieldKind.Text => MaxTextLength,
|
||||
_ => FeedbackRules.MaxMultilineLength
|
||||
};
|
||||
if (field.MaxLength < minLength || field.MaxLength > maxLength)
|
||||
errors.Add($"{label} : longueur maximale de {minLength} à {maxLength} caractères.");
|
||||
|
||||
var options = field.Options ?? Array.Empty<FeedbackOption>();
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes)
|
||||
{
|
||||
if (options.Count < MinOptions || options.Count > MaxOptions)
|
||||
errors.Add($"{label} : {MinOptions} à {MaxOptions} choix.");
|
||||
var optionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var option in options)
|
||||
{
|
||||
if (option is null || !IsValidId(option.Id) || !optionIds.Add(option.Id))
|
||||
{
|
||||
errors.Add($"{label} : identifiant de choix invalide ou en double.");
|
||||
continue;
|
||||
}
|
||||
CheckLine(option.Label, MaxOptionLabelLength, required: true, $"{label} : choix « {option.Label} »", errors);
|
||||
}
|
||||
}
|
||||
else if (options.Count > 0)
|
||||
{
|
||||
errors.Add($"{label} : seules les listes et les cases à cocher ont des choix.");
|
||||
}
|
||||
|
||||
if (field.Kind == FeedbackFieldKind.Rating && !RatingSizes.Contains(field.RatingMax))
|
||||
errors.Add($"{label} : nombre d'étoiles invalide ({string.Join(", ", RatingSizes)}).");
|
||||
if (field.ShowInList && field.Kind is not (FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo))
|
||||
errors.Add($"{label} : seules une liste, une note ou une case oui/non peuvent être affichées en aperçu.");
|
||||
}
|
||||
if (fields.Count(f => f.ShowInList) > 1) errors.Add($"{where} : un seul champ peut être affiché en aperçu dans la liste.");
|
||||
}
|
||||
|
||||
/// <summary>Texte d'une ligne déjà propre (tel qu'il sera affiché), sans lien.</summary>
|
||||
private static void CheckLine(string? value, int maxLength, bool required, string what, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (required) errors.Add($"{what} : obligatoire.");
|
||||
return;
|
||||
}
|
||||
if (ApiText.CleanLine(value, maxLength) != value)
|
||||
errors.Add($"{what} : {maxLength} caractères au plus, sur une ligne, sans espace en trop ni caractère spécial invisible.");
|
||||
else if (FeedbackLinks.Contains(value))
|
||||
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
|
||||
}
|
||||
|
||||
/// <summary>Aide d'un champ : quelques lignes déjà propres, sans lien.</summary>
|
||||
private static void CheckHint(string? value, string what, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return;
|
||||
var clean = FeedbackRules.CleanMultiline(value, MaxHintLength, out var problem);
|
||||
if (problem is not null || clean != value || value.Count(c => c == '\n') + 1 > MaxHintLines)
|
||||
errors.Add($"{what} : {MaxHintLength} caractères et {MaxHintLines} lignes au plus, sans caractère spécial invisible.");
|
||||
else if (FeedbackLinks.Contains(value))
|
||||
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mots qui laissent penser qu'un champ demande des données personnelles ou secrètes : AVERTISSEMENT seulement
|
||||
/// dans le menu (pas un refus). Les demandes sont anonymes : ne rien demander qui identifie le joueur.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> PersonalDataWords { get; } = new[]
|
||||
{
|
||||
"mot de passe", "password", "e-mail", "email", "courriel", "adresse", "téléphone", "telephone", "nom complet",
|
||||
"date de naissance", "carte", "iban", "identifiant de connexion", "pseudo steam", "compte"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Icônes des types de demande : l'une des quatre icônes d'origine, ou une icône importée depuis un fichier .ico
|
||||
/// (OBLIGATOIREMENT) contenant une image de 64 × 64 pixels.
|
||||
/// • L'import (menu d'administration) lit le fichier .ico sans bibliothèque : en-tête et répertoire contrôlés, image
|
||||
/// 64 × 64 au format PNG (revérifiée) ou bitmap 32 ou 24 bits (avec son masque de transparence) ; tout le reste est
|
||||
/// refusé. L'image est réécrite en PNG 64 × 64 neuf, sans aucune métadonnée.
|
||||
/// • Le PNG obtenu est intégré (base64) aux formulaires SIGNÉS : le serveur et les launchers le revérifient
|
||||
/// (<see cref="CheckCustom"/> : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé).
|
||||
/// </summary>
|
||||
public static class FeedbackIcons
|
||||
{
|
||||
/// <summary>Côté de l'image attendue dans le fichier .ico et de l'icône publiée.</summary>
|
||||
public const int Size = 64;
|
||||
|
||||
/// <summary>Taille d'un fichier .ico accepté à l'import.</summary>
|
||||
public const int MaxIcoBytes = 1024 * 1024;
|
||||
|
||||
/// <summary>Taille de l'icône publiée (PNG).</summary>
|
||||
public const int MaxPngBytes = 20 * 1024;
|
||||
|
||||
/// <summary>Les quatre icônes d'origine des types de demande (ressources « Icon.<clé> » du launcher).</summary>
|
||||
public static readonly IReadOnlyList<string> DefaultIcons = new[] { "Star", "Lightbulb", "Bug", "Forum" };
|
||||
|
||||
public static string DefaultIconLabel(string? key) => key switch
|
||||
{
|
||||
"Star" => "Étoile",
|
||||
"Lightbulb" => "Ampoule",
|
||||
"Bug" => "Insecte (bug)",
|
||||
"Forum" => "Bulles de discussion",
|
||||
_ => key ?? ""
|
||||
};
|
||||
|
||||
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
/// <summary>
|
||||
/// Convertit un fichier .ico en icône publiable (PNG 64 × 64 sans métadonnées). Renvoie null si tout va bien, sinon
|
||||
/// la raison du refus (française, lisible).
|
||||
/// </summary>
|
||||
public static string? FromIco(ReadOnlySpan<byte> ico, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (ico.Length > MaxIcoBytes) return $"Fichier trop volumineux (au plus {MaxIcoBytes / 1024} Ko).";
|
||||
// ICONDIR : réservé = 0, type = 1 (icône), nombre d'images.
|
||||
if (ico.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(ico) != 0 || BinaryPrimitives.ReadUInt16LittleEndian(ico[2..]) != 1)
|
||||
return "Ce fichier n'est pas une icône .ico.";
|
||||
var count = BinaryPrimitives.ReadUInt16LittleEndian(ico[4..]);
|
||||
if (count is 0 or > 64 || ico.Length < 6 + count * 16) return "Icône .ico endommagée (répertoire des images).";
|
||||
|
||||
var sizes = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entry = ico.Slice(6 + i * 16, 16);
|
||||
var width = entry[0] == 0 ? 256 : entry[0];
|
||||
var height = entry[1] == 0 ? 256 : entry[1];
|
||||
sizes.Add($"{width} × {height}");
|
||||
if (width != Size || height != Size) continue;
|
||||
|
||||
var length = BinaryPrimitives.ReadUInt32LittleEndian(entry[8..]);
|
||||
var offset = BinaryPrimitives.ReadUInt32LittleEndian(entry[12..]);
|
||||
if (length == 0 || offset < 6 + (uint)count * 16 || (ulong)offset + length > (ulong)ico.Length)
|
||||
return "Icône .ico endommagée (image 64 × 64 hors du fichier).";
|
||||
var image = ico.Slice((int)offset, (int)length);
|
||||
return image.StartsWith(PngSignature) ? FromPng(image, out png) : FromBitmap(image, out png);
|
||||
}
|
||||
return $"Le fichier .ico doit contenir une image de {Size} × {Size} pixels (images trouvées : {string.Join(", ", sizes.Distinct())}).";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Icône publiée (base64) contrôlée : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé (aucune
|
||||
/// métadonnée). Null si elle est acceptable, sinon la raison du refus.
|
||||
/// </summary>
|
||||
public static string? CheckCustom(string? base64)
|
||||
{
|
||||
var bytes = Decode(base64);
|
||||
if (bytes is null) return "Icône importée illisible.";
|
||||
if (bytes.Length > MaxPngBytes) return $"Icône importée trop volumineuse (au plus {MaxPngBytes / 1024} Ko).";
|
||||
if (FeedbackImages.Sanitize(bytes, out var info, out var clean) is { } error) return $"Icône importée refusée : {error}";
|
||||
if (info!.Format != FeedbackImages.Png || info.Width != Size || info.Height != Size)
|
||||
return $"L'icône importée doit être une image PNG de {Size} × {Size} pixels.";
|
||||
if (!clean!.AsSpan().SequenceEqual(bytes)) return "Icône importée non nettoyée (métadonnées).";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Octets d'une icône publiée (null : absente ou base64 invalide).</summary>
|
||||
public static byte[]? Decode(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64) || base64.Length > MaxPngBytes * 2) return null;
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images de l'icône
|
||||
|
||||
/// <summary>Image PNG de l'icône : revérifiée et nettoyée (métadonnées retirées), 64 × 64 exigé.</summary>
|
||||
private static string? FromPng(ReadOnlySpan<byte> image, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (FeedbackImages.Sanitize(image, out var info, out var clean) is { } error) return $"Image 64 × 64 de l'icône refusée : {error}";
|
||||
if (info!.Width != Size || info.Height != Size) return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
|
||||
if (clean!.Length > MaxPngBytes) return $"Image de l'icône trop volumineuse (au plus {MaxPngBytes / 1024} Ko une fois nettoyée).";
|
||||
png = clean;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image bitmap de l'icône (BITMAPINFOHEADER, sans compression, 32 ou 24 bits) : pixels lus ligne par ligne (de bas
|
||||
/// en haut), transparence prise du canal alpha (32 bits) ou du masque 1 bit, puis réécrits en PNG neuf.
|
||||
/// </summary>
|
||||
private static string? FromBitmap(ReadOnlySpan<byte> image, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (image.Length < 40 || BinaryPrimitives.ReadUInt32LittleEndian(image) != 40)
|
||||
return "Image de l'icône dans un format non pris en charge (PNG ou bitmap 24/32 bits seulement).";
|
||||
var width = BinaryPrimitives.ReadInt32LittleEndian(image[4..]);
|
||||
var height = BinaryPrimitives.ReadInt32LittleEndian(image[8..]);
|
||||
var planes = BinaryPrimitives.ReadUInt16LittleEndian(image[12..]);
|
||||
var bits = BinaryPrimitives.ReadUInt16LittleEndian(image[14..]);
|
||||
var compression = BinaryPrimitives.ReadUInt32LittleEndian(image[16..]);
|
||||
if (width != Size || height != Size * 2 || planes != 1)
|
||||
return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
|
||||
if (compression != 0 || bits is not (32 or 24))
|
||||
return "Image de l'icône en 256 couleurs ou moins : enregistrez l'icône en couleurs 24 ou 32 bits (ou PNG).";
|
||||
|
||||
var stride = (Size * bits / 8 + 3) & ~3;
|
||||
var maskStride = (Size + 31) / 32 * 4;
|
||||
var pixels = image[40..];
|
||||
if (pixels.Length < stride * Size) return "Icône .ico endommagée (image incomplète).";
|
||||
var mask = pixels.Length >= stride * Size + maskStride * Size ? pixels.Slice(stride * Size, maskStride * Size) : ReadOnlySpan<byte>.Empty;
|
||||
|
||||
var rgba = new byte[Size * Size * 4];
|
||||
var hasAlpha = false;
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var row = pixels.Slice((Size - 1 - y) * stride, stride); // lignes de bas en haut
|
||||
for (var x = 0; x < Size; x++)
|
||||
{
|
||||
var source = x * (bits / 8);
|
||||
var target = (y * Size + x) * 4;
|
||||
rgba[target] = row[source + 2];
|
||||
rgba[target + 1] = row[source + 1];
|
||||
rgba[target + 2] = row[source];
|
||||
rgba[target + 3] = bits == 32 ? row[source + 3] : (byte)255;
|
||||
if (bits == 32 && row[source + 3] != 0) hasAlpha = true;
|
||||
}
|
||||
}
|
||||
// Pas de canal alpha utilisable : transparence donnée par le masque (bit à 1 = transparent).
|
||||
if (!hasAlpha && !mask.IsEmpty)
|
||||
{
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var row = mask.Slice((Size - 1 - y) * maskStride, maskStride);
|
||||
for (var x = 0; x < Size; x++)
|
||||
rgba[(y * Size + x) * 4 + 3] = (row[x / 8] & (0x80 >> (x % 8))) != 0 ? (byte)0 : (byte)255;
|
||||
}
|
||||
}
|
||||
else if (!hasAlpha)
|
||||
{
|
||||
for (var i = 3; i < rgba.Length; i += 4) rgba[i] = 255;
|
||||
}
|
||||
|
||||
var encoded = EncodePng(rgba);
|
||||
// Contrôle final avec les règles des images (comme toute icône publiée).
|
||||
if (FeedbackImages.Sanitize(encoded, out _, out var clean) is { } error) return $"Conversion de l'icône impossible : {error}";
|
||||
if (clean!.Length > MaxPngBytes) return $"Icône trop détaillée (au plus {MaxPngBytes / 1024} Ko une fois convertie en PNG).";
|
||||
png = clean;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PNG 64 × 64 RVBA (8 bits par canal, sans entrelacement, filtre « Sub »), sans aucun bloc facultatif.</summary>
|
||||
private static byte[] EncodePng(byte[] rgba)
|
||||
{
|
||||
var raw = new byte[Size * (1 + Size * 4)];
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var rowStart = y * (1 + Size * 4);
|
||||
raw[rowStart] = 1; // Sub : meilleure compression des aplats
|
||||
for (var i = 0; i < Size * 4; i++)
|
||||
{
|
||||
var value = rgba[y * Size * 4 + i];
|
||||
var left = i >= 4 ? rgba[y * Size * 4 + i - 4] : (byte)0;
|
||||
raw[rowStart + 1 + i] = unchecked((byte)(value - left));
|
||||
}
|
||||
}
|
||||
byte[] compressed;
|
||||
using (var buffer = new MemoryStream())
|
||||
{
|
||||
using (var zlib = new ZLibStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true)) zlib.Write(raw);
|
||||
compressed = buffer.ToArray();
|
||||
}
|
||||
|
||||
using var png = new MemoryStream();
|
||||
png.Write(PngSignature);
|
||||
var header = new byte[13];
|
||||
BinaryPrimitives.WriteInt32BigEndian(header, Size);
|
||||
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), Size);
|
||||
header[8] = 8; // bits par canal
|
||||
header[9] = 6; // RVBA
|
||||
WriteChunk(png, "IHDR", header);
|
||||
WriteChunk(png, "IDAT", compressed);
|
||||
WriteChunk(png, "IEND", Array.Empty<byte>());
|
||||
return png.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteChunk(Stream stream, string type, byte[] body)
|
||||
{
|
||||
Span<byte> number = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(number, body.Length);
|
||||
stream.Write(number);
|
||||
var typeBytes = System.Text.Encoding.ASCII.GetBytes(type);
|
||||
stream.Write(typeBytes);
|
||||
stream.Write(body);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(number, Crc32(typeBytes, body));
|
||||
stream.Write(number);
|
||||
}
|
||||
|
||||
private static readonly uint[] CrcTable = Enumerable.Range(0, 256).Select(n =>
|
||||
{
|
||||
var c = (uint)n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
||||
return c;
|
||||
}).ToArray();
|
||||
|
||||
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var c = 0xFFFFFFFFu;
|
||||
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
return c ^ 0xFFFFFFFFu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Captures d'écran jointes à une demande : contrôle ET nettoyage, mêmes règles au launcher (avant l'envoi), au serveur
|
||||
/// (refus de tout fichier non conforme) et dans le menu d'administration (avant l'affichage).
|
||||
/// • <b>PNG ou JPEG uniquement</b>, reconnus à leur contenu, jamais à l'extension. Le launcher convertit les autres
|
||||
/// formats courants (BMP, GIF, WebP) en PNG avant l'envoi ;
|
||||
/// • <b>vraie image</b> : structure complète vérifiée. PNG : blocs et CRC, en-tête cohérent, palette si nécessaire,
|
||||
/// données décompressées de la taille exacte de l'image, filtres de lignes valides. JPEG : segments, tables,
|
||||
/// une trame baseline ou progressive, scans et fin d'image ;
|
||||
/// • dimensions bornées (<see cref="MaxSide"/>, <see cref="MaxPixels"/>), taille bornée (<see cref="MaxBytes"/>),
|
||||
/// aucune donnée après la fin de l'image, décompression interrompue au-delà de la taille attendue (bombe zlib) ;
|
||||
/// • <b>métadonnées retirées</b> : PNG : blocs texte, eXIf, date… (seuls IHDR, PLTE, tRNS, IDAT, IEND sont
|
||||
/// gardés) ; JPEG : EXIF (position GPS, appareil…), XMP, commentaires… (seuls JFIF et Adobe sont gardés).
|
||||
/// </summary>
|
||||
public static class FeedbackImages
|
||||
{
|
||||
public const int MaxAttachments = 3;
|
||||
public const int MaxBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Corps d'une requête d'envoi d'une capture (image + marge) ; voir nginx-api.conf.</summary>
|
||||
public const int MaxRequestBytes = MaxBytes + 16 * 1024;
|
||||
|
||||
public const int MinSide = 16;
|
||||
public const int MaxSide = 4096;
|
||||
public const long MaxPixels = 4096L * 2304;
|
||||
|
||||
/// <summary>Le launcher réduit les captures plus grandes à cette taille (côté le plus long) avant l'envoi.</summary>
|
||||
public const int TargetMaxSide = 3840;
|
||||
|
||||
public const string Png = "png";
|
||||
public const string Jpeg = "jpg";
|
||||
|
||||
/// <summary>Formats acceptés en entrée par le launcher (convertis en PNG/JPEG avant l'envoi).</summary>
|
||||
public static readonly IReadOnlyList<string> InputExtensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
|
||||
|
||||
public sealed record ImageInfo(string Format, int Width, int Height);
|
||||
|
||||
/// <summary>Format reconnu au contenu parmi les formats d'entrée du launcher (null : autre chose).</summary>
|
||||
public static string? DetectInputFormat(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.StartsWith(PngSignature)) return Png;
|
||||
if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) return Jpeg;
|
||||
if (data.Length >= 6 && (data[..6].SequenceEqual("GIF87a"u8) || data[..6].SequenceEqual("GIF89a"u8))) return "gif";
|
||||
if (data.Length >= 14 && data[0] == (byte)'B' && data[1] == (byte)'M') return "bmp";
|
||||
if (data.Length >= 12 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) return "webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet et nettoyage. Null si l'image est acceptée (<paramref name="clean"/> : l'image sans
|
||||
/// métadonnées, à enregistrer ou afficher à la place de l'original), sinon la raison du refus (français).
|
||||
/// </summary>
|
||||
public static string? Sanitize(ReadOnlySpan<byte> data, out ImageInfo? info, out byte[]? clean)
|
||||
{
|
||||
info = null;
|
||||
clean = null;
|
||||
if (data.Length == 0) return "Fichier vide.";
|
||||
if (data.Length > MaxBytes) return $"Image trop volumineuse : {MaxBytes / 1024 / 1024} Mo au plus.";
|
||||
string? error;
|
||||
if (data.StartsWith(PngSignature)) (info, clean, error) = SanitizePng(data);
|
||||
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) (info, clean, error) = SanitizeJpeg(data);
|
||||
else return "Ce fichier n'est pas une image PNG ou JPEG.";
|
||||
if (error is not null)
|
||||
{
|
||||
info = null;
|
||||
clean = null;
|
||||
return error;
|
||||
}
|
||||
if (info!.Width < MinSide || info.Height < MinSide || info.Width > MaxSide || info.Height > MaxSide ||
|
||||
(long)info.Width * info.Height > MaxPixels)
|
||||
{
|
||||
var refused = info;
|
||||
info = null;
|
||||
clean = null;
|
||||
return $"Dimensions refusées : {refused.Width} × {refused.Height} px ({MinSide} à {MaxSide} px de côté).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ PNG
|
||||
|
||||
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
private static (ImageInfo?, byte[]?, string?) SanitizePng(ReadOnlySpan<byte> data)
|
||||
{
|
||||
const string invalid = "Image PNG invalide ou incomplète.";
|
||||
var output = new MemoryStream(data.Length);
|
||||
output.Write(PngSignature);
|
||||
var idat = new MemoryStream();
|
||||
int width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
|
||||
bool header = false, palette = false, ended = false, idatDone = false, inIdat = false;
|
||||
var pos = 8;
|
||||
while (pos < data.Length)
|
||||
{
|
||||
if (ended) return (null, null, "Image PNG suivie de données étrangères à l'image.");
|
||||
if (pos + 12 > data.Length) return (null, null, invalid);
|
||||
var length = BinaryPrimitives.ReadUInt32BigEndian(data[pos..]);
|
||||
if (length > int.MaxValue || pos + 12 + (long)length > data.Length) return (null, null, invalid);
|
||||
var type = data.Slice(pos + 4, 4);
|
||||
foreach (var b in type)
|
||||
if (b is not ((>= (byte)'A' and <= (byte)'Z') or (>= (byte)'a' and <= (byte)'z'))) return (null, null, invalid);
|
||||
var body = data.Slice(pos + 8, (int)length);
|
||||
var crc = BinaryPrimitives.ReadUInt32BigEndian(data[(pos + 8 + (int)length)..]);
|
||||
if (Crc32(type, body) != crc) return (null, null, "Image PNG endommagée (somme de contrôle incorrecte).");
|
||||
var chunk = data.Slice(pos, 12 + (int)length);
|
||||
pos += 12 + (int)length;
|
||||
|
||||
var name = System.Text.Encoding.ASCII.GetString(type);
|
||||
if (!header && name != "IHDR") return (null, null, invalid);
|
||||
if (inIdat && name != "IDAT") idatDone = true;
|
||||
inIdat = false;
|
||||
switch (name)
|
||||
{
|
||||
case "IHDR":
|
||||
if (header || length != 13) return (null, null, invalid);
|
||||
header = true;
|
||||
var w = BinaryPrimitives.ReadUInt32BigEndian(body);
|
||||
var h = BinaryPrimitives.ReadUInt32BigEndian(body[4..]);
|
||||
if (w is 0 or > MaxSide || h is 0 or > MaxSide)
|
||||
return (null, null, $"Dimensions refusées ({MinSide} à {MaxSide} px de côté).");
|
||||
(width, height, depth, colorType, interlace) = ((int)w, (int)h, body[8], body[9], body[12]);
|
||||
var validDepth = colorType switch
|
||||
{
|
||||
0 => depth is 1 or 2 or 4 or 8 or 16,
|
||||
3 => depth is 1 or 2 or 4 or 8,
|
||||
2 or 4 or 6 => depth is 8 or 16,
|
||||
_ => false
|
||||
};
|
||||
if (!validDepth || body[10] != 0 || body[11] != 0 || interlace > 1) return (null, null, invalid);
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "PLTE":
|
||||
if (palette || idat.Length > 0 || length == 0 || length % 3 != 0 || length > 768 || colorType is 0 or 4)
|
||||
return (null, null, invalid);
|
||||
palette = true;
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "tRNS":
|
||||
if (idat.Length > 0) return (null, null, invalid);
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "IDAT":
|
||||
if (idatDone) return (null, null, invalid); // blocs IDAT non consécutifs
|
||||
inIdat = true;
|
||||
idat.Write(body);
|
||||
output.Write(chunk);
|
||||
break;
|
||||
case "IEND":
|
||||
if (length != 0 || idat.Length == 0) return (null, null, invalid);
|
||||
ended = true;
|
||||
output.Write(chunk);
|
||||
break;
|
||||
default:
|
||||
// Bloc critique inconnu (majuscule) : image non standard, refusée. Bloc auxiliaire (texte, eXIf,
|
||||
// date, profil…) : retiré.
|
||||
if (char.IsUpper(name[0])) return (null, null, "Image PNG non standard.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ended) return (null, null, "Image PNG incomplète.");
|
||||
if (colorType == 3 && !palette) return (null, null, invalid);
|
||||
|
||||
var channels = colorType switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, _ => 4 };
|
||||
var bitsPerPixel = channels * depth;
|
||||
if (!CheckPixels(idat.ToArray(), width, height, bitsPerPixel, interlace == 1))
|
||||
return (null, null, "Image PNG endommagée (données de l'image incorrectes).");
|
||||
return (new ImageInfo(Png, width, height), output.ToArray(), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Décompresse les données (sans dépasser la taille attendue + 1 octet) et vérifie qu'elles ont exactement la taille
|
||||
/// de l'image, ligne par ligne, avec un type de filtre valide (0 à 4) au début de chaque ligne.
|
||||
/// </summary>
|
||||
private static bool CheckPixels(byte[] compressed, int width, int height, int bitsPerPixel, bool interlaced)
|
||||
{
|
||||
var rows = new List<long>(); // octets par ligne (filtre compris), dans l'ordre
|
||||
if (!interlaced) AddRows(rows, width, height, bitsPerPixel);
|
||||
else
|
||||
{
|
||||
int[] x0 = { 0, 4, 0, 2, 0, 1, 0 }, y0 = { 0, 0, 4, 0, 2, 0, 1 }, dx = { 8, 8, 4, 4, 2, 2, 1 }, dy = { 8, 8, 8, 4, 4, 2, 2 };
|
||||
for (var p = 0; p < 7; p++)
|
||||
{
|
||||
var pw = width > x0[p] ? (width - x0[p] + dx[p] - 1) / dx[p] : 0;
|
||||
var ph = height > y0[p] ? (height - y0[p] + dy[p] - 1) / dy[p] : 0;
|
||||
if (pw > 0 && ph > 0) AddRows(rows, pw, ph, bitsPerPixel);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var zlib = new ZLibStream(new MemoryStream(compressed, writable: false), CompressionMode.Decompress);
|
||||
var buffer = new byte[64 * 1024];
|
||||
var row = 0;
|
||||
long left = rows.Count > 0 ? rows[0] : 0; // octets restants dans la ligne en cours
|
||||
int read;
|
||||
while ((read = zlib.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
for (var i = 0; i < read;)
|
||||
{
|
||||
if (row >= rows.Count) return false; // plus de données que l'image
|
||||
if (left == rows[row] && buffer[i] > 4) return false; // type de filtre de la ligne
|
||||
var take = (int)Math.Min(left, read - i);
|
||||
i += take;
|
||||
left -= take;
|
||||
if (left == 0 && ++row < rows.Count) left = rows[row];
|
||||
}
|
||||
}
|
||||
return row == rows.Count;
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRows(List<long> rows, int width, int height, int bitsPerPixel)
|
||||
{
|
||||
var bytesPerRow = 1 + ((long)width * bitsPerPixel + 7) / 8;
|
||||
for (var y = 0; y < height; y++) rows.Add(bytesPerRow);
|
||||
}
|
||||
|
||||
private static readonly uint[] CrcTable = BuildCrcTable();
|
||||
|
||||
private static uint[] BuildCrcTable()
|
||||
{
|
||||
var table = new uint[256];
|
||||
for (uint n = 0; n < 256; n++)
|
||||
{
|
||||
var c = n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var c = 0xFFFFFFFFu;
|
||||
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
return c ^ 0xFFFFFFFFu;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ JPEG
|
||||
|
||||
private static (ImageInfo?, byte[]?, string?) SanitizeJpeg(ReadOnlySpan<byte> data)
|
||||
{
|
||||
const string invalid = "Image JPEG invalide ou incomplète.";
|
||||
var output = new MemoryStream(data.Length);
|
||||
output.Write(data[..2]); // SOI
|
||||
int width = 0, height = 0, scans = 0;
|
||||
bool frame = false, tables = false, ended = false;
|
||||
var pos = 2;
|
||||
while (pos < data.Length)
|
||||
{
|
||||
if (ended) return (null, null, "Image JPEG suivie de données étrangères à l'image.");
|
||||
if (pos + 2 > data.Length || data[pos] != 0xFF) return (null, null, invalid);
|
||||
var marker = data[pos + 1];
|
||||
if (marker == 0xFF)
|
||||
{
|
||||
pos++; // octet de remplissage
|
||||
continue;
|
||||
}
|
||||
if (marker == 0xD9)
|
||||
{
|
||||
if (!frame || scans == 0) return (null, null, invalid);
|
||||
output.Write(data.Slice(pos, 2));
|
||||
pos += 2;
|
||||
ended = true;
|
||||
continue;
|
||||
}
|
||||
if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) return (null, null, invalid);
|
||||
if (pos + 4 > data.Length) return (null, null, invalid);
|
||||
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]);
|
||||
if (length < 2 || pos + 2 + length > data.Length) return (null, null, invalid);
|
||||
var segment = data.Slice(pos, 2 + length);
|
||||
var body = data.Slice(pos + 4, length - 2);
|
||||
pos += 2 + length;
|
||||
|
||||
switch (marker)
|
||||
{
|
||||
case 0xE0: // APP0 (JFIF) : gardé
|
||||
case 0xEE: // APP14 (Adobe : transformation des couleurs) : gardé
|
||||
output.Write(segment);
|
||||
break;
|
||||
case >= 0xE1 and <= 0xEF: // APP1 à APP15 : EXIF (position, appareil), XMP, profils… : retirés
|
||||
case 0xFE: // commentaire : retiré
|
||||
break;
|
||||
case 0xDB: // tables de quantification
|
||||
case 0xC4: // tables de Huffman
|
||||
case 0xDD: // intervalle de resynchronisation
|
||||
if (marker != 0xDD) tables = true;
|
||||
output.Write(segment);
|
||||
break;
|
||||
case 0xC0 or 0xC1 or 0xC2: // trame baseline, étendue ou progressive (Huffman)
|
||||
if (frame || body.Length < 6) return (null, null, invalid);
|
||||
frame = true;
|
||||
if (body[0] != 8) return (null, null, "Image JPEG non prise en charge (précision).");
|
||||
height = BinaryPrimitives.ReadUInt16BigEndian(body[1..]);
|
||||
width = BinaryPrimitives.ReadUInt16BigEndian(body[3..]);
|
||||
var components = body[5];
|
||||
if (width == 0 || height == 0 || components is not (1 or 3) || body.Length != 6 + 3 * components)
|
||||
return (null, null, invalid);
|
||||
output.Write(segment);
|
||||
break;
|
||||
case 0xDA: // début d'un scan : en-tête, puis données compressées jusqu'au marqueur suivant
|
||||
if (!frame || !tables || body.Length < 6) return (null, null, invalid);
|
||||
scans++;
|
||||
output.Write(segment);
|
||||
var start = pos;
|
||||
while (true)
|
||||
{
|
||||
if (pos + 1 >= data.Length) return (null, null, "Image JPEG incomplète.");
|
||||
if (data[pos] == 0xFF && data[pos + 1] != 0x00 && data[pos + 1] is not (>= 0xD0 and <= 0xD7) && data[pos + 1] != 0xFF)
|
||||
break;
|
||||
pos++;
|
||||
}
|
||||
if (pos == start) return (null, null, invalid); // scan vide
|
||||
output.Write(data[start..pos]);
|
||||
break;
|
||||
default:
|
||||
// Autres trames (sans perte, arithmétiques, hiérarchiques), marqueurs réservés : refusés.
|
||||
return (null, null, "Image JPEG non prise en charge.");
|
||||
}
|
||||
}
|
||||
if (!ended) return (null, null, "Image JPEG incomplète.");
|
||||
return (new ImageInfo(Jpeg, width, height), output.ToArray(), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Repère les liens et adresses dans le texte d'une demande (envoyé par un inconnu) pour avertir l'administrateur
|
||||
/// qui la lit : un lien peut mener à un site d'hameçonnage ou à un fichier malveillant. Rien n'est jamais rendu
|
||||
/// cliquable ; l'avertissement montre les liens « désamorcés » (<see cref="Defang"/>).
|
||||
///
|
||||
/// Formes reconnues, y compris déguisées (« hxxp », « http[:]// », « [.] », « (dot) », caractères pleine chasse) :
|
||||
/// • adresses avec schéma : http://, https://, ftp://, file://, ws://… et toute forme « schéma:// » ;
|
||||
/// • schémas dangereux sans « // » : javascript:, data:, vbscript:, file:, mailto:, ms-…:, search-ms:, steam:… ;
|
||||
/// • « www. » suivi d'un nom ;
|
||||
/// • noms de domaine nus avec une extension courante (« exemple.com/page », « bit.ly/x », « discord.gg/… ») ;
|
||||
/// • adresses IP (avec port ou chemin éventuels) ; chemins réseau Windows (\\serveur\partage) ;
|
||||
/// • adresses e-mail.
|
||||
/// </summary>
|
||||
public static partial class FeedbackLinks
|
||||
{
|
||||
/// <summary>Liens rapportés au plus (le nombre total reste exact).</summary>
|
||||
public const int MaxReported = 10;
|
||||
|
||||
// « . » collé, ou déguisé : « [.] », « (.) », « {.} », « [dot] », « (dot) » (espaces permis autour), « dot » entre espaces.
|
||||
private const string Dot = @"(?:\.|\s*(?:\[\.\]|\(\.\)|\{\.\}|\[dot\]|\(dot\))\s*|\s+dot\s+)";
|
||||
|
||||
private const string Tlds =
|
||||
"com|net|org|info|biz|xyz|top|site|online|app|dev|io|gg|co|me|tv|cc|ly|to|ai|gl|link|click|live|shop|store|" +
|
||||
"page|zip|mov|pw|tk|ml|ga|cf|gq|su|ru|cn|fr|be|ch|ca|de|uk|eu|us|es|it|nl|pl|pt|br|in|jp|kr|au|io|lu|re|tech|" +
|
||||
"space|website|club|fun|icu|win|bid|work|ink|lol|zone|cloud|download|stream|games|game|social|chat|email";
|
||||
|
||||
[GeneratedRegex(@"\b[a-z][a-z0-9+.\-]{0,20}\s*(?::|\[:\])\s*(?:/|\\){2}\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SchemeUrl();
|
||||
|
||||
[GeneratedRegex(@"\b(?:javascript|vbscript|data|file|mailto|steam|discord|tel|sms|skype|magnet|ms-[a-z\-]+|search-ms|shell)\s*:\s*\S+",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex DangerousScheme();
|
||||
|
||||
[GeneratedRegex(@"\bh(?:xx|\*\*|XX)ps?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex DisguisedHttp();
|
||||
|
||||
[GeneratedRegex(@"\bwww\d{0,3}\s*" + Dot + @"\s*[a-z0-9\-]+\S*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Www();
|
||||
|
||||
[GeneratedRegex(@"\b[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?(?:" + Dot + @"[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*" + Dot +
|
||||
"(?:" + Tlds + @")\b(?:[/:?#]\S*)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex BareDomain();
|
||||
|
||||
[GeneratedRegex(@"\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:" + Dot + @"(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b(?::\d{1,5})?(?:/\S*)?",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex IpAddress();
|
||||
|
||||
[GeneratedRegex(@"\\\\[a-z0-9.\-_$]+\\\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex UncPath();
|
||||
|
||||
[GeneratedRegex(@"\b[a-z0-9._%+\-]+\s*(?:@|\[at\]|\(at\))\s*[a-z0-9\-]+(?:" + Dot + @"[a-z0-9\-]+)+\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Email();
|
||||
|
||||
/// <summary>Le texte contient-il au moins un lien ou une adresse ?</summary>
|
||||
public static bool Contains(string? text) => Find(text).Count > 0;
|
||||
|
||||
/// <summary>Liens et adresses trouvés (sans doublon, dans l'ordre d'apparition, <see cref="MaxReported"/> au plus).</summary>
|
||||
public static IReadOnlyList<string> Find(string? text) => Find(text, out _);
|
||||
|
||||
public static IReadOnlyList<string> Find(string? text, out int total)
|
||||
{
|
||||
total = 0;
|
||||
if (string.IsNullOrWhiteSpace(text)) return Array.Empty<string>();
|
||||
var normalized = Normalize(text);
|
||||
var found = new List<(int Start, int End, string Value)>();
|
||||
|
||||
void Collect(Regex regex)
|
||||
{
|
||||
foreach (Match match in regex.Matches(normalized))
|
||||
{
|
||||
var value = TrimEnd(match.Value);
|
||||
if (value.Length == 0) continue;
|
||||
var end = match.Index + value.Length;
|
||||
if (found.Any(f => match.Index < f.End && end > f.Start)) continue; // déjà couvert par un motif plus large
|
||||
found.Add((match.Index, end, value));
|
||||
}
|
||||
}
|
||||
|
||||
Collect(SchemeUrl());
|
||||
Collect(DangerousScheme());
|
||||
Collect(UncPath());
|
||||
Collect(Email());
|
||||
Collect(Www());
|
||||
Collect(IpAddress());
|
||||
Collect(BareDomain());
|
||||
Collect(DisguisedHttp());
|
||||
|
||||
var distinct = found.OrderBy(f => f.Start).Select(f => f.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
total = distinct.Count;
|
||||
return distinct.Take(MaxReported).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forme « désamorcée » à afficher (« hxxps[://]exemple[.]com ») : ni cliquable, ni ouverte par un copier-coller
|
||||
/// distrait dans la barre d'adresse.
|
||||
/// </summary>
|
||||
public static string Defang(string link)
|
||||
{
|
||||
var text = link.Length > 120 ? link[..117] + "…" : link;
|
||||
text = Regex.Replace(text, @"(?i)\bhttp", "hxxp");
|
||||
text = Regex.Replace(text, @"(?i)\bftp", "fxp");
|
||||
return text.Replace("://", "[://]").Replace(".", "[.]").Replace("@", "[@]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caractères pleine chasse (« https:// ») ramenés à leur équivalent ASCII, et « ⁄ ∕ / » à « / » : les
|
||||
/// déguisements les plus courants ne passent pas inaperçus. (Pas de normalisation Unicode complète : indisponible
|
||||
/// sur le serveur en mode « culture invariante » ; les caractères invisibles sont déjà refusés à l'envoi.)
|
||||
/// </summary>
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
var sb = new StringBuilder(text.Length);
|
||||
foreach (var c in text)
|
||||
sb.Append(c switch
|
||||
{
|
||||
>= '!' and <= '~' => (char)(c - 0xFEE0),
|
||||
'。' or '。' or '․' => '.', // points idéographique, demi-chasse, « point de conduite »
|
||||
'⁄' or '∕' => '/',
|
||||
_ => c
|
||||
});
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Ponctuation de fin de phrase retirée ; une parenthèse ou un crochet fermant seulement s'il n'est pas ouvert dans le lien.</summary>
|
||||
private static string TrimEnd(string value)
|
||||
{
|
||||
while (value.Length > 0)
|
||||
{
|
||||
var last = value[^1];
|
||||
var trim = last switch
|
||||
{
|
||||
'.' or ',' or ';' or ':' or '!' or '?' or '»' or '"' or '\'' or '>' or '…' => true,
|
||||
')' => value.Count(c => c == '(') < value.Count(c => c == ')'),
|
||||
']' => value.Count(c => c == '[') < value.Count(c => c == ']'),
|
||||
'}' => value.Count(c => c == '{') < value.Count(c => c == '}'),
|
||||
_ => false
|
||||
};
|
||||
if (!trim) break;
|
||||
value = value[..^1];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Preuve de travail anti-spam des demandes anonymes : trouver une solution (nombre) telle que
|
||||
/// SHA-256(domaine ‖ nonce ‖ solution) commence par <c>difficulty</c> bits à zéro. Environ 2^difficulty essais
|
||||
/// (une fraction de seconde pour un joueur, coûteux pour un robot qui veut en envoyer des milliers) ; la
|
||||
/// vérification par le serveur ne coûte qu'une empreinte. Aucun service tiers, aucun captcha.
|
||||
/// </summary>
|
||||
public static class FeedbackProof
|
||||
{
|
||||
public const int MinDifficulty = 8;
|
||||
public const int MaxDifficulty = 26;
|
||||
|
||||
/// <summary>Solution : nombre décimal (20 chiffres au plus).</summary>
|
||||
public const int MaxSolutionLength = 20;
|
||||
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Proof/v1\n");
|
||||
|
||||
/// <summary>Bits à zéro en tête de l'empreinte de cette solution.</summary>
|
||||
public static int LeadingZeroBits(ReadOnlySpan<byte> nonce, ulong solution)
|
||||
{
|
||||
Span<byte> data = stackalloc byte[Domain.Length + nonce.Length + 8];
|
||||
Domain.CopyTo(data);
|
||||
nonce.CopyTo(data[Domain.Length..]);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(data[(Domain.Length + nonce.Length)..], solution);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
var bits = 0;
|
||||
foreach (var b in hash)
|
||||
{
|
||||
if (b == 0)
|
||||
{
|
||||
bits += 8;
|
||||
continue;
|
||||
}
|
||||
bits += System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
|
||||
break;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
public static bool Verify(ReadOnlySpan<byte> nonce, int difficulty, string? solution) =>
|
||||
solution is { Length: > 0 and <= MaxSolutionLength } &&
|
||||
solution.All(char.IsAsciiDigit) &&
|
||||
ulong.TryParse(solution, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value) &&
|
||||
LeadingZeroBits(nonce, value) >= difficulty;
|
||||
|
||||
/// <summary>Cherche une solution (launcher). Annulable ; part d'un point aléatoire.</summary>
|
||||
public static string Solve(ReadOnlySpan<byte> nonce, int difficulty, CancellationToken ct = default)
|
||||
{
|
||||
if (difficulty is < 0 or > MaxDifficulty) throw new ArgumentOutOfRangeException(nameof(difficulty));
|
||||
var start = BinaryPrimitives.ReadUInt64BigEndian(RandomNumberGenerator.GetBytes(8)) >> 1;
|
||||
for (var i = 0UL; ; i++)
|
||||
{
|
||||
if ((i & 0xFFFF) == 0) ct.ThrowIfCancellationRequested();
|
||||
var candidate = start + i;
|
||||
if (LeadingZeroBits(nonce, candidate) >= difficulty)
|
||||
return candidate.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Règles communes au launcher (contrôle avant l'envoi) et au serveur (refus de toute valeur invalide) pour les
|
||||
/// demandes des joueurs. La route d'envoi est anonyme : n'importe qui peut l'appeler avec un client modifié, d'où
|
||||
/// des bornes strictes et le refus des caractères invisibles (de contrôle, de mise en forme, d'inversion du sens
|
||||
/// d'écriture) qui pourraient tromper l'administrateur qui lit la demande.
|
||||
/// </summary>
|
||||
public static partial class FeedbackRules
|
||||
{
|
||||
public const int MinTitleLength = 5;
|
||||
public const int MaxTitleLength = 120;
|
||||
public const int MaxMultilineLength = 2000;
|
||||
public const int MaxLines = 40;
|
||||
|
||||
/// <summary>Total des textes d'une demande (titre et champs).</summary>
|
||||
public const int MaxTotalLength = 8000;
|
||||
|
||||
/// <summary>Corps d'une requête d'envoi (Kestrel et nginx).</summary>
|
||||
public const int MaxRequestBytes = 16 * 1024;
|
||||
|
||||
public const int MaxAdminNoteLength = 500;
|
||||
|
||||
/// <summary>Valeurs d'une case oui/non.</summary>
|
||||
public const string Yes = "yes";
|
||||
public const string No = "no";
|
||||
|
||||
/// <summary>Identifiant interne d'une demande : 128 bits en hexadécimal minuscule.</summary>
|
||||
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
|
||||
private static partial Regex IdRegex();
|
||||
|
||||
/// <summary>Référence montrée au joueur : « FB-7K3Q-9P2M » (base32 sans caractères ambigus).</summary>
|
||||
[GeneratedRegex(@"\AFB-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}\z")]
|
||||
private static partial Regex ReferenceRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9A-Za-z][0-9A-Za-z.+\-]{0,31}\z")]
|
||||
private static partial Regex VersionRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9 .,()+\-]{0,79}\z")]
|
||||
private static partial Regex OsRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,2}\z")]
|
||||
private static partial Regex LanguageRegex();
|
||||
|
||||
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidReference(string? reference) => reference is not null && ReferenceRegex().IsMatch(reference);
|
||||
|
||||
/// <summary>Alphabet des références (Crockford : ni I, L, O, U).</summary>
|
||||
public const string ReferenceAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// <summary>Référence aléatoire (40 bits).</summary>
|
||||
public static string NewReference()
|
||||
{
|
||||
var bytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(5);
|
||||
ulong value = 0;
|
||||
foreach (var b in bytes) value = (value << 8) | b;
|
||||
var chars = new char[8];
|
||||
for (var i = 7; i >= 0; i--)
|
||||
{
|
||||
chars[i] = ReferenceAlphabet[(int)(value & 31)];
|
||||
value >>= 5;
|
||||
}
|
||||
return $"FB-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
|
||||
}
|
||||
|
||||
/// <summary>Contrôle complet d'une demande avec les formulaires d'origine (révision 0).</summary>
|
||||
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors) =>
|
||||
Validate(submission, FeedbackForms.Find(submission?.Kind), errors);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'une demande avec le formulaire de son type (celui de la révision annoncée). Renvoie la
|
||||
/// demande nettoyée (textes normalisés, champs vides retirés, défi conservé) ou null avec les erreurs
|
||||
/// (françaises, lisibles par le joueur).
|
||||
/// </summary>
|
||||
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, FeedbackForm? form, ICollection<string> errors)
|
||||
{
|
||||
if (submission is null)
|
||||
{
|
||||
errors.Add("Demande vide.");
|
||||
return null;
|
||||
}
|
||||
if (!Catalog.CatalogRules.IsValidGameId(submission.GameId)) errors.Add("Jeu inconnu.");
|
||||
if (submission.FormVersion != FeedbackForms.CurrentVersion)
|
||||
errors.Add("Formulaire d'une autre version du launcher : mettez le launcher à jour.");
|
||||
if (submission.FormRevision < 0) errors.Add("Révision des formulaires invalide.");
|
||||
if (form is null || !string.Equals(form.Kind, submission.Kind, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add("Type de demande inconnu.");
|
||||
return null;
|
||||
}
|
||||
if (!form.AllowAttachments && submission.AttachmentCount > 0)
|
||||
errors.Add("Les captures d'écran ne sont pas acceptées pour ce type de demande.");
|
||||
|
||||
var title = ApiText.CleanLine(submission.Title, MaxTitleLength);
|
||||
if (title is null || title.Length < MinTitleLength)
|
||||
errors.Add($"Titre : {MinTitleLength} à {MaxTitleLength} caractères sur une ligne, sans caractère spécial invisible.");
|
||||
|
||||
var fields = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var given = submission.Fields ?? new Dictionary<string, string>();
|
||||
foreach (var key in given.Keys)
|
||||
if (form.Field(key) is null)
|
||||
{
|
||||
errors.Add("Champ inconnu dans la demande.");
|
||||
break;
|
||||
}
|
||||
|
||||
var total = title?.Length ?? 0;
|
||||
foreach (var field in form.Fields)
|
||||
{
|
||||
given.TryGetValue(field.Id, out var raw);
|
||||
var value = CheckField(field, raw, errors);
|
||||
if (value is null) continue;
|
||||
fields[field.Id] = value;
|
||||
total += value.Length;
|
||||
}
|
||||
if (total > MaxTotalLength) errors.Add($"Demande trop longue ({total} caractères, {MaxTotalLength} au plus).");
|
||||
|
||||
if (submission.AttachmentCount is < 0 or > FeedbackImages.MaxAttachments)
|
||||
errors.Add($"Captures d'écran : {FeedbackImages.MaxAttachments} au plus.");
|
||||
|
||||
var tech = CheckTech(submission.Tech, errors);
|
||||
return errors.Count > 0 ? null : submission with
|
||||
{
|
||||
Title = title!,
|
||||
Fields = fields,
|
||||
Tech = tech
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Valeur d'un champ contrôlée (null : vide ou invalide ; l'erreur est ajoutée si besoin).</summary>
|
||||
public static string? CheckField(FeedbackField field, string? raw, ICollection<string> errors)
|
||||
{
|
||||
switch (field.Kind)
|
||||
{
|
||||
case FeedbackFieldKind.Choice:
|
||||
case FeedbackFieldKind.Rating:
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : choix obligatoire.");
|
||||
return null;
|
||||
}
|
||||
var valid = field.Kind == FeedbackFieldKind.Rating
|
||||
? raw.Length <= 2 && raw.All(char.IsAsciiDigit) && raw[0] != '0' &&
|
||||
int.Parse(raw, CultureInfo.InvariantCulture) <= field.RatingMax
|
||||
: field.Choices.Any(o => string.Equals(o.Id, raw, StringComparison.Ordinal));
|
||||
if (!valid)
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
|
||||
case FeedbackFieldKind.Checkboxes:
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : cochez au moins un choix.");
|
||||
return null;
|
||||
}
|
||||
var ids = raw.Split(',');
|
||||
if (ids.Length > field.Choices.Count || ids.Distinct(StringComparer.Ordinal).Count() != ids.Length ||
|
||||
ids.Any(id => !field.Choices.Any(o => string.Equals(o.Id, id, StringComparison.Ordinal))))
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
// Ordre des options du formulaire : une même sélection donne toujours la même valeur.
|
||||
return string.Join(',', field.Choices.Select(o => o.Id).Where(id => ids.Contains(id, StringComparer.Ordinal)));
|
||||
|
||||
case FeedbackFieldKind.YesNo:
|
||||
if (string.IsNullOrEmpty(raw)) raw = No;
|
||||
if (raw is not (Yes or No))
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
if (field.Required && raw != Yes) errors.Add($"{field.Label} : case à cocher obligatoire.");
|
||||
return raw;
|
||||
|
||||
case FeedbackFieldKind.Text:
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : champ obligatoire.");
|
||||
return null;
|
||||
}
|
||||
var line = ApiText.CleanLine(raw, field.MaxLength);
|
||||
if (line is null)
|
||||
{
|
||||
errors.Add($"{field.Label} : {field.MaxLength} caractères au plus, sur une ligne, sans caractère spécial invisible.");
|
||||
return null;
|
||||
}
|
||||
return line;
|
||||
|
||||
default:
|
||||
var text = CleanMultiline(raw, field.MaxLength, out var problem);
|
||||
if (problem is not null)
|
||||
{
|
||||
errors.Add($"{field.Label} : {problem}");
|
||||
return null;
|
||||
}
|
||||
if (text is null && field.Required) errors.Add($"{field.Label} : champ obligatoire.");
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Texte sur plusieurs lignes : fins de ligne « \n », tabulations remplacées par une espace, espaces de fin de
|
||||
/// ligne retirés, trois lignes vides consécutives au plus. Null si vide. <paramref name="problem"/> : raison du
|
||||
/// refus (caractère interdit, trop long, trop de lignes).
|
||||
/// </summary>
|
||||
public static string? CleanMultiline(string? value, int maxLength, out string? problem)
|
||||
{
|
||||
problem = null;
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
if (value.Length > maxLength * 2)
|
||||
{
|
||||
problem = $"{maxLength} caractères au plus.";
|
||||
return null;
|
||||
}
|
||||
var sb = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var c = value[i];
|
||||
if (c == '\r')
|
||||
{
|
||||
sb.Append('\n');
|
||||
if (i + 1 < value.Length && value[i + 1] == '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '\n')
|
||||
{
|
||||
sb.Append('\n');
|
||||
continue;
|
||||
}
|
||||
if (c == '\t')
|
||||
{
|
||||
sb.Append(' ');
|
||||
continue;
|
||||
}
|
||||
if (char.IsHighSurrogate(c))
|
||||
{
|
||||
if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]) &&
|
||||
CharUnicodeInfo.GetUnicodeCategory(value, i) is not (UnicodeCategory.Format or UnicodeCategory.PrivateUse))
|
||||
{
|
||||
sb.Append(c).Append(value[i + 1]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
problem = "caractère non autorisé.";
|
||||
return null;
|
||||
}
|
||||
if (char.IsLowSurrogate(c) || char.IsControl(c) || c is '' or '' ||
|
||||
CharUnicodeInfo.GetUnicodeCategory(c) is UnicodeCategory.Format or UnicodeCategory.LineSeparator
|
||||
or UnicodeCategory.ParagraphSeparator or UnicodeCategory.PrivateUse)
|
||||
{
|
||||
problem = "caractère invisible ou spécial non autorisé.";
|
||||
return null;
|
||||
}
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
var lines = sb.ToString().Split('\n').Select(l => l.TrimEnd()).ToList();
|
||||
var kept = new List<string>(lines.Count);
|
||||
var blanks = 0;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
blanks = line.Length == 0 ? blanks + 1 : 0;
|
||||
if (blanks <= 2) kept.Add(line);
|
||||
}
|
||||
var text = string.Join('\n', kept).Trim('\n', ' ');
|
||||
if (text.Length == 0) return null;
|
||||
if (text.Length > maxLength)
|
||||
{
|
||||
problem = $"{maxLength} caractères au plus ({text.Length} saisis).";
|
||||
return null;
|
||||
}
|
||||
if (text.Count(ch => ch == '\n') + 1 > MaxLines)
|
||||
{
|
||||
problem = $"{MaxLines} lignes au plus.";
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>Informations techniques : formats stricts (rien d'autre que ce que le launcher envoie).</summary>
|
||||
public static FeedbackTechInfo? CheckTech(FeedbackTechInfo? tech, ICollection<string> errors)
|
||||
{
|
||||
if (tech is null) return null;
|
||||
bool Ok(string? value, Regex regex) => value is null || regex.IsMatch(value);
|
||||
if (!Ok(tech.LauncherVersion, VersionRegex()) || !Ok(tech.GameVersion, VersionRegex()) ||
|
||||
!Ok(tech.Os, OsRegex()) || !Ok(tech.Language, LanguageRegex()))
|
||||
{
|
||||
errors.Add("Informations techniques invalides.");
|
||||
return null;
|
||||
}
|
||||
return tech.LauncherVersion is null && tech.GameVersion is null && tech.Os is null && tech.Language is null ? null : tech;
|
||||
}
|
||||
|
||||
/// <summary>Note interne d'un administrateur (vide : null) ; <paramref name="valid"/> faux si elle est refusée.</summary>
|
||||
public static string? CleanAdminNote(string? note, out bool valid)
|
||||
{
|
||||
var text = CleanMultiline(note, MaxAdminNoteLength, out var problem);
|
||||
valid = problem is null;
|
||||
return valid ? text : null;
|
||||
}
|
||||
|
||||
/// <summary>Version au format des informations techniques (« 2.5.0 », « 0.9.3-beta »), sinon null.</summary>
|
||||
public static string? SafeVersion(string? value) => value is not null && VersionRegex().IsMatch(value) ? value : null;
|
||||
|
||||
/// <summary>Description de Windows au format des informations techniques, sinon null.</summary>
|
||||
public static string? SafeOs(string? value) => value is not null && OsRegex().IsMatch(value) ? value : null;
|
||||
|
||||
public static string? SafeLanguage(string? value) => value is not null && LanguageRegex().IsMatch(value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
namespace GameLauncher.Contracts.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Fichiers du serveur » : contenu d'un dossier du site public (www), état de chaque fichier
|
||||
/// vis-à-vis des manifests, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu.
|
||||
/// Le serveur décide seul de ce qui peut être supprimé ; le launcher ne fait qu'afficher.
|
||||
/// </summary>
|
||||
public static class ServerFileStatus
|
||||
{
|
||||
/// <summary>Dossier (ouvrable, jamais supprimé depuis le menu).</summary>
|
||||
public const string Directory = "directory";
|
||||
|
||||
/// <summary>Fichier de description (.json : manifest, catalogue, trousseau, mise à jour du launcher) : jamais supprimé.</summary>
|
||||
public const string Protected = "protected";
|
||||
|
||||
/// <summary>Référencé par un manifest (archive, fichier de la version en ligne) ou par le catalogue (image).</summary>
|
||||
public const string InUse = "in_use";
|
||||
|
||||
/// <summary>Référencé par aucun fichier de description : peut être supprimé.</summary>
|
||||
public const string Unused = "unused";
|
||||
|
||||
/// <summary>Un fichier de description voisin est illisible : impossible de vérifier, donc jamais supprimé.</summary>
|
||||
public const string Unknown = "unknown";
|
||||
|
||||
/// <summary>Lien symbolique : ni suivi, ni supprimé.</summary>
|
||||
public const string Link = "link";
|
||||
}
|
||||
|
||||
/// <summary>Un élément d'un dossier.</summary>
|
||||
public sealed record ServerFileEntry
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public bool IsDirectory { get; init; }
|
||||
|
||||
/// <summary>Taille en octets (dossier : total de son contenu ; -1 si trop de fichiers pour le calculer).</summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>Nombre de fichiers d'un dossier (-1 si non calculé).</summary>
|
||||
public long FileCount { get; init; }
|
||||
|
||||
public DateTimeOffset Modified { get; init; }
|
||||
|
||||
/// <summary>Voir <see cref="ServerFileStatus"/>.</summary>
|
||||
public string Status { get; init; } = ServerFileStatus.Unknown;
|
||||
|
||||
/// <summary>Explication de l'état (« Archive de la version 1.2.0 (manifest de exovivalzombie/) »…).</summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'un dossier du site public.</summary>
|
||||
public sealed record ServerFolder
|
||||
{
|
||||
/// <summary>Chemin relatif à la racine du site (« » : racine ; séparateur « / »).</summary>
|
||||
public string Path { get; init; } = "";
|
||||
|
||||
/// <summary>Nom affiché de la racine (dernier élément du chemin configuré, ex. « www »).</summary>
|
||||
public string RootName { get; init; } = "";
|
||||
|
||||
public List<ServerFileEntry> Entries { get; init; } = new();
|
||||
|
||||
/// <summary>Le dossier contient plus d'éléments que la limite affichée.</summary>
|
||||
public bool Truncated { get; init; }
|
||||
|
||||
/// <summary>Espace libre et taille du disque du site (-1 si inconnus).</summary>
|
||||
public long FreeBytes { get; init; } = -1;
|
||||
|
||||
public long TotalBytes { get; init; } = -1;
|
||||
|
||||
/// <summary>Jeux dont le dossier « objects » peut être nettoyé (identifiants configurés sur le serveur).</summary>
|
||||
public List<string> PrunableGames { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Suppression d'un fichier inutilisé (requête signée).</summary>
|
||||
public sealed record ServerFileDeleteRequest
|
||||
{
|
||||
/// <summary>Chemin relatif à la racine du site (séparateur « / »).</summary>
|
||||
public string Path { get; init; } = "";
|
||||
|
||||
/// <summary>Taille vue dans la liste : refus si le fichier a changé depuis (remplacé entre-temps).</summary>
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ServerFileDeleteResult
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public long FreedBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Analyse du dossier « objects » d'un jeu : fichiers que le manifest en ligne n'utilise plus.</summary>
|
||||
public sealed record ObjectsPrunePreview
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version du manifest en ligne.</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest analysé : à renvoyer pour le nettoyage (refus s'il a changé entre-temps).</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>Fichiers de la version en ligne (objets distincts).</summary>
|
||||
public int ReferencedCount { get; init; }
|
||||
|
||||
/// <summary>Fichiers de la version en ligne absents du serveur (information : à republier).</summary>
|
||||
public int MissingCount { get; init; }
|
||||
|
||||
public int OrphanCount { get; init; }
|
||||
|
||||
public long OrphanBytes { get; init; }
|
||||
|
||||
/// <summary>Quelques fichiers inutilisés (aperçu).</summary>
|
||||
public List<string> Examples { get; init; } = new();
|
||||
|
||||
/// <summary>Une publication de ce jeu est en cours : nettoyage impossible pour l'instant.</summary>
|
||||
public bool PublicationInProgress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Nettoyage du dossier « objects » d'un jeu (requête signée).</summary>
|
||||
public sealed record ObjectsPruneRequest
|
||||
{
|
||||
/// <summary><see cref="ObjectsPrunePreview.ManifestSha256"/> de l'analyse affichée.</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ObjectsPruneResult
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public int Deleted { get; init; }
|
||||
public long FreedBytes { get; init; }
|
||||
|
||||
/// <summary>Fichiers qui n'ont pas pu être supprimés (droits) : le serveur l'explique dans son journal.</summary>
|
||||
public int Failed { get; init; }
|
||||
}
|
||||
|
||||
public static class ServerFilesLimits
|
||||
{
|
||||
/// <summary>Éléments renvoyés au plus pour un dossier.</summary>
|
||||
public const int MaxEntries = 2000;
|
||||
|
||||
/// <summary>Fichiers parcourus au plus pour calculer la taille des sous-dossiers d'un dossier.</summary>
|
||||
public const int MaxScannedFiles = 200_000;
|
||||
|
||||
public const int MaxPathLength = 1024;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (maintenance.json, commun à tous les jeux), activé ou désactivé depuis le menu
|
||||
/// d'administration. Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée
|
||||
/// « maintenance » dans le trousseau : les launchers des joueurs le vérifient comme le catalogue avant d'en tenir compte.
|
||||
/// Maintenance active : le launcher passe au rouge / orange, affiche le message au centre et bloque tout, sauf la
|
||||
/// désinstallation des jeux et le menu d'administration.
|
||||
/// Révision 0 = rien n'a jamais été publié (maintenance inactive).
|
||||
/// </summary>
|
||||
public sealed record MaintenanceDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-maintenance";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Révision : +1 à chaque publication (couverte par la signature).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Vrai : maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Précision facultative affichée aux joueurs sous le texte principal (« Mise à jour des serveurs, retour vers 18 h »).
|
||||
/// Texte brut, sans lien, quelques lignes au plus (<see cref="MaintenanceRules"/>).
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>Début de la maintenance (à la seconde, UTC) ; null quand elle est désactivée. Couvert par la signature.</summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État du mode maintenance pour le menu d'administration (GET v1/maintenance).</summary>
|
||||
public sealed record MaintenanceState
|
||||
{
|
||||
/// <summary>Document en vigueur (révision 0, maintenance inactive, si rien n'a été publié).</summary>
|
||||
public MaintenanceDocument Document { get; init; } = new();
|
||||
|
||||
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
|
||||
public bool PublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
|
||||
public string? PublicUrl { get; init; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé la dernière publication, si le serveur la connaît.</summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication du mode maintenance (PUT v1/maintenance, requête signée, maintenance.manage).</summary>
|
||||
public sealed record MaintenancePublishRequest
|
||||
{
|
||||
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
/// <summary>Document signé, révision <see cref="BaseRevision"/> + 1.</summary>
|
||||
public MaintenanceDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles du mode maintenance, communes au menu (contrôle avant de signer), au serveur (refus avant publication) et au
|
||||
/// launcher des joueurs (refus d'un fichier hors limites, même signé). Le message est du texte brut, déjà nettoyé
|
||||
/// (<see cref="CleanMessage"/>) et SANS lien : rien ne doit pouvoir renvoyer le joueur vers un site depuis ce bandeau.
|
||||
/// </summary>
|
||||
public static class MaintenanceRules
|
||||
{
|
||||
/// <summary>Taille de maintenance.json (et de la requête de publication, sous la limite de 64 Ko de l'API).</summary>
|
||||
public const int MaxBytes = 8 * 1024;
|
||||
|
||||
public const int MaxMessageLength = 300;
|
||||
public const int MaxMessageLines = 6;
|
||||
|
||||
/// <summary>Texte principal affiché aux joueurs (non modifiable : toujours présent, même sans message).</summary>
|
||||
public const string Title = "Maintenance en cours";
|
||||
|
||||
public const string Subtitle = "Merci de revenir plus tard.";
|
||||
|
||||
/// <summary>Écart toléré entre le début annoncé et l'heure du serveur (horloges des PC).</summary>
|
||||
public static readonly TimeSpan MaxSinceSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Rien de publié (révision 0) : maintenance inactive.</summary>
|
||||
public static MaintenanceDocument Defaults() => new() { Revision = 0, Enabled = false };
|
||||
|
||||
/// <summary>Lit maintenance.json (null : vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible ou trop volumineux.</exception>
|
||||
public static MaintenanceDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"fichier de maintenance trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<MaintenanceDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(MaintenanceDocument document) => JsonSerializer.Serialize(document, FileOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Condensé signé : format, version, révision, état, message et début (à la seconde). La date de publication et la
|
||||
/// signature n'en font pas partie.
|
||||
/// </summary>
|
||||
public static string Digest(MaintenanceDocument document)
|
||||
{
|
||||
var canonical = new CanonicalMaintenance(document.Format ?? "", document.Version, document.Revision, document.Enabled,
|
||||
string.IsNullOrEmpty(document.Message) ? null : document.Message,
|
||||
document.Since is { } since ? since.ToUnixTimeSeconds() : null);
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
|
||||
}
|
||||
|
||||
private sealed record CanonicalMaintenance(string Format, int Version, long Revision, bool Enabled, string? Message, long? Since);
|
||||
|
||||
/// <summary>
|
||||
/// Nettoie le message saisi par l'administrateur (fins de ligne, espaces, lignes vides ; mêmes refus que les demandes
|
||||
/// des joueurs : caractères invisibles, de mise en forme ou d'inversion du sens d'écriture), puis vérifie les bornes et
|
||||
/// l'absence de lien. Null si vide ; <paramref name="problem"/> : raison du refus.
|
||||
/// </summary>
|
||||
public static string? CleanMessage(string? value, out string? problem)
|
||||
{
|
||||
var text = FeedbackRules.CleanMultiline(value, MaxMessageLength, out problem);
|
||||
if (problem is not null) return null;
|
||||
if (text is null) return null;
|
||||
if (text.Count(c => c == '\n') + 1 > MaxMessageLines)
|
||||
{
|
||||
problem = $"{MaxMessageLines} lignes au plus.";
|
||||
return null;
|
||||
}
|
||||
if (FeedbackLinks.Contains(text))
|
||||
{
|
||||
problem = "aucun lien ni adresse de site n'est accepté (les joueurs ne doivent pas être renvoyés vers un site depuis ce message).";
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un document publié (révision 1 au moins). Les erreurs, en français, sont ajoutées à <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
/// <param name="now">Heure de référence (serveur) : le début ne peut pas être dans le futur ; null : non contrôlé (launcher).</param>
|
||||
public static bool Validate(MaintenanceDocument? document, ICollection<string> errors, DateTimeOffset? now = null)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (document is null)
|
||||
{
|
||||
errors.Add("Document de maintenance absent.");
|
||||
return false;
|
||||
}
|
||||
if (document.Format != MaintenanceDocument.FormatName || document.Version != MaintenanceDocument.CurrentVersion)
|
||||
errors.Add($"Format du mode maintenance non pris en charge ({document.Format} v{document.Version}).");
|
||||
if (document.Revision < 1) errors.Add("Révision invalide (1 au moins).");
|
||||
|
||||
if (document.Message is { } message)
|
||||
{
|
||||
var clean = CleanMessage(message, out var problem);
|
||||
if (problem is not null) errors.Add($"Message : {problem}");
|
||||
else if (!string.Equals(clean, message, StringComparison.Ordinal)) errors.Add("Message : texte non nettoyé (espaces, lignes vides).");
|
||||
}
|
||||
|
||||
if (document.Enabled)
|
||||
{
|
||||
if (document.Since is not { } since || since == default) errors.Add("Début de la maintenance absent.");
|
||||
else if (since.UtcTicks % TimeSpan.TicksPerSecond != 0)
|
||||
errors.Add("Début de la maintenance : à la seconde près.");
|
||||
else if (now is { } reference && since > reference + MaxSinceSkew)
|
||||
errors.Add("Début de la maintenance dans le futur : vérifiez l'heure de Windows.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (document.Since is not null) errors.Add("Maintenance désactivée : pas de date de début.");
|
||||
if (document.Message is not null) errors.Add("Maintenance désactivée : pas de message.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>Résumé pour l'audit et les journaux (« activée — message : … », « désactivée »).</summary>
|
||||
public static string Describe(MaintenanceDocument document) =>
|
||||
document.Enabled
|
||||
? "activée" + (document.Message is { } message ? $" — message : « {OneLine(message)} »" : " (sans message)")
|
||||
: "désactivée";
|
||||
|
||||
private static string OneLine(string text)
|
||||
{
|
||||
var line = text.Replace('\n', ' ');
|
||||
return line.Length <= 120 ? line : line[..119] + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>Ce que contient un manifest.json contrôlé.</summary>
|
||||
/// <param name="Objects">Objets à servir (empreinte en minuscules → taille), une fois chacun.</param>
|
||||
public sealed record ManifestSummary(
|
||||
string GameId,
|
||||
string Version,
|
||||
string Executable,
|
||||
string ArchiveName,
|
||||
long ArchiveSize,
|
||||
string ArchiveSha256,
|
||||
IReadOnlyDictionary<string, long> Objects,
|
||||
int FileCount,
|
||||
long TotalSize,
|
||||
string? NotesTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un manifest.json produit par ManifestBuilder, AVANT de le publier : par le launcher
|
||||
/// (dossier choisi) et par le serveur (manifest reçu). Mêmes règles des deux côtés ; le serveur ne fait
|
||||
/// confiance à rien de ce qu'il reçoit.
|
||||
/// • identifiant de jeu et version attendus ;
|
||||
/// • archive : simple nom de fichier .zip (jamais un chemin), taille et empreinte valides ;
|
||||
/// • objets dans « objects/ » (seul dossier écrit par la publication) ;
|
||||
/// • chemins des fichiers relatifs et sûrs (ni « .. », ni chemin absolu, ni « : »), sans doublon ;
|
||||
/// • exécutable présent parmi les fichiers ; tailles et nombre de fichiers bornés.
|
||||
/// </summary>
|
||||
public static partial class ManifestCheck
|
||||
{
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9._+\-]{1,64}\z")]
|
||||
private static partial Regex VersionRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}\.zip\z", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ArchiveNameRegex();
|
||||
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
public static bool IsValidVersion(string? version) => version is not null && VersionRegex().IsMatch(version);
|
||||
|
||||
/// <summary>Contrôle ; null (et <paramref name="errors"/> rempli) si le manifest n'est pas publiable.</summary>
|
||||
public static ManifestSummary? Check(ReadOnlySpan<byte> json, string expectedGameId, string? expectedVersion, List<string> errors)
|
||||
{
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
if (json.Length > PublishingLimits.MaxManifestBytes)
|
||||
{
|
||||
errors.Add("Manifest trop volumineux.");
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(json.ToArray(), ReadOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"Manifest illisible : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add("Manifest illisible : objet JSON attendu.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Get(root, "formatVersion") is { } format && (format.ValueKind != JsonValueKind.Number || format.GetDouble() != 1))
|
||||
errors.Add("Format de manifest non pris en charge (formatVersion 1 attendu).");
|
||||
|
||||
var gameId = Text(root, "gameId");
|
||||
if (!string.Equals(gameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add($"Ce manifest est celui du jeu « {gameId} », pas de « {expectedGameId} ».");
|
||||
|
||||
var version = Text(root, "version");
|
||||
if (!IsValidVersion(version))
|
||||
errors.Add($"Version « {version} » invalide (lettres, chiffres, « . », « _ », « + », « - »).");
|
||||
else if (expectedVersion is not null && !string.Equals(version, expectedVersion, StringComparison.Ordinal))
|
||||
errors.Add($"Le manifest est celui de la version {version}, pas {expectedVersion}.");
|
||||
|
||||
var objectsBase = Text(root, "objectsBaseUrl");
|
||||
if (!string.IsNullOrEmpty(objectsBase) && objectsBase is not ("objects/" or "objects"))
|
||||
errors.Add($"« objectsBaseUrl » doit être « objects/ » (manifest produit par ManifestBuilder), pas « {objectsBase} ».");
|
||||
|
||||
// Archive (installation initiale)
|
||||
string archiveName = "", archiveSha = "";
|
||||
long archiveSize = 0;
|
||||
if (Get(root, "archive") is { ValueKind: JsonValueKind.Object } archive)
|
||||
{
|
||||
archiveName = Text(archive, "url") ?? "";
|
||||
archiveSha = (Text(archive, "sha256") ?? "").ToLowerInvariant();
|
||||
archiveSize = Number(archive, "size");
|
||||
if (!ArchiveNameRegex().IsMatch(archiveName))
|
||||
errors.Add($"Archive « {archiveName} » : simple nom de fichier .zip attendu (à côté du manifest).");
|
||||
if (!PublishingLimits.IsValidSha256(archiveSha)) errors.Add("Archive : empreinte SHA-256 invalide.");
|
||||
if (archiveSize <= 0 || archiveSize > PublishingLimits.MaxArchiveBytes) errors.Add("Archive : taille invalide.");
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Archive absente du manifest.");
|
||||
}
|
||||
|
||||
// Fichiers du jeu
|
||||
var objects = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalSize = 0;
|
||||
var fileCount = 0;
|
||||
if (Get(root, "files") is { ValueKind: JsonValueKind.Array } files)
|
||||
{
|
||||
fileCount = files.GetArrayLength();
|
||||
if (fileCount == 0) errors.Add("Aucun fichier dans le manifest.");
|
||||
if (fileCount > PublishingLimits.MaxFiles) errors.Add($"Trop de fichiers ({fileCount} ; {PublishingLimits.MaxFiles} au plus).");
|
||||
else
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var file in files.EnumerateArray())
|
||||
{
|
||||
index++;
|
||||
if (errors.Count > 20) break;
|
||||
if (file.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add($"Fichier n° {index} invalide.");
|
||||
continue;
|
||||
}
|
||||
var path = Text(file, "path") ?? "";
|
||||
var sha = (Text(file, "sha256") ?? "").ToLowerInvariant();
|
||||
var size = Number(file, "size");
|
||||
if (!IsSafeRelativePath(path)) errors.Add($"Chemin non autorisé : « {path} ».");
|
||||
else if (!paths.Add(path.Replace('\\', '/'))) errors.Add($"Fichier en double : « {path} ».");
|
||||
if (!PublishingLimits.IsValidSha256(sha)) errors.Add($"« {path} » : empreinte SHA-256 invalide.");
|
||||
else if (size < 0 || size > PublishingLimits.MaxObjectBytes) errors.Add($"« {path} » : taille invalide.");
|
||||
else if (objects.TryGetValue(sha, out var known) && known != size)
|
||||
errors.Add($"« {path} » : même empreinte qu'un autre fichier mais taille différente.");
|
||||
else
|
||||
{
|
||||
objects[sha] = size;
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Liste des fichiers absente du manifest.");
|
||||
}
|
||||
|
||||
var executable = (Text(root, "executable") ?? "").Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(executable)) errors.Add("Exécutable absent du manifest.");
|
||||
else if (!paths.Contains(executable)) errors.Add($"L'exécutable « {executable} » ne fait pas partie des fichiers.");
|
||||
|
||||
if (Get(root, "changelog") is { } changelog && changelog.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null))
|
||||
errors.Add("Notes de version (« changelog ») : liste attendue.");
|
||||
|
||||
if (errors.Count > 0) return null;
|
||||
return new ManifestSummary(gameId!, version!, executable, archiveName, archiveSize, archiveSha, objects, fileCount, totalSize,
|
||||
NotesTitle(root, version!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chemin relatif sûr (mêmes règles que le launcher pour l'installation).</summary>
|
||||
public static bool IsSafeRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path.Length > 1024 || path.Any(char.IsControl)) return false;
|
||||
var p = path.Replace('\\', '/');
|
||||
if (p.StartsWith('/') || p.Contains(':')) return false;
|
||||
return p.Split('/').All(s => s.Length > 0 && s != "." && s != "..");
|
||||
}
|
||||
|
||||
/// <summary>Chemin d'un objet sous le dossier du manifest : « objects/ab/abcdef….bin ».</summary>
|
||||
public static string ObjectPath(string sha256)
|
||||
{
|
||||
var h = sha256.ToLowerInvariant();
|
||||
return $"objects/{h[..2]}/{h}.bin";
|
||||
}
|
||||
|
||||
private static string? NotesTitle(JsonElement root, string version)
|
||||
{
|
||||
if (Get(root, "changelog") is not { ValueKind: JsonValueKind.Array } changelog) return null;
|
||||
foreach (var entry in changelog.EnumerateArray())
|
||||
if (entry.ValueKind == JsonValueKind.Object && Text(entry, "version") == version)
|
||||
return Text(entry, "title");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement? Get(JsonElement element, string name)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Text(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null;
|
||||
|
||||
private static long Number(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : -1;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Bornes de la publication d'une version depuis le menu d'administration. Les requêtes d'envoi
|
||||
/// (morceaux de fichiers) sont les seules à dépasser 64 Ko : elles ont leur propre limite, côté
|
||||
/// serveur et côté nginx (server/deploy/nginx-publications.conf).
|
||||
/// </summary>
|
||||
public static partial class PublishingLimits
|
||||
{
|
||||
/// <summary>Taille d'un morceau envoyé par le launcher.</summary>
|
||||
public const int ChunkBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Taille maximale d'un corps de requête d'envoi accepté par le serveur (morceau ou lot d'objets).</summary>
|
||||
public const long MaxUploadRequestBytes = 8L * 1024 * 1024 + 64 * 1024;
|
||||
|
||||
/// <summary>Objets de cette taille au plus : envoyés par lots ; au-delà, en morceaux.</summary>
|
||||
public const int BatchObjectMaxBytes = 2 * 1024 * 1024;
|
||||
|
||||
public const int MaxObjectsPerBatch = 500;
|
||||
public const int MaxFiles = 10_000;
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
public const long MaxObjectBytes = 32L * 1024 * 1024 * 1024;
|
||||
public const long MaxArchiveBytes = 128L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>Publication abandonnée (aucun envoi) au-delà de cette durée : supprimée par le serveur.</summary>
|
||||
public static readonly TimeSpan IdleExpiry = TimeSpan.FromHours(48);
|
||||
|
||||
/// <summary>Publication d'un autre administrateur sans envoi depuis cette durée : peut être remplacée.</summary>
|
||||
public static readonly TimeSpan TakeoverAfter = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>Type du corps d'un lot d'objets (voir <see cref="ObjectBatch"/>).</summary>
|
||||
public const string ObjectBatchContentType = "application/vnd.gamelauncher.objects";
|
||||
|
||||
public const string BinaryContentType = "application/octet-stream";
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
|
||||
private static partial Regex PublicationIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-fA-F]{64}\z")]
|
||||
private static partial Regex Sha256Regex();
|
||||
|
||||
public static bool IsValidPublicationId(string? id) => id is not null && PublicationIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidSha256(string? value) => value is not null && Sha256Regex().IsMatch(value);
|
||||
}
|
||||
|
||||
/// <summary>Début (ou reprise) de la publication d'une version : le manifest est envoyé ensuite.</summary>
|
||||
public sealed record PublicationStartRequest
|
||||
{
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest.json à publier : même manifest = reprise d'un envoi interrompu.</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État d'une publication en cours (ce qui reste à envoyer).</summary>
|
||||
public sealed record PublicationState
|
||||
{
|
||||
public string PublicationId { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne au début de la publication (vide : aucune).</summary>
|
||||
public string PublishedVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Vrai si un envoi interrompu de ce même manifest est repris.</summary>
|
||||
public bool Resumed { get; init; }
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
public long ManifestReceived { get; init; }
|
||||
|
||||
/// <summary>Manifest reçu et contrôlé : la liste des objets à envoyer est connue.</summary>
|
||||
public bool ManifestAccepted { get; init; }
|
||||
|
||||
/// <summary>Fichiers (objets) du manifest ; ceux déjà présents sur le serveur ne sont pas renvoyés.</summary>
|
||||
public int ObjectCount { get; init; }
|
||||
public int ObjectsOnServer { get; init; }
|
||||
|
||||
/// <summary>Empreintes des objets restant à envoyer.</summary>
|
||||
public List<string> PendingObjects { get; init; } = new();
|
||||
|
||||
/// <summary>Objets envoyés en partie (empreinte → octets reçus) : l'envoi reprend à cet endroit.</summary>
|
||||
public Dictionary<string, long> PartialObjects { get; init; } = new();
|
||||
|
||||
public string? ArchiveName { get; init; }
|
||||
public long ArchiveSize { get; init; }
|
||||
public long ArchiveReceived { get; init; }
|
||||
public bool ArchiveComplete { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réponse à l'envoi d'un morceau (ou d'un lot d'objets).</summary>
|
||||
public sealed record ChunkResult
|
||||
{
|
||||
/// <summary>Octets reçus pour ce fichier (ou nombre d'objets enregistrés pour un lot).</summary>
|
||||
public long Received { get; init; }
|
||||
|
||||
/// <summary>Fichier complet et vérifié (SHA-256).</summary>
|
||||
public bool Complete { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Version publiée.</summary>
|
||||
public sealed record PublicationResult
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string PreviousVersion { get; init; } = "";
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
public int ObjectsAdded { get; init; }
|
||||
public bool ArchiveAdded { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lot d'objets dans un seul corps de requête (petits fichiers : une requête pour des centaines de
|
||||
/// fichiers) : pour chaque objet, empreinte SHA-256 (32 octets bruts), taille (8 octets, gros-boutiste)
|
||||
/// puis contenu.
|
||||
/// </summary>
|
||||
public static class ObjectBatch
|
||||
{
|
||||
public const int HeaderBytes = 32 + 8;
|
||||
|
||||
public static byte[] Write(IReadOnlyList<(string Sha256, byte[] Data)> objects)
|
||||
{
|
||||
var total = objects.Sum(o => (long)HeaderBytes + o.Data.Length);
|
||||
var buffer = new byte[total];
|
||||
var position = 0;
|
||||
foreach (var (sha, data) in objects)
|
||||
{
|
||||
Convert.FromHexString(sha).CopyTo(buffer, position);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(buffer.AsSpan(position + 32, 8), data.Length);
|
||||
data.CopyTo(buffer, position + HeaderBytes);
|
||||
position += HeaderBytes + data.Length;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>Lecture bornée ; <see cref="InvalidDataException"/> si le lot est mal formé.</summary>
|
||||
public static List<(string Sha256, ReadOnlyMemory<byte> Data)> Read(ReadOnlyMemory<byte> body, int maxObjects)
|
||||
{
|
||||
var result = new List<(string, ReadOnlyMemory<byte>)>();
|
||||
var position = 0;
|
||||
while (position < body.Length)
|
||||
{
|
||||
if (result.Count >= maxObjects) throw new InvalidDataException($"Lot trop grand ({maxObjects} objets au plus).");
|
||||
if (body.Length - position < HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
var span = body.Span;
|
||||
var sha = Convert.ToHexString(span.Slice(position, 32)).ToLowerInvariant();
|
||||
var length = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(span.Slice(position + 32, 8));
|
||||
if (length < 0 || length > body.Length - position - HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
result.Add((sha, body.Slice(position + HeaderBytes, (int)length)));
|
||||
position += HeaderBytes + (int)length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Groupes d'administrateurs (anciennement « rôles ») et permissions personnalisées : règles communes au
|
||||
/// serveur (refus de toute valeur invalide) et au menu d'administration (contrôle avant l'envoi).
|
||||
/// • Chaque administrateur appartient à UN groupe ; ses droits sont les permissions de ce groupe, relues
|
||||
/// à chaque requête (une modification s'applique immédiatement, sessions ouvertes comprises).
|
||||
/// • Le groupe « Propriétaire » (<see cref="AdminRoles.Owner"/>) est un groupe SYSTÈME : il a toujours
|
||||
/// toutes les permissions (y compris celles créées plus tard) et ne peut être ni modifié ni supprimé.
|
||||
/// • Les groupes « Administrateur » et « Éditeur » sont créés par défaut ; comme tout autre groupe, ils
|
||||
/// se modifient, et se suppriment s'ils n'ont plus de membre ni d'invitation en cours.
|
||||
/// </summary>
|
||||
public static partial class AdminGroups
|
||||
{
|
||||
public const int MaxGroups = 50;
|
||||
public const int MaxNameLength = 40;
|
||||
public const int MaxDescriptionLength = 200;
|
||||
public const int MaxCustomPermissions = 40;
|
||||
public const int MaxPermissionLabelLength = 40;
|
||||
public const int MaxPermissionNameLength = 57;
|
||||
|
||||
/// <summary>Identifiant d'un groupe : minuscules, chiffres et tirets (jamais modifié après création).</summary>
|
||||
[GeneratedRegex(@"\A[a-z][a-z0-9-]{1,31}\z")]
|
||||
private static partial Regex GroupIdRegex();
|
||||
|
||||
/// <summary>Permission personnalisée : « module.action » (une seule séparation, minuscules).</summary>
|
||||
[GeneratedRegex(@"\A[a-z][a-z0-9]{1,23}\.[a-z][a-z0-9_-]{1,31}\z")]
|
||||
private static partial Regex PermissionNameRegex();
|
||||
|
||||
public static bool IsValidGroupId(string? id) => id is not null && GroupIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidPermissionName(string? name) => name is not null && PermissionNameRegex().IsMatch(name);
|
||||
|
||||
/// <summary>Groupe système (« Propriétaire ») : toutes les permissions, non modifiable.</summary>
|
||||
public static bool IsSystem(string? groupId) => groupId == AdminRoles.Owner;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant proposé pour un nouveau groupe, d'après son nom : « Modérateurs forum » → « moderateurs-forum ».
|
||||
/// Le serveur ajoute un suffixe s'il existe déjà.
|
||||
/// </summary>
|
||||
public static string Slug(string name)
|
||||
{
|
||||
var decomposed = name.Normalize(NormalizationForm.FormD);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in decomposed)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
|
||||
var lower = char.ToLowerInvariant(c);
|
||||
if (lower is >= 'a' and <= 'z' or >= '0' and <= '9') sb.Append(lower);
|
||||
else if (sb.Length > 0 && sb[^1] != '-') sb.Append('-');
|
||||
}
|
||||
var slug = sb.ToString().Trim('-');
|
||||
if (slug.Length > 24) slug = slug[..24].TrimEnd('-');
|
||||
if (slug.Length < 2 || !char.IsAsciiLetterLower(slug[0])) slug = "groupe-" + slug;
|
||||
return slug.TrimEnd('-');
|
||||
}
|
||||
|
||||
/// <summary>Nom d'un groupe : une ligne, sans caractère invisible, <see cref="MaxNameLength"/> caractères au plus.</summary>
|
||||
public static string? CleanName(string? name) => ApiText.CleanLine(name, MaxNameLength);
|
||||
|
||||
/// <summary>Description facultative (vide : null) ; <paramref name="valid"/> faux si elle est invalide.</summary>
|
||||
public static string? CleanDescription(string? description, int maxLength, out bool valid)
|
||||
{
|
||||
valid = true;
|
||||
if (string.IsNullOrWhiteSpace(description)) return null;
|
||||
var clean = ApiText.CleanLine(description, maxLength);
|
||||
valid = clean is not null;
|
||||
return clean;
|
||||
}
|
||||
|
||||
/// <summary>Nom affiché d'un groupe connu par défaut (null : groupe créé depuis le menu d'administration).</summary>
|
||||
public static string? DefaultName(string groupId) => groupId switch
|
||||
{
|
||||
AdminRoles.Owner => "Propriétaire",
|
||||
AdminRoles.Admin => "Administrateur",
|
||||
AdminRoles.Editor => "Éditeur",
|
||||
_ => null
|
||||
};
|
||||
|
||||
public static string? DefaultDescription(string groupId) => groupId switch
|
||||
{
|
||||
AdminRoles.Owner => "Tous les droits, y compris ceux des permissions créées plus tard. Groupe système : non modifiable.",
|
||||
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes des joueurs et journal d'audit.",
|
||||
AdminRoles.Editor => "Informations des jeux et notes de version.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Permissions d'administration, au format « module.action ». Un module futur ajoute les siennes
|
||||
/// ici (ou dans son propre fichier de contrats) et les attribue aux rôles dans <see cref="AdminRoles"/>.
|
||||
/// Permissions d'administration INTÉGRÉES, au format « module.action » : celles que le code vérifie (routes
|
||||
/// du serveur, pages du launcher). Un module futur ajoute les siennes ici. Elles ne peuvent être ni créées ni
|
||||
/// supprimées depuis le menu d'administration ; les groupes (<see cref="AdminGroups"/>) les reçoivent.
|
||||
/// Des permissions PERSONNALISÉES peuvent être ajoutées depuis la page « Groupes et permissions » (même
|
||||
/// format) : elles sont attribuables aux groupes, en prévision de modules futurs.
|
||||
/// Le serveur est seul juge : côté launcher, elles servent uniquement à masquer les pages inutiles.
|
||||
/// </summary>
|
||||
public static class AdminPermissions
|
||||
@@ -22,8 +25,65 @@ public static class AdminPermissions
|
||||
/// <summary>Publication d'une nouvelle version du jeu — phase 5.</summary>
|
||||
public const string ReleasesPublish = "releases.publish";
|
||||
|
||||
/// <summary>Fichiers du site public (www) : consultation, suppression des fichiers inutilisés, nettoyage d'« objects ».</summary>
|
||||
public const string FilesManage = "files.manage";
|
||||
|
||||
/// <summary>Lecture des demandes des joueurs (avis, évolutions, bugs…), rangées par jeu.</summary>
|
||||
public const string FeedbackRead = "feedback.read";
|
||||
|
||||
/// <summary>Traitement des demandes des joueurs : statut, note interne, suppression.</summary>
|
||||
public const string FeedbackManage = "feedback.manage";
|
||||
|
||||
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
|
||||
public const string FeedbackForms = "feedback.forms";
|
||||
|
||||
/// <summary>Mode maintenance du launcher : activation, désactivation et message affiché aux joueurs (publication signée).</summary>
|
||||
public const string MaintenanceManage = "maintenance.manage";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
|
||||
MaintenanceManage
|
||||
};
|
||||
|
||||
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
|
||||
public static bool IsBuiltIn(string? permission) => permission is not null && All.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Nom court d'une permission intégrée (null : permission personnalisée).</summary>
|
||||
public static string? BuiltInLabel(string permission) => permission switch
|
||||
{
|
||||
AdminsManage => "Gestion des administrateurs",
|
||||
AuditRead => "Journal d'audit",
|
||||
GamesEdit => "Informations des jeux",
|
||||
ChangelogEdit => "Notes de version",
|
||||
ReleasesPublish => "Publication des versions",
|
||||
FilesManage => "Fichiers du serveur",
|
||||
FeedbackRead => "Demandes des joueurs (lecture)",
|
||||
FeedbackManage => "Demandes des joueurs (traitement)",
|
||||
FeedbackForms => "Formulaires des demandes",
|
||||
MaintenanceManage => "Mode maintenance",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>Ce que permet une permission intégrée (null : permission personnalisée).</summary>
|
||||
public static string? BuiltInDescription(string permission) => permission switch
|
||||
{
|
||||
AdminsManage => "Inviter, approuver, changer de groupe et révoquer les administrateurs ; gérer les groupes et les permissions ; " +
|
||||
"publier le trousseau de signature. Équivaut presque aux droits d'un propriétaire.",
|
||||
AuditRead => "Consulter et exporter le journal d'audit.",
|
||||
GamesEdit => "Modifier et publier les informations des jeux (nom affiché, présentation, liens, icône, image de fond).",
|
||||
ChangelogEdit => "Corriger les notes des versions déjà publiées.",
|
||||
ReleasesPublish => "Publier une nouvelle version d'un jeu et signer une version déjà en ligne.",
|
||||
FilesManage => "Parcourir le site, supprimer les fichiers inutilisés et nettoyer le dossier « objects ».",
|
||||
FeedbackRead => "Lire les demandes envoyées par les joueurs (avis, évolutions, bugs) : leur texte libre peut contenir " +
|
||||
"des données personnelles. Chaque consultation est inscrite au journal d'audit.",
|
||||
FeedbackManage => "Changer le statut des demandes des joueurs, écrire une note interne, supprimer une demande " +
|
||||
"(à donner avec la lecture).",
|
||||
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
|
||||
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
|
||||
MaintenanceManage => "Activer ou désactiver le mode maintenance du launcher (tout est bloqué chez les joueurs, sauf la " +
|
||||
"désinstallation des jeux) et modifier son message. Publication signée : la clé doit avoir " +
|
||||
"l'autorisation « mode maintenance » du trousseau.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>Rôles des administrateurs et permissions associées.</summary>
|
||||
/// <summary>
|
||||
/// Groupes créés par défaut (identifiants historiques des « rôles ») et leurs permissions INITIALES.
|
||||
/// Depuis la gestion des groupes, les permissions effectives d'un groupe sont celles enregistrées sur le
|
||||
/// serveur (admins.json, voir <see cref="AdminGroups"/>) : <see cref="PermissionsOf"/> ne sert plus qu'à
|
||||
/// créer les groupes par défaut (premier démarrage, migration) et de valeur de repli pour un ancien serveur.
|
||||
/// </summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
/// <summary>Tous les droits, dont la gestion des administrateurs.</summary>
|
||||
public const string Owner = "owner";
|
||||
|
||||
/// <summary>Contenu et publication, sans gestion des administrateurs.</summary>
|
||||
/// <summary>Contenu, publication et fichiers du site, sans gestion des administrateurs.</summary>
|
||||
public const string Admin = "admin";
|
||||
|
||||
/// <summary>Contenu uniquement (informations des jeux, notes de version).</summary>
|
||||
@@ -21,7 +26,9 @@ public static class AdminRoles
|
||||
[Admin] = new[]
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
|
||||
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
|
||||
AdminPermissions.MaintenanceManage
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
@@ -39,7 +46,8 @@ public static class AdminRoles
|
||||
Owner => "Propriétaire",
|
||||
Admin => "Administrateur",
|
||||
Editor => "Éditeur",
|
||||
_ => "Inconnu"
|
||||
null or "" => "Inconnu",
|
||||
_ => role // groupe créé depuis le menu d'administration : son identifiant (le nom vient du serveur)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,28 @@ public static class SignedMessages
|
||||
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
|
||||
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
|
||||
|
||||
/// <summary>
|
||||
/// Signature d'un contenu servi aux joueurs (manifest d'un jeu, catalogue) par la clé d'un administrateur
|
||||
/// (phase 7). En-tête distinct : une signature de contenu ne peut pas servir de signature de requête,
|
||||
/// et inversement.
|
||||
/// </summary>
|
||||
public const string ContentHeader = "GameLauncher/Content/v1";
|
||||
|
||||
/// <summary>Signature du trousseau (clés autorisées à signer le contenu) par la clé racine du launcher.</summary>
|
||||
public const string KeyringHeader = "GameLauncher/Keyring/v1";
|
||||
|
||||
/// <param name="kind">« manifest » ou « catalog ».</param>
|
||||
/// <param name="id">Identifiant du jeu (minuscules) ou « catalog ».</param>
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du contenu canonique.</param>
|
||||
public static byte[] Content(string kind, string id, long signedAt, string keyId, string digest) =>
|
||||
Build(ContentHeader,
|
||||
("kind", kind), ("id", id), ("signed-at", signedAt.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
("key", keyId), ("digest", digest));
|
||||
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du trousseau canonique (sans sa signature).</param>
|
||||
public static byte[] Keyring(string rootKeyId, string digest) =>
|
||||
Build(KeyringHeader, ("root", rootKeyId), ("digest", digest));
|
||||
|
||||
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
|
||||
public static string HashInvitationCode(string normalizedCode) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature d'un contenu servi aux joueurs (propriété « signature » du manifest.json d'un jeu ou du
|
||||
/// catalog.json), produite par la clé d'un administrateur. Les anciens launchers ignorent cette propriété.
|
||||
/// </summary>
|
||||
public sealed record ContentSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé qui a signé (16 caractères hexadécimaux).</summary>
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Date de la signature (heure du serveur, à la seconde) : sert aussi à refuser un contenu plus ancien que le dernier vu.</summary>
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256 / SHA-256 (64 octets, IEEE P1363) en Base64.</summary>
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Types de contenu signés et autorisations (« scopes ») correspondantes dans le trousseau.</summary>
|
||||
public static class ContentKinds
|
||||
{
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
public const string FeedbackForms = "feedback-forms";
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
|
||||
public const string CatalogId = "catalog";
|
||||
|
||||
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
|
||||
public const string FeedbackFormsId = "feedback-forms";
|
||||
|
||||
/// <summary>Identifiant signé du mode maintenance (commun à tous les jeux).</summary>
|
||||
public const string MaintenanceId = "maintenance";
|
||||
|
||||
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
|
||||
public static string ScopeOf(string kind) => kind switch
|
||||
{
|
||||
Manifest => SigningScopes.Releases,
|
||||
FeedbackForms => SigningScopes.Forms,
|
||||
Maintenance => SigningScopes.Maintenance,
|
||||
_ => SigningScopes.Catalog
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Autorisations d'une clé du trousseau.</summary>
|
||||
public static class SigningScopes
|
||||
{
|
||||
/// <summary>Signer le manifest d'un jeu (ce qui est installé et exécuté) : permission releases.publish.</summary>
|
||||
public const string Releases = "releases";
|
||||
|
||||
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
|
||||
public const string Forms = "forms";
|
||||
|
||||
/// <summary>Signer le mode maintenance (activation, message affiché aux joueurs) : permission maintenance.manage.</summary>
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms, Maintenance };
|
||||
|
||||
/// <summary>
|
||||
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
|
||||
/// acceptée dans le trousseau mais n'autorise rien ici (<see cref="ContentSigning.Verify"/> exige l'autorisation exacte).
|
||||
/// </summary>
|
||||
public static bool IsWellFormed(string? scope) =>
|
||||
scope is { Length: >= 1 and <= 32 } && scope[0] is >= 'a' and <= 'z' && scope.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
|
||||
|
||||
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
|
||||
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
|
||||
{
|
||||
var set = permissions.ToHashSet(StringComparer.Ordinal);
|
||||
var scopes = new List<string>();
|
||||
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
|
||||
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
|
||||
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
|
||||
if (set.Contains(AdminPermissions.MaintenanceManage)) scopes.Add(Maintenance);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public static string DisplayName(string scope) => scope switch
|
||||
{
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
Forms => "formulaires des demandes",
|
||||
Maintenance => "mode maintenance",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partie d'un manifest couverte par la signature : tout ce qui décide de ce qui est installé et exécuté
|
||||
/// (jeu, version, exécutable, arguments, archive, fichiers et leurs empreintes). Les notes de version et la
|
||||
/// date ne le sont pas : elles restent modifiables en ligne (texte borné, jamais exécuté).
|
||||
/// </summary>
|
||||
public sealed record ManifestContent
|
||||
{
|
||||
public int FormatVersion { get; init; } = 1;
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string Executable { get; init; } = "";
|
||||
public string? LaunchArguments { get; init; }
|
||||
public ManifestContentArchive Archive { get; init; } = new();
|
||||
public string? ObjectsBaseUrl { get; init; }
|
||||
public List<ManifestContentFile> Files { get; init; } = new();
|
||||
|
||||
/// <summary>Lit un manifest.json (mêmes noms de propriétés que le launcher, casse indifférente).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static ManifestContent Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.StartsWith(Utf8Bom)) json = json[Utf8Bom.Length..];
|
||||
return JsonSerializer.Deserialize<ManifestContent>(json, ReadOptions) ?? throw new JsonException("manifest vide");
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 32
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ManifestContentArchive
|
||||
{
|
||||
public string Url { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ManifestContentFile
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une vérification de signature de contenu.</summary>
|
||||
public sealed record ContentCheck(bool Valid, string Reason, KeyringKey? Key)
|
||||
{
|
||||
public static ContentCheck Fail(string reason) => new(false, reason, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Condensés canoniques et signatures du contenu. Le même code est utilisé par le menu d'administration
|
||||
/// (qui signe), le serveur (qui contrôle avant d'écrire) et le launcher des joueurs (qui vérifie) : chacun
|
||||
/// calcule le condensé à partir de ce qu'il a lui-même lu.
|
||||
/// </summary>
|
||||
public static class ContentSigning
|
||||
{
|
||||
/// <summary>Écart toléré par le serveur entre la date de signature et son horloge.</summary>
|
||||
public static readonly TimeSpan MaxSignedAtSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest (fichiers triés, empreintes en minuscules ; valeurs absentes = vides).</summary>
|
||||
public static string ManifestDigest(ManifestContent manifest)
|
||||
{
|
||||
var archive = manifest.Archive ?? new ManifestContentArchive();
|
||||
var canonical = new ManifestContent
|
||||
{
|
||||
FormatVersion = manifest.FormatVersion,
|
||||
GameId = (manifest.GameId ?? "").ToLowerInvariant(),
|
||||
Version = manifest.Version ?? "",
|
||||
Executable = (manifest.Executable ?? "").Replace('\\', '/'),
|
||||
LaunchArguments = string.IsNullOrEmpty(manifest.LaunchArguments) ? null : manifest.LaunchArguments,
|
||||
Archive = new ManifestContentArchive
|
||||
{
|
||||
Url = archive.Url ?? "",
|
||||
Size = archive.Size,
|
||||
Sha256 = (archive.Sha256 ?? "").ToLowerInvariant()
|
||||
},
|
||||
ObjectsBaseUrl = string.IsNullOrWhiteSpace(manifest.ObjectsBaseUrl) ? "objects/" : manifest.ObjectsBaseUrl,
|
||||
Files = (manifest.Files ?? new List<ManifestContentFile>())
|
||||
.Where(f => f is not null)
|
||||
.Select(f => new ManifestContentFile { Path = f.Path ?? "", Size = f.Size, Sha256 = (f.Sha256 ?? "").ToLowerInvariant() })
|
||||
.OrderBy(f => f.Path, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Sha256, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest.json (octets bruts).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static string ManifestDigest(ReadOnlySpan<byte> manifestJson) => ManifestDigest(ManifestContent.Parse(manifestJson));
|
||||
|
||||
/// <summary>
|
||||
/// Condensé du contenu signé d'un catalogue : tout sauf la révision, la date et la signature (posées par le
|
||||
/// serveur) ; jeux triés par identifiant, tels qu'écrits (sans changer la casse : deux identifiants qui ne
|
||||
/// diffèrent que par la casse donneraient deux entrées distinctes, voir <see cref="HasDuplicateGames"/>).
|
||||
/// </summary>
|
||||
public static string CatalogDigest(CatalogDocument catalog)
|
||||
{
|
||||
var games = new SortedDictionary<string, CatalogGame>(StringComparer.Ordinal);
|
||||
foreach (var (id, game) in catalog.Games ?? new Dictionary<string, CatalogGame>())
|
||||
games[id] = game;
|
||||
var canonical = new CanonicalCatalog(catalog.Format, catalog.Version, catalog.Launcher, games);
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifiants de jeux en double à la casse près : le launcher n'en garderait qu'un, différent de celui
|
||||
/// couvert par la signature. Un tel catalogue est refusé par la vérification.
|
||||
/// </summary>
|
||||
public static bool HasDuplicateGames(CatalogDocument catalog) =>
|
||||
(catalog.Games?.Keys ?? Enumerable.Empty<string>()).GroupBy(k => k, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
|
||||
|
||||
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
|
||||
|
||||
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
|
||||
public static string SignedId(string kind, string id) => kind switch
|
||||
{
|
||||
ContentKinds.Catalog => ContentKinds.CatalogId,
|
||||
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
|
||||
ContentKinds.Maintenance => ContentKinds.MaintenanceId,
|
||||
_ => id.ToLowerInvariant()
|
||||
};
|
||||
|
||||
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
|
||||
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
|
||||
|
||||
/// <summary>Message à signer par la clé de l'administrateur.</summary>
|
||||
public static byte[] Message(string kind, string id, DateTimeOffset signedAt, string keyId, string digest) =>
|
||||
SignedMessages.Content(kind, SignedId(kind, id), signedAt.ToUnixTimeSeconds(), keyId, digest);
|
||||
|
||||
/// <summary>Vérifie une signature avec une clé publique donnée (le serveur : la clé de l'administrateur qui envoie).</summary>
|
||||
public static bool VerifyWithKey(string kind, string id, string digest, ContentSignature signature, ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId) || signature.SignedAt == default) return false;
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value) return false;
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(publicKey), signature.KeyId, StringComparison.Ordinal)) return false;
|
||||
return AdminKeys.Verify(publicKey, Message(kind, id, signature.SignedAt, signature.KeyId, digest), value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie une signature avec le trousseau (le launcher des joueurs) : clé présente, autorisée pour ce type
|
||||
/// de contenu, signature valide sur le condensé calculé localement.
|
||||
/// </summary>
|
||||
public static ContentCheck Verify(string kind, string id, string digest, ContentSignature? signature, KeyringDocument keyring)
|
||||
{
|
||||
if (signature is null) return ContentCheck.Fail("contenu non signé");
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId)) return ContentCheck.Fail("signature mal formée");
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.Ordinal));
|
||||
if (key is null) return ContentCheck.Fail($"signé par une clé absente du trousseau ({signature.KeyId})");
|
||||
if (key.Scopes is null || !key.Scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal))
|
||||
return ContentCheck.Fail($"la clé {signature.KeyId} ({key.Name}) n'est pas autorisée à signer ce contenu");
|
||||
byte[] publicKey;
|
||||
try
|
||||
{
|
||||
publicKey = Convert.FromBase64String(key.PublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return ContentCheck.Fail("clé du trousseau illisible");
|
||||
}
|
||||
return VerifyWithKey(kind, id, digest, signature, publicKey)
|
||||
? new ContentCheck(true, $"signé par {key.Name} le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC", key)
|
||||
: ContentCheck.Fail("SIGNATURE INVALIDE (contenu modifié après signature, ou signature d'un autre contenu)");
|
||||
}
|
||||
|
||||
internal static string Sha256(ReadOnlySpan<byte> data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau (keyring.json) : clés d'administrateurs autorisées à signer le contenu servi aux joueurs, signé
|
||||
/// par la clé RACINE (celle des mises à jour du launcher, dont la clé publique est intégrée à l'exe). La clé
|
||||
/// racine reste hors ligne (LauncherPublisher, « sign-keyring.bat ») : un serveur compromis ne peut ni
|
||||
/// ajouter une clé, ni modifier le trousseau sans que les launchers le refusent.
|
||||
/// </summary>
|
||||
public sealed record KeyringDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-keyring";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Numéro croissant : un launcher refuse un trousseau plus ancien que le dernier qu'il a vu.</summary>
|
||||
public long Sequence { get; init; }
|
||||
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
public List<KeyringKey> Keys { get; init; } = new();
|
||||
|
||||
/// <summary>Signature de la clé racine (absente dans une demande de trousseau, avant signature).</summary>
|
||||
public KeyringSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
public sealed record KeyringKey
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Clé publique ECDSA P-256 (SPKI, Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Administrateur et appareil (affichage, journal du launcher).</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Ce que la clé peut signer (<see cref="SigningScopes"/>).</summary>
|
||||
public List<string> Scopes { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record KeyringSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé racine (16 caractères hexadécimaux, comme pour les mises à jour du launcher).</summary>
|
||||
public string RootKeyId { get; init; } = "";
|
||||
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Contrôles, condensé et signature du trousseau (même code partout).</summary>
|
||||
public static class Keyring
|
||||
{
|
||||
/// <summary>Taille maximale de keyring.json.</summary>
|
||||
public const int MaxBytes = 60 * 1024; // envoyé à l'API : sous la limite de 64 Ko des requêtes
|
||||
|
||||
public const int MaxKeys = 100;
|
||||
public const int MaxNameLength = 120;
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Lit un trousseau (null : JSON vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static KeyringDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"trousseau trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<KeyringDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(KeyringDocument keyring) =>
|
||||
JsonSerializer.Serialize(keyring, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true });
|
||||
|
||||
/// <summary>Condensé du trousseau sans sa signature (clés triées, autorisations triées).</summary>
|
||||
public static string Digest(KeyringDocument keyring)
|
||||
{
|
||||
var canonical = keyring with
|
||||
{
|
||||
Signature = null,
|
||||
IssuedAt = ContentSigning.Truncate(keyring.IssuedAt),
|
||||
Keys = (keyring.Keys ?? new List<KeyringKey>())
|
||||
.Select(k => k with { Scopes = (k.Scopes ?? new List<string>()).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal).ToList() })
|
||||
.OrderBy(k => k.KeyId, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Contrôle de structure (sans la signature racine). Les erreurs sont ajoutées à <paramref name="errors"/>.</summary>
|
||||
public static bool Validate(KeyringDocument keyring, List<string> errors)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (keyring.Format != KeyringDocument.FormatName || keyring.Version != KeyringDocument.CurrentVersion)
|
||||
errors.Add($"Format de trousseau non pris en charge ({keyring.Format} v{keyring.Version}).");
|
||||
if (keyring.Sequence < 1) errors.Add("Numéro du trousseau invalide (1 au moins).");
|
||||
if (keyring.IssuedAt == default) errors.Add("Date du trousseau absente.");
|
||||
if (keyring.Keys is null || keyring.Keys.Any(k => k is null || k.Scopes is null || k.PublicKey is null || k.Name is null))
|
||||
{
|
||||
errors.Add("Trousseau incomplet (clés, clé publique, nom ou autorisations absents).");
|
||||
return false;
|
||||
}
|
||||
if (keyring.Keys.Count > MaxKeys) errors.Add($"Trop de clés dans le trousseau (au plus {MaxKeys}).");
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var key in keyring.Keys)
|
||||
{
|
||||
var label = AdminKeys.IsValidKeyId(key.KeyId) ? key.KeyId : "(identifiant invalide)";
|
||||
if (!AdminKeys.IsValidKeyId(key.KeyId)) errors.Add("Clé du trousseau : identifiant invalide.");
|
||||
else if (!seen.Add(key.KeyId)) errors.Add($"Clé {label} en double.");
|
||||
try
|
||||
{
|
||||
var spki = AdminKeys.ParsePublicKey(key.PublicKey);
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(spki), key.KeyId, StringComparison.Ordinal))
|
||||
errors.Add($"Clé {label} : l'identifiant ne correspond pas à la clé publique.");
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
errors.Add($"Clé {label} : clé publique illisible ou d'un autre type que ECDSA P-256.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
|
||||
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
|
||||
// Autorisation inconnue mais bien formée (ajoutée par une version plus récente) : acceptée, sans effet ici.
|
||||
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.IsWellFormed(s)))
|
||||
errors.Add($"Clé {label} : autorisations absentes ou invalides.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom affichable sans tromperie : ni caractères de contrôle, ni caractères de mise en forme invisibles
|
||||
/// (inversion du sens d'écriture…), qui pourraient maquiller un nom dans la console ou les fenêtres.
|
||||
/// </summary>
|
||||
public static bool IsDisplaySafe(string name) =>
|
||||
!name.Any(c => char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
|
||||
or System.Globalization.UnicodeCategory.LineSeparator
|
||||
or System.Globalization.UnicodeCategory.ParagraphSeparator);
|
||||
|
||||
/// <summary>Signe le trousseau avec la clé racine (LauncherPublisher).</summary>
|
||||
public static KeyringDocument Sign(KeyringDocument keyring, ECDsa rootKey)
|
||||
{
|
||||
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
|
||||
var unsigned = keyring with { Signature = null, IssuedAt = ContentSigning.Truncate(keyring.IssuedAt) };
|
||||
var signature = AdminKeys.Sign(rootKey, SignedMessages.Keyring(rootKeyId, Digest(unsigned)));
|
||||
return unsigned with { Signature = new KeyringSignature { RootKeyId = rootKeyId, Value = Convert.ToBase64String(signature) } };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la signature racine avec les clés publiques intégrées au launcher (celles de update-signing.pub).
|
||||
/// </summary>
|
||||
/// <param name="rootKeys">Identifiant et clé publique SPKI de chaque clé racine de confiance.</param>
|
||||
public static bool VerifyRoot(KeyringDocument keyring, IEnumerable<(string KeyId, byte[] PublicKey)> rootKeys, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (keyring.Signature is not { } signature)
|
||||
{
|
||||
error = "trousseau non signé par la clé racine";
|
||||
return false;
|
||||
}
|
||||
var root = rootKeys.FirstOrDefault(k => string.Equals(k.KeyId, signature.RootKeyId, StringComparison.OrdinalIgnoreCase));
|
||||
if (root.PublicKey is null)
|
||||
{
|
||||
error = $"trousseau signé par une clé racine inconnue de ce launcher ({signature.RootKeyId})";
|
||||
return false;
|
||||
}
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value ||
|
||||
!AdminKeys.Verify(root.PublicKey, SignedMessages.Keyring(signature.RootKeyId.ToLowerInvariant(), Digest(keyring)), value))
|
||||
{
|
||||
error = "SIGNATURE DU TROUSSEAU INVALIDE";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>Mise en ligne d'une version : signature du manifest par l'administrateur qui publie.</summary>
|
||||
public sealed record PublicationCommitRequest
|
||||
{
|
||||
/// <summary>Signature du contenu du manifest envoyé (<see cref="ContentSigning.ManifestDigest(ManifestContent)"/>).</summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du manifest en ligne d'un jeu (avec le condensé calculé par le serveur).</summary>
|
||||
public sealed record ManifestSignatureState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Condensé du contenu signé de la version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé, si le serveur la connaît.</summary>
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature de la version en ligne (déjà publiée, par exemple avec WinSCP) à partir du manifest.json du dossier
|
||||
/// de publication : le serveur ne l'accepte que si le contenu en ligne est identique.
|
||||
/// </summary>
|
||||
public sealed record ManifestSignRequest
|
||||
{
|
||||
/// <summary>Condensé calculé par le launcher à partir du manifest.json local.</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Signatures du contenu, pour la page « Signature » (propriétaires).</summary>
|
||||
public sealed record SigningState
|
||||
{
|
||||
/// <summary>Trousseau publié (null : aucun).</summary>
|
||||
public KeyringDocument? Keyring { get; init; }
|
||||
|
||||
/// <summary>Faux : publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou catalogue absent).</summary>
|
||||
public bool KeyringPublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du trousseau (à mettre dans « keyringUrl » de launcher.json).</summary>
|
||||
public string? KeyringPublicUrl { get; init; }
|
||||
|
||||
/// <summary>Vrai : le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require).</summary>
|
||||
public bool RequireSignatures { get; init; }
|
||||
|
||||
public List<ManifestSignatureState> Manifests { get; init; } = new();
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
|
||||
/// <summary>Formulaires des demandes (null : publication des formulaires non configurée, ou serveur antérieur à la 3.2.1).</summary>
|
||||
public FeedbackFormsSignatureState? FeedbackForms { get; init; }
|
||||
|
||||
/// <summary>Mode maintenance (null : publication non configurée, ou serveur antérieur à la 3.3.0).</summary>
|
||||
public MaintenanceSignatureState? Maintenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
{
|
||||
public long Revision { get; init; }
|
||||
public string Digest { get; init; } = "";
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature des formulaires des demandes en vigueur (feedback-forms.json).</summary>
|
||||
public sealed record FeedbackFormsSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : formulaires d'origine, intégrés au launcher, jamais publiés).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : formulaires d'origine, ou fichier illisible).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
|
||||
/// <summary>Vrai : le fichier de référence des formulaires est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du mode maintenance en vigueur (maintenance.json).</summary>
|
||||
public sealed record MaintenanceSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : jamais publié, maintenance inactive).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : jamais publié, ou fichier illisible).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
|
||||
/// <summary>Vrai : le fichier de référence du mode maintenance est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,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É (repris par le raccourci du Bureau ; 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,209 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue des jeux côté joueur : catalog.json (statique, publié depuis le menu d'administration)
|
||||
/// téléchargé, contrôlé et borné (<see cref="CatalogRules"/> : une valeur invalide est ignorée), puis
|
||||
/// mis en cache dans le dossier des données du launcher. Sans réseau : dernier cache, sinon les
|
||||
/// valeurs de launcher.json. Images (icône, fond) : PNG ou JPEG du serveur du catalogue, 4 Mo au plus,
|
||||
/// téléchargées une fois puis conservées en cache. Image envoyée depuis le menu d'administration (nom =
|
||||
/// empreinte SHA-256 du contenu, <see cref="CatalogImages"/>) : l'empreinte est vérifiée au téléchargement ;
|
||||
/// l'adresse figurant dans le catalogue signé, l'image est ainsi couverte par sa signature.
|
||||
/// </summary>
|
||||
public sealed class CatalogService
|
||||
{
|
||||
public const long MaxImageBytes = CatalogImages.MaxBytes;
|
||||
private const string FolderName = "catalog";
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri _catalogUrl;
|
||||
|
||||
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ContentTrustService? _trust;
|
||||
|
||||
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl, ContentTrustService? trust = null)
|
||||
: this(http.GetBytesAsync, dataDirectory, catalogUrl, trust) { }
|
||||
|
||||
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
|
||||
/// <param name="trust">Vérification de la signature (phase 7) : en mode « require », un catalogue refusé est ignoré.</param>
|
||||
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl,
|
||||
ContentTrustService? trust = null)
|
||||
{
|
||||
_download = download;
|
||||
_catalogUrl = catalogUrl;
|
||||
_trust = trust;
|
||||
Directory = Path.Combine(dataDirectory, FolderName);
|
||||
CachePath = Path.Combine(Directory, "catalog.json");
|
||||
ImagesDirectory = Path.Combine(Directory, "images");
|
||||
}
|
||||
|
||||
public string Directory { get; }
|
||||
public string CachePath { get; }
|
||||
public string ImagesDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Catalogue du cache (null : absent, illisible ou, en mode « require », non signé). Le cache contient le
|
||||
/// catalogue tel que publié (signature comprise) : il est revérifié puis contrôlé à chaque lecture.
|
||||
/// </summary>
|
||||
public CatalogDocument? LoadCached()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
|
||||
if (ParseRaw(File.ReadAllBytes(CachePath)) is not { } raw) return null;
|
||||
if (_trust is not null && !_trust.VerifyCachedCatalog(raw)) return null;
|
||||
return CatalogRules.Normalize(raw, _catalogUrl.Host, new List<string>());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
|
||||
{
|
||||
Log.Warn($"Catalogue en cache illisible : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge le catalogue, le contrôle et le met en cache. Null si le serveur ne répond pas ou si
|
||||
/// le fichier est inutilisable (le cache est alors conservé tel quel).
|
||||
/// </summary>
|
||||
public async Task<CatalogDocument?> FetchAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
|
||||
if (ParseRaw(bytes) is not { } raw) return null;
|
||||
// Signature vérifiée sur le catalogue tel que publié, avant tout contrôle des valeurs.
|
||||
if (_trust is not null && !await _trust.VerifyCatalogAsync(raw, ct).ConfigureAwait(false)) return null;
|
||||
var errors = new List<string>();
|
||||
var catalog = CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
|
||||
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
|
||||
|
||||
// Cache : le catalogue tel que publié (la signature reste vérifiable hors ligne).
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(raw, ApiJson.Options));
|
||||
return catalog;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
|
||||
InvalidDataException or JsonException or OperationCanceledException)
|
||||
{
|
||||
Log.Warn($"Catalogue des jeux indisponible ({_catalogUrl.Host}) : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Catalogue tel que publié (null : format non pris en charge).</summary>
|
||||
private static CatalogDocument? ParseRaw(byte[] bytes)
|
||||
{
|
||||
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
|
||||
?? throw new InvalidDataException("catalogue vide");
|
||||
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
|
||||
{
|
||||
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images
|
||||
|
||||
/// <summary>Image déjà en cache (null sinon) : utilisable immédiatement, même hors ligne.</summary>
|
||||
public string? CachedImage(string? url)
|
||||
{
|
||||
if (ImagePath(url) is not { } path) return null;
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image en cache, sinon téléchargée (PNG ou JPEG contrôlé). Null si indisponible. Appels simultanés
|
||||
/// pour la même image (démarrage, puis arrière-plan) : un seul téléchargement, partagé.
|
||||
/// </summary>
|
||||
public Task<string?> GetImageAsync(string? url)
|
||||
{
|
||||
if (ImagePath(url) is not { } path) return Task.FromResult<string?>(null);
|
||||
if (File.Exists(path)) return Task.FromResult<string?>(path);
|
||||
var download = _imageDownloads.GetOrAdd(path, _ => new Lazy<Task<string?>>(() => DownloadImageAsync(url!, path)));
|
||||
return download.Value;
|
||||
}
|
||||
|
||||
private async Task<string?> DownloadImageAsync(string url, string path)
|
||||
{
|
||||
var temp = $"{path}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
var bytes = await _download(new Uri(url), MaxImageBytes, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!IsPngOrJpeg(bytes)) throw new InvalidDataException("ce n'est pas une image PNG ou JPEG");
|
||||
if (CatalogImages.ExpectedSha256(new Uri(url)) is { } expected &&
|
||||
!string.Equals(Convert.ToHexString(SHA256.HashData(bytes)), expected, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("son contenu ne correspond pas à l'empreinte de son nom (image modifiée sur le serveur ?)");
|
||||
System.IO.Directory.CreateDirectory(ImagesDirectory);
|
||||
await File.WriteAllBytesAsync(temp, bytes).ConfigureAwait(false);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
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>
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, à côté du trousseau), lu par le launcher
|
||||
/// des joueurs :
|
||||
/// • au démarrage, AVANT la recherche des mises à jour des jeux (le launcher l'attend quelques secondes au plus) ;
|
||||
/// • puis toutes les <see cref="NormalInterval"/> (<see cref="ActiveInterval"/> pendant une maintenance, pour en voir la
|
||||
/// fin rapidement) et à la demande (publication depuis le menu d'administration de ce PC) ;
|
||||
/// • fichier borné en taille, contrôlé avec les règles du mode maintenance (même signé, un fichier hors limites est
|
||||
/// refusé) ; signature vérifiée avec le trousseau (autorisation « maintenance »), retour en arrière refusé (révision
|
||||
/// et date de signature), selon le mode « contentSigning » du launcher, comme le catalogue.
|
||||
/// Rien n'est conservé sur le disque (hormis la date de la dernière signature acceptée, dans l'état des signatures) :
|
||||
/// au démarrage, un serveur injoignable ne bloque jamais le launcher (le jeu installé reste jouable hors ligne). Pendant
|
||||
/// que le launcher tourne, une coupure ou un fichier refusé garde le dernier état vérifié ; un fichier absent (404)
|
||||
/// termine la maintenance.
|
||||
/// Ce n'est pas une barrière de sécurité (un launcher modifié peut l'ignorer) : c'est un affichage pour les joueurs.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceService
|
||||
{
|
||||
public static readonly TimeSpan NormalInterval = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan ActiveInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly object _stateLock = new();
|
||||
private byte[]? _lastAccepted;
|
||||
private bool _unreachableLogged;
|
||||
private MaintenanceDocument? _current;
|
||||
|
||||
/// <param name="url">Adresse du fichier publié (null : jamais de maintenance).</param>
|
||||
/// <param name="trust">Vérification des signatures (null : mode « off », document utilisé sans signature).</param>
|
||||
/// <param name="time">Horloge (tests).</param>
|
||||
public MaintenanceService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, ContentTrustService? trust,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_download = download;
|
||||
Url = url;
|
||||
_trust = trust;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Uri? Url { get; }
|
||||
|
||||
/// <summary>Dernier état accepté (null : rien de connu, ou rien de publié).</summary>
|
||||
public MaintenanceDocument? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock) return _current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maintenance en cours (dernier état vérifié).</summary>
|
||||
public bool IsActive => Current is { Enabled: true };
|
||||
|
||||
/// <summary>
|
||||
/// L'état affiché change (début, fin, nouveau message). Levé hors du fil de l'interface : l'appelant s'y ramène.
|
||||
/// Null : plus de maintenance (fichier absent).
|
||||
/// </summary>
|
||||
public event Action<MaintenanceDocument?>? Changed;
|
||||
|
||||
/// <summary>Adresse du fichier publié, déduite de celle du trousseau (ou, à défaut, du catalogue) : même dossier.</summary>
|
||||
public static Uri? PublishedUrl(string? keyringUrl, string? catalogUrl)
|
||||
{
|
||||
var baseUrl = keyringUrl ?? catalogUrl;
|
||||
return baseUrl is not null && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ? new Uri(uri, "maintenance.json") : null;
|
||||
}
|
||||
|
||||
/// <summary>Relit le fichier publié. Jamais d'exception (sauf annulation demandée par l'appelant).</summary>
|
||||
/// <returns>Vrai : maintenance en cours (dernier état vérifié).</returns>
|
||||
public async Task<bool> RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Url is null) return false;
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
bytes = await _download(Url, MaintenanceRules.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
_unreachableLogged = false;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Rien de publié (ou fichier retiré à la main) : pas de maintenance.
|
||||
_unreachableLogged = false;
|
||||
_lastAccepted = null;
|
||||
Apply(null);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande : dernier état vérifié conservé, nouvel essai plus tard.
|
||||
if (!_unreachableLogged)
|
||||
Log.Warn($"Mode maintenance injoignable : {ex.Message}" + (_current is { Enabled: true } ? " (maintenance toujours affichée)." : "."));
|
||||
_unreachableLogged = true;
|
||||
return _current is { Enabled: true };
|
||||
}
|
||||
|
||||
// Même fichier que la dernière fois : déjà vérifié.
|
||||
if (_lastAccepted is not null && bytes.AsSpan().SequenceEqual(_lastAccepted)) return _current is { Enabled: true };
|
||||
|
||||
var document = await AcceptAsync(bytes, ct).ConfigureAwait(false);
|
||||
if (document is null) return _current is { Enabled: true };
|
||||
_lastAccepted = bytes;
|
||||
Apply(document);
|
||||
return document.Enabled;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Relecture périodique jusqu'à la fermeture du launcher (rythme plus rapide pendant une maintenance).</summary>
|
||||
public async Task RunAsync(CancellationToken lifetime)
|
||||
{
|
||||
if (Url is null) return;
|
||||
try
|
||||
{
|
||||
while (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(IsActive ? ActiveInterval : NormalInterval, _time, lifetime).ConfigureAwait(false);
|
||||
await RefreshAsync(lifetime).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
|
||||
{
|
||||
// fermeture du launcher
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Relecture du mode maintenance interrompue", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier lisible, dans les règles, pas plus ancien que l'état connu, à la signature acceptée ; sinon null.</summary>
|
||||
private async Task<MaintenanceDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
MaintenanceDocument? document;
|
||||
try
|
||||
{
|
||||
document = MaintenanceRules.Parse(bytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log.Error($"Mode maintenance illisible ({ex.Message}) : ignoré.");
|
||||
return null;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors))
|
||||
{
|
||||
Log.Error($"Mode maintenance refusé : {errors[0]}");
|
||||
return null;
|
||||
}
|
||||
if (_current is { } current && document!.Revision < current.Revision)
|
||||
{
|
||||
Log.Error($"Mode maintenance révision {document.Revision} refusé : plus ancien que la révision {current.Revision} déjà vue (retour en arrière).");
|
||||
return null;
|
||||
}
|
||||
if (_trust is not null && !await _trust.VerifyMaintenanceAsync(document!, ct).ConfigureAwait(false)) return null;
|
||||
return document;
|
||||
}
|
||||
|
||||
private void Apply(MaintenanceDocument? document)
|
||||
{
|
||||
MaintenanceDocument? previous;
|
||||
lock (_stateLock)
|
||||
{
|
||||
previous = _current;
|
||||
_current = document;
|
||||
}
|
||||
if (SameDisplay(previous, document)) return;
|
||||
Log.Info(document is { Enabled: true }
|
||||
? $"Mode maintenance ACTIVÉ (révision {document.Revision}) : tout est bloqué sauf la désinstallation et l'administration."
|
||||
: previous is { Enabled: true } ? "Mode maintenance terminé." : $"Mode maintenance : inactif (révision {document?.Revision ?? 0}).");
|
||||
try
|
||||
{
|
||||
Changed?.Invoke(document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Application du mode maintenance", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Même affichage pour le joueur (état, message, début) : rien à signaler.</summary>
|
||||
private static bool SameDisplay(MaintenanceDocument? a, MaintenanceDocument? b)
|
||||
{
|
||||
var activeA = a is { Enabled: true };
|
||||
var activeB = b is { Enabled: true };
|
||||
if (!activeA && !activeB) return true;
|
||||
return activeA == activeB && a!.Message == b!.Message && a.Since == b.Since;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
@@ -39,9 +41,28 @@ public sealed class GameManifest
|
||||
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signature de l'administrateur qui a mis la version en ligne (phase 7), vérifiée avec le trousseau
|
||||
/// (<see cref="Trust.ContentTrustService"/>). Absente des manifests produits par ManifestBuilder.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long TotalSize => Files.Sum(f => f.Size);
|
||||
|
||||
/// <summary>Partie couverte par la signature (tout ce qui décide de ce qui est installé et exécuté).</summary>
|
||||
public ManifestContent ToSignedContent() => new()
|
||||
{
|
||||
FormatVersion = FormatVersion,
|
||||
GameId = GameId,
|
||||
Version = Version,
|
||||
Executable = Executable,
|
||||
LaunchArguments = LaunchArguments,
|
||||
Archive = new ManifestContentArchive { Url = Archive.Url, Size = Archive.Size, Sha256 = Archive.Sha256 },
|
||||
ObjectsBaseUrl = ObjectsBaseUrl,
|
||||
Files = Files.Select(f => new ManifestContentFile { Path = f.Path, Size = f.Size, Sha256 = f.Sha256 }).ToList()
|
||||
};
|
||||
|
||||
/// <summary>URL d'où le manifest a été téléchargé (sert à résoudre les URL relatives).</summary>
|
||||
[JsonIgnore]
|
||||
public Uri? SourceUri { get; set; }
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
@@ -34,21 +34,34 @@ public sealed class LauncherCatalog
|
||||
Publisher = publisher;
|
||||
DefaultGameId = defaultGameId;
|
||||
Games = games;
|
||||
DeclaredGames = games;
|
||||
DeclaredLauncherName = launcherName;
|
||||
DeclaredDefaultGameId = defaultGameId;
|
||||
IsMultiGameFormat = isMultiGameFormat;
|
||||
}
|
||||
|
||||
/// <summary>Identifiant technique du launcher (dossier de données, instance unique).</summary>
|
||||
public string LauncherId { get; }
|
||||
|
||||
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés.</summary>
|
||||
public string LauncherName { get; }
|
||||
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés (modifiable par le catalogue).</summary>
|
||||
public string LauncherName { get; private set; }
|
||||
|
||||
public string Publisher { get; }
|
||||
|
||||
/// <summary>Jeu sélectionné au premier lancement (sinon le premier de la liste).</summary>
|
||||
public string? DefaultGameId { get; }
|
||||
public string? DefaultGameId { get; private set; }
|
||||
|
||||
public IReadOnlyList<LauncherConfig> Games { get; }
|
||||
/// <summary>Jeux affichés, dans l'ordre (le catalogue peut changer l'ordre et masquer des jeux non installés).</summary>
|
||||
public IReadOnlyList<LauncherConfig> Games { get; private set; }
|
||||
|
||||
/// <summary>Tous les jeux déclarés dans launcher.json, y compris ceux masqués par le catalogue.</summary>
|
||||
public IReadOnlyList<LauncherConfig> DeclaredGames { get; }
|
||||
|
||||
/// <summary>Nom du launcher selon launcher.json (avant le catalogue).</summary>
|
||||
public string DeclaredLauncherName { get; }
|
||||
|
||||
/// <summary>Jeu par défaut selon launcher.json (avant le catalogue).</summary>
|
||||
public string? DeclaredDefaultGameId { get; }
|
||||
|
||||
/// <summary>Vrai si launcher.json utilise le tableau "games" (même avec un seul jeu).</summary>
|
||||
public bool IsMultiGameFormat { get; }
|
||||
@@ -75,6 +88,52 @@ public sealed class LauncherCatalog
|
||||
/// <summary>Raison pour laquelle « apiUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? ApiUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du catalogue des jeux (catalog.json : informations modifiables publiées depuis le menu
|
||||
/// d'administration), ex. « https://launcher.lanisys.fr/catalog/catalog.json ». Facultatif. Comme
|
||||
/// <see cref="ApiUrl"/>, le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public string? CatalogUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? CatalogUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du trousseau (keyring.json : clés autorisées à signer le contenu, signé par la clé racine du
|
||||
/// launcher), ex. « https://launcher.lanisys.fr/catalog/keyring.json ». Facultatif. Comme <see cref="ApiUrl"/>,
|
||||
/// le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public string? KeyringUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « keyringUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? KeyringUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « contentSigning » : vérification des signatures du manifest et du catalogue. « warn » (défaut si
|
||||
/// keyringUrl est renseignée) : un contenu non signé ou mal signé est signalé dans le journal mais utilisé ;
|
||||
/// « require » : il est refusé (aucune installation ni mise à jour, catalogue ignoré) ; « off » : rien n'est vérifié.
|
||||
/// Comme keyringUrl, lu uniquement dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public Trust.ContentSigningMode ContentSigning { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « contentSigning » a été ignorée ou est incohérente, sinon null.</summary>
|
||||
public string? ContentSigningError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « keyringMinSequence » : numéro minimal du trousseau accepté (0 par défaut). Un trousseau plus ancien, même
|
||||
/// signé par la clé racine, est refusé : à relever dans le launcher publié après le retrait d'une clé compromise,
|
||||
/// pour qu'une installation neuve n'accepte pas un ancien trousseau qui la contenait encore. Intégré à l'exe.
|
||||
/// </summary>
|
||||
public long KeyringMinSequence { get; private set; }
|
||||
|
||||
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
|
||||
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
|
||||
{
|
||||
LauncherName = launcherName;
|
||||
DefaultGameId = defaultGameId;
|
||||
Games = games;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom du dossier des données du launcher et de sa clé de registre (« dataFolder » dans
|
||||
/// launcher.json, « GameLauncher » par défaut). Indépendant de <see cref="LauncherId"/>, qui
|
||||
@@ -215,10 +274,11 @@ public sealed class LauncherCatalog
|
||||
? $@"Local\{Publisher}.{Games[0].GameId}.Launcher"
|
||||
: $@"Local\{Publisher}.{LauncherId}";
|
||||
|
||||
/// <summary>Jeu déclaré dans launcher.json (même s'il est masqué par le catalogue).</summary>
|
||||
public LauncherConfig? Find(string? gameId) =>
|
||||
string.IsNullOrWhiteSpace(gameId)
|
||||
? null
|
||||
: Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
|
||||
: DeclaredGames.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static LauncherCatalog Load(string path)
|
||||
{
|
||||
@@ -255,8 +315,10 @@ public sealed class LauncherCatalog
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var singleApiUrlError),
|
||||
ApiUrlError = singleApiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
|
||||
CatalogUrlError = singleCatalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
if (gamesNode is not JsonArray array || array.Count == 0)
|
||||
@@ -308,8 +370,40 @@ public sealed class LauncherCatalog
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var apiUrlError),
|
||||
ApiUrlError = apiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
|
||||
CatalogUrlError = catalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
/// <summary>« keyringUrl », « contentSigning » et « keyringMinSequence » (phase 7).</summary>
|
||||
private LauncherCatalog WithContentSigning(JsonObject root)
|
||||
{
|
||||
KeyringUrl = ReadTrustedUrl(root, "keyringUrl", "https://launcher.exemple.fr/catalog/keyring.json", out var keyringError);
|
||||
KeyringUrlError = keyringError;
|
||||
var text = Get(root, "contentSigning") is JsonValue value && value.TryGetValue<string>(out var s) ? s.Trim() : null;
|
||||
var declared = Get(root, "contentSigning") is not null;
|
||||
(ContentSigning, ContentSigningError) = text?.ToLowerInvariant() switch
|
||||
{
|
||||
"off" => (Trust.ContentSigningMode.Off, null),
|
||||
"warn" => (Trust.ContentSigningMode.Warn, null),
|
||||
"require" => (Trust.ContentSigningMode.Require, null),
|
||||
_ when !declared => (KeyringUrl is null ? Trust.ContentSigningMode.Off : Trust.ContentSigningMode.Warn, null),
|
||||
// Valeur invalide : prudence (vérification sans blocage), signalée dans le journal.
|
||||
_ => (Trust.ContentSigningMode.Warn,
|
||||
"launcher.json : contentSigning ignorée (valeurs possibles : \"off\", \"warn\", \"require\") : vérification sans blocage (« warn »).")
|
||||
};
|
||||
if (Get(root, "keyringMinSequence") is { } minNode)
|
||||
{
|
||||
if (minNode is JsonValue minValue && minValue.TryGetValue<long>(out var min) && min >= 0)
|
||||
KeyringMinSequence = min;
|
||||
else
|
||||
ContentSigningError ??= "launcher.json : keyringMinSequence ignorée (nombre entier positif attendu).";
|
||||
}
|
||||
if (ContentSigning == Trust.ContentSigningMode.Require && KeyringUrl is null)
|
||||
ContentSigningError = "launcher.json : contentSigning « require » sans keyringUrl valide : aucun manifest ni catalogue ne pourra être " +
|
||||
"vérifié, donc aucune installation ni mise à jour ne sera possible.";
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
@@ -333,6 +427,24 @@ public sealed class LauncherCatalog
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
@@ -14,7 +15,7 @@ public sealed class LauncherConfig
|
||||
|
||||
public string GameName { get; set; } = "Mon Jeu";
|
||||
|
||||
public string Publisher { get; set; } = "Xbhost";
|
||||
public string Publisher { get; set; } = "LanisysGames";
|
||||
|
||||
public string Tagline { get; set; } = "";
|
||||
|
||||
@@ -47,6 +48,27 @@ public sealed class LauncherConfig
|
||||
/// <summary>Image de fond remplaçant celle du thème (image, chemin relatif au launcher). Facultatif.</summary>
|
||||
public string? Background { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (affichage modifiable)
|
||||
|
||||
/// <summary>
|
||||
/// Nom AFFICHÉ, donné par le catalogue (repris par le nom du raccourci du Bureau). <see cref="GameName"/>
|
||||
/// reste le nom de launcher.json : celui de « Applications installées » et du dossier des données du jeu.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>Nom à afficher : celui du catalogue, sinon celui de launcher.json.</summary>
|
||||
[JsonIgnore]
|
||||
public string ShownName => string.IsNullOrWhiteSpace(DisplayName) ? GameName : DisplayName;
|
||||
|
||||
/// <summary>Présentation du jeu (catalogue, texte brut de plusieurs lignes).</summary>
|
||||
[JsonIgnore]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Liens affichés sous la présentation (catalogue).</summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<GameLink> Links { get; set; } = Array.Empty<GameLink>();
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du jeu Unity : « Société\Produit » des Player Settings de Unity
|
||||
/// (ex. "LanisysGames\\Exovival Zombie Reborn"). Sert à ouvrir, depuis les paramètres du jeu,
|
||||
@@ -127,3 +149,7 @@ public sealed class LauncherConfig
|
||||
part.Trim() == part && part.IndexOfAny(invalid) < 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lien affiché sous la présentation d'un jeu (Discord, YouTube…), adresse https contrôlée.</summary>
|
||||
/// <param name="Kind">Type de lien (icône intégrée) : website, discord, youtube, x, twitch, steam, other.</param>
|
||||
public sealed record GameLink(string Kind, string Label, string Url);
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Core.SelfUpdate;
|
||||
@@ -49,9 +50,58 @@ public sealed partial class LauncherRelease
|
||||
/// </summary>
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
|
||||
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
|
||||
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
|
||||
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
|
||||
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
|
||||
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
|
||||
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
|
||||
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
|
||||
/// </summary>
|
||||
public string? PriorityVersion { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Version ParsedVersion => VersionUtil.Parse(Version);
|
||||
|
||||
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
|
||||
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
|
||||
/// </summary>
|
||||
public bool IsPriorityFor(Version currentVersion) =>
|
||||
TryGetPriorityVersion(out var priority) && priority > currentVersion;
|
||||
|
||||
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
|
||||
public bool TryGetPriorityVersion(out Version priority)
|
||||
{
|
||||
priority = new Version(0, 0, 0);
|
||||
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
|
||||
/// <list type="bullet">
|
||||
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
|
||||
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
|
||||
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
|
||||
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
|
||||
{
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
|
||||
if (markPriority) return VersionUtil.Display(published);
|
||||
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
|
||||
return null;
|
||||
return VersionUtil.Display(carried);
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Format != 1)
|
||||
@@ -66,6 +116,12 @@ public sealed partial class LauncherRelease
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
|
||||
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
|
||||
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
|
||||
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
|
||||
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
|
||||
else if (priority > ParsedVersion)
|
||||
throw new UpdateSecurityException(
|
||||
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
|
||||
Notes ??= new List<string>();
|
||||
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
|
||||
Changelog = ChangelogLimits.Normalize(Changelog);
|
||||
|
||||
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
|
||||
public bool IsAvailable => Release is not null && DownloadUri is not null;
|
||||
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
|
||||
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
|
||||
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
|
||||
/// </summary>
|
||||
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Historique des notes de version lors d'une publication (ManifestBuilder). Avec --remote-manifest,
|
||||
/// l'historique est repris du manifest EN LIGNE : les corrections faites depuis le menu
|
||||
/// d'administration ne sont pas écrasées par le manifest.json local (resté dans son état d'origine).
|
||||
/// </summary>
|
||||
public static class ChangelogHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest publié (null s'il n'existe pas encore : première publication). Toute autre erreur
|
||||
/// (réseau, fichier illisible) est signalée : publier sans elle perdrait les corrections en ligne.
|
||||
/// </summary>
|
||||
public static async Task<GameManifest?> FetchPublishedAsync(HttpDownloader http, Uri url, CancellationToken ct = default)
|
||||
{
|
||||
if (url.Scheme != Uri.UriSchemeHttps && !(url.Scheme == Uri.UriSchemeHttp && url.IsLoopback))
|
||||
throw new ArgumentException($"Adresse du manifest en ligne : https:// attendu ({url}).");
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = await http.GetStringAsync(url, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)
|
||||
?? throw new InvalidDataException("manifest vide");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"manifest illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes de la version publiée en tête, puis l'historique (sans cette version si elle est republiée
|
||||
/// avec de nouvelles notes), bornés comme par le launcher. Sans nouvelles notes (pas de --notes),
|
||||
/// les notes existantes de la version, éventuellement corrigées en ligne, sont conservées.
|
||||
/// </summary>
|
||||
public static List<ChangelogEntry> Build(IEnumerable<ChangelogEntry?>? history, ChangelogEntry entry)
|
||||
{
|
||||
var list = (history ?? Enumerable.Empty<ChangelogEntry?>()).OfType<ChangelogEntry>().ToList();
|
||||
if (!entry.IsEmpty)
|
||||
{
|
||||
list.RemoveAll(c => string.Equals(c.Version, entry.Version, StringComparison.Ordinal));
|
||||
list.Insert(0, entry);
|
||||
}
|
||||
return ChangelogLimits.Normalize(list);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -11,8 +11,15 @@ namespace GameLauncher.Core.Services;
|
||||
public sealed class HttpDownloader : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(30);
|
||||
private const int MaxSameHostRedirects = 3;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
/// <summary>
|
||||
/// Client SANS redirection automatique (catalogue, images) : une redirection n'est suivie que vers le
|
||||
/// même serveur, après vérification, et aucune requête ne part vers un autre serveur.
|
||||
/// </summary>
|
||||
private readonly HttpClient _strictHttp;
|
||||
|
||||
public HttpDownloader(string userAgent)
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
@@ -25,6 +32,15 @@ public sealed class HttpDownloader : IDisposable
|
||||
};
|
||||
_http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
|
||||
_strictHttp = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(15),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
_strictHttp.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
/// <summary>Taille maximale par défaut d'un document texte (manifest du jeu).</summary>
|
||||
@@ -79,6 +95,64 @@ public sealed class HttpDownloader : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge un petit fichier en mémoire (catalogue, image) sans passer par les caches, en
|
||||
/// refusant toute redirection vers un autre serveur ou vers une adresse non https.
|
||||
/// Au-delà de <paramref name="maxBytes"/> octets, le téléchargement est interrompu.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GetBytesAsync(Uri uri, long maxBytes, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
var current = uri;
|
||||
HttpResponseMessage response;
|
||||
for (var redirects = 0; ; redirects++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true };
|
||||
request.Headers.Pragma.ParseAdd("no-cache");
|
||||
response = await _strictHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
var status = (int)response.StatusCode;
|
||||
if (status is < 300 or > 399 || status == 304) break;
|
||||
|
||||
// Redirection : suivie seulement vers le même serveur (hôte, schéma, port), quelques fois au plus.
|
||||
var location = response.Headers.Location;
|
||||
response.Dispose();
|
||||
var next = location is null ? null : location.IsAbsoluteUri ? location : new Uri(current, location);
|
||||
if (next is null || redirects >= MaxSameHostRedirects ||
|
||||
!string.Equals(next.Host, uri.Host, StringComparison.OrdinalIgnoreCase) ||
|
||||
next.Scheme != uri.Scheme || next.Port != uri.Port)
|
||||
throw new InvalidDataException($"Redirection refusée ({uri.Host} → {next?.Host ?? "?"}).");
|
||||
current = next;
|
||||
}
|
||||
|
||||
using var _ = response;
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength > maxBytes)
|
||||
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[81920];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, timeout.Token).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > maxBytes)
|
||||
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Le serveur ne répond pas ({uri.Host}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge <paramref name="uri"/> vers <paramref name="destination"/>.
|
||||
/// Si <paramref name="allowResume"/> est vrai et qu'un fichier partiel existe, le
|
||||
@@ -155,5 +229,9 @@ public sealed class HttpDownloader : IDisposable
|
||||
throw new IOException($"Téléchargement incomplet ({Format.Bytes(position)} sur {Format.Bytes(expectedSize)}).");
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
_strictHttp.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
@@ -8,11 +10,16 @@ public sealed class ManifestClient
|
||||
{
|
||||
private readonly HttpDownloader _http;
|
||||
private readonly Uri _manifestUri;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly string? _gameId;
|
||||
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri)
|
||||
/// <param name="trust">Vérification des signatures (phase 7), avec l'identifiant du jeu de launcher.json.</param>
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri, ContentTrustService? trust = null, string? gameId = null)
|
||||
{
|
||||
_http = http;
|
||||
_manifestUri = manifestUri;
|
||||
_trust = trust;
|
||||
_gameId = gameId;
|
||||
}
|
||||
|
||||
public async Task<GameManifest> FetchAsync(CancellationToken ct = default)
|
||||
@@ -31,6 +38,8 @@ public sealed class ManifestClient
|
||||
|
||||
manifest.SourceUri = _manifestUri;
|
||||
manifest.Validate();
|
||||
// Signature (phase 7) : refus en mode « require » (ContentSecurityException), sinon journal seulement.
|
||||
if (_trust is not null && _gameId is not null) await _trust.VerifyManifestAsync(manifest, _gameId, ct).ConfigureAwait(false);
|
||||
// Notes de version bornées et nettoyées : elles sont affichées telles quelles dans l'interface.
|
||||
manifest.Changelog = ChangelogLimits.Normalize(manifest.Changelog);
|
||||
Log.Info($"Manifest récupéré : version {manifest.Version}, {manifest.Files.Count} fichiers, {Format.Bytes(manifest.TotalSize)}.");
|
||||
|
||||
Loaded 100 of 342 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user