Compare commits

...
88 Commits
Author SHA1 Message Date
nicolas 5ac3d153ab Version 2.3.0 2026-11-07 11:50:35 +01:00
nicolas 7d26a4d59c docs: pages multi-jeu 2026-11-07 11:40:26 +01:00
nicolas 924735a0dc chore(uisnapshot): captures avec choix du jeu 2026-11-07 11:40:09 +01:00
nicolas d5a992ad62 test: pages multi-jeu 2026-11-07 11:39:50 +01:00
nicolas e2523d1bff feat(ui): carte de choix du jeu, liste « Jeu » et texte indicatif des listes déroulantes
src/GameLauncher/Themes/Admin.xaml : style des tuiles, modèles de choix et de changement de jeu, utilisés par les deux pages, texte indicatif du nettoyage.
src/GameLauncher/Themes/Controls.xaml : texte indicatif des listes déroulantes vides.
2026-11-07 11:39:33 +01:00
nicolas d444940b63 feat(admin): choix du jeu obligatoire pour les pages Notes de version et Publier une version
src/GameLauncher.Modules.Admin/ViewModels/GameTile.cs (nouveau) : tuiles et règle du jeu choisi d’office.
src/GameLauncher.Modules.Admin/Changelog/ChangelogPanelViewModel.cs
src/GameLauncher.Modules.Admin/Publishing/PublishPanelViewModel.cs
src/GameLauncher.Modules.Admin/Files/FilesPanelViewModel.cs
2026-11-07 11:39:04 +01:00
nicolas 8e69ec108e Merge pull request 'Dev/2.2.0' (#117) from dev/2.2.0 into master
Reviewed-on: #117
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 11:04:50 +01:00
nicolas 84bcb80108 Version 2.2.0 2026-11-07 11:02:29 +01:00
nicolas e9f650ef9e docs: traçabilité des consultations et exports du journal 2026-11-07 10:53:55 +01:00
nicolas 27b57526ee chore(uisnapshot): route d'export et exemples de lignes 2026-11-07 10:53:44 +01:00
nicolas 3996f580f6 test: consultations et exports du journal d'audit 2026-11-07 10:53:31 +01:00
nicolas ca0eb044e8 feat(admin): l'export CSV est annoncé au serveur avant l'écriture du fichier
src/GameLauncher.ApiClient/AdminSession.cs : nouvelle méthode RecordAuditExportAsync.
src/GameLauncher.Modules.Admin/Audit/AuditPanelViewModel.cs : annonce de l’export avant l’écriture du fichier, libellés des deux nouvelles actions et nouvelle catégorie de filtre.
2026-11-07 10:53:13 +01:00
nicolas 5b0999e47c feat(server): consultations et exports du journal d'audit inscrits au journal
src/GameLauncher.Contracts/Dtos/AuditDtos.cs : description de l’export (nombre d’entrées, période, filtres) et ses limites.
src/GameLauncher.Contracts/ApiRoutes.cs : nouvelle route v1/audit/export.
server/GameLauncher.Server/Modules/Audit/AuditModule.cs : inscription des consultations (avec la règle des 10 minutes pour les pages suivantes) et des exports, avec contrôle des données reçues.
2026-11-07 10:52:46 +01:00
nicolas 9f6210598c Version 2.2.0 2026-11-07 10:14:42 +01:00
nicolas 08e1476726 docs: Fichiers du serveur, période de l'audit, déploiement 2026-11-07 10:13:39 +01:00
nicolas 818716b7a9 test: Fichiers du serveur, nettoyage, période de l'audit 2026-11-07 10:13:13 +01:00
nicolas 4971c926ac chore(uisnapshot): fausses routes Fichiers et nettoyage 2026-11-07 10:12:50 +01:00
nicolas 99502a2003 feat(ui): barre d'actions fixe, molette sûre, textes indicatifs, page Fichiers 2026-11-07 10:12:28 +01:00
nicolas 81108699c3 feat(admin): retour en haut après une action, période de l'audit, page Fichiers 2026-11-07 10:11:56 +01:00
nicolas 7cce8676b4 feat(server): page Fichiers du serveur, nettoyage d'objects, filtre de date de l'audit 2026-11-07 10:10:05 +01:00
nicolas 0f8f64f00f Merge pull request 'Dev/2.1.0' (#115) from dev/2.1.0 into master
Reviewed-on: #115
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 08:53:25 +01:00
nicolas 68ada960cd Note de version 2.1.0 2026-11-07 08:51:08 +01:00
nicolas b08cad3e8e Ajout du dossier Claude outputs 2026-11-07 08:50:56 +01:00
nicolas 42fc1e58cc Version 2.1.0 2026-11-07 08:45:39 +01:00
nicolas b2f1415bff docs: raccourci, nom du launcher, blocage TPM
README.md : nom du raccourci, où apparaît le nom du launcher, comment le blocage TPM est lu.
2026-11-07 08:45:15 +01:00
nicolas d499bfb11a feat(raccourci): le raccourci du Bureau suit le nom affiché du jeu
src/GameLauncher/Services/ShortcutService.cs : nom affiché, renommage sans écrasement, repli sur le nom de launcher.json, suppression des raccourcis nommés par le launcher à la désinstallation.
src/GameLauncher/Services/Interfaces.cs : ajout du nom du raccourci, du renommage et du commentaire du raccourci.
src/GameLauncher/ViewModels/GameViewModel.cs : « Réparer » renomme le raccourci et le signale ; le bouton « Mettre à jour » affiche le nouveau nom.
src/GameLauncher.Modules.Admin/Catalog/CatalogEditors.cs : texte d’aide du champ « Nom affiché ».
src/GameLauncher.Core/Models/LauncherConfig.cs, src/GameLauncher.Core/Catalog/CatalogMerger.cs, src/GameLauncher.Contracts/Catalog/CatalogDocument.cs : commentaires du code mis à jour.

tools/UiSnapshot/Fakes/FakeServices.cs. J’y ai ajouté les deux membres demandés par l’interface :

ShortcutName : renvoie un nom fixe (« Exovival Zombie Reborn ») ;
RenameDesktopShortcuts() : renvoie 0 (aucun raccourci renommé).
2026-11-07 08:45:00 +01:00
nicolas bddd46e9bb fix(admin): la puce TPM n'est plus vue comme toujours bloquée
src/GameLauncher.Modules.Admin/Services/IAdminTpm.cs : nouvelle lecture de l’état de la puce (bon indicateur de blocage, contrôles de la réponse).
src/GameLauncher/Platform/TpmKeyStore.cs : utilise cette lecture ; l’ancienne, fautive, est supprimée.
tests/GameLauncher.Tests/Admin/AdminTpmTests.cs : 3 tests (PC normal non bloqué ; blocage détecté par l’indicateur ou par le compteur ; réponses en erreur ou incomplètes).
2026-11-07 08:43:54 +01:00
nicolas b774ca119f fix(admin): listes lisibles et sans fond blanc pendant le rechargement
src/GameLauncher/Themes/Admin.xaml : nouveau style de liste, couleur de texte des titres (jeux, versions, clés). Ce fichier contient aussi le texte d’aide « Nom du launcher » (point 2). Garde-le dans ce commit, ou indexe ces lignes à part.
src/GameLauncher/Views/Admin/AdminWindow.xaml : la navigation utilise le nouveau style.
src/GameLauncher/Views/Admin/LoginView.xaml : la liste des clés utilise le nouveau style.
2026-11-07 08:43:09 +01:00
nicolas fbfb456b5d Merge pull request 'Dev/2.0.0' (#114) from dev/2.0.0 into master
Reviewed-on: #114
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 08:01:45 +01:00
nicolas 15b234995b Ajout : tools/UiSnapshot/ (projet UiSnapshot.csproj, Program.cs, SnapshotOptions.cs, SnapshotRunner.cs, Capture.cs, Report.cs, Poke.cs, Fixture.cs, Fakes/FakeApi.cs, Fakes/FakeServices.cs, Fakes/SampleData.cs, Scenes/PlayerScenes.cs, Scenes/AdminScenes.cs, LISEZMOI.md)
Ajout : tools/ui-snapshot.bat
Modif : GameLauncher.sln — projet UiSnapshot dans le dossier tools (configurations Debug/Release, Any CPU/x64), ui-snapshot.bat dans Solution Items
Modif : src/GameLauncher/Views/MainWindow.xaml et Views/Admin/AdminWindow.xaml — l’icône utilise l’adresse complète pack://application:,,,/GameLauncher;component/Assets/launcher.ico, comme Default.xaml. Aucun effet sur le launcher, et l’outil la trouve même depuis un autre exécutable.
Modif : README.md — l’outil est ajouté au §2 (structure) et au §5 (personnalisation).
2026-11-07 07:58:27 +01:00
fred 3eada0b878 Merge pull request 'Admin/phase7' (#113) from admin/phase7 into dev/2.0.0
Reviewed-on: #113
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-01 21:40:51 +01:00
nicolas 92ae5cbe63 Version 2.0.0 2026-11-01 21:37:38 +01:00
nicolas 86593a43ee Montée en version 2.0.0 2026-11-01 21:26:15 +01:00
nicolas 8a7902e5e8 Version 2.0.0 2026-11-01 21:04:06 +01:00
nicolas 50ef1025d8 Changelog version 2.0.0 2026-11-01 20:54:52 +01:00
nicolas bcae86f1a1 Documentation : phase 7 (signature de bout en bout)
README.md, docs/ADMIN-ARCHITECTURE.md, server/deploy/LISEZMOI.md
2026-11-01 20:54:38 +01:00
nicolas bcde2a0dac Tests : signature du contenu, trousseau, page Signature, module serveur
tests/GameLauncher.Tests/Signing/SigningKit.cs, ContentSigningTests.cs, ContentTrustTests.cs (nouveaux)
Server/SigningModuleTests.cs, Admin/SigningPanelTests.cs (nouveaux)
Admin/AdminServicesTests.cs, Admin/AdminTestKit.cs
2026-11-01 20:54:11 +01:00
nicolas 86fef875a6 Outils : sign-keyring (signature hors ligne du trousseau, comparaison au précédent)
tools/LauncherPublisher/Program.cs, tools/sign-keyring.bat (nouveau), GameLauncher.sln
2026-11-01 20:53:13 +01:00
nicolas 1b397ec930 Launcher : configuration contentSigning/keyringMinSequence, blocage par sécurité, vues de la page Signature
src/GameLauncher/App.xaml.cs, Platform/LauncherUpdateService.cs, Services/AdminWindowService.cs, Themes/Admin.xaml, ViewModels/GameViewModel.cs, launcher.json
2026-11-01 20:52:45 +01:00
nicolas c89bd62aff Launcher (joueurs) : vérification du trousseau, du manifest et du catalogue (contentSigning)
src/GameLauncher.Core/Trust/ContentTrustService.cs (nouveau)
Models/GameManifest.cs, Models/LauncherCatalog.cs, Services/ManifestClient.cs, Catalog/CatalogService.cs
2026-11-01 20:51:37 +01:00
nicolas f556848da4 Menu d’administration : page Signature, signature à la publication, clé racine refusée à la connexion
src/GameLauncher.ApiClient/AdminSession.cs
src/GameLauncher.Modules.Admin/Signing/SigningAdminModule.cs, SigningPanelViewModel.cs (nouveaux)
Publishing/ReleaseUploader.cs, Publishing/PublishPanelViewModel.cs, Catalog/GamesPanelViewModel.cs, Accounts/AccountItems.cs, Services/AdminLoginService.cs, ViewModels/LoginViewModel.cs, ViewModels/AdminEnvironment.cs, ViewModels/AdminWorkspaceViewModel.cs
src/GameLauncher.Modules/Admin/IAdminContext.cs
2026-11-01 20:50:31 +01:00
nicolas 1cf9e87e82 Serveur : publication du trousseau, vérification des signatures du manifest et du catalogue
Nouveaux : server/GameLauncher.Server/Modules/Signing/SigningModule.cs, Security/ContentSignatures.cs, Storage/KeyringStore.cs, Storage/ManifestSignatures.cs, Storage/PublicFileWriter.cs
Modifiés : Hosting/IApiModule.cs, Hosting/LauncherApiOptions.cs, Storage/CatalogStore.cs, Storage/PublicationStore.cs, Modules/Catalog/CatalogModule.cs, Modules/Publishing/PublishingModule.cs, Modules/Admins/AdminsModule.cs, appsettings.json
server/deploy/nginx-catalog.conf
2026-11-01 20:48:51 +01:00
nicolas dc09c3ecef Contrats : trousseau des clés et signature du contenu (phase 7)
src/GameLauncher.Contracts/Signing/ContentSigning.cs, Keyring.cs, SigningDtos.cs (nouveaux)
Security/SignedMessages.cs, ApiRoutes.cs, Catalog/CatalogDocument.cs, Dtos/AdminDtos.cs
2026-11-01 20:46:19 +01:00
nicolas cbcc0b4f44 Documentation : phase 6 (administrateurs et journal d'audit) 2026-11-01 14:45:25 +01:00
nicolas d0db02d7f3 Tests : pages Mes appareils, Administrateurs et Journal d'audit
tests/GameLauncher.Tests/Admin/AccountsPanelTests.cs (nouveau, 4 tests)
tests/GameLauncher.Tests/Admin/AuditPanelTests.cs (nouveau, 2 tests)
2026-11-01 14:45:03 +01:00
nicolas 63c2e37dad Launcher : vues des pages Mes appareils, Administrateurs et Journal d'audit, enregistrement des modules
src/GameLauncher/Themes/Admin.xaml : modèles des trois pages, plus les liens du tableau de bord.
src/GameLauncher/Themes/Icons.xaml : icônes Laptop et AccountRemove.
src/GameLauncher/App.xaml.cs : AccountsAdminModule et AuditAdminModule.
2026-11-01 14:44:43 +01:00
nicolas 78780c20f1 Tableau de bord : liens vers « Administrateurs » et « Mes appareils » 2026-11-01 14:44:16 +01:00
nicolas 3b95317d4c Administration : page « Journal d'audit » (filtres, pages, export CSV) 2026-11-01 14:44:01 +01:00
nicolas bb165c4be0 Administration : pages « Mes appareils » et « Administrateurs » (ViewModels)
src/GameLauncher.Modules.Admin/Accounts/AccountItems.cs (nouveau) : base commune, appareils, invitations, code affiché une seule fois.
src/GameLauncher.Modules.Admin/Accounts/AdminsPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/MyDevicesPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/AccountsAdminModule.cs (nouveau)
2026-11-01 14:43:38 +01:00
nicolas d2cb48b901 Serveur : route « mon compte », suppression de ses propres invitations « nouvel appareil », audit détaillé
server/GameLauncher.Server/Modules/Admins/AdminsModule.cs
tests/GameLauncher.Tests/Server/AdminManagementTests.cs : nouveau test (son compte, ses invitations, droits de suppression, journal).
2026-11-01 14:43:01 +01:00
nicolas e06ddacf04 Contrats et client : compte de l'administrateur connecté (GET v1/admins/me), auteur des invitations
src/GameLauncher.Contracts/ApiRoutes.cs : route MyAccount.
src/GameLauncher.Contracts/Dtos/AdminDtos.cs : MyAccount, et InvitationSummary.CreatedById.
src/GameLauncher.ApiClient/AdminSession.cs : GetMyAccountAsync
2026-11-01 14:39:59 +01:00
nicolas 2e4b779be5 Déploiement : dossiers objects/00 à ff créés sur le VPS avec le bit setgid 2026-11-01 13:36:14 +01:00
nicolas 10558a6e5f Documentation : création des dossiers objects sous systemd 2026-11-01 12:52:36 +01:00
nicolas 54941a1009 Tests : droits des dossiers objects, test du bandeau d'envoi interrompu stabilisé
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : nouveau test sur les droits des dossiers objects/ et objects/xx.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : test du bandeau rendu stable (le refus 429 tombe sur un petit envoi).
tests/GameLauncher.Tests/Server/ChangelogModuleTests.cs : le faux dossier du jeu ne redemande pas le setgid s’il l’a déjà.
2026-11-01 12:52:25 +01:00
nicolas bd8b76015f Serveur : dossiers objects créés sans demander le bit setgid (RestrictSUIDSGID de systemd) 2026-11-01 12:51:55 +01:00
nicolas 0d3d76a68e Déploiement : zone nginx publication_par_ip, guide et architecture
server/deploy/nginx-publications.conf
server/deploy/LISEZMOI.md
docs/ADMIN-ARCHITECTURE.md
2026-11-01 12:27:08 +01:00
nicolas 8862047e36 Tests : limite des envois, délais d'attente, bandeau d'envoi interrompu:
tests/GameLauncher.Tests/Infrastructure/TestApi.cs : valeur par défaut de la nouvelle limite.
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : deux tests.
Le premier vérifie qu’un envoi n’est pas freiné par la limite générale, et que les refus indiquent le délai d’attente.
Le second vérifie le calcul des attentes.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : un test vérifie que le bandeau n’apparaît pas pendant l’envoi, que la pause est affichée, qu’on peut interrompre pendant la pause puis abandonner.
2026-11-01 12:26:52 +01:00
nicolas 25c87d672e Publication : bandeau « interrompu » masqué pendant l'envoi 2026-11-01 12:25:58 +01:00
nicolas d687414872 Publication : pauses courtes après un refus (Retry-After), pause affichée sur la page 2026-11-01 12:25:42 +01:00
nicolas fb71661f17 Serveur : limite de requêtes propre aux envois de publication, Retry-After sur les refus 429 2026-11-01 12:25:04 +01:00
nicolas 19ccd3bf9a Version 1.8 du jeu 2026-11-01 11:59:29 +01:00
nicolas 370443f3d4 Commandes : notification des boutons renvoyée sur le fil de l'interface, tests 2026-11-01 11:59:13 +01:00
nicolas 213cb9a533 Publication : avancement et ouverture rappelés sur le fil de l'interface 2026-11-01 11:58:39 +01:00
nicolas dc740e0452 Documentation : phase 5 (publication d'une version) 2026-10-31 20:17:22 +01:00
nicolas 72e979afd6 Déploiement : routes d'envoi dans nginx, guide du VPS 2026-10-31 20:17:02 +01:00
nicolas 1d5f85a77c Launcher : page « Publier une version » (vues, icônes) et enregistrement du module 2026-10-31 20:16:38 +01:00
nicolas 629fb600e9 Administration : module « Publication » (contrôle du dossier, envoi avec reprise, opération longue) 2026-10-31 20:16:04 +01:00
nicolas 9455277c25 Client API : requêtes d'envoi signées, délai par requête 2026-10-31 20:14:47 +01:00
nicolas f1032df74a Serveur : module Publishing (envoi par morceaux, reprise, mise en ligne, audit) 2026-10-31 20:14:27 +01:00
nicolas a341bc641a Serveur : fichiers publics des jeux communs aux notes de version et à la publication 2026-10-31 20:13:55 +01:00
nicolas 9b544cbc03 Contrats : publication d'une version (routes, échanges, contrôle des manifests) 2026-10-31 20:12:46 +01:00
nicolas 001ab82fda VErsion 1.7 du jeu 2026-10-31 17:51:29 +01:00
nicolas 67f4c02efc Documentation : phase 4 (notes de version) 2026-10-31 11:55:23 +01:00
nicolas 8808fe2615 Déploiement : notes de version modifiables sur le VPS (complément systemd, guide) 2026-10-31 11:55:09 +01:00
nicolas bd5b9d46ca Launcher : page « Notes de version » (vues) et enregistrement du module 2026-10-31 11:54:45 +01:00
nicolas 688b726e24 Administration : module « Notes de version » (éditeur, aperçu, enregistrement) 2026-10-31 11:54:21 +01:00
nicolas 929b27dbeb Client API : lecture et modification des notes de version 2026-10-31 11:53:48 +01:00
nicolas 090d8c64ec Serveur : module Changelog (notes d'une version dans le manifest publié, empreinte, audit) 2026-10-31 11:53:28 +01:00
nicolas 93f3ba630c Notes de version : modèle partagé dans Contracts, historique en ligne pour ManifestBuilder (--remote-manifest) 2026-10-31 11:52:25 +01:00
nicolas 89775bd73b Documentation : phase 3 (catalogue des jeux) 2026-10-31 10:34:13 +01:00
nicolas 944e47dea7 Déploiement : catalogue sur le VPS (complément systemd, nginx, guide) 2026-10-31 10:33:56 +01:00
nicolas c44c9fe3ef Launcher : catalogue au démarrage, fiche du jeu (description, liens), vues de la page « Jeux » 2026-10-31 10:33:25 +01:00
nicolas 6d44b5680a Administration : module « Catalogue des jeux » et page « Jeux » (ViewModels) 2026-10-31 10:32:30 +01:00
nicolas a6b2f2d6bb Core : catalogue côté joueur (téléchargement borné, cache, images, application) 2026-10-31 10:31:31 +01:00
nicolas e888b4037b Client API : lecture et publication du catalogue 2026-10-31 10:30:31 +01:00
nicolas 417dd78df5 Serveur : module Catalog (GET/PUT v1/catalog, fichier public, révisions, audit) 2026-10-31 10:30:12 +01:00
nicolas c8836a9baf Contrats : format et règles du catalogue des jeux (catalog.json) 2026-10-31 10:27:51 +01:00
nicolas 9aa3876a0e Changelog 1.3.0 2026-10-25 12:11:21 +01:00
163 changed files with 22418 additions and 347 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ Publish/
.vs/
*.user
*.suo
Claude outputs/
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
+17
View File
@@ -30,6 +30,8 @@ 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
server-example\web.config = server-example\web.config
EndProjectSection
@@ -40,8 +42,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,6 +60,8 @@ 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}"
@@ -156,6 +164,14 @@ Global
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -173,6 +189,7 @@ Global
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+120 -12
View File
@@ -32,15 +32,16 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| Projet | Rôle |
|---|---|
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés (voir §8) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord (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`) |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
```
@@ -49,7 +50,8 @@ 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)
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
@@ -71,12 +73,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 +191,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 |
|---|---|
@@ -332,7 +352,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 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 +396,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 |
@@ -472,8 +493,17 @@ 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ée ensuite : la page **Fichiers du serveur**.
* **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.
* **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é,
@@ -494,14 +524,91 @@ 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, 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` : propriétaires) : inviter (nom, rôle → code
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
qu'il vous dicte, changer un rôle, révoquer un appareil ou un compte, supprimer une invitation.
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
(`keyring.json`), signé **hors ligne** par la clé racine du launcher avec `tools\sign-keyring.bat` et publié
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
de 200). Filtres par **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, 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 ».
* **Page « Fichiers du serveur »** (permission `files.manage` : propriétaires et administrateurs) : contenu du
site public (dossiers, tailles, dates, espace libre du disque) et état de chaque fichier : **Utilisé** (archive,
fichier d'une version, exécutable du launcher, image du catalogue), **Protégé** (`.json`), **Inutilisé**
(supprimable) ou **Non vérifiable**. Un fichier inutilisé se supprime après confirmation ; « Nettoyer le
dossier objects » supprime les fichiers d'un jeu que la version en ligne n'utilise plus (analyse d'abord). Le
serveur relit tous les manifests du site avant chaque suppression et refuse tout fichier encore désigné, ainsi
que pendant une publication. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 sexies.
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
PNG/JPEG du même serveur, déposées dans `catalog/images/` (nouveau nom à chaque changement).
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).
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu,
publication, journal d'audit, fichiers du serveur), éditeur (contenu).
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
@@ -510,5 +617,6 @@ 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 ») ; 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/` |
+302 -45
View File
@@ -67,8 +67,8 @@ Conséquences :
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu (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.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
@@ -76,8 +76,9 @@ Conséquences :
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
(+ `Core` à partir de la phase 3) ; `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` ; `GameLauncher` → `Core`, `Modules`,
`Modules.Admin`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
## 5. Modules
@@ -267,11 +268,12 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|---|:-:|:-:|:-:|---|
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
| `admins.manage` | ✔ | | | Gestion des administrateurs (page « Administrateurs ») |
| `audit.read` | ✔ | ✔ | | Journal d'audit |
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
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.
@@ -287,8 +289,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 +308,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 +332,301 @@ 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).
* 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).
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
**Ancre de confiance** : la ou les clés racine intégrées à l'exe (`Security\update-signing.pub`, celles des
mises à jour du launcher). La clé privée racine reste **hors ligne** : le menu d'administration refuse toujours
de l'ouvrir (nom de fichier réservé, et toute clé dont l'identifiant ou la clé publique est celle d'une racine
intégrée, quel que soit le nom du fichier) ; elle ne sert qu'à `LauncherPublisher` (mises à jour du launcher,
`sign-keyring`).
**Trousseau** (`keyring.json`, `Contracts/Signing/Keyring`) : `sequence` croissante, `issuedAt`, clés
(identifiant, clé publique SPKI, nom, autorisations `releases` et/ou `catalog`), signature racine sur le SHA-256
du trousseau canonique (message `GameLauncher/Keyring/v1`, clés et autorisations triées). Production : page
**Signature** (propriétaires) › « Exporter la demande » (appareils ACTIFS des comptes actifs ; `releases` si le rôle
a `releases.publish`, `catalog` s'il a `games.edit` ; noms débarrassés des caractères invisibles, de mise en forme
ou d'inversion du sens d'écriture, refusés aussi par `Keyring.Validate`) → `tools\sign-keyring.bat` (compare la
demande au trousseau signé précédent `keyring-N.json` du même dossier, vérifié avec `update-signing.pub`, et
signale les **nouvelles clés** et autorisations élargies ; la demande vient du serveur et n'est donc pas digne de
confiance : ces empreintes se vérifient hors du serveur, avec celle que la page **Mes appareils** calcule sur le
PC de chacun — pas celle annoncée par le serveur ; confirmation, phrase de passe ; refuse une racine absente
d'`update-signing.pub` ; écrit `keyring-N.json`) → « Publier le trousseau signé » (le launcher vérifie la racine et
le numéro avant l'envoi ; l'export reste possible quand le trousseau publié est refusé par ce launcher). Les noms
sont publics. Serveur : `PUT v1/keyring` (`admins.manage`,
requête signée) contrôle la structure et un numéro strictement croissant, écrit le fichier public (par défaut à
côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish` (clés ajoutées/retirées). La liste
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
permettrait de calculer l'empreinte à dicter).
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
chacun à partir de ce qu'il a lu (même code dans `Contracts`) :
* manifest : jeu, version, exécutable, arguments, archive (adresse, taille, SHA-256), base des objets, fichiers
(chemin, taille, SHA-256) triés — **pas** les notes ni la date : les notes restent corrigeables en ligne (texte
borné, jamais exécuté) ; le launcher des joueurs calcule le même condensé depuis son modèle `GameManifest` ;
* catalogue : tout sauf `revision`, `updatedAt` et `signature` (posés par le serveur), jeux triés par identifiant
**tel que publié** (sans mise en minuscules) ; un catalogue dont deux jeux ne diffèrent que par la casse est
refusé par les launchers (`HasDuplicateGames`), même signé.
Les membres absents ou `null` (trousseau, manifest) ne provoquent jamais d'exception : trousseau refusé, condensé
calculé sur des valeurs vides.
**Production des signatures** (clé de la session ; date = heure du serveur) : à la mise en ligne d'une version
(`ReleaseUploader`, juste avant le commit : la reprise des envois, fondée sur l'empreinte du manifest, n'est pas
touchée ; corps `PublicationCommitRequest`), à chaque publication du catalogue (page Jeux, sur le catalogue
contrôlé tel qu'il sera publié), et pour une version déjà en ligne (WinSCP, antérieure) : page « Publier une
version » › « Signer la version en ligne » depuis son dossier (`POST v1/games/{id}/signature`, accepté seulement si
le condensé en ligne est celui du dossier). Le serveur vérifie chaque signature (clé = celle de la session, date à
± 10 min de son heure — une date future bloquerait les mises à jour suivantes chez les joueurs, signature valide)
et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire toute `signature` d'un manifest reçu
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
sécurité », pas d'installation ni de mise à jour, le jeu installé reste jouable), catalogue ignoré (cache conservé).
Le cache du catalogue contient le catalogue tel que publié (signature comprise) : il est revérifié à chaque
démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou modifié sur le disque est ignoré en
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
pas les versions suivantes).
**Limites assumées** : images du catalogue non signées ; un launcher qui n'a jamais vu de trousseau accepte tout
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
(la page Signature le signale : re-signer d'abord).
## 13. Phases
@@ -380,11 +636,12 @@ 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 | ✅ |
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.
@@ -25,7 +25,12 @@ public static class ApiModules
new Modules.System.SystemModule(),
new Modules.Auth.AuthModule(),
new Modules.Admins.AdminsModule(),
new Modules.Audit.AuditModule()
new Modules.Audit.AuditModule(),
new Modules.Catalog.CatalogModule(),
new Modules.Changelog.ChangelogModule(),
new Modules.Publishing.PublishingModule(),
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule()
};
}
@@ -34,4 +39,10 @@ public static class RateLimitPolicies
{
public const string Auth = "auth";
public const string Enrollment = "enrollment";
/// <summary>
/// Morceaux envoyés pendant une publication (manifest, objets, archive) : limite propre, plus haute,
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
/// </summary>
public const string PublicationUpload = "publication-upload";
}
@@ -39,6 +39,20 @@ 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>
/// 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 +67,69 @@ 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>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 +138,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 +163,31 @@ 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>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>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
public void Validate(string contentRoot)
{
@@ -105,17 +214,201 @@ public sealed class LauncherApiOptions
}
TrustedProxyAddresses = proxies;
ValidateCatalog();
ValidateKeyring();
ValidateGames();
ValidateFiles();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1, "Limits");
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1 &&
Limits.PublicationUploadsPerMinute >= 1, "Limits");
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
}
private void ValidateCatalog()
{
CatalogPublicFileFullPath = null;
if (!string.IsNullOrWhiteSpace(Catalog.PublicFile))
{
var file = Catalog.PublicFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile doit être un chemin absolu vers un fichier .json.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
CatalogPublicFileFullPath = full;
}
if (string.IsNullOrWhiteSpace(Catalog.PublicUrl))
{
CatalogPublicUrl = NormalizedOrigin + "/catalog/catalog.json";
}
else if (Uri.TryCreate(Catalog.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
{
CatalogPublicUrl = url.AbsoluteUri;
}
else
{
throw new InvalidOperationException($"{Section}:Catalog:PublicUrl doit être une adresse https://.");
}
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
}
private void ValidateKeyring()
{
KeyringPublicFileFullPath = null;
if (!string.IsNullOrWhiteSpace(Keyring.PublicFile))
{
var file = Keyring.PublicFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être un chemin absolu vers un fichier .json.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, "Keyring:PublicFile");
KeyringPublicFileFullPath = full;
}
else if (CatalogPublicFileFullPath is { } catalog)
{
KeyringPublicFileFullPath = Path.Combine(Path.GetDirectoryName(catalog)!, "keyring.json");
}
if (KeyringPublicFileFullPath is { } keyring && CatalogPublicFileFullPath is { } catalogFile &&
string.Equals(keyring, catalogFile, StringComparison.Ordinal))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être différent du fichier du catalogue.");
if (string.IsNullOrWhiteSpace(Keyring.PublicUrl))
{
KeyringPublicUrl = new Uri(new Uri(CatalogPublicUrl), "keyring.json").AbsoluteUri;
}
else if (Uri.TryCreate(Keyring.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
{
KeyringPublicUrl = url.AbsoluteUri;
}
else
{
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
}
}
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (gameId, game) in Games)
{
var name = $"Games:{gameId}:ManifestFile";
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
var file = game.ManifestFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, name);
if (files.Values.Contains(full, StringComparer.Ordinal))
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
files[gameId] = full;
}
GameManifestFiles = files;
}
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)
{
if (!condition) throw new InvalidOperationException($"{Section}:{name} : valeur hors limites.");
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Threading.RateLimiting;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
@@ -5,6 +6,7 @@ using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Hosting;
@@ -64,10 +66,20 @@ public static class ServerApp
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = (context, _) => new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
limiter.OnRejected = (context, _) =>
{
// Délai avant la prochaine fenêtre : le launcher reprend juste à temps au lieu d'attendre à l'aveugle.
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
};
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
IsPublicationUpload(context)
? RateLimitPartition.GetNoLimiter("publication") // limite propre (politique PublicationUpload)
: RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.PublicationUpload, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.PublicationUploadsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
@@ -112,6 +124,10 @@ public static class ServerApp
return app;
}
/// <summary>Route d'envoi de morceaux d'une publication (politique <see cref="RateLimitPolicies.PublicationUpload"/>).</summary>
private static bool IsPublicationUpload(HttpContext context) =>
context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == RateLimitPolicies.PublicationUpload;
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
{
PermitLimit = permits,
@@ -33,10 +33,12 @@ public sealed class AdminsModule : IApiModule
manage.MapGet(ApiRoutes.Admins, List);
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
// Ouvertes à tout administrateur connecté : chacun peut voir son compte, ajouter et approuver SES
// appareils, et supprimer les invitations « nouvel appareil » qu'il a créées pour lui-même ;
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
api.MapGet(ApiRoutes.MyAccount, MyAccount).RequireAdmin();
api.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
@@ -49,6 +51,24 @@ public sealed class AdminsModule : IApiModule
private static IResult List(AdminStore store) =>
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
/// <summary>
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
/// la liste complète : il la lit sur le nouvel appareil) et ses invitations « nouvel appareil » en cours.
/// </summary>
private static IResult MyAccount(HttpContext context, AdminStore store, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var db = store.Read();
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
return Results.Json(new MyAccount
{
Admin = ToSummary(admin),
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
.Select(ToSummary).ToList()
}, ApiJson.Options);
}
private static IResult ListInvitations(AdminStore store, TimeProvider time)
{
var now = time.GetUtcNow();
@@ -107,20 +127,24 @@ public sealed class AdminsModule : IApiModule
return record;
});
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
}
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
{
var actor = context.Admin();
store.Update(db =>
var deleted = store.Update(db =>
{
EnsureActor(db, actor);
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
// Sans admins.manage : seulement une invitation « nouvel appareil » créée par soi-même, pour soi-même.
var invitation = db.Invitations.FirstOrDefault(i => i.Id == invitationId);
var own = invitation is not null && invitation.ForAdminId == actor.Admin.Id && invitation.CreatedById == actor.Admin.Id;
EnsureActor(db, actor, own ? AdminPermission.None : AdminPermission.Manage);
if (invitation is null) throw ApiRejection.NotFound("Invitation introuvable.");
db.Invitations.Remove(invitation);
return invitation;
});
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
return Results.NoContent();
}
@@ -285,6 +309,9 @@ public sealed class AdminsModule : IApiModule
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
}
private static string InvitationDetails(InvitationRecord invitation) =>
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}";
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
string result = AuditResults.Ok)
{
@@ -306,6 +333,7 @@ public sealed class AdminsModule : IApiModule
Status = k.Status,
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
PublicKey = k.Status == AdminStatus.Active ? Convert.ToBase64String(k.PublicKeyBytes()) : null,
AddedAt = k.AddedAt,
LastUsedAt = k.LastUsedAt
}).ToList()
@@ -319,6 +347,7 @@ public sealed class AdminsModule : IApiModule
ForAdminId = invitation.ForAdminId,
CreatedAt = invitation.CreatedAt,
CreatedBy = invitation.CreatedBy,
CreatedById = invitation.CreatedById,
ExpiresAt = invitation.ExpiresAt
};
}
@@ -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,113 @@ 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)
{
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 = audit.Read(limit ?? 50, cursor, until);
// 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);
}
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;
}
}
}
@@ -0,0 +1,108 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Catalog;
/// <summary>
/// Catalogue des jeux (informations MODIFIABLES : nom affiché, accroche, présentation, liens, thème
/// intégré, images, intervalle de mise à jour, ordre et visibilité). Les jeux restent déclarés dans le
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// </summary>
public sealed class CatalogModule : IApiModule
{
public string Name => "Catalog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<CatalogStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
}
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
Results.Json(State(store.Read(), store, options.Value), ApiJson.Options);
private static IResult Publish(CatalogUpdateRequest request, HttpContext context, CatalogStore store, AuditLog audit,
TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<CatalogModule> logger)
{
if (request.Catalog is null) return ApiResults.BadRequest("Catalogue absent.");
if (request.BaseRevision < 0) return ApiResults.BadRequest("Révision invalide.");
var errors = new List<string>();
var catalog = CatalogRules.Normalize(request.Catalog with
{
Format = CatalogDocument.FormatName,
Version = CatalogDocument.CurrentVersion,
Revision = 0
}, options.Value.CatalogImageHost, errors);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
// Signature (phase 7) : sur le catalogue tel qu'il sera publié (après contrôle), par la clé de cette session.
var actor = context.Admin();
if (request.Catalog.Signature is { } signature)
{
ContentSignatures.RequireFromActor(signature, ContentKinds.Catalog, ContentKinds.CatalogId,
ContentSigning.CatalogDigest(catalog), actor, time.GetUtcNow());
catalog = catalog with { Signature = signature };
}
else if (options.Value.ContentSigning.Require)
{
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
"Le serveur exige un catalogue signé (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
}
CatalogDocument published, previous;
try
{
(published, previous) = store.Publish(catalog, request.BaseRevision, time.GetUtcNow());
}
catch (IOException ex)
{
logger.LogError(ex, "Publication du catalogue");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}",
Describe(previous, published) + (published.Signature is null ? " ; non signé" : $" ; signé (clé {published.Signature.KeyId})"));
return Results.Json(State(published, store, options.Value), ApiJson.Options);
}
private static CatalogState State(CatalogDocument catalog, CatalogStore store, LauncherApiOptions options) => new()
{
Catalog = catalog,
PublishingEnabled = store.PublishingEnabled,
PublicUrl = options.CatalogPublicUrl
};
/// <summary>Résumé pour l'audit : jeux et réglages modifiés.</summary>
private static string Describe(CatalogDocument before, CatalogDocument after)
{
var changed = new List<string>();
if (Serialize(before.Launcher) != Serialize(after.Launcher)) changed.Add("launcher");
foreach (var id in before.Games.Keys.Union(after.Games.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase))
{
before.Games.TryGetValue(id, out var a);
after.Games.TryGetValue(id, out var b);
if (Serialize(a) != Serialize(b)) changed.Add(id);
}
return changed.Count == 0 ? "aucun changement" : "modifié : " + string.Join(", ", changed.Take(20));
}
private static string Serialize(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -0,0 +1,90 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Changelog;
/// <summary>
/// Notes de version des jeux (éditeur du menu d'administration) : modification des versions EXISTANTES
/// dans le manifest.json publié de chaque jeu (LauncherApi:Games). La création d'une version reste
/// l'affaire de ManifestBuilder (et de la publication).
/// • GET : tout administrateur connecté (le manifest est public de toute façon) ;
/// • PUT (une version) : permission changelog.edit, requête signée, mêmes bornes que le launcher,
/// empreinte du manifest attendue (409 si le manifest a changé entre-temps), audit.
/// </summary>
public sealed class ChangelogModule : IApiModule
{
public string Name => "Changelog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<ChangelogStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.GameChangelogPattern, Get).RequireAdmin();
api.MapPut(ApiRoutes.GameChangelogEntryPattern, Save).RequirePermission(AdminPermissions.ChangelogEdit).RequireSignedRequest();
}
private static IResult Get(string gameId, ChangelogStore store, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
try
{
return Results.Json(store.Read(gameId), ApiJson.Options);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Lecture du manifest de {GameId}", gameId);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex is InvalidDataException
? $"Le serveur n'a pas pu lire le manifest de « {gameId} » : {ex.Message}"
: $"Le serveur n'a pas pu lire le manifest de « {gameId} » : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static IResult Save(string gameId, string version, ChangelogUpdateRequest request, HttpContext context,
ChangelogStore store, AuditLog audit, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
if (!ChangelogLimits.IsEditableVersion(version)) return ApiResults.BadRequest("Numéro de version invalide.");
if (request.Entry is null) return ApiResults.BadRequest("Notes de version absentes.");
if (string.IsNullOrWhiteSpace(request.BaseSha256) || request.BaseSha256.Length != 64)
return ApiResults.BadRequest("Empreinte du manifest absente ou invalide.");
if (!string.Equals(request.Entry.Version, version, StringComparison.Ordinal))
return ApiResults.BadRequest("La version des notes ne correspond pas à l'adresse.");
var errors = ChangelogLimits.Check(request.Entry);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
ChangelogState state;
ChangelogEntry previous;
try
{
(state, previous) = store.Save(gameId, version, request.Entry, request.BaseSha256);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Modification des notes de {GameId} {Version}", gameId, version);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex switch
{
InvalidDataException => $"Le serveur n'a pas pu lire le manifest : {ex.Message}",
ManifestWriteRefusedException => $"Modification refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).",
_ => "Le serveur n'a pas pu modifier le manifest : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter)."
});
}
var actor = context.Admin();
var after = state.Entries.FirstOrDefault(e => e.Version == version);
audit.Write("changelog.edit", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{state.GameId} {version}",
$"{Count(previous)} → {Count(after)} élément(s), manifest {state.ManifestSha256[..12]}");
return Results.Json(state, ApiJson.Options);
}
private static int Count(ChangelogEntry? entry) => entry is null ? 0 : ChangelogLimits.DisplayedItems(entry);
}
@@ -0,0 +1,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,151 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Publishing;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5) : permission releases.publish,
/// TOUTES les requêtes signées (y compris chaque morceau de fichier), audit du début, de la mise en
/// ligne et de l'abandon. Les routes d'envoi acceptent des corps jusqu'à 8 Mo (les autres : 64 Ko).
/// </summary>
public sealed class PublishingModule : IApiModule
{
public string Name => "Publishing";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<PublicationStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
const string permission = AdminPermissions.ReleasesPublish;
api.MapPost(ApiRoutes.PublicationStartPattern, Start).RequirePermission(permission).RequireSignedRequest();
api.MapGet(ApiRoutes.PublicationPattern, GetState).RequirePermission(permission);
api.MapDelete(ApiRoutes.PublicationPattern, Cancel).RequirePermission(permission).RequireSignedRequest();
api.MapPut(ApiRoutes.PublicationManifestPattern, Manifest).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationArchivePattern, Archive).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationObjectPattern, UploadObject).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationObjectsPattern, UploadObjects).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationCommitPattern, Commit).RequirePermission(permission).RequireSignedRequest();
}
private static IResult Start(string gameId, PublicationStartRequest request, HttpContext context, PublicationStore store, AuditLog audit,
ILogger<PublishingModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
var actor = context.Admin();
return Guard(logger, () =>
{
var (state, replaced) = store.Start(gameId, request, actor.Admin.Id, actor.Admin.DisplayName);
if (replaced is not null)
Audit(audit, context, "release.cancel", replaced.GameId, replaced.Version, $"remplacée par la version {state.Version}");
if (!state.Resumed)
Audit(audit, context, "release.start", state.GameId, state.Version, $"version en ligne : {Display(state.PublishedVersion)}");
return Results.Json(state, ApiJson.Options);
});
}
private static IResult GetState(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.GetState(id, context.Admin().Admin.Id), ApiJson.Options));
private static IResult Cancel(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
var record = store.Cancel(id, context.Admin().Admin.Id);
Audit(audit, context, "release.cancel", record.GameId, record.Version, "abandonnée par l'administrateur");
return Results.NoContent();
});
private static IResult Manifest(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendManifest(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult Archive(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendArchive(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult UploadObject(string id, string sha256, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger)
{
if (!PublishingLimits.IsValidSha256(sha256)) return ApiResults.BadRequest("Empreinte d'objet invalide.");
return Guard(logger, () =>
Results.Json(store.AppendObject(id, context.Admin().Admin.Id, sha256, offset, context.CapturedBody()), ApiJson.Options));
}
private static IResult UploadObjects(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AddObjects(id, context.Admin().Admin.Id, context.CapturedBody()), ApiJson.Options));
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
// Corps facultatif : signature du contenu du manifest (phase 7). Sans corps : mise en ligne non signée.
PublicationCommitRequest? request = null;
var body = context.CapturedBody();
if (body.Length > 0)
{
try
{
request = JsonSerializer.Deserialize<PublicationCommitRequest>(body, ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Requête de mise en ligne illisible.");
}
}
var signature = request?.Signature;
if (signature is null && options.Value.ContentSigning.Require)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
"Le serveur exige une version signée (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
var actor = context.Admin();
var (result, record) = store.Commit(id, actor.Admin.Id, signature,
(gameId, digest) => ContentSignatures.RequireFromActor(signature!, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()));
if (record is null) return Results.Json(result, ApiJson.Options); // déjà en ligne (réponse précédente perdue)
Audit(audit, context, "release.publish", result.GameId, result.Version,
$"version précédente : {Display(result.PreviousVersion)} ; {result.ObjectsAdded} fichier(s) ajouté(s)" +
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}" +
(signature is null ? " ; non signée" : $" ; signée (clé {signature.KeyId})"));
return Results.Json(result, ApiJson.Options);
});
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin (le détail va dans journalctl).</summary>
private static IResult Guard(ILogger logger, Func<IResult> action)
{
try
{
return action();
}
catch (ManifestWriteRefusedException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Publication refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
}
catch (InvalidDataException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Publication d'une version");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire dans le dossier du jeu : vérifiez ses droits et l'espace disque (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static void Audit(AuditLog audit, HttpContext context, string action, string gameId, string version, string details)
{
var actor = context.Admin();
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{gameId} {version}", details);
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,191 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Signing;
/// <summary>
/// Signature du contenu servi aux joueurs (phase 7) :
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
/// </summary>
public sealed class SigningModule : IApiModule
{
public string Name => "Signing";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<KeyringStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Signing, State).RequirePermission(AdminPermissions.AdminsManage);
api.MapPut(ApiRoutes.Keyring, PublishKeyring).RequirePermission(AdminPermissions.AdminsManage).RequireSignedRequest();
api.MapGet(ApiRoutes.ManifestSignaturePattern, GetManifest).RequirePermission(AdminPermissions.ReleasesPublish);
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
}
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
ILogger<SigningModule> logger)
{
var settings = options.Value;
var db = admins.Read();
var manifests = new List<ManifestSignatureState>();
foreach (var (gameId, path) in settings.GameManifestFiles.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
{
try
{
manifests.Add(ManifestSignatures.Read(gameId, path, db));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or
FormatException or InvalidOperationException)
{
// Manifest illisible (fichier modifié à la main…) : affiché « non signé », la page reste utilisable.
logger.LogWarning(ex, "Signature du manifest de {GameId}", gameId);
manifests.Add(new ManifestSignatureState { GameId = gameId });
}
}
CatalogSignatureState? catalog = null;
if (catalogs.PublishingEnabled)
{
CatalogDocument? published;
try
{
published = catalogs.ReadPublished();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature du catalogue");
published = null;
}
catalog = published is null
? new CatalogSignatureState()
: new CatalogSignatureState
{
Revision = published.Revision,
Digest = ContentSigning.CatalogDigest(published),
Signature = published.Signature,
SignerName = ContentSignatures.SignerName(db, published.Signature)
};
}
KeyringDocument? keyring;
try
{
keyring = keyrings.Read();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
// Trousseau de référence illisible : la page propose d'en publier un nouveau (numéro choisi par le serveur au contrôle).
logger.LogWarning(ex, "Trousseau de référence illisible");
keyring = null;
}
return Results.Json(new SigningState
{
Keyring = keyring,
KeyringPublishingEnabled = keyrings.PublishingEnabled,
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
RequireSignatures = settings.ContentSigning.Require,
Manifests = manifests,
Catalog = catalog
}, ApiJson.Options);
}
private static IResult PublishKeyring(HttpContext context, KeyringStore keyrings, AuditLog audit, ILogger<SigningModule> logger)
{
KeyringDocument? keyring;
try
{
keyring = Keyring.Parse(context.CapturedBody());
}
catch (JsonException ex)
{
return ApiResults.BadRequest($"Trousseau illisible : {ex.Message}");
}
if (keyring is null) return ApiResults.BadRequest("Trousseau absent.");
KeyringDocument? previous;
try
{
previous = keyrings.Publish(keyring);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Publication du trousseau");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le trousseau public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
catch (Exception ex) when (ex is InvalidDataException or JsonException)
{
// Trousseau de référence du serveur illisible : refus (le numéro du trousseau publié ne peut pas être contrôlé).
logger.LogError(ex, "Trousseau de référence illisible");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le trousseau de référence du serveur est illisible : restaurez-le depuis l'historique (keyring-history/) avant de publier.");
}
var actor = context.Admin();
var added = keyring.Keys.Select(k => k.KeyId).Except(previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).ToList();
var removed = (previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).Except(keyring.Keys.Select(k => k.KeyId)).ToList();
audit.Write("keyring.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"trousseau n° {keyring.Sequence}",
$"{keyring.Keys.Count} clé(s), racine {keyring.Signature?.RootKeyId}" +
(added.Count > 0 ? $" ; ajoutées : {string.Join(", ", added)}" : "") +
(removed.Count > 0 ? $" ; retirées : {string.Join(", ", removed)}" : ""));
return Results.Json(keyring, ApiJson.Options);
}
private static IResult GetManifest(string gameId, AdminStore admins, IOptions<LauncherApiOptions> options)
{
var path = ManifestPath(gameId, options.Value);
return Results.Json(ManifestSignatures.Read(gameId, path, admins.Read()), ApiJson.Options);
}
private static IResult SignManifest(string gameId, ManifestSignRequest request, HttpContext context, AdminStore admins,
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
{
var path = ManifestPath(gameId, options.Value);
if (request.Signature is null || !PublishingSha(request.Digest)) return ApiResults.BadRequest("Signature ou condensé absent.");
var actor = context.Admin();
ManifestSignatureState state;
try
{
state = ManifestSignatures.Sign(gameId, path, options.Value.DataDirectoryFullPath, request,
digest => ContentSignatures.RequireFromActor(request.Signature, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()),
admins.Read());
}
catch (ManifestWriteRefusedException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Signature refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Signature du manifest de {GameId}", gameId);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu réécrire le manifest : vérifiez les droits du dossier du jeu (server/deploy/LISEZMOI.md, étape 3 ter).");
}
audit.Write("release.sign", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{gameId} {state.Version}", $"version en ligne signée (contenu {state.Digest[..12]})");
return Results.Json(state, ApiJson.Options);
}
private static bool PublishingSha(string? value) => Contracts.Publishing.PublishingLimits.IsValidSha256(value);
private static string ManifestPath(string gameId, LauncherApiOptions options)
{
if (!CatalogRules.IsValidGameId(gameId)) throw ApiRejection.BadRequest("Identifiant de jeu invalide.");
return options.GameManifestFiles.TryGetValue(gameId, out var path)
? path
: throw new ApiRejection(404, ApiErrorCodes.NotConfigured,
"Jeu non configuré sur le serveur (LauncherApi:Games, voir server/deploy/LISEZMOI.md).");
}
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Security;
/// <summary>
/// Contrôle, par le serveur, des signatures de contenu envoyées par le menu d'administration (phase 7). Le
/// serveur n'est pas l'ancre de confiance (ce sont les launchers des joueurs, avec le trousseau) : ce contrôle
/// évite seulement de mettre en ligne une signature que les launchers refuseraient, et garantit que la clé qui
/// signe est celle de la session (rien n'est signé au nom d'un autre).
/// </summary>
public static class ContentSignatures
{
/// <exception cref="ApiRejection">400 : signature d'une autre clé, date hors tolérance, ou signature invalide.</exception>
public static void RequireFromActor(ContentSignature signature, string kind, string id, string digest,
AuthenticatedAdmin actor, DateTimeOffset now)
{
if (!string.Equals(signature.KeyId, actor.Key.KeyId, StringComparison.Ordinal))
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid, "La signature du contenu doit être faite par la clé de cette session.");
if (signature.SignedAt < now - ContentSigning.MaxSignedAtSkew || signature.SignedAt > now + ContentSigning.MaxSignedAtSkew)
throw new ApiRejection(400, ApiErrorCodes.ClockSkew,
"Date de signature du contenu trop éloignée de l'heure du serveur : vérifiez l'heure de Windows, puis recommencez.");
if (!ContentSigning.VerifyWithKey(kind, id, digest, signature, actor.Key.PublicKeyBytes()))
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid,
"Signature du contenu invalide : le contenu signé ne correspond pas à celui envoyé.");
}
/// <summary>« Nicolas (PC fixe) » pour une clé connue du serveur, sinon null.</summary>
public static string? SignerName(AdminDatabase db, ContentSignature? signature)
{
if (signature is null || db.FindKey(signature.KeyId) is not { } found) return null;
return string.IsNullOrWhiteSpace(found.Key.Label) ? found.Admin.DisplayName : $"{found.Admin.DisplayName} ({found.Key.Label})";
}
/// <summary>Signature présente dans un manifest publié (null : absente ou illisible).</summary>
public static ContentSignature? ReadSignature(JsonObject manifest)
{
if (PublishedFiles.Property(manifest, "signature") is not JsonObject node) return null;
try
{
return node.Deserialize<ContentSignature>(ApiJson.Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -24,15 +24,43 @@ public static class SignedRequests
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
!HttpMethods.IsOptions(context.Request.Method))
{
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
var body = buffer.ToArray();
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
feature.MaxRequestBodySize = limit.MaxBytes;
byte[] body;
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
{
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
body = new byte[length];
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
throw new BadHttpRequestException("Corps plus long que annoncé.");
}
else
{
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
body = buffer.ToArray();
}
context.Items[BodyKey] = body;
context.Request.Body = new MemoryStream(body, writable: false);
}
await next();
});
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
public static byte[] CapturedBody(this HttpContext context) =>
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
builder.WithMetadata(new RequestBodyLimit(maxBytes));
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.AddEndpointFilter(async (invocation, next) =>
@@ -94,3 +122,6 @@ public static class SignedRequests
return null;
}
}
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
public sealed record RequestBodyLimit(long MaxBytes);
+10 -3
View File
@@ -145,10 +145,11 @@ 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.
/// 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)
public AuditPage Read(int limit, string? cursor, DateTimeOffset? before = null)
{
limit = Math.Clamp(limit, 1, MaxPageSize);
var months = Directory.Exists(_directory)
@@ -169,17 +170,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,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:&lt;id&gt;:ManifestFile).
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
/// </summary>
public sealed class ChangelogStore
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = PublishedFiles.MaxManifestBytes;
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly object _writeLock = new();
public ChangelogStore(IOptions<LauncherApiOptions> options)
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
{
_manifests = manifests;
_dataDirectory = dataDirectory;
}
/// <summary>Notes de version publiées du jeu.</summary>
public ChangelogState Read(string gameId)
{
var (path, key) = Locate(gameId);
var bytes = ReadManifest(path);
var root = Parse(bytes, key);
return State(key, root, bytes);
}
/// <summary>
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
/// </summary>
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
{
var (path, key) = Locate(gameId);
lock (_writeLock)
using (PublishedFiles.LockGame(_dataDirectory, key))
{
var bytes = ReadManifest(path);
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
throw Conflict();
var root = Parse(bytes, key);
var changelog = ChangelogArray(root)
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
var index = -1;
for (var i = 0; i < changelog.Count; i++)
if (changelog[i] is JsonObject candidate && string.Equals(PublishedFiles.StringValue(candidate, "version"), version, StringComparison.Ordinal))
{
index = i;
break;
}
if (index < 0)
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
var old = (JsonObject)changelog[index]!;
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
// anciens launchers ; numéro et date de la version repris du manifest.
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
var replacement = old.DeepClone().AsObject();
var edited = JsonSerializer.SerializeToNode(clean, PublishedFiles.ManifestJson)!.AsObject();
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.DeepClone());
changelog[index] = replacement;
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
if (total > ChangelogLimits.MaxTotalItems)
throw ApiRejection.BadRequest(
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
var newBytes = PublishedFiles.Serialize(root);
PublishedFiles.Backup(_dataDirectory, key, bytes);
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
return (State(key, root, newBytes), previous);
}
}
// ------------------------------------------------------------------ Lecture
private (string Path, string GameId) Locate(string gameId)
{
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw PublishedFiles.NotConfigured(gameId, "Modification des notes de version");
}
private static byte[] ReadManifest(string path) =>
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
internal static JsonObject Parse(byte[] bytes, string gameId)
{
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
var manifestGameId = PublishedFiles.StringValue(root, "gameId");
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
return root;
}
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
{
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
return new ChangelogState
{
GameId = gameId,
ManifestVersion = PublishedFiles.StringValue(root, "version") ?? "",
ManifestSha256 = Sha256(bytes),
Entries = entries
};
}
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
{
try
{
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.ManifestJson) ?? new List<ChangelogEntry?>();
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
}
}
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
private static ApiRejection Conflict() => ApiRejection.Conflict(
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
ApiErrorCodes.RevisionConflict);
}
@@ -0,0 +1,98 @@
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Trousseau publié (phase 7).
/// • Référence : keyring.json dans le dossier des données privées (+ historique).
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Keyring:PublicFile, par défaut à côté
/// du catalogue), écrite atomiquement, lisible par tous.
/// • Le serveur ne détient pas la clé racine et ne vérifie que la structure et le numéro (toujours croissant) :
/// c'est le launcher qui vérifie la signature racine (avant l'envoi dans le menu d'administration, puis chez
/// chaque joueur). Un trousseau falsifié serait refusé par tous les launchers.
/// </summary>
public sealed class KeyringStore
{
public const string FileName = "keyring.json";
private const string HistoryFolder = "keyring-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _writeLock = new();
public KeyringStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.KeyringPublicFileFullPath) { }
public KeyringStore(string dataDirectory, string? publicFile)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
}
public bool PublishingEnabled => _publicFile is not null;
/// <summary>Trousseau publié, ou null.</summary>
public KeyringDocument? Read()
{
if (!File.Exists(_path)) return null;
return Keyring.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
/// <summary>Publie un trousseau signé (structure contrôlée, numéro supérieur au trousseau actuel).</summary>
/// <returns>Le trousseau précédent (null : premier).</returns>
public KeyringDocument? Publish(KeyringDocument keyring)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou LauncherApi:Catalog:PublicFile).");
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors)) throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
if (keyring.Signature is null || string.IsNullOrEmpty(keyring.Signature.Value))
throw ApiRejection.BadRequest("Trousseau non signé : signez-le d'abord avec la clé racine (tools\\sign-keyring.bat).");
lock (_writeLock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var current = Read();
if (current is not null && keyring.Sequence <= current.Sequence)
throw ApiRejection.Conflict(
$"Trousseau n° {keyring.Sequence} refusé : le trousseau publié porte déjà le n° {current.Sequence} " +
"(exportez une nouvelle demande, qui prend le numéro suivant).", ApiErrorCodes.RevisionConflict);
var json = Keyring.Serialize(keyring);
if (System.Text.Encoding.UTF8.GetByteCount(json) > Keyring.MaxBytes)
throw ApiRejection.BadRequest($"Trousseau trop volumineux (au plus {Keyring.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte le numéro) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "trousseau public");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(keyring.Sequence, json);
return current;
}
}
private void WriteHistory(long sequence, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"keyring-{sequence:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "keyring-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,68 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Signature du manifest déjà en ligne d'un jeu (version publiée avant la phase 7, ou avec WinSCP) : le launcher
/// envoie la signature du contenu de SON manifest.json (dossier de publication) ; le serveur ne l'ajoute que si la
/// version en ligne a exactement ce contenu, puis réécrit le manifest atomiquement (mêmes règles que les notes).
/// </summary>
public static class ManifestSignatures
{
public static ManifestSignatureState Read(string gameId, string manifestPath, AdminDatabase admins)
{
if (PublishedFiles.ReadManifest(manifestPath) is not { } bytes) return new ManifestSignatureState { GameId = gameId };
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
var signature = ContentSignatures.ReadSignature(root);
return new ManifestSignatureState
{
GameId = gameId,
Version = PublishedFiles.StringValue(root, "version") ?? "",
Digest = Digest(bytes),
Signature = signature,
SignerName = ContentSignatures.SignerName(admins, signature)
};
}
/// <param name="verify">Contrôle de la signature sur le condensé de la version en ligne (lève une ApiRejection).</param>
public static ManifestSignatureState Sign(string gameId, string manifestPath, string dataDirectory, ManifestSignRequest request,
Action<string> verify, AdminDatabase admins)
{
using (PublishedFiles.LockGame(dataDirectory, gameId))
{
var bytes = PublishedFiles.ReadManifest(manifestPath)
?? throw ApiRejection.NotFound("Aucune version en ligne pour ce jeu.");
var digest = Digest(bytes);
if (!string.Equals(digest, request.Digest, StringComparison.OrdinalIgnoreCase))
throw ApiRejection.Conflict(
"La version en ligne ne correspond pas au manifest.json choisi (fichiers, archive ou exécutable différents) : " +
"choisissez le dossier de publication de la version en ligne.", ApiErrorCodes.RevisionConflict);
verify(digest);
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
PublishedFiles.SetProperty(root, "signature", JsonSerializer.SerializeToNode(request.Signature, ApiJson.Options));
var newBytes = PublishedFiles.Serialize(root);
PublishedFiles.Backup(dataDirectory, gameId, bytes);
PublishedFiles.WriteManifest(manifestPath, newBytes, PublishedFiles.Sha256(bytes),
() => ApiRejection.Conflict("Le manifest a été modifié pendant la signature : recommencez.", ApiErrorCodes.RevisionConflict));
return Read(gameId, manifestPath, admins);
}
}
private static string Digest(byte[] bytes)
{
try
{
return ContentSigning.ManifestDigest(PublishedFiles.StripBom(bytes));
}
catch (JsonException ex)
{
throw new InvalidDataException($"Manifest en ligne illisible : {ex.Message}", ex);
}
}
}
@@ -0,0 +1,47 @@
using System.Text;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichier PUBLIC servi par nginx (catalogue, trousseau) : écrit atomiquement (fichier temporaire caché du
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
/// </summary>
internal static class PublicFileWriter
{
public const UnixFileMode Mode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
public static void Write(string path, string text, string what)
{
var directory = Path.GetDirectoryName(path)!;
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
using (var stream = new FileStream(temp, options))
{
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text));
stream.Flush(flushToDisk: true);
}
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, Mode);
File.Move(temp, path, overwrite: true); // renommage atomique dans le même dossier
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique)
}
throw new IOException($"Écriture du {what} impossible ({path}) : {ex.Message}", ex);
}
}
}
@@ -0,0 +1,850 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5).
/// • Dépôt : dossier CACHÉ « .publish-&lt;id&gt; » 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) { }
}
@@ -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,37 @@
},
"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": ""
},
"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,
+288 -15
View File
@@ -65,6 +65,266 @@ Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), l
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
signatures seront refusées.
## 3 bis. Catalogue des jeux (VPS)
La page **Jeux** du menu d'administration publie `catalog.json` (nom affiché, accroche, description,
liens, thème, images, ordre des jeux), que les launchers des joueurs lisent au démarrage à l'adresse
`catalogUrl` de `launcher.json`. Le serveur l'écrit dans **un seul** dossier, servi par nginx, séparé
de ses données privées. Exemple ci-dessous avec le site de « Serveur de téléchargement Exovival »
(`/srv/exovival/www`) : adapte le chemin à la racine réelle de ton site.
1. Crée le dossier public (propriétaire `deploy`, groupe `www-data` pour nginx) :
```
sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog /srv/exovival/www/catalog/images
```
2. Indique le fichier au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages) :
```
{ "LauncherApi": { "Catalog": { "PublicFile": "/srv/exovival/www/catalog/catalog.json" } } }
```
L'adresse publique est déduite de `PublicOrigin` (`https://launcher.lanisys.fr/catalog/catalog.json`) ;
sinon, ajoute `"PublicUrl"`. Elle doit être **identique** à `catalogUrl` dans `launcher.json`.
Le serveur refuse de démarrer si le dossier n'existe pas, ou si le dossier public et
`/var/lib/launcher-api` sont l'un dans l'autre.
3. Autorise le service à écrire dans ce dossier, et seulement lui (le reste du système reste en
lecture seule) :
```
sudo mkdir -p /etc/systemd/system/launcher-api.service.d
sudo nano /etc/systemd/system/launcher-api.service.d/catalog.conf
```
Colle le contenu de `server/deploy/launcher-api-catalog.conf` (ligne `ReadWritePaths=` adaptée),
puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. nginx : colle le bloc `location = /catalog/catalog.json { … }` de `server/deploy/nginx-catalog.conf`
dans le bloc HTTPS du site, avant `location / {`, puis `sudo nginx -t && sudo systemctl reload nginx`.
5. Publie depuis la page **Jeux**, puis vérifie depuis ton PC :
```
PS> curl.exe -sI https://launcher.lanisys.fr/catalog/catalog.json
```
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
**Images** (icône, fond) : envoie-les en SFTP (compte `deploy`) dans `www/catalog/images/`, en PNG
ou JPEG de 4 Mo au plus, puis saisis leur adresse dans la page Jeux, par ex.
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
en cache : pour changer une image, envoie-la sous un **nouveau nom**. Les images d'un autre
serveur sont refusées.
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
## 3 ter. Notes de version modifiables en ligne (VPS)
La page **Notes de version** du menu d'administration corrige les notes des versions déjà publiées,
directement dans le `manifest.json` du jeu (rien d'autre n'y est modifié). Exemple ci-dessous avec le
dossier du jeu de « Serveur de téléchargement Exovival » (`/srv/exovival/www/exovivalzombie`) : adapte
le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
> **Compromis de sécurité** : le service pourra écrire dans le dossier du jeu (il remplace le manifest
> par renommage dans ce dossier). Un service compromis pourrait donc modifier les fichiers du jeu de ce
> dossier. L'autorisation se limite à ce seul dossier ; sans cette étape, rien n'est modifiable en ligne.
1. Vérifie que le dossier du jeu a le **bit setgid** (le `s` de `drwxr-s---`), pour que le manifest
remplacé garde le groupe `www-data` lu par nginx :
```
stat -c '%A %U:%G' /srv/exovival/www/exovivalzombie
sudo chmod g+s /srv/exovival/www/exovivalzombie # seulement s'il manque
```
Sans lui, le serveur refuse la modification plutôt que de rendre le manifest illisible.
2. Indique le manifest au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages ; la clé est le `gameId` de `launcher.json`) :
```
{ "LauncherApi": { "Games": { "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } } } }
```
3. Autorise le service à écrire dans ce dossier, et seulement lui :
```
sudo nano /etc/systemd/system/launcher-api.service.d/games.conf
```
Colle le contenu de `server/deploy/launcher-api-games.conf` (ligne `ReadWritePaths=` adaptée,
une par jeu), puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. Sur ton PC, ajoute `--remote-manifest <adresse du manifest>` à ton script de publication
(déjà dans `tools\publish-example.bat`) : sans lui, la publication suivante écraserait les notes
corrigées en ligne avec celles du `manifest.json` local.
5. Teste : menu d'administration › **Notes de version**, corrige une faute, **Enregistrer**. L'ancien
manifest est copié dans `/var/lib/launcher-api/manifest-history/`.
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
## 3 quater. Publier une version depuis le launcher (VPS)
La page **Publier une version** du menu d'administration remplace l'envoi avec WinSCP : seuls les
fichiers absents du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est mis
en ligne en dernier. Prérequis : l'étape 3 ter (dossier du jeu déclaré dans `LauncherApi:Games`,
complément `games.conf`, bit setgid).
1. nginx : ajoute la zone de limite des envois dans `/etc/nginx/conf.d/securite.conf`, à côté de
`api_par_ip` (un envoi enchaîne plusieurs morceaux de 4 Mo par seconde ; avec la limite générale de
l'API, il serait bridé et ferait des pauses) :
```
limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
```
Puis colle le bloc `location ^~ /api/v1/publications/ { … }` de `server/deploy/nginx-publications.conf`
dans le bloc HTTPS du site, à côté du bloc `/api/`, et recharge :
```
sudo nginx -t && sudo systemctl reload nginx
```
Seules ces routes acceptent des envois de 8 Mo ; le reste de l'API reste limité à 64 Ko. Vérifie
aussi que le site contient toujours la règle `location ~ /\. { deny all; }` : les fichiers en cours
d'envoi sont rangés dans un dossier caché (`.publish-…`) du dossier du jeu.
2. Crée le dossier `objects` du jeu **et ses 256 sous-dossiers** (`00` à `ff`), avec le groupe et le bit
setgid du dossier du jeu (sans risque s'ils existent déjà : leurs droits sont simplement remis en état) :
```
JEU=/srv/exovival/www/exovivalzombie
sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects"
for i in $(seq 0 255); do sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects/$(printf %02x $i)"; done
```
Pourquoi : systemd interdit au service de demander le bit setgid (`RestrictSUIDSGID=yes`, gardé). Un
sous-dossier qui manquerait serait créé par le service en `drwxr-x---` (groupe `www-data`, lisible par
nginx) mais **sans setgid** : un fichier déposé ensuite dedans **avec WinSCP** prendrait le groupe
`deploy` et ne serait plus lisible par nginx. Relance la boucle si `stat` montre un `drwxr-x---`.
3. Espace disque : l'envoi est stocké sur le même disque que le jeu jusqu'à la mise en ligne. Prévois la
taille de l'archive et des nouveaux fichiers (le serveur refuse l'envoi s'il manque de place).
4. Teste : lance le .bat de publication (ManifestBuilder) sur ton PC, puis menu d'administration ›
**Publier une version** › « Choisir le manifest.json… » (dossier `--output`) › **Publier**.
> **Déjà fait avant la correction des pauses ?** Ajoute la ligne `limit_req_zone … publication_par_ip …`
> ci-dessus, remplace dans le bloc `/api/v1/publications/` du site `limit_req zone=api_par_ip burst=20 nodelay;`
> par `limit_req zone=publication_par_ip burst=50 nodelay;`, recharge nginx, puis mets à jour le serveur
> (étape 8) : il a désormais sa propre limite pour les envois (`LauncherApi:Limits:PublicationUploadsPerMinute`,
> 1500 par minute par défaut, rien à ajouter dans `appsettings.Production.json`).
Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cliques de nouveau sur
« Publier » avec le même dossier ; sans reprise, elle est supprimée au bout de 48 h. WinSCP reste
possible, mais n'envoie pas une version avec les deux en même temps : le serveur refuse alors de
mettre la sienne en ligne plutôt que d'écraser l'autre.
## 3 quinquies. Signer ce que reçoivent les joueurs (PC et VPS)
Sans signature, quelqu'un qui prendrait le contrôle du VPS pourrait distribuer un faux jeu. Avec la
signature, chaque version mise en ligne (fichiers, archive, exécutable) et le catalogue portent la
signature de l'administrateur qui les a publiés. Les launchers ne font confiance qu'aux clés du
**trousseau**, lui-même signé **hors ligne** par la clé racine du launcher (`launcher-signing.key`, celle des
mises à jour du launcher). Le VPS ne détient aucune de ces clés privées.
Prérequis : étapes 3 bis (catalogue : le trousseau est écrit dans le même dossier) et 3 quater.
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le trousseau
est écrit à côté du catalogue (`/srv/exovival/www/catalog/keyring.json`).
2. **nginx** : ajoute le bloc `location = /catalog/keyring.json { … }` de `server/deploy/nginx-catalog.conf` à côté
de celui du catalogue, puis `sudo nginx -t && sudo systemctl reload nginx`.
3. **Trousseau** (PC) :
1. menu d'administration › **Signature** › « Exporter la demande… » : enregistre `keyring-request-1.json`
(les appareils actifs des administrateurs autorisés à publier) ;
2. glisse ce fichier sur `tools\sign-keyring.bat` : la demande est comparée au trousseau signé précédent
(`keyring-N.json` du même dossier) et chaque **NOUVELLE CLÉ** (ou autorisation élargie) est signalée.
**Vérifie ces empreintes hors du serveur** : la demande vient du VPS, un serveur compromis pourrait y glisser
sa propre clé. Fais lire à chaque personne l'empreinte de son appareil (page **Mes appareils**, calculée sur
son PC) de vive voix ou par téléphone, et compare. Puis la phrase de passe de la clé racine → `keyring-N.json`
(garde ce fichier dans le même dossier : il servira de référence la fois suivante) ;
3. page **Signature** › « Publier le trousseau signé… » : le launcher vérifie la signature racine avant de l'envoyer.
Les noms des appareils (« Nicolas — PC fixe ») sont **publics** : le trousseau est lisible par tous.
4. **Contenu déjà en ligne** :
* version du jeu : page **Publier une version** › « Choisir le manifest.json… » du dossier de **la version
en ligne** › « Signer la version en ligne » (le serveur vérifie que les fichiers sont identiques) ;
* catalogue : page **Jeux** › « Publier » (il est signé à chaque publication).
La page **Signature** doit alors tout indiquer « valide pour les launchers ».
5. **Launcher** : dans `src\GameLauncher\launcher.json`, décommente `"keyringUrl"` (adresse affichée par la page
Signature) et `"contentSigning": "warn"`, puis publie le launcher (mise à jour signée habituelle). En « warn »,
les problèmes sont seulement inscrits au journal du launcher (`%AppData%\<éditeur>\GameLauncher\logs\launcher.log`) : vérifie qu'il
indique « Manifest de EZR … signé par … » et « Trousseau n° 1 vérifié ».
6. **Protection complète** : passe à `"contentSigning": "require"` et publie le launcher : un contenu non signé,
mal signé ou plus ancien que le dernier vu est alors refusé (le jeu installé reste jouable). Optionnel, sur le
VPS : `"ContentSigning": { "Require": true }` dans la section `LauncherApi` d'`appsettings.Production.json`
(puis `sudo systemctl restart launcher-api`) pour que le serveur refuse toute publication non signée.
Ensuite, les nouvelles versions et le catalogue sont signés automatiquement à leur publication. **À refaire
(étape 3)** : à chaque nouvel administrateur ou nouvel appareil qui doit publier (avant sa première publication),
et à chaque révocation (la page Signature signale le contenu signé par une clé qui va disparaître : re-signe-le
d'abord). Une version envoyée avec WinSCP doit être signée ensuite (étape 4).
**Clé compromise** (appareil volé…) : révoque l'appareil, signe et publie un nouveau trousseau (étape 3), puis
mets son numéro dans `"keyringMinSequence"` de `launcher.json` et publie le launcher. Sans cela, un launcher qui
n'a encore jamais vu le nouveau trousseau (installation neuve, cache effacé) accepterait encore l'ancien,
toujours signé par la clé racine, si un serveur compromis le lui présentait.
Limites :
* les images du catalogue ne sont pas signées (un serveur compromis pourrait les remplacer, pas les liens ni les
textes) ;
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
et au moins celui du dernier trousseau qu'il a vu ;
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
à la fois les mises à jour du launcher et le trousseau.
## 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.
## 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 +368,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)
@@ -181,6 +445,15 @@ recréer les administrateurs.
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
├─ server-id identifiant aléatoire du serveur
├─ audit/audit-AAAA-MM.jsonl journal d'audit
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
├─ catalog-history/ 20 dernières versions publiées
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
├─ publications/ publications en cours (version, fichiers restant à envoyer)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
└─ images/ images du catalogue (envoyées en SFTP)
/etc/systemd/system/launcher-api.service
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
```
+14
View File
@@ -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
+17
View File
@@ -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
+4
View File
@@ -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
+28
View File
@@ -0,0 +1,28 @@
# =============================================================================
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
#
# Les images du catalogue (catalog/images/*.png|jpg) sont servies par « location / » ; les
# fichiers temporaires de la Launcher API (.catalog.json.….tmp) par la règle des fichiers cachés
# (refusés).
# =============================================================================
# Catalogue : toujours relu sur le serveur (les launchers le lisent au démarrage).
location = /catalog/catalog.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Trousseau (phase 7) : clés autorisées à signer versions et catalogue, signé par la clé racine du launcher.
location = /catalog/keyring.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
+39
View File
@@ -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;
}
+146 -6
View File
@@ -1,7 +1,13 @@
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.Security;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.ApiClient;
@@ -41,6 +47,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 +91,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 +115,132 @@ 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)
/// <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);
// ------------------------------------------------------------------ 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);
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 +250,13 @@ public sealed class AdminSession : IAsyncDisposable
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct)
/// <summary>Requête qui modifie avec un corps binaire (morceau de fichier), signée par la clé.</summary>
public Task<T> SendSignedBytesAsync<T>(HttpMethod method, string route, byte[] body, string contentType, TimeSpan timeout,
CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
{
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
@@ -142,7 +282,7 @@ public sealed class AdminSession : IAsyncDisposable
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
}
}, ct).ConfigureAwait(false);
}, ct, contentType, timeout).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
@@ -17,5 +17,8 @@ public sealed class ApiException : Exception
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
public string Code { get; }
/// <summary>Délai annoncé par le serveur avant un nouvel essai (en-tête Retry-After), s'il y en a un.</summary>
public TimeSpan? RetryAfter { get; init; }
public bool IsUnauthorized => Status == 401;
}
+44 -15
View File
@@ -41,7 +41,8 @@ public sealed class LauncherApiClient : IDisposable
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
}, disposeHandler: handler is null)
{
Timeout = RequestTimeout
// Délai propre à chaque requête (SendAsync) : 30 s par défaut, plus pour l'envoi de fichiers.
Timeout = Timeout.InfiniteTimeSpan
};
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -129,33 +130,50 @@ public sealed class LauncherApiClient : IDisposable
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
Action<HttpRequestMessage>? prepare, CancellationToken ct)
Action<HttpRequestMessage>? prepare, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
{
using var request = new HttpRequestMessage(method, Resolve(route));
if (body is not null)
{
request.Content = new ByteArrayContent(body);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
request.Content.Headers.ContentType = contentType == "application/json"
? new MediaTypeHeaderValue(contentType) { CharSet = "utf-8" }
: new MediaTypeHeaderValue(contentType);
}
prepare?.Invoke(request);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout ?? RequestTimeout);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
}
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
using (response)
{
var bytes = await ReadBoundedAsync(response, ct).ConfigureAwait(false);
byte[] bytes;
try
{
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Connexion au serveur d'administration interrompue ({ex.Message}).", ex);
}
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
try
@@ -191,25 +209,36 @@ public sealed class LauncherApiClient : IDisposable
private static ApiException ToException(HttpResponseMessage response, byte[] body)
{
var status = (int)response.StatusCode;
var retryAfter = RetryAfterOf(response);
try
{
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
return new ApiException(status, error.Code, error.Message);
return new ApiException(status, error.Code, error.Message) { RetryAfter = retryAfter };
}
catch (JsonException)
{
// réponse d'erreur non JSON (nginx, proxy…)
}
return status switch
var (code, message) = status switch
{
401 => new ApiException(status, ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
403 => new ApiException(status, ApiErrorCodes.Forbidden, "Action non autorisée."),
404 => new ApiException(status, ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
413 => new ApiException(status, ApiErrorCodes.InvalidRequest, "Requête trop volumineuse."),
429 => new ApiException(status, ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
>= 500 => new ApiException(status, ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
_ => new ApiException(status, ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
401 => (ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
403 => (ApiErrorCodes.Forbidden, "Action non autorisée."),
404 => (ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
413 => (ApiErrorCodes.InvalidRequest,
"Requête trop volumineuse pour le serveur (envoi de fichiers : vérifiez nginx-publications.conf sur le VPS)."),
429 => (ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
>= 500 => (ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
_ => (ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
};
return new ApiException(status, code, message) { RetryAfter = retryAfter };
}
/// <summary>En-tête Retry-After (en secondes ou en date) ; absent ou incohérent : null.</summary>
private static TimeSpan? RetryAfterOf(HttpResponseMessage response)
{
var header = response.Headers.RetryAfter;
var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null);
return delay is { } value && value >= TimeSpan.Zero ? value : null;
}
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
+73
View File
@@ -28,6 +28,9 @@ public static class ApiRoutes
public const string Admins = "v1/admins";
public const string Invitations = "v1/admins/invitations";
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
public const string MyAccount = "v1/admins/me";
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
@@ -36,4 +39,74 @@ public static class ApiRoutes
// ------------------------------------------------------------------ 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";
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
// ------------------------------------------------------------------ Signature du contenu (phase 7)
/// <summary>État des signatures (trousseau publié, manifests, catalogue) : propriétaires.</summary>
public const string Signing = "v1/signing";
/// <summary>Publication d'un trousseau signé par la clé racine : propriétaires.</summary>
public const string Keyring = "v1/keyring";
public const string ManifestSignaturePattern = "v1/games/{gameId}/signature";
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
public const string PublicationManifestPattern = "v1/publications/{id}/manifest";
public const string PublicationArchivePattern = "v1/publications/{id}/archive";
public const string PublicationObjectsPattern = "v1/publications/{id}/objects";
public const string PublicationObjectPattern = "v1/publications/{id}/objects/{sha256}";
public const string PublicationCommitPattern = "v1/publications/{id}/commit";
/// <summary>Préfixe des routes d'envoi (limite de taille propre, voir nginx-publications.conf).</summary>
public const string PublicationsPrefix = "v1/publications/";
public static string PublicationStart(string gameId) => $"v1/games/{gameId}/publications";
public static string Publication(string id) => $"v1/publications/{id}";
public static string PublicationManifest(string id, long offset) => $"v1/publications/{id}/manifest?offset={offset}";
public static string PublicationArchive(string id, long offset) => $"v1/publications/{id}/archive?offset={offset}";
public static string PublicationObjects(string id) => $"v1/publications/{id}/objects";
public static string PublicationObject(string id, string sha256, long offset) => $"v1/publications/{id}/objects/{sha256}?offset={offset}";
public static string PublicationCommit(string id) => $"v1/publications/{id}/commit";
}
@@ -0,0 +1,134 @@
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Informations MODIFIABLES des jeux (catalog.json, publié par le serveur et lu par les launchers).
/// Les jeux restent déclarés dans launcher.json (intégré à l'exe) : une entrée pour un jeu non déclaré
/// est ignorée par le launcher. Toute valeur absente (null) = valeur de launcher.json.
/// Ce fichier est PUBLIC : il ne contient que de l'affichage (aucun nom d'administrateur).
/// </summary>
public sealed record CatalogDocument
{
public const string FormatName = "gamelauncher-catalog";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Numéro de version du contenu, augmenté à chaque publication (contrôle des modifications concurrentes).</summary>
public long Revision { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
public CatalogLauncher? Launcher { get; init; }
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Signature de l'administrateur qui a publié (phase 7) : couvre tout sauf la révision et la date
/// (<see cref="ContentSigning.CatalogDigest"/>). Ignorée par les launchers antérieurs.
/// </summary>
public ContentSignature? Signature { get; init; }
}
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
public sealed record CatalogLauncher
{
public string? LauncherName { get; init; }
/// <summary>Jeu affiché au premier lancement.</summary>
public string? DefaultGame { get; init; }
/// <summary>Ordre des jeux dans la liste (les jeux non cités suivent, dans l'ordre de launcher.json).</summary>
public List<string>? GameOrder { get; init; }
}
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
public sealed record CatalogGame
{
/// <summary>Nom AFFICHÉ, 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,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] + "…";
}
@@ -1,11 +1,14 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using GameLauncher.Core.Utils;
using System.Text.RegularExpressions;
namespace GameLauncher.Core.Models;
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes d'une version du jeu (manifest.json → "changelog").
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
///
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
public List<string> Changes { get; set; } = new();
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
[JsonIgnore]
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
[JsonIgnore]
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
@@ -142,6 +147,69 @@ public static class ChangelogLimits
public const int MaxTitleLength = 200;
public const int MaxTagLength = 24;
public const int MaxTextLength = 2000;
public const int MaxVersionLength = 64;
/// <summary>
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
/// </summary>
public const int MaxEditRequestBytes = 60 * 1024;
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
/// <summary>
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
/// </summary>
public static List<string> Check(ChangelogEntry entry)
{
var errors = new List<string>();
void Length(string? value, int max, string what)
{
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
}
Length(entry.Title, MaxTitleLength, "Titre");
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
if (introduction.Count > MaxIntroductionParagraphs)
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
var items = 0;
for (var s = 0; s < sections.Count; s++)
{
var section = sections[s];
var where = $"Section {s + 1}";
Length(section.Title, MaxTitleLength, $"{where} (titre)");
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
items += sectionItems.Count;
for (var i = 0; i < sectionItems.Count; i++)
{
var item = sectionItems[i];
var at = $"{where}, élément {i + 1}";
Length(item.Text, MaxTextLength, at);
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
Length(item.Note, MaxTextLength, $"{at} (note)");
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
}
}
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
errors.Add($"{MaxFlatChanges} modifications au plus.");
return errors.Distinct().ToList();
}
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
{
@@ -229,7 +297,8 @@ public static class ChangelogLimits
var text = sb.ToString().Trim();
if (text.Length == 0) return null;
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
return text;
}
@@ -0,0 +1,34 @@
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
/// l'éditeur du menu d'administration.
/// </summary>
public sealed record ChangelogState
{
public string GameId { get; init; } = "";
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
public string ManifestVersion { get; init; } = "";
/// <summary>
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// </summary>
public string ManifestSha256 { get; init; } = "";
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
public List<ChangelogEntry> Entries { get; init; } = new();
}
/// <summary>
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
/// </summary>
public sealed record ChangelogUpdateRequest
{
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
public string BaseSha256 { get; init; } = "";
public ChangelogEntry? Entry { get; init; }
}
@@ -12,6 +12,12 @@ public sealed record AdminKeySummary
/// </summary>
public string? Fingerprint { get; init; }
/// <summary>
/// Clé publique (SPKI, Base64) des clés ACTIVES seulement (trousseau de la phase 7). Absente pour une clé en
/// attente (on pourrait en déduire l'empreinte) ou révoquée.
/// </summary>
public string? PublicKey { get; init; }
public DateTimeOffset AddedAt { get; init; }
public DateTimeOffset? LastUsedAt { get; init; }
}
@@ -26,6 +32,13 @@ public sealed record AdminSummary
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
}
/// <summary>Compte de l'administrateur connecté : ses appareils (clés) et les invitations « nouvel appareil » en cours.</summary>
public sealed record MyAccount
{
public AdminSummary Admin { get; init; } = new();
public IReadOnlyList<InvitationSummary> Invitations { get; init; } = Array.Empty<InvitationSummary>();
}
public sealed record CreateInvitationRequest
{
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
@@ -46,6 +59,10 @@ public sealed record InvitationSummary
public Guid? ForAdminId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
/// <summary>Administrateur qui a créé l'invitation (null : auteur inconnu).</summary>
public Guid? CreatedById { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
@@ -26,6 +26,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";
@@ -53,4 +53,19 @@ public static class ApiErrorCodes
public const string SelfAction = "self_action";
public const string Busy = "busy";
public const string HttpsRequired = "https_required";
/// <summary>Quelqu'un a modifié la même donnée entre-temps (révision dépassée) : recharger avant de recommencer.</summary>
public const string RevisionConflict = "revision_conflict";
/// <summary>Fonction non configurée sur le serveur.</summary>
public const string NotConfigured = "not_configured";
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
public const string OffsetMismatch = "offset_mismatch";
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
public const string PublicationInProgress = "publication_in_progress";
/// <summary>Espace disque insuffisant sur le serveur.</summary>
public const string InsufficientStorage = "insufficient_storage";
}
@@ -0,0 +1,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,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;
}
}
@@ -22,8 +22,11 @@ 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";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage
};
}
@@ -6,7 +6,7 @@ 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 +21,7 @@ public static class AdminRoles
[Admin] = new[]
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -38,6 +38,28 @@ public static class SignedMessages
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
/// <summary>
/// Signature d'un contenu servi aux joueurs (manifest d'un jeu, catalogue) par la clé d'un administrateur
/// (phase 7). En-tête distinct : une signature de contenu ne peut pas servir de signature de requête,
/// et inversement.
/// </summary>
public const string ContentHeader = "GameLauncher/Content/v1";
/// <summary>Signature du trousseau (clés autorisées à signer le contenu) par la clé racine du launcher.</summary>
public const string KeyringHeader = "GameLauncher/Keyring/v1";
/// <param name="kind">« manifest » ou « catalog ».</param>
/// <param name="id">Identifiant du jeu (minuscules) ou « catalog ».</param>
/// <param name="digest">SHA-256 (hexadécimal minuscule) du contenu canonique.</param>
public static byte[] Content(string kind, string id, long signedAt, string keyId, string digest) =>
Build(ContentHeader,
("kind", kind), ("id", id), ("signed-at", signedAt.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("key", keyId), ("digest", digest));
/// <param name="digest">SHA-256 (hexadécimal minuscule) du trousseau canonique (sans sa signature).</param>
public static byte[] Keyring(string rootKeyId, string digest) =>
Build(KeyringHeader, ("root", rootKeyId), ("digest", digest));
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
public static string HashInvitationCode(string normalizedCode) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
@@ -0,0 +1,242 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Signing;
/// <summary>
/// Signature d'un contenu servi aux joueurs (propriété « signature » du manifest.json d'un jeu ou du
/// catalog.json), produite par la clé d'un administrateur. Les anciens launchers ignorent cette propriété.
/// </summary>
public sealed record ContentSignature
{
/// <summary>Identifiant de la clé qui a signé (16 caractères hexadécimaux).</summary>
public string KeyId { get; init; } = "";
/// <summary>Date de la signature (heure du serveur, à la seconde) : sert aussi à refuser un contenu plus ancien que le dernier vu.</summary>
public DateTimeOffset SignedAt { get; init; }
/// <summary>Signature ECDSA P-256 / SHA-256 (64 octets, IEEE P1363) en Base64.</summary>
public string Value { get; init; } = "";
}
/// <summary>Types de contenu signés et autorisations (« scopes ») correspondantes dans le trousseau.</summary>
public static class ContentKinds
{
public const string Manifest = "manifest";
public const string Catalog = "catalog";
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
public const string CatalogId = "catalog";
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
public static string ScopeOf(string kind) => kind == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
}
/// <summary>Autorisations d'une clé du trousseau.</summary>
public static class SigningScopes
{
/// <summary>Signer le manifest d'un jeu (ce qui est installé et exécuté) : permission releases.publish.</summary>
public const string Releases = "releases";
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
public const string Catalog = "catalog";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog };
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
{
var set = permissions.ToHashSet(StringComparer.Ordinal);
var scopes = new List<string>();
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
return scopes;
}
public static string DisplayName(string scope) => scope switch
{
Releases => "versions du jeu",
Catalog => "catalogue",
_ => scope
};
}
/// <summary>
/// Partie d'un manifest couverte par la signature : tout ce qui décide de ce qui est installé et exécuté
/// (jeu, version, exécutable, arguments, archive, fichiers et leurs empreintes). Les notes de version et la
/// date ne le sont pas : elles restent modifiables en ligne (texte borné, jamais exécuté).
/// </summary>
public sealed record ManifestContent
{
public int FormatVersion { get; init; } = 1;
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string Executable { get; init; } = "";
public string? LaunchArguments { get; init; }
public ManifestContentArchive Archive { get; init; } = new();
public string? ObjectsBaseUrl { get; init; }
public List<ManifestContentFile> Files { get; init; } = new();
/// <summary>Lit un manifest.json (mêmes noms de propriétés que le launcher, casse indifférente).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static ManifestContent Parse(ReadOnlySpan<byte> json)
{
if (json.StartsWith(Utf8Bom)) json = json[Utf8Bom.Length..];
return JsonSerializer.Deserialize<ManifestContent>(json, ReadOptions) ?? throw new JsonException("manifest vide");
}
private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 32
};
}
public sealed record ManifestContentArchive
{
public string Url { get; init; } = "";
public long Size { get; init; }
public string Sha256 { get; init; } = "";
}
public sealed record ManifestContentFile
{
public string Path { get; init; } = "";
public long Size { get; init; }
public string Sha256 { get; init; } = "";
}
/// <summary>Résultat d'une vérification de signature de contenu.</summary>
public sealed record ContentCheck(bool Valid, string Reason, KeyringKey? Key)
{
public static ContentCheck Fail(string reason) => new(false, reason, null);
}
/// <summary>
/// Condensés canoniques et signatures du contenu. Le même code est utilisé par le menu d'administration
/// (qui signe), le serveur (qui contrôle avant d'écrire) et le launcher des joueurs (qui vérifie) : chacun
/// calcule le condensé à partir de ce qu'il a lui-même lu.
/// </summary>
public static class ContentSigning
{
/// <summary>Écart toléré par le serveur entre la date de signature et son horloge.</summary>
public static readonly TimeSpan MaxSignedAtSkew = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions Canonical = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Condensé du contenu signé d'un manifest (fichiers triés, empreintes en minuscules ; valeurs absentes = vides).</summary>
public static string ManifestDigest(ManifestContent manifest)
{
var archive = manifest.Archive ?? new ManifestContentArchive();
var canonical = new ManifestContent
{
FormatVersion = manifest.FormatVersion,
GameId = (manifest.GameId ?? "").ToLowerInvariant(),
Version = manifest.Version ?? "",
Executable = (manifest.Executable ?? "").Replace('\\', '/'),
LaunchArguments = string.IsNullOrEmpty(manifest.LaunchArguments) ? null : manifest.LaunchArguments,
Archive = new ManifestContentArchive
{
Url = archive.Url ?? "",
Size = archive.Size,
Sha256 = (archive.Sha256 ?? "").ToLowerInvariant()
},
ObjectsBaseUrl = string.IsNullOrWhiteSpace(manifest.ObjectsBaseUrl) ? "objects/" : manifest.ObjectsBaseUrl,
Files = (manifest.Files ?? new List<ManifestContentFile>())
.Where(f => f is not null)
.Select(f => new ManifestContentFile { Path = f.Path ?? "", Size = f.Size, Sha256 = (f.Sha256 ?? "").ToLowerInvariant() })
.OrderBy(f => f.Path, StringComparer.Ordinal)
.ThenBy(f => f.Sha256, StringComparer.Ordinal)
.ToList()
};
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>Condensé du contenu signé d'un manifest.json (octets bruts).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static string ManifestDigest(ReadOnlySpan<byte> manifestJson) => ManifestDigest(ManifestContent.Parse(manifestJson));
/// <summary>
/// Condensé du contenu signé d'un catalogue : tout sauf la révision, la date et la signature (posées par le
/// serveur) ; jeux triés par identifiant, tels qu'écrits (sans changer la casse : deux identifiants qui ne
/// diffèrent que par la casse donneraient deux entrées distinctes, voir <see cref="HasDuplicateGames"/>).
/// </summary>
public static string CatalogDigest(CatalogDocument catalog)
{
var games = new SortedDictionary<string, CatalogGame>(StringComparer.Ordinal);
foreach (var (id, game) in catalog.Games ?? new Dictionary<string, CatalogGame>())
games[id] = game;
var canonical = new CanonicalCatalog(catalog.Format, catalog.Version, catalog.Launcher, games);
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>
/// Identifiants de jeux en double à la casse près : le launcher n'en garderait qu'un, différent de celui
/// couvert par la signature. Un tel catalogue est refusé par la vérification.
/// </summary>
public static bool HasDuplicateGames(CatalogDocument catalog) =>
(catalog.Games?.Keys ?? Enumerable.Empty<string>()).GroupBy(k => k, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
public static string SignedId(string kind, string id) => kind == ContentKinds.Catalog ? ContentKinds.CatalogId : id.ToLowerInvariant();
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
/// <summary>Message à signer par la clé de l'administrateur.</summary>
public static byte[] Message(string kind, string id, DateTimeOffset signedAt, string keyId, string digest) =>
SignedMessages.Content(kind, SignedId(kind, id), signedAt.ToUnixTimeSeconds(), keyId, digest);
/// <summary>Vérifie une signature avec une clé publique donnée (le serveur : la clé de l'administrateur qui envoie).</summary>
public static bool VerifyWithKey(string kind, string id, string digest, ContentSignature signature, ReadOnlySpan<byte> publicKey)
{
if (!AdminKeys.IsValidKeyId(signature.KeyId) || signature.SignedAt == default) return false;
if (AdminKeys.DecodeSignature(signature.Value) is not { } value) return false;
if (!string.Equals(AdminKeys.ComputeKeyId(publicKey), signature.KeyId, StringComparison.Ordinal)) return false;
return AdminKeys.Verify(publicKey, Message(kind, id, signature.SignedAt, signature.KeyId, digest), value);
}
/// <summary>
/// Vérifie une signature avec le trousseau (le launcher des joueurs) : clé présente, autorisée pour ce type
/// de contenu, signature valide sur le condensé calculé localement.
/// </summary>
public static ContentCheck Verify(string kind, string id, string digest, ContentSignature? signature, KeyringDocument keyring)
{
if (signature is null) return ContentCheck.Fail("contenu non signé");
if (!AdminKeys.IsValidKeyId(signature.KeyId)) return ContentCheck.Fail("signature mal formée");
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.Ordinal));
if (key is null) return ContentCheck.Fail($"signé par une clé absente du trousseau ({signature.KeyId})");
if (key.Scopes is null || !key.Scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal))
return ContentCheck.Fail($"la clé {signature.KeyId} ({key.Name}) n'est pas autorisée à signer ce contenu");
byte[] publicKey;
try
{
publicKey = Convert.FromBase64String(key.PublicKey);
}
catch (FormatException)
{
return ContentCheck.Fail("clé du trousseau illisible");
}
return VerifyWithKey(kind, id, digest, signature, publicKey)
? new ContentCheck(true, $"signé par {key.Name} le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC", key)
: ContentCheck.Fail("SIGNATURE INVALIDE (contenu modifié après signature, ou signature d'un autre contenu)");
}
internal static string Sha256(ReadOnlySpan<byte> data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}
@@ -0,0 +1,180 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Signing;
/// <summary>
/// Trousseau (keyring.json) : clés d'administrateurs autorisées à signer le contenu servi aux joueurs, signé
/// par la clé RACINE (celle des mises à jour du launcher, dont la clé publique est intégrée à l'exe). La clé
/// racine reste hors ligne (LauncherPublisher, « sign-keyring.bat ») : un serveur compromis ne peut ni
/// ajouter une clé, ni modifier le trousseau sans que les launchers le refusent.
/// </summary>
public sealed record KeyringDocument
{
public const string FormatName = "gamelauncher-keyring";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Numéro croissant : un launcher refuse un trousseau plus ancien que le dernier qu'il a vu.</summary>
public long Sequence { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public List<KeyringKey> Keys { get; init; } = new();
/// <summary>Signature de la clé racine (absente dans une demande de trousseau, avant signature).</summary>
public KeyringSignature? Signature { get; init; }
}
public sealed record KeyringKey
{
public string KeyId { get; init; } = "";
/// <summary>Clé publique ECDSA P-256 (SPKI, Base64).</summary>
public string PublicKey { get; init; } = "";
/// <summary>Administrateur et appareil (affichage, journal du launcher).</summary>
public string Name { get; init; } = "";
/// <summary>Ce que la clé peut signer (<see cref="SigningScopes"/>).</summary>
public List<string> Scopes { get; init; } = new();
}
public sealed record KeyringSignature
{
/// <summary>Identifiant de la clé racine (16 caractères hexadécimaux, comme pour les mises à jour du launcher).</summary>
public string RootKeyId { get; init; } = "";
public string Value { get; init; } = "";
}
/// <summary>Contrôles, condensé et signature du trousseau (même code partout).</summary>
public static class Keyring
{
/// <summary>Taille maximale de keyring.json.</summary>
public const int MaxBytes = 60 * 1024; // envoyé à l'API : sous la limite de 64 Ko des requêtes
public const int MaxKeys = 100;
public const int MaxNameLength = 120;
private static readonly JsonSerializerOptions Canonical = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Lit un trousseau (null : JSON vide).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static KeyringDocument? Parse(ReadOnlySpan<byte> json)
{
if (json.Length > MaxBytes) throw new JsonException($"trousseau trop volumineux (au plus {MaxBytes / 1024} Ko)");
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
return JsonSerializer.Deserialize<KeyringDocument>(json, ApiJson.Options);
}
public static string Serialize(KeyringDocument keyring) =>
JsonSerializer.Serialize(keyring, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true });
/// <summary>Condensé du trousseau sans sa signature (clés triées, autorisations triées).</summary>
public static string Digest(KeyringDocument keyring)
{
var canonical = keyring with
{
Signature = null,
IssuedAt = ContentSigning.Truncate(keyring.IssuedAt),
Keys = (keyring.Keys ?? new List<KeyringKey>())
.Select(k => k with { Scopes = (k.Scopes ?? new List<string>()).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal).ToList() })
.OrderBy(k => k.KeyId, StringComparer.Ordinal)
.ToList()
};
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>Contrôle de structure (sans la signature racine). Les erreurs sont ajoutées à <paramref name="errors"/>.</summary>
public static bool Validate(KeyringDocument keyring, List<string> errors)
{
var count = errors.Count;
if (keyring.Format != KeyringDocument.FormatName || keyring.Version != KeyringDocument.CurrentVersion)
errors.Add($"Format de trousseau non pris en charge ({keyring.Format} v{keyring.Version}).");
if (keyring.Sequence < 1) errors.Add("Numéro du trousseau invalide (1 au moins).");
if (keyring.IssuedAt == default) errors.Add("Date du trousseau absente.");
if (keyring.Keys is null || keyring.Keys.Any(k => k is null || k.Scopes is null || k.PublicKey is null || k.Name is null))
{
errors.Add("Trousseau incomplet (clés, clé publique, nom ou autorisations absents).");
return false;
}
if (keyring.Keys.Count > MaxKeys) errors.Add($"Trop de clés dans le trousseau (au plus {MaxKeys}).");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in keyring.Keys)
{
var label = AdminKeys.IsValidKeyId(key.KeyId) ? key.KeyId : "(identifiant invalide)";
if (!AdminKeys.IsValidKeyId(key.KeyId)) errors.Add("Clé du trousseau : identifiant invalide.");
else if (!seen.Add(key.KeyId)) errors.Add($"Clé {label} en double.");
try
{
var spki = AdminKeys.ParsePublicKey(key.PublicKey);
if (!string.Equals(AdminKeys.ComputeKeyId(spki), key.KeyId, StringComparison.Ordinal))
errors.Add($"Clé {label} : l'identifiant ne correspond pas à la clé publique.");
}
catch (CryptographicException)
{
errors.Add($"Clé {label} : clé publique illisible ou d'un autre type que ECDSA P-256.");
}
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.All.Contains(s, StringComparer.Ordinal)))
errors.Add($"Clé {label} : autorisations absentes ou inconnues.");
}
return errors.Count == count;
}
/// <summary>
/// Nom affichable sans tromperie : ni caractères de contrôle, ni caractères de mise en forme invisibles
/// (inversion du sens d'écriture…), qui pourraient maquiller un nom dans la console ou les fenêtres.
/// </summary>
public static bool IsDisplaySafe(string name) =>
!name.Any(c => char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
or System.Globalization.UnicodeCategory.LineSeparator
or System.Globalization.UnicodeCategory.ParagraphSeparator);
/// <summary>Signe le trousseau avec la clé racine (LauncherPublisher).</summary>
public static KeyringDocument Sign(KeyringDocument keyring, ECDsa rootKey)
{
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
var unsigned = keyring with { Signature = null, IssuedAt = ContentSigning.Truncate(keyring.IssuedAt) };
var signature = AdminKeys.Sign(rootKey, SignedMessages.Keyring(rootKeyId, Digest(unsigned)));
return unsigned with { Signature = new KeyringSignature { RootKeyId = rootKeyId, Value = Convert.ToBase64String(signature) } };
}
/// <summary>
/// Vérifie la signature racine avec les clés publiques intégrées au launcher (celles de update-signing.pub).
/// </summary>
/// <param name="rootKeys">Identifiant et clé publique SPKI de chaque clé racine de confiance.</param>
public static bool VerifyRoot(KeyringDocument keyring, IEnumerable<(string KeyId, byte[] PublicKey)> rootKeys, out string error)
{
error = "";
if (keyring.Signature is not { } signature)
{
error = "trousseau non signé par la clé racine";
return false;
}
var root = rootKeys.FirstOrDefault(k => string.Equals(k.KeyId, signature.RootKeyId, StringComparison.OrdinalIgnoreCase));
if (root.PublicKey is null)
{
error = $"trousseau signé par une clé racine inconnue de ce launcher ({signature.RootKeyId})";
return false;
}
if (AdminKeys.DecodeSignature(signature.Value) is not { } value ||
!AdminKeys.Verify(root.PublicKey, SignedMessages.Keyring(signature.RootKeyId.ToLowerInvariant(), Digest(keyring)), value))
{
error = "SIGNATURE DU TROUSSEAU INVALIDE";
return false;
}
return true;
}
}
@@ -0,0 +1,66 @@
namespace GameLauncher.Contracts.Signing;
/// <summary>Mise en ligne d'une version : signature du manifest par l'administrateur qui publie.</summary>
public sealed record PublicationCommitRequest
{
/// <summary>Signature du contenu du manifest envoyé (<see cref="ContentSigning.ManifestDigest(ManifestContent)"/>).</summary>
public ContentSignature? Signature { get; init; }
}
/// <summary>Signature du manifest en ligne d'un jeu (avec le condensé calculé par le serveur).</summary>
public sealed record ManifestSignatureState
{
public string GameId { get; init; } = "";
/// <summary>Version en ligne (vide : aucun manifest publié).</summary>
public string Version { get; init; } = "";
/// <summary>Condensé du contenu signé de la version en ligne (vide : aucun manifest publié).</summary>
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
/// <summary>Administrateur et appareil de la clé qui a signé, si le serveur la connaît.</summary>
public string? SignerName { get; init; }
}
/// <summary>
/// Signature de la version en ligne (déjà publiée, par exemple avec WinSCP) à partir du manifest.json du dossier
/// de publication : le serveur ne l'accepte que si le contenu en ligne est identique.
/// </summary>
public sealed record ManifestSignRequest
{
/// <summary>Condensé calculé par le launcher à partir du manifest.json local.</summary>
public string Digest { get; init; } = "";
public ContentSignature Signature { get; init; } = new();
}
/// <summary>Signatures du contenu, pour la page « Signature » (propriétaires).</summary>
public sealed record SigningState
{
/// <summary>Trousseau publié (null : aucun).</summary>
public KeyringDocument? Keyring { get; init; }
/// <summary>Faux : publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou catalogue absent).</summary>
public bool KeyringPublishingEnabled { get; init; }
/// <summary>Adresse publique du trousseau (à mettre dans « keyringUrl » de launcher.json).</summary>
public string? KeyringPublicUrl { get; init; }
/// <summary>Vrai : le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require).</summary>
public bool RequireSignatures { get; init; }
public List<ManifestSignatureState> Manifests { get; init; } = new();
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
public CatalogSignatureState? Catalog { get; init; }
}
public sealed record CatalogSignatureState
{
public long Revision { get; init; }
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
}
@@ -0,0 +1,87 @@
using System.Runtime.CompilerServices;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
/// <summary>
/// Applique le catalogue aux jeux déclarés dans launcher.json — affichage uniquement :
/// • jeux inconnus de launcher.json : ignorés (le serveur ne peut ni ajouter ni retirer un jeu) ;
/// • nom AFFICHÉ (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,204 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
/// <summary>
/// Catalogue des jeux côté joueur : catalog.json (statique, publié depuis le menu d'administration)
/// téléchargé, contrôlé et borné (<see cref="CatalogRules"/> : une valeur invalide est ignorée), puis
/// mis en cache dans le dossier des données du launcher. Sans réseau : dernier cache, sinon les
/// valeurs de launcher.json. Images (icône, fond) : PNG ou JPEG du serveur du catalogue, 4 Mo au plus,
/// téléchargées une fois puis conservées en cache.
/// </summary>
public sealed class CatalogService
{
public const long MaxImageBytes = 4 * 1024 * 1024;
private const string FolderName = "catalog";
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri _catalogUrl;
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
private readonly ContentTrustService? _trust;
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl, ContentTrustService? trust = null)
: this(http.GetBytesAsync, dataDirectory, catalogUrl, trust) { }
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
/// <param name="trust">Vérification de la signature (phase 7) : en mode « require », un catalogue refusé est ignoré.</param>
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl,
ContentTrustService? trust = null)
{
_download = download;
_catalogUrl = catalogUrl;
_trust = trust;
Directory = Path.Combine(dataDirectory, FolderName);
CachePath = Path.Combine(Directory, "catalog.json");
ImagesDirectory = Path.Combine(Directory, "images");
}
public string Directory { get; }
public string CachePath { get; }
public string ImagesDirectory { get; }
/// <summary>
/// Catalogue du cache (null : absent, illisible ou, en mode « require », non signé). Le cache contient le
/// catalogue tel que publié (signature comprise) : il est revérifié puis contrôlé à chaque lecture.
/// </summary>
public CatalogDocument? LoadCached()
{
try
{
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
if (ParseRaw(File.ReadAllBytes(CachePath)) is not { } raw) return null;
if (_trust is not null && !_trust.VerifyCachedCatalog(raw)) return null;
return CatalogRules.Normalize(raw, _catalogUrl.Host, new List<string>());
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
{
Log.Warn($"Catalogue en cache illisible : {ex.Message}");
return null;
}
}
/// <summary>
/// Télécharge le catalogue, le contrôle et le met en cache. Null si le serveur ne répond pas ou si
/// le fichier est inutilisable (le cache est alors conservé tel quel).
/// </summary>
public async Task<CatalogDocument?> FetchAsync(CancellationToken ct = default)
{
try
{
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
if (ParseRaw(bytes) is not { } raw) return null;
// Signature vérifiée sur le catalogue tel que publié, avant tout contrôle des valeurs.
if (_trust is not null && !await _trust.VerifyCatalogAsync(raw, ct).ConfigureAwait(false)) return null;
var errors = new List<string>();
var catalog = CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
// Cache : le catalogue tel que publié (la signature reste vérifiable hors ligne).
System.IO.Directory.CreateDirectory(Directory);
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(raw, ApiJson.Options));
return catalog;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
InvalidDataException or JsonException or OperationCanceledException)
{
Log.Warn($"Catalogue des jeux indisponible ({_catalogUrl.Host}) : {ex.Message}");
return null;
}
}
/// <summary>Catalogue tel que publié (null : format non pris en charge).</summary>
private static CatalogDocument? ParseRaw(byte[] bytes)
{
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
?? throw new InvalidDataException("catalogue vide");
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
{
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
return null;
}
return raw;
}
// ------------------------------------------------------------------ Images
/// <summary>Image déjà en cache (null sinon) : utilisable immédiatement, même hors ligne.</summary>
public string? CachedImage(string? url)
{
if (ImagePath(url) is not { } path) return null;
return File.Exists(path) ? path : null;
}
/// <summary>
/// Image en cache, sinon téléchargée (PNG ou JPEG contrôlé). Null si indisponible. Appels simultanés
/// pour la même image (démarrage, puis arrière-plan) : un seul téléchargement, partagé.
/// </summary>
public Task<string?> GetImageAsync(string? url)
{
if (ImagePath(url) is not { } path) return Task.FromResult<string?>(null);
if (File.Exists(path)) return Task.FromResult<string?>(path);
var download = _imageDownloads.GetOrAdd(path, _ => new Lazy<Task<string?>>(() => DownloadImageAsync(url!, path)));
return download.Value;
}
private async Task<string?> DownloadImageAsync(string url, string path)
{
var temp = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
var bytes = await _download(new Uri(url), MaxImageBytes, CancellationToken.None).ConfigureAwait(false);
if (!IsPngOrJpeg(bytes)) throw new InvalidDataException("ce n'est pas une image PNG ou JPEG");
System.IO.Directory.CreateDirectory(ImagesDirectory);
await File.WriteAllBytesAsync(temp, bytes).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
return path;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
InvalidDataException or OperationCanceledException)
{
FileUtil.TryDeleteFile(temp);
Log.Warn($"Image du catalogue indisponible ({url}) : {ex.Message}");
return null;
}
finally
{
_imageDownloads.TryRemove(path, out _);
}
}
/// <summary>Supprime les images du cache qui ne sont plus utilisées par le catalogue.</summary>
public void PruneImages(CatalogDocument catalog)
{
try
{
if (!System.IO.Directory.Exists(ImagesDirectory)) return;
var used = catalog.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
.Select(ImagePath).Where(p => p is not null).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in System.IO.Directory.GetFiles(ImagesDirectory))
{
if (used.Contains(file)) continue;
// Fichier temporaire : peut-être un téléchargement en cours (supprimé s'il est ancien).
if (file.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) &&
DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < TimeSpan.FromHours(1))
continue;
FileUtil.TryDeleteFile(file);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Nettoyage des images du catalogue : {ex.Message}");
}
}
/// <summary>Fichier du cache pour une image (nom = empreinte de l'adresse) ; null si l'adresse est refusée.</summary>
private string? ImagePath(string? url)
{
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, _catalogUrl.Host, StringComparison.OrdinalIgnoreCase) ||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
return null;
var extension = uri.AbsolutePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? ".png" : ".jpg";
var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32].ToLowerInvariant();
return Path.Combine(ImagesDirectory, name + extension);
}
internal static bool IsPngOrJpeg(ReadOnlySpan<byte> data) =>
data.StartsWith(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }) ||
data.StartsWith(new byte[] { 0xFF, 0xD8, 0xFF });
}
@@ -13,4 +13,9 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<!-- Catalogue des jeux (catalog.json) : format et règles partagés avec la Launcher API. -->
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,7 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Models;
@@ -39,9 +41,28 @@ public sealed class GameManifest
public List<ChangelogEntry> Changelog { get; set; } = new();
/// <summary>
/// Signature de l'administrateur qui a mis la version en ligne (phase 7), vérifiée avec le trousseau
/// (<see cref="Trust.ContentTrustService"/>). Absente des manifests produits par ManifestBuilder.
/// </summary>
public ContentSignature? Signature { get; set; }
[JsonIgnore]
public long TotalSize => Files.Sum(f => f.Size);
/// <summary>Partie couverte par la signature (tout ce qui décide de ce qui est installé et exécuté).</summary>
public ManifestContent ToSignedContent() => new()
{
FormatVersion = FormatVersion,
GameId = GameId,
Version = Version,
Executable = Executable,
LaunchArguments = LaunchArguments,
Archive = new ManifestContentArchive { Url = Archive.Url, Size = Archive.Size, Sha256 = Archive.Sha256 },
ObjectsBaseUrl = ObjectsBaseUrl,
Files = Files.Select(f => new ManifestContentFile { Path = f.Path, Size = f.Size, Sha256 = f.Sha256 }).ToList()
};
/// <summary>URL d'où le manifest a été téléchargé (sert à résoudre les URL relatives).</summary>
[JsonIgnore]
public Uri? SourceUri { get; set; }
+119 -7
View File
@@ -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%\&lt;Publisher&gt;\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;
@@ -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;
@@ -0,0 +1,61 @@
using System.Net;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Services;
/// <summary>
/// Historique des notes de version lors d'une publication (ManifestBuilder). Avec --remote-manifest,
/// l'historique est repris du manifest EN LIGNE : les corrections faites depuis le menu
/// d'administration ne sont pas écrasées par le manifest.json local (resté dans son état d'origine).
/// </summary>
public static class ChangelogHistory
{
/// <summary>
/// Manifest publié (null s'il n'existe pas encore : première publication). Toute autre erreur
/// (réseau, fichier illisible) est signalée : publier sans elle perdrait les corrections en ligne.
/// </summary>
public static async Task<GameManifest?> FetchPublishedAsync(HttpDownloader http, Uri url, CancellationToken ct = default)
{
if (url.Scheme != Uri.UriSchemeHttps && !(url.Scheme == Uri.UriSchemeHttp && url.IsLoopback))
throw new ArgumentException($"Adresse du manifest en ligne : https:// attendu ({url}).");
string json;
try
{
json = await http.GetStringAsync(url, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
try
{
return JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)
?? throw new InvalidDataException("manifest vide");
}
catch (JsonException ex)
{
throw new InvalidDataException($"manifest illisible : {ex.Message}", ex);
}
}
/// <summary>
/// Notes de la version publiée en tête, puis l'historique (sans cette version si elle est republiée
/// avec de nouvelles notes), bornés comme par le launcher. Sans nouvelles notes (pas de --notes),
/// les notes existantes de la version, éventuellement corrigées en ligne, sont conservées.
/// </summary>
public static List<ChangelogEntry> Build(IEnumerable<ChangelogEntry?>? history, ChangelogEntry entry)
{
var list = (history ?? Enumerable.Empty<ChangelogEntry?>()).OfType<ChangelogEntry>().ToList();
if (!entry.IsEmpty)
{
list.RemoveAll(c => string.Equals(c.Version, entry.Version, StringComparison.Ordinal));
list.Insert(0, entry);
}
return ChangelogLimits.Normalize(list);
}
}
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
@@ -11,8 +11,15 @@ namespace GameLauncher.Core.Services;
public sealed class HttpDownloader : IDisposable
{
private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(30);
private const int MaxSameHostRedirects = 3;
private readonly HttpClient _http;
/// <summary>
/// Client SANS redirection automatique (catalogue, images) : une redirection n'est suivie que vers le
/// même serveur, après vérification, et aucune requête ne part vers un autre serveur.
/// </summary>
private readonly HttpClient _strictHttp;
public HttpDownloader(string userAgent)
{
var handler = new SocketsHttpHandler
@@ -25,6 +32,15 @@ public sealed class HttpDownloader : IDisposable
};
_http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
_strictHttp = new HttpClient(new SocketsHttpHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
ConnectTimeout = TimeSpan.FromSeconds(15),
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
}) { Timeout = Timeout.InfiniteTimeSpan };
_strictHttp.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
}
/// <summary>Taille maximale par défaut d'un document texte (manifest du jeu).</summary>
@@ -79,6 +95,64 @@ public sealed class HttpDownloader : IDisposable
}
}
/// <summary>
/// Télécharge un petit fichier en mémoire (catalogue, image) sans passer par les caches, en
/// refusant toute redirection vers un autre serveur ou vers une adresse non https.
/// Au-delà de <paramref name="maxBytes"/> octets, le téléchargement est interrompu.
/// </summary>
public async Task<byte[]> GetBytesAsync(Uri uri, long maxBytes, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var current = uri;
HttpResponseMessage response;
for (var redirects = 0; ; redirects++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, current);
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true };
request.Headers.Pragma.ParseAdd("no-cache");
response = await _strictHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
.ConfigureAwait(false);
var status = (int)response.StatusCode;
if (status is < 300 or > 399 || status == 304) break;
// Redirection : suivie seulement vers le même serveur (hôte, schéma, port), quelques fois au plus.
var location = response.Headers.Location;
response.Dispose();
var next = location is null ? null : location.IsAbsoluteUri ? location : new Uri(current, location);
if (next is null || redirects >= MaxSameHostRedirects ||
!string.Equals(next.Host, uri.Host, StringComparison.OrdinalIgnoreCase) ||
next.Scheme != uri.Scheme || next.Port != uri.Port)
throw new InvalidDataException($"Redirection refusée ({uri.Host} → {next?.Host ?? "?"}).");
current = next;
}
using var _ = response;
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > maxBytes)
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
using var buffer = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await stream.ReadAsync(chunk, timeout.Token).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > maxBytes)
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"Le serveur ne répond pas ({uri.Host}).");
}
}
/// <summary>
/// Télécharge <paramref name="uri"/> vers <paramref name="destination"/>.
/// Si <paramref name="allowResume"/> est vrai et qu'un fichier partiel existe, le
@@ -155,5 +229,9 @@ public sealed class HttpDownloader : IDisposable
throw new IOException($"Téléchargement incomplet ({Format.Bytes(position)} sur {Format.Bytes(expectedSize)}).");
}
public void Dispose() => _http.Dispose();
public void Dispose()
{
_http.Dispose();
_strictHttp.Dispose();
}
}
@@ -1,5 +1,7 @@
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Services;
@@ -8,11 +10,16 @@ public sealed class ManifestClient
{
private readonly HttpDownloader _http;
private readonly Uri _manifestUri;
private readonly ContentTrustService? _trust;
private readonly string? _gameId;
public ManifestClient(HttpDownloader http, Uri manifestUri)
/// <param name="trust">Vérification des signatures (phase 7), avec l'identifiant du jeu de launcher.json.</param>
public ManifestClient(HttpDownloader http, Uri manifestUri, ContentTrustService? trust = null, string? gameId = null)
{
_http = http;
_manifestUri = manifestUri;
_trust = trust;
_gameId = gameId;
}
public async Task<GameManifest> FetchAsync(CancellationToken ct = default)
@@ -31,6 +38,8 @@ public sealed class ManifestClient
manifest.SourceUri = _manifestUri;
manifest.Validate();
// Signature (phase 7) : refus en mode « require » (ContentSecurityException), sinon journal seulement.
if (_trust is not null && _gameId is not null) await _trust.VerifyManifestAsync(manifest, _gameId, ct).ConfigureAwait(false);
// Notes de version bornées et nettoyées : elles sont affichées telles quelles dans l'interface.
manifest.Changelog = ChangelogLimits.Normalize(manifest.Changelog);
Log.Info($"Manifest récupéré : version {manifest.Version}, {manifest.Files.Count} fichiers, {Format.Bytes(manifest.TotalSize)}.");
@@ -0,0 +1,322 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Trust;
/// <summary>Vérification des signatures du contenu (« contentSigning » de launcher.json).</summary>
public enum ContentSigningMode
{
/// <summary>Rien n'est vérifié (comportement antérieur à la phase 7).</summary>
Off,
/// <summary>Signatures vérifiées ; un problème est inscrit au journal, le contenu est utilisé quand même (mise en place).</summary>
Warn,
/// <summary>Un contenu non signé, mal signé ou plus ancien que le dernier vu est refusé.</summary>
Require
}
/// <summary>Contenu refusé (mode « require ») : signature absente ou invalide, trousseau indisponible, retour en arrière.</summary>
public sealed class ContentSecurityException : Exception
{
public ContentSecurityException(string message) : base(message) { }
}
/// <summary>
/// Confiance dans le contenu servi par le serveur (phase 7) :
/// • trousseau (keyring.json) téléchargé puis vérifié avec la ou les clés RACINE intégrées à l'exe (celles des
/// mises à jour du launcher), conservé en cache ; un trousseau plus ancien que le dernier vu est refusé ;
/// • manifest et catalogue : signature d'une clé du trousseau autorisée pour ce contenu, sur le condensé calculé
/// ici à partir de ce qui a été téléchargé ; un contenu signé plus tôt que le dernier accepté est refusé (un
/// serveur compromis ne peut pas resservir une ancienne version vulnérable) ;
/// • mode « warn » : problèmes inscrits au journal sans blocage ; « require » : contenu refusé.
/// État conservé dans le dossier des données du launcher (trust\content-trust.json).
/// </summary>
public sealed class ContentTrustService
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri? _keyringUrl;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
private readonly TimeProvider _time;
private readonly object _lock = new();
private Task<KeyringDocument?>? _keyringTask;
private bool _keyringFetched;
private DateTimeOffset _lastAttempt;
private TrustState? _state;
/// <param name="rootKeys">Clés racine de confiance (update-signing.pub intégré à l'exe).</param>
/// <param name="minKeyringSequence">
/// Numéro minimal du trousseau (« keyringMinSequence » de launcher.json) : un trousseau plus ancien, même signé par
/// la racine, est refusé. À relever après le retrait d'une clé compromise, pour les installations neuves.
/// </param>
/// <param name="time">Horloge (tests).</param>
public ContentTrustService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri? keyringUrl,
IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys, ContentSigningMode mode, long minKeyringSequence = 0,
TimeProvider? time = null)
{
_download = download;
_time = time ?? TimeProvider.System;
_keyringUrl = keyringUrl;
_rootKeys = rootKeys;
Mode = mode;
MinKeyringSequence = Math.Max(0, minKeyringSequence);
StatePath = Path.Combine(dataDirectory, "trust", "content-trust.json");
}
public ContentSigningMode Mode { get; }
public long MinKeyringSequence { get; }
public string StatePath { get; }
// ------------------------------------------------------------------ Trousseau
/// <summary>
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
/// </summary>
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
{
Task<KeyringDocument?> task;
lock (_lock)
{
var elapsed = _time.GetUtcNow() - _lastAttempt;
if (_keyringTask is null
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
{
_lastAttempt = _time.GetUtcNow();
_keyringTask = FetchKeyringAsync();
}
task = _keyringTask;
}
return await task.WaitAsync(ct).ConfigureAwait(false);
}
/// <summary>Dernier trousseau vérifié en cache (sans réseau).</summary>
public KeyringDocument? CachedKeyring => LoadState().Keyring;
private async Task<KeyringDocument?> FetchKeyringAsync()
{
var cached = LoadState().Keyring;
if (_keyringUrl is null || Mode == ContentSigningMode.Off) return cached;
try
{
using var timeout = new CancellationTokenSource(FetchTimeout);
var bytes = await _download(_keyringUrl, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0]);
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error);
if (keyring.Sequence < MinKeyringSequence)
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
lock (_lock)
{
_keyringFetched = true;
if (cached is not null)
{
if (keyring.Sequence < cached.Sequence)
{
Log.Error($"Trousseau n° {keyring.Sequence} refusé : plus ancien que le n° {cached.Sequence} déjà vu (retour en arrière).");
return cached;
}
if (keyring.Sequence == cached.Sequence && Keyring.Digest(keyring) != Keyring.Digest(cached))
{
Log.Error($"Trousseau n° {keyring.Sequence} refusé : différent de celui déjà vu sous le même numéro.");
return cached;
}
}
var state = LoadState();
if (cached is null || keyring.Sequence > cached.Sequence)
{
state.Keyring = keyring;
SaveState(state);
Log.Info($"Trousseau n° {keyring.Sequence} vérifié ({keyring.Keys.Count} clé(s)).");
}
return keyring;
}
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
return cached;
}
catch (Exception ex)
{
// Réseau, délai dépassé, réponse trop grande… : jamais d'exception vers l'appelant, nouvel essai plus tard.
Log.Warn($"Trousseau injoignable : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu est utilisé)."));
return cached;
}
}
// ------------------------------------------------------------------ Contenu
/// <summary>Vérifie le manifest d'un jeu (lève <see cref="ContentSecurityException"/> en mode « require »).</summary>
public Task VerifyManifestAsync(GameManifest manifest, string gameId, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return Task.CompletedTask;
return VerifyAsync(ContentKinds.Manifest, gameId, ContentSigning.ManifestDigest(manifest.ToSignedContent()), manifest.Signature,
$"Manifest de {gameId} (version {manifest.Version})", ct);
}
/// <summary>Vérifie le catalogue tel que téléchargé (avant tout contrôle des valeurs). Faux : à ignorer.</summary>
public async Task<bool> VerifyCatalogAsync(CatalogDocument catalog, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Catalogue (révision {catalog.Revision})";
try
{
if (ContentSigning.HasDuplicateGames(catalog))
return Report(what, "jeux en double (identifiants ne différant que par la casse)");
await VerifyAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, what, ct)
.ConfigureAwait(false);
return true;
}
catch (ContentSecurityException)
{
return false;
}
}
/// <summary>
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
/// L'antériorité n'est pas contrôlée ici (le cache est ce qui a déjà été accepté).
/// </summary>
public bool VerifyCachedCatalog(CatalogDocument catalog)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Catalogue en cache (révision {catalog.Revision})";
if (ContentSigning.HasDuplicateGames(catalog)) return Report(what, "jeux en double (identifiants ne différant que par la casse)");
var keyring = LoadState().Keyring;
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, keyring);
return check.Valid || Report(what, check.Reason);
}
/// <summary>Problème : refus en mode « require » (faux), avertissement en mode « warn » (vrai).</summary>
private bool Report(string what, string problem)
{
if (Mode == ContentSigningMode.Require)
{
Log.Error($"{what} : {problem}. Contenu refusé (contentSigning « require »).");
return false;
}
Log.Warn($"{what} : {problem}. Contenu utilisé quand même (contentSigning « warn »).");
return true;
}
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
{
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
string problem;
if (keyring is null)
{
problem = _keyringUrl is null ? "aucun trousseau configuré (keyringUrl)" : "trousseau indisponible (jamais téléchargé)";
}
else
{
var check = ContentSigning.Verify(kind, id, digest, signature, keyring);
if (!check.Valid)
{
problem = check.Reason;
}
else
{
var slot = $"{kind}:{ContentSigning.SignedId(kind, id)}";
lock (_lock)
{
var state = LoadState();
if (state.LastSigned.TryGetValue(slot, out var last) && signature!.SignedAt < last)
{
problem = $"signé le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC, plus ancien que le contenu déjà vu " +
$"(signé le {last:yyyy-MM-dd HH:mm} UTC) : retour en arrière refusé";
}
else
{
// Date enregistrée plafonnée : une signature datée loin dans le futur (horloge déréglée) ne doit
// pas faire refuser toutes les publications suivantes.
var ceiling = ContentSigning.Truncate(_time.GetUtcNow() + MaxFutureSignedAt);
var recorded = signature!.SignedAt > ceiling ? ceiling : signature.SignedAt;
if (recorded > last)
{
state.LastSigned[slot] = recorded;
SaveState(state);
}
Log.Info($"{what} : {check.Reason}.");
return;
}
}
}
}
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
}
// ------------------------------------------------------------------ État
private sealed class TrustState
{
public KeyringDocument? Keyring { get; set; }
public Dictionary<string, DateTimeOffset> LastSigned { get; set; } = new(StringComparer.Ordinal);
}
/// <summary>État en cache (le trousseau est revérifié à la lecture : un fichier modifié à la main est ignoré).</summary>
private TrustState LoadState()
{
lock (_lock)
{
if (_state is not null) return _state;
var state = new TrustState();
try
{
if (File.Exists(StatePath) && new FileInfo(StatePath).Length <= Keyring.MaxBytes * 2)
{
var read = JsonSerializer.Deserialize<TrustState>(File.ReadAllText(StatePath), ApiJson.Options);
if (read is not null)
{
state.LastSigned = new Dictionary<string, DateTimeOffset>(read.LastSigned ?? new(), StringComparer.Ordinal);
var errors = new List<string>();
if (read.Keyring is { } keyring && Keyring.Validate(keyring, errors) && Keyring.VerifyRoot(keyring, _rootKeys, out _)
&& keyring.Sequence >= MinKeyringSequence)
state.Keyring = keyring;
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"État de vérification des signatures illisible ({ex.Message}) : recréé.");
}
return _state = state;
}
}
private void SaveState(TrustState state)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!);
FileUtil.WriteAllTextAtomic(StatePath, JsonSerializer.Serialize(state, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true }));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"État de vérification des signatures non enregistré : {ex.Message}");
}
}
}
@@ -0,0 +1,310 @@
using System.Globalization;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>Textes communs des pages « Administrateurs » et « Mes appareils ».</summary>
public static class AccountTexts
{
/// <summary>« 01/11/2026 à 13:40 » (heure du PC).</summary>
public static string Date(DateTimeOffset value) =>
value.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
public static string Status(string status) => status switch
{
AdminStatus.Active => "Actif",
AdminStatus.Pending => "En attente d'approbation",
AdminStatus.Revoked => "Révoqué",
_ => status
};
/// <summary>Ce que permet chaque rôle (liste des rôles proposés).</summary>
public static string RoleHint(string role) => role switch
{
AdminRoles.Owner => "tous les droits, dont la gestion des administrateurs",
AdminRoles.Admin => "jeux, notes de version, publication et journal d'audit",
AdminRoles.Editor => "informations des jeux et notes de version",
_ => ""
};
public static string KeyLabel(string? label) => string.IsNullOrWhiteSpace(label) ? "Appareil sans nom" : label;
/// <summary>Son propre appareil en attente : l'empreinte se lit sur le nouveau PC.</summary>
public const string OwnPendingText = "Empreinte affichée sur le nouveau PC : saisissez-la ci-dessous pour l'approuver.";
}
/// <summary>
/// Actions communes aux pages de comptes (approuver ou révoquer un appareil, supprimer une invitation,
/// afficher un code d'invitation) : confirmation, appel signé, puis rechargement de la page.
/// </summary>
public abstract class AccountsPanelBase : AdminPanelViewModel
{
private InvitationCodeViewModel? _createdInvitation;
private string? _statusMessage;
protected AccountsPanelBase(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
}
public AsyncRelayCommand RefreshCommand { get; }
/// <summary>Faux pendant un échange avec le serveur (boutons des listes désactivés).</summary>
public bool CanEdit => !IsBusy;
/// <summary>Résultat de la dernière action (« Appareil approuvé. »…).</summary>
public string? StatusMessage
{
get => _statusMessage;
protected set
{
if (!SetProperty(ref _statusMessage, value)) return;
OnPropertyChanged(nameof(HasStatus));
if (value is not null) RequestScrollToTop(); // résultat d'une action d'une liste : bandeau en haut de la page
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Code d'une invitation tout juste créée : affiché une seule fois, jusqu'à « J'ai transmis le code ».</summary>
public InvitationCodeViewModel? CreatedInvitation
{
get => _createdInvitation;
private set
{
if (!SetProperty(ref _createdInvitation, value)) return;
OnPropertyChanged(nameof(HasCreatedInvitation));
OnCommandsChanged(); // pas de nouvelle invitation tant qu'un code non transmis est affiché
}
}
public bool HasCreatedInvitation => _createdInvitation is not null;
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
RefreshCommand.RaiseCanExecuteChanged();
OnCommandsChanged();
}
/// <summary>Réévalue les commandes propres à la page.</summary>
protected virtual void OnCommandsChanged()
{
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
/// <summary>Relit les données de la page sur le serveur.</summary>
protected abstract Task ReloadAsync();
/// <summary>
/// Modification signée, puis rechargement SÉPARÉ : si seul le rechargement échoue, la modification
/// (faite sur le serveur) est quand même annoncée, avec l'erreur du rechargement.
/// </summary>
protected async Task<bool> ApplyAsync(Func<Task> change, string success)
{
StatusMessage = null;
if (!await RunAsync(change)) return false;
StatusMessage = success;
await RunAsync(ReloadAsync);
return true;
}
protected void ShowInvitation(InvitationCreated created, string title, string instructions)
{
StatusMessage = null;
CreatedInvitation = new InvitationCodeViewModel(created.Code, title, instructions, created.Invitation.ExpiresAt,
Context.Dialogs, () => CreatedInvitation = null);
}
internal async Task ApproveAsync(AdminKeyItem key)
{
StatusMessage = null;
var typed = AdminKeys.NormalizeFingerprint(key.FingerprintInput);
if (typed is null)
{
ErrorMessage = "Empreinte incomplète : saisissez les 20 caractères (5 groupes de 4) affichés sur le nouvel appareil.";
return;
}
if (!Context.Dialogs.Confirm($"Approuver l'appareil « {key.Label} » ?",
$"{key.AdminName} pourra se connecter depuis cet appareil.\n\nN'approuvez que si l'empreinte vous a été " +
"communiquée par la personne elle-même (téléphone, message), et non recopiée depuis un message reçu d'un inconnu.",
"Approuver"))
return;
await ApplyAsync(() => Context.Session.ApproveKeyAsync(key.AdminId, key.KeyId, typed),
$"Appareil « {key.Label} » de {key.AdminName} approuvé.");
}
internal async Task RevokeKeyAsync(AdminKeyItem key)
{
if (!Context.Dialogs.Confirm($"Révoquer l'appareil « {key.Label} » ?",
$"{key.AdminName} ne pourra plus se connecter depuis cet appareil ; les sessions qui l'utilisent sont fermées " +
"immédiatement. Une clé révoquée ne peut pas être réactivée.", "Révoquer", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeKeyAsync(key.AdminId, key.KeyId),
$"Appareil « {key.Label} » de {key.AdminName} révoqué.");
}
internal async Task DeleteInvitationAsync(InvitationItem invitation)
{
if (!Context.Dialogs.Confirm("Supprimer l'invitation ?",
$"{invitation.Title} : le code ne pourra plus être utilisé.", "Supprimer", danger: true))
return;
await ApplyAsync(async () =>
{
try
{
await Context.Session.DeleteInvitationAsync(invitation.Id);
}
catch (ApiException ex) when (ex.Status == 404)
{
// déjà utilisée ou expirée entre-temps
}
}, "Invitation supprimée.");
}
}
/// <summary>Appareil (clé) d'un administrateur.</summary>
public sealed class AdminKeyItem : ObservableObject
{
private string _fingerprintInput = "";
private readonly string? _fingerprintWarning;
internal AdminKeyItem(AccountsPanelBase page, Guid adminId, string adminName, AdminKeySummary key, bool isCurrentSession,
bool canApprove, string? approveHint, bool canRevoke, string pendingText)
{
// Cet appareil : empreinte calculée sur le PC (celle à dicter lors de la vérification du trousseau), pas celle
// annoncée par le serveur ; une différence est signalée.
var local = isCurrentSession ? page.Context.Session.LocalFingerprint : null;
if (local is not null && key.Fingerprint is not null &&
!string.Equals(AdminKeys.NormalizeFingerprint(local), AdminKeys.NormalizeFingerprint(key.Fingerprint), StringComparison.Ordinal))
_fingerprintWarning = $" · ATTENTION : le serveur annonce une autre empreinte ({key.Fingerprint})";
else if (local is not null)
_fingerprintWarning = " · empreinte calculée sur ce PC";
FingerprintText = local ?? key.Fingerprint ?? pendingText;
AdminId = adminId;
AdminName = adminName;
KeyId = key.KeyId;
Label = AccountTexts.KeyLabel(key.Label);
Status = key.Status;
IsCurrentSession = isCurrentSession;
Fingerprint = local ?? key.Fingerprint;
AddedText = $"Ajouté le {AccountTexts.Date(key.AddedAt)}";
LastUsedText = key.LastUsedAt is { } used ? $"Dernière connexion le {AccountTexts.Date(used)}" : "Jamais utilisé";
CanApprove = canApprove && IsPending;
ApproveHint = IsPending ? approveHint : null;
CanRevoke = canRevoke && !IsRevoked && !isCurrentSession;
ApproveCommand = new AsyncRelayCommand(() => page.ApproveAsync(this), () => CanApprove);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeKeyAsync(this), () => CanRevoke);
}
public Guid AdminId { get; }
public string AdminName { get; }
public string KeyId { get; }
public string Label { get; }
public string Status { get; }
public string StatusText => IsCurrentSession ? "Cet appareil (session en cours)" : AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsActive => Status == AdminStatus.Active;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsCurrentSession { get; }
/// <summary>Empreinte (clés actives ou révoquées) ; masquée par le serveur pour une clé en attente.</summary>
public string? Fingerprint { get; }
/// <summary>Empreinte, ou (clé en attente) où la trouver : sur le nouvel appareil.</summary>
public string FingerprintText { get; }
public string AddedText { get; }
public string LastUsedText { get; }
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}{_fingerprintWarning}";
public bool CanApprove { get; }
/// <summary>Pourquoi ce n'est pas vous qui approuvez (nouvel appareil d'un autre administrateur…).</summary>
public string? ApproveHint { get; }
public bool HasApproveHint => !string.IsNullOrEmpty(ApproveHint);
public bool CanRevoke { get; }
/// <summary>Empreinte dictée par la personne (« AB12 CD34 EF56 7890 1A2B »).</summary>
public string FingerprintInput
{
get => _fingerprintInput;
set => SetProperty(ref _fingerprintInput, value ?? "");
}
public AsyncRelayCommand ApproveCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
}
/// <summary>Invitation en cours (code pas encore utilisé).</summary>
public sealed class InvitationItem
{
internal InvitationItem(AccountsPanelBase page, InvitationSummary invitation, bool canDelete)
{
Id = invitation.Id;
IsDevice = invitation.ForAdminId is not null;
Title = IsDevice
? $"Nouvel appareil de {invitation.DisplayName}"
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role)}";
DetailText = $"Créée par {invitation.CreatedBy ?? "le serveur"} le {AccountTexts.Date(invitation.CreatedAt)} · " +
$"expire le {AccountTexts.Date(invitation.ExpiresAt)}";
CanDelete = canDelete;
DeleteCommand = new AsyncRelayCommand(() => page.DeleteInvitationAsync(this), () => CanDelete);
}
public Guid Id { get; }
public bool IsDevice { get; }
public string Title { get; }
public string DetailText { get; }
public bool CanDelete { get; }
public AsyncRelayCommand DeleteCommand { get; }
}
/// <summary>
/// Code d'une invitation tout juste créée. Le serveur n'en garde que l'empreinte : il n'est affiché
/// qu'ici, une seule fois, et oublié dès que l'administrateur indique l'avoir transmis.
/// </summary>
public sealed class InvitationCodeViewModel : ObservableObject
{
private readonly IModuleDialogs _dialogs;
private bool _copied;
internal InvitationCodeViewModel(string code, string title, string instructions, DateTimeOffset expiresAt,
IModuleDialogs dialogs, Action dismiss)
{
Code = code;
Title = title;
Instructions = instructions;
ExpiresText = $"Valable une seule fois, jusqu'au {AccountTexts.Date(expiresAt)}. Il ne sera plus affiché.";
_dialogs = dialogs;
CopyCommand = new RelayCommand(Copy);
DismissCommand = new RelayCommand(dismiss);
}
public string Code { get; }
public string Title { get; }
public string Instructions { get; }
public string ExpiresText { get; }
public bool Copied
{
get => _copied;
private set => SetProperty(ref _copied, value);
}
public RelayCommand CopyCommand { get; }
/// <summary>« J'ai transmis le code » : le code disparaît de l'écran.</summary>
public RelayCommand DismissCommand { get; }
private void Copy()
{
_dialogs.CopyToClipboard(Code);
Copied = true;
}
}
@@ -0,0 +1,35 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Module « Comptes » : page « Mes appareils » (tout administrateur) et page « Administrateurs »
/// (gestion des administrateurs : propriétaires).
/// </summary>
public sealed class AccountsAdminModule : ILauncherModule
{
public string Id => "accounts";
public string DisplayName => "Comptes";
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor(
MyDevicesPanelViewModel.PanelId,
"Mes appareils",
"Les PC depuis lesquels vous pouvez vous connecter : ajoutez-en un (invitation « nouvel appareil »), puis approuvez-le " +
"ici avec l'empreinte qu'il affiche.",
"KeyVariant",
RequiredPermission: null,
Order: 40,
context => new MyDevicesPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
AdminsPanelViewModel.PanelId,
"Administrateurs",
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son rôle ou révoquez-le.",
"AccountGroup",
AdminPermissions.AdminsManage,
Order: 50,
context => new AdminsPanelViewModel(context)));
}
}
@@ -0,0 +1,263 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Administrateurs » (admins.manage) : comptes et appareils, invitations, approbation par
/// empreinte, rôles, révocation. Le serveur reste seul juge : cette page ne propose que ce qu'il
/// accepte (pas de changement de son propre rôle, pas d'appareil approuvé au nom d'un autre
/// administrateur actif…) et affiche ses refus tels quels.
/// </summary>
public sealed class AdminsPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.manage";
private List<AdminSummary> _summaries = new();
private IReadOnlyList<AdminAccountItem> _admins = Array.Empty<AdminAccountItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
private bool _showRevoked;
private string _newName = "";
private Choice _newRole;
public AdminsPanelViewModel(IAdminContext context) : base(context)
{
RoleChoices = AdminRoles.All.Select(r => new Choice(r, AdminTexts.Role(r))).ToList();
_newRole = RoleChoices.First(c => c.Value == AdminRoles.Editor);
InviteCommand = new AsyncRelayCommand(InviteAsync, () => !IsBusy && !HasCreatedInvitation && !string.IsNullOrWhiteSpace(_newName));
}
public IReadOnlyList<Choice> RoleChoices { get; }
public IReadOnlyList<AdminAccountItem> Admins
{
get => _admins;
private set => SetProperty(ref _admins, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
/// <summary>Afficher aussi les comptes révoqués (historique).</summary>
public bool ShowRevoked
{
get => _showRevoked;
set
{
if (SetProperty(ref _showRevoked, value)) BuildAdmins();
}
}
public string RevokedText
{
get
{
var count = _summaries.Count(a => a.Status == AdminStatus.Revoked);
return count == 0 ? "Aucun compte révoqué" : $"Afficher les comptes révoqués ({count})";
}
}
public string SummaryText
{
get
{
var active = _summaries.Count(a => a.Status == AdminStatus.Active);
var pending = _summaries.Where(a => a.Status != AdminStatus.Revoked).SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
return $"{active} compte(s) actif(s)" + (pending == 0 ? "" : $" · {pending} appareil(s) en attente d'approbation");
}
}
// ------------------------------------------------------------------ Nouvelle invitation
public string NewName
{
get => _newName;
set
{
if (SetProperty(ref _newName, value ?? "")) InviteCommand.RaiseCanExecuteChanged();
}
}
public Choice NewRole
{
get => _newRole;
set
{
if (value is not null && SetProperty(ref _newRole, value)) OnPropertyChanged(nameof(NewRoleHint));
}
}
public string NewRoleHint => $"{AdminTexts.Role(_newRole.Value)} : {AccountTexts.RoleHint(_newRole.Value ?? "")}.";
public AsyncRelayCommand InviteCommand { get; }
protected override void OnCommandsChanged() => InviteCommand.RaiseCanExecuteChanged();
private async Task InviteAsync()
{
var name = _newName.Trim();
var role = _newRole.Value!;
if (role == AdminRoles.Owner &&
!Context.Dialogs.Confirm("Inviter un propriétaire ?",
$"{name} aura tous les droits, dont celui de gérer les administrateurs (y compris de révoquer votre compte).",
"Inviter", danger: true))
return;
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = name, Role = role })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
NewName = "";
ShowInvitation(created!, $"Invitation de {created!.Invitation.DisplayName} ({AdminTexts.Role(created.Invitation.Role)})",
$"Transmettez ce code à {created.Invitation.DisplayName}. Dans le launcher : menu d'administration › « J'ai une invitation ». " +
"Le launcher lui affichera ensuite une empreinte : faites-la-vous dicter (téléphone, message), puis approuvez son appareil " +
"ci-dessous en la saisissant.");
await RunAsync(ReloadAsync);
}
// ------------------------------------------------------------------ Comptes
internal async Task ChangeRoleAsync(AdminAccountItem admin)
{
var role = admin.SelectedRole.Value!;
if (role == admin.Role) return;
var message = $"{admin.DisplayName} : {AdminTexts.Role(admin.Role)} → {AdminTexts.Role(role)}. Le changement s'applique " +
"immédiatement, y compris à ses sessions ouvertes.";
if (role == AdminRoles.Owner) message += "\n\nUn propriétaire peut gérer tous les administrateurs, y compris révoquer votre compte.";
if (!Context.Dialogs.Confirm("Changer le rôle ?", message, "Changer le rôle", danger: role == AdminRoles.Owner))
{
admin.ResetRole();
return;
}
if (!await ApplyAsync(() => Context.Session.ChangeRoleAsync(admin.Id, role),
$"{admin.DisplayName} est maintenant {AdminTexts.Role(role).ToLowerInvariant()}."))
admin.ResetRole();
}
internal async Task RevokeAdminAsync(AdminAccountItem admin)
{
if (!Context.Dialogs.Confirm($"Révoquer le compte de {admin.DisplayName} ?",
"Ses sessions sont fermées et tous ses appareils révoqués immédiatement ; ses invitations « nouvel appareil » sont " +
"supprimées. Un compte révoqué ne peut pas être réactivé : il faudrait l'inviter de nouveau.",
"Révoquer le compte", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeAdminAsync(admin.Id), $"Compte de {admin.DisplayName} révoqué.");
}
// ------------------------------------------------------------------ Chargement
protected override async Task ReloadAsync()
{
var admins = await Context.Session.ListAdminsAsync();
var invitations = await Context.Session.ListInvitationsAsync();
_summaries = admins;
BuildAdmins();
Invitations = invitations.Select(i => new InvitationItem(this, i, canDelete: true)).ToList();
OnPropertyChanged(nameof(RevokedText));
OnPropertyChanged(nameof(SummaryText));
}
private void BuildAdmins()
{
var me = Context.Session.Profile;
// À traiter d'abord : comptes en attente, puis comptes actifs (vous en premier), puis révoqués.
Admins = _summaries
.Where(a => _showRevoked || a.Status != AdminStatus.Revoked)
.OrderBy(a => a.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenBy(a => a.Id == me.Id ? 0 : 1)
.ThenBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(a => new AdminAccountItem(this, a, me.Id, me.KeyId, RoleChoices))
.ToList();
}
}
/// <summary>Compte d'un administrateur et ses appareils.</summary>
public sealed class AdminAccountItem : ObservableObject
{
private readonly IReadOnlyList<Choice> _roles;
private Choice _selectedRole;
internal AdminAccountItem(AdminsPanelViewModel page, AdminSummary admin, Guid myId, string myKeyId, IReadOnlyList<Choice> roles)
{
_roles = roles;
Id = admin.Id;
DisplayName = admin.DisplayName;
Initials = AdminTexts.Initials(admin.DisplayName);
Role = admin.Role;
Status = admin.Status;
IsYou = admin.Id == myId;
CreatedText = $"Invité le {AccountTexts.Date(admin.CreatedAt)}";
_selectedRole = roles.FirstOrDefault(r => r.Value == admin.Role) ?? roles[^1];
var hasActiveKey = admin.Keys.Any(k => k.Status == AdminStatus.Active);
// Premier appareil (compte en attente) : approuvé par un propriétaire. Nouvel appareil d'un compte
// actif : par son titulaire seulement, depuis un de ses appareils actifs (règle du serveur).
string? hint = null;
var canApprove = !IsRevoked && (Status == AdminStatus.Pending || IsYou);
if (!canApprove && Status == AdminStatus.Active)
hint = hasActiveKey
? $"Nouvel appareil : {DisplayName} l'approuve lui-même depuis un de ses appareils actifs (page « Mes appareils »)."
: $"{DisplayName} n'a plus aucun appareil actif : restaurez son accès sur le VPS (« add-key »), ou révoquez ce compte et invitez-le de nouveau.";
Keys = admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(page, admin.Id, admin.DisplayName, k, IsYou && k.KeyId == myKeyId,
canApprove, hint, canRevoke: !IsRevoked,
IsYou ? AccountTexts.OwnPendingText : $"Empreinte affichée sur le nouvel appareil : faites-la-vous dicter par {admin.DisplayName}."))
.ToList();
ChangeRoleCommand = new AsyncRelayCommand(() => page.ChangeRoleAsync(this), () => CanChangeRole && RoleChanged);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeAdminAsync(this), () => CanRevoke);
}
public Guid Id { get; }
public string DisplayName { get; }
public string Initials { get; }
public string Role { get; }
public string RoleText => AdminTexts.Role(Role);
public string Status { get; }
public string StatusText => AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsYou { get; }
public string CreatedText { get; }
public IReadOnlyList<AdminKeyItem> Keys { get; }
/// <summary>On ne change ni son propre rôle ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
public bool CanChangeRole => !IsYou && !IsRevoked;
public bool CanRevoke => !IsYou && !IsRevoked;
public IReadOnlyList<Choice> RoleChoices => _roles;
public Choice SelectedRole
{
get => _selectedRole;
set
{
if (value is null || !SetProperty(ref _selectedRole, value)) return;
OnPropertyChanged(nameof(RoleChanged));
ChangeRoleCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Rôle choisi différent du rôle actuel : bouton « Appliquer » affiché.</summary>
public bool RoleChanged => _selectedRole.Value != Role;
public AsyncRelayCommand ChangeRoleCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
internal void ResetRole() => SelectedRole = _roles.FirstOrDefault(r => r.Value == Role) ?? _selectedRole;
}
@@ -0,0 +1,81 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Mes appareils » (tout administrateur connecté) : ses clés, l'ajout d'un nouveau PC (invitation
/// « nouvel appareil », puis approbation ici avec l'empreinte affichée sur le nouveau PC), ses invitations.
/// Personne d'autre ne peut approuver un appareil à son nom (règle du serveur).
/// </summary>
public sealed class MyDevicesPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.devices";
private IReadOnlyList<AdminKeyItem> _devices = Array.Empty<AdminKeyItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
public MyDevicesPanelViewModel(IAdminContext context) : base(context)
{
AddDeviceCommand = new AsyncRelayCommand(AddDeviceAsync, () => !IsBusy && !HasCreatedInvitation);
}
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
/// <summary>Révoquer un de ses appareils exige la gestion des administrateurs (sinon : un propriétaire s'en charge).</summary>
public bool CanRevokeDevices => Context.Session.Has(AdminPermissions.AdminsManage);
public IReadOnlyList<AdminKeyItem> Devices
{
get => _devices;
private set => SetProperty(ref _devices, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
public AsyncRelayCommand AddDeviceCommand { get; }
protected override void OnCommandsChanged() => AddDeviceCommand.RaiseCanExecuteChanged();
private async Task AddDeviceAsync()
{
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = Context.Session.Profile.Id })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
ShowInvitation(created!, "Invitation pour votre nouvel appareil",
"Sur le nouveau PC : launcher › menu d'administration › « J'ai une invitation », puis collez ce code. Le nouveau PC " +
"affiche ensuite une empreinte : revenez ici, saisissez-la sous l'appareil en attente et approuvez-le.");
await RunAsync(ReloadAsync);
}
protected override async Task ReloadAsync()
{
var account = await Context.Session.GetMyAccountAsync();
var me = Context.Session.Profile;
Devices = account.Admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(this, account.Admin.Id, account.Admin.DisplayName, k, k.KeyId == me.KeyId,
canApprove: true, approveHint: null, canRevoke: CanRevokeDevices, AccountTexts.OwnPendingText))
.ToList();
Invitations = account.Invitations
.Select(i => new InvitationItem(this, i, canDelete: i.CreatedById == me.Id || CanRevokeDevices))
.ToList();
}
}
@@ -0,0 +1,21 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>Module « Audit » : page « Journal d'audit » (permission audit.read : propriétaires, administrateurs).</summary>
public sealed class AuditAdminModule : ILauncherModule
{
public string Id => "audit";
public string DisplayName => "Audit";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
AuditPanelViewModel.PanelId,
"Journal d'audit",
"Connexions, publications, modifications et refus enregistrés par le serveur, les plus récents d'abord.",
"History",
AdminPermissions.AuditRead,
Order: 60,
context => new AuditPanelViewModel(context)));
}
@@ -0,0 +1,542 @@
using System.Globalization;
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>
/// Page « Journal d'audit » (audit.read) : actions enregistrées par le serveur, les plus récentes
/// d'abord, chargées par pages de 200 ; filtres (période, texte, catégorie, résultat) ; export CSV
/// des entrées filtrées. Période : le serveur ne renvoie que les entrées antérieures à la fin de la
/// période, et les pages sont chargées jusqu'à son début (dans la limite de <see cref="MaxLoaded"/>).
/// Chaque consultation est inscrite au journal par le serveur ; chaque export y est inscrit avant l'écriture du fichier.
/// </summary>
public sealed class AuditPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "audit.read";
/// <summary>Entrées par page (maximum accepté par le serveur).</summary>
public const int PageSize = 200;
/// <summary>Au-delà, « Charger plus » s'arrête (mémoire du launcher) : utiliser journalctl ou les fichiers du VPS.</summary>
public const int MaxLoaded = 5000;
/// <summary>Entrées affichées au plus (la page n'est pas virtualisée) ; l'export reprend toutes celles filtrées.</summary>
public const int MaxDisplayed = 500;
private readonly List<AuditEntry> _loaded = new();
private IReadOnlyList<AuditItem> _filtered = Array.Empty<AuditItem>();
private string? _nextCursor;
private IReadOnlyList<AuditItem> _entries = Array.Empty<AuditItem>();
private string _searchText = "";
private Choice _category;
private Choice _result;
private string? _lastRefresh;
private bool _hasLoaded;
private Choice _period;
private string _fromText = "";
private string _toText = "";
private string? _dateError;
private DateTime? _from; // début de la période (jour inclus, heure locale)
private DateTime? _to; // fin de la période (jour inclus, heure locale)
private bool _settingPeriod;
private bool _periodReloadPending;
public AuditPanelViewModel(IAdminContext context) : base(context)
{
Categories = new List<Choice>
{
new(null, "Toutes les actions"),
new("auth.", "Connexions"),
new("admins.", "Administrateurs"),
new("cli.", "Outil du VPS (ligne de commande)"),
new("catalog.", "Informations des jeux"),
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("files.", "Fichiers du serveur"),
new("audit.", "Consultations et exports du journal"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
{
new(null, "Tous les résultats"),
new(AuditResults.Ok, "Réussis"),
new(AuditResults.Denied, "Refusés")
};
Periods = new List<Choice>
{
new(null, "Toutes les dates"),
new(PeriodToday, "Aujourd'hui"),
new(PeriodWeek, "7 derniers jours"),
new(PeriodMonth, "30 derniers jours"),
new(PeriodThisMonth, "Ce mois-ci"),
new(PeriodLastMonth, "Mois précédent"),
new(PeriodCustom, "Dates choisies")
};
_category = Categories[0];
_result = Results[0];
_period = Periods[0];
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(reset: true))), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: false)), () => !IsBusy && CanLoadMore);
ClearFiltersCommand = new RelayCommand(ClearFilters, () => HasFilters);
ExportCommand = new AsyncRelayCommand(ExportAsync, () => !IsBusy && _entries.Count > 0);
}
public IReadOnlyList<Choice> Categories { get; }
public IReadOnlyList<Choice> Results { get; }
public IReadOnlyList<Choice> Periods { get; }
private const string PeriodToday = "today", PeriodWeek = "7d", PeriodMonth = "30d", PeriodThisMonth = "this-month",
PeriodLastMonth = "last-month", PeriodCustom = "custom";
private const string DateFormat = "dd/MM/yyyy";
/// <summary>Aujourd'hui (heure locale) ; remplaçable par les tests.</summary>
internal Func<DateTime> Today { get; set; } = () => DateTime.Today;
/// <summary>Rechargement lancé par un changement de période (tests).</summary>
internal Task PeriodReload { get; private set; } = Task.CompletedTask;
/// <summary>Période rapide ; « Dates choisies » : champs « Du » / « Au ».</summary>
public Choice Period
{
get => _period;
set
{
if (value is null || !SetProperty(ref _period, value)) return;
if (value.Value == PeriodCustom) return; // les champs restent tels quels
var today = Today().Date;
var (from, to) = value.Value switch
{
PeriodToday => (today, today),
PeriodWeek => (today.AddDays(-6), today),
PeriodMonth => (today.AddDays(-29), today),
PeriodThisMonth => (new DateTime(today.Year, today.Month, 1), today),
PeriodLastMonth => (new DateTime(today.Year, today.Month, 1).AddMonths(-1), new DateTime(today.Year, today.Month, 1).AddDays(-1)),
_ => ((DateTime?)null, (DateTime?)null)
};
_settingPeriod = true;
FromText = from?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
ToText = to?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
_settingPeriod = false;
ApplyPeriod();
}
}
/// <summary>Début de la période (jj/mm/aaaa, inclus ; vide : depuis le début du journal).</summary>
public string FromText
{
get => _fromText;
set
{
if (!SetProperty(ref _fromText, value ?? "")) return;
if (!_settingPeriod) OnDateEdited();
}
}
/// <summary>Fin de la période (jj/mm/aaaa, inclus ; vide : jusqu'à maintenant).</summary>
public string ToText
{
get => _toText;
set
{
if (!SetProperty(ref _toText, value ?? "")) return;
if (!_settingPeriod) OnDateEdited();
}
}
/// <summary>Date saisie invalide (affichée sous les champs) ; la période précédente reste appliquée.</summary>
public string? DateError
{
get => _dateError;
private set
{
if (SetProperty(ref _dateError, value)) OnPropertyChanged(nameof(HasDateError));
}
}
public bool HasDateError => !string.IsNullOrEmpty(DateError);
public bool HasPeriod => _from is not null || _to is not null;
private void OnDateEdited()
{
if (_period.Value != PeriodCustom)
{
_period = Periods.First(p => p.Value == PeriodCustom);
OnPropertyChanged(nameof(Period));
}
ApplyPeriod();
}
/// <summary>Contrôle les champs ; période changée → rechargement depuis le serveur.</summary>
private void ApplyPeriod()
{
if (!TryParseDate(_fromText, out var from) || !TryParseDate(_toText, out var to))
{
DateError = "Date attendue au format jj/mm/aaaa (ex. 07/11/2026).";
return;
}
if (from is { } f && to is { } t && f > t)
{
DateError = "La date de début est postérieure à la date de fin.";
return;
}
DateError = null;
if (from == _from && to == _to) return;
_from = from;
_to = to;
OnPropertyChanged(nameof(HasPeriod));
OnPropertyChanged(nameof(HasFilters));
ClearFiltersCommand.RaiseCanExecuteChanged();
ApplyFilters();
if (IsBusy) _periodReloadPending = true; // rechargé dès la fin du chargement en cours
else if (_hasLoaded) PeriodReload = RunAsync(() => LoadAsync(reset: true));
}
private static bool TryParseDate(string text, out DateTime? date)
{
date = null;
if (string.IsNullOrWhiteSpace(text)) return true;
if (!DateTime.TryParseExact(text.Trim(), new[] { DateFormat, "d/M/yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ||
parsed.Year < 2000)
return false;
date = parsed.Date;
return true;
}
/// <summary>Fin de la période en heure universelle (le lendemain du dernier jour, 0 h locale), pour le serveur.</summary>
private DateTimeOffset? Before => _to is { } to ? new DateTimeOffset(to.AddDays(1), TimeZoneInfo.Local.GetUtcOffset(to.AddDays(1))) : null;
private DateTimeOffset? After => _from is { } from ? new DateTimeOffset(from, TimeZoneInfo.Local.GetUtcOffset(from)) : null;
public IReadOnlyList<AuditItem> Entries
{
get => _entries;
private set
{
if (!SetProperty(ref _entries, value)) return;
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(EmptyText));
OnPropertyChanged(nameof(TruncatedText));
OnPropertyChanged(nameof(IsTruncated));
ExportCommand.RaiseCanExecuteChanged();
}
}
public bool HasEntries => _entries.Count > 0;
public bool IsTruncated => _filtered.Count > _entries.Count;
public string TruncatedText => IsTruncated
? $"Seules les {MaxDisplayed} premières entrées sont affichées : affinez la recherche, ou exportez les {_filtered.Count} entrées filtrées."
: "";
/// <summary>Message quand rien n'est affiché (vide tant que rien n'a été chargé : l'erreur éventuelle suffit).</summary>
public string EmptyText => !_hasLoaded || HasEntries ? ""
: _loaded.Count == 0 ? "Le journal est vide."
: "Aucune entrée chargée ne correspond aux filtres" + (CanLoadMore ? " : chargez des entrées plus anciennes." : ".");
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) ApplyFilters();
}
}
public Choice Category
{
get => _category;
set
{
if (value is not null && SetProperty(ref _category, value)) ApplyFilters();
}
}
public Choice Result
{
get => _result;
set
{
if (value is not null && SetProperty(ref _result, value)) ApplyFilters();
}
}
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null || HasPeriod;
/// <summary>« 37 entrées affichées sur 200 chargées (depuis le 12/10/2026 à 08:14) ».</summary>
public string SummaryText
{
get
{
if (_loaded.Count == 0) return "";
var oldest = _loaded[^1].Timestamp.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
var shown = HasFilters ? $"{_filtered.Count} entrée(s) sur {_loaded.Count} chargée(s)" : $"{_loaded.Count} entrée(s) chargée(s)";
var period = _to is { } to ? $" jusqu'au {to.ToString(DateFormat, CultureInfo.InvariantCulture)}" : "";
return $"{shown}{period}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
: _loaded.Count >= MaxLoaded ? $". Limite de {MaxLoaded} entrées atteinte : les plus anciennes restent dans les fichiers du journal sur le VPS."
: ".");
}
}
public bool CanLoadMore => _nextCursor is not null && _loaded.Count < MaxLoaded;
public string? LastRefresh
{
get => _lastRefresh;
private set => SetProperty(ref _lastRefresh, value);
}
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public RelayCommand ClearFiltersCommand { get; }
/// <summary>Exporte les entrées filtrées (toutes, pas seulement celles affichées).</summary>
public AsyncRelayCommand ExportCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
LoadMoreCommand.RaiseCanExecuteChanged();
ExportCommand.RaiseCanExecuteChanged();
if (!IsBusy && _periodReloadPending)
{
_periodReloadPending = false;
PeriodReload = ReloadAfterCurrentAsync();
}
}
/// <summary>Période changée pendant un chargement : nouveau chargement juste après celui-ci.</summary>
private async Task ReloadAfterCurrentAsync()
{
await Task.Yield(); // après la fin du chargement en cours (fil de l'interface)
await RunAsync(() => LoadAsync(reset: true));
}
/// <summary>Chargé au premier affichage ; ensuite « Actualiser » (garde les pages déjà chargées et les filtres d'ici là).</summary>
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync(reset: true)) : Task.CompletedTask;
private async Task LoadAsync(bool reset)
{
var before = Before;
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor, before);
if (reset) _loaded.Clear();
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
// Période : pages suivantes jusqu'à son début (l'export doit contenir toute la période chargée).
while (reset && After is { } after && _nextCursor is not null && _loaded.Count < MaxLoaded && _loaded[^1].Timestamp >= after)
{
page = await Context.Session.GetAuditAsync(PageSize, _nextCursor, before);
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
}
_hasLoaded = true;
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
OnPropertyChanged(nameof(CanLoadMore));
LoadMoreCommand.RaiseCanExecuteChanged();
ApplyFilters();
}
private void ClearFilters()
{
_searchText = "";
_category = Categories[0];
_result = Results[0];
OnPropertyChanged(nameof(SearchText));
OnPropertyChanged(nameof(Category));
OnPropertyChanged(nameof(Result));
if (HasPeriod || _period.Value is not null || HasDateError) Period = Periods[0]; // recharge toutes les dates
ApplyFilters();
}
private void ApplyFilters()
{
var text = _searchText.Trim();
var (after, before) = (After, Before);
_filtered = _loaded
.Where(e => (after is null || e.Timestamp >= after) && (before is null || e.Timestamp < before))
.Where(e => AuditTexts.InCategory(e.Action, _category.Value))
.Where(e => _result.Value is null || e.Result == _result.Value)
.Select(e => new AuditItem(e))
.Where(item => text.Length == 0 || item.Matches(text))
.ToList();
Entries = _filtered.Count > MaxDisplayed ? _filtered.Take(MaxDisplayed).ToList() : _filtered;
OnPropertyChanged(nameof(HasFilters));
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(EmptyText));
ClearFiltersCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Export
private async Task ExportAsync()
{
var period = (_from, _to) switch
{
(null, null) => "",
({ } f, null) => $"-depuis-{f:yyyy-MM-dd}",
(null, { } t) => $"-jusqu-au-{t:yyyy-MM-dd}",
({ } f, { } t) => $"-{f:yyyy-MM-dd}-au-{t:yyyy-MM-dd}"
};
var path = Context.Dialogs.PickSaveFile("Exporter le journal d'audit", "Fichier CSV (*.csv)|*.csv",
$"journal-audit{period}-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
if (path is null) return;
var entries = _filtered;
var announce = new AuditExportRequest
{
Count = entries.Count,
From = _from is { } from ? DateOnly.FromDateTime(from) : null,
To = _to is { } to ? DateOnly.FromDateTime(to) : null,
Filters = ExportFiltersText()
};
// L'export est inscrit au journal du serveur AVANT l'écriture du fichier : s'il ne peut pas l'être
// (serveur injoignable, session expirée, signature refusée), le fichier n'est pas écrit.
if (await RunAsync(async () =>
{
await Context.Session.RecordAuditExportAsync(announce);
await File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
}))
Context.Dialogs.ShowMessage("Journal exporté", $"{entries.Count} entrée(s) enregistrée(s) dans {Path.GetFileName(path)}.",
ModuleMessageKind.Success);
}
/// <summary>Filtres de l'export, en clair pour le journal (la période est transmise à part).</summary>
private string? ExportFiltersText()
{
var parts = new List<string>();
if (_category.Value is not null) parts.Add($"catégorie « {_category.Label} »");
if (_result.Value is not null) parts.Add($"résultat « {_result.Label} »");
var text = _searchText.Trim();
if (text.Length > 0) parts.Add($"recherche « {(text.Length > 80 ? text[..80] + "…" : text)} »");
return parts.Count == 0 ? null : string.Join(", ", parts);
}
}
/// <summary>Entrée du journal telle qu'affichée.</summary>
public sealed class AuditItem
{
internal AuditItem(AuditEntry entry)
{
Entry = entry;
TimeText = entry.Timestamp.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
ActionText = AuditTexts.Action(entry.Action);
ResultText = AuditTexts.Result(entry.Result);
ActorText = entry.ActorName ?? (entry.KeyId is null ? "Inconnu" : "Clé inconnue");
var parts = new List<string>();
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
DetailText = string.Join(" — ", parts);
var origin = new List<string>();
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
OriginText = string.Join(" · ", origin);
}
public AuditEntry Entry { get; }
public string TimeText { get; }
public string ActionText { get; }
/// <summary>Code technique (« admins.key_approve »), en infobulle.</summary>
public string ActionCode => Entry.Action;
public string Result => Entry.Result;
public string ResultText { get; }
public bool IsOk => Entry.Result == AuditResults.Ok;
public bool IsDenied => Entry.Result == AuditResults.Denied;
public bool IsFailed => Entry.Result == AuditResults.Failed;
public string ActorText { get; }
public string DetailText { get; }
public bool HasDetail => DetailText.Length > 0;
public string OriginText { get; }
/// <summary>Recherche dans tout ce qui est affiché (sans tenir compte de la casse).</summary>
internal bool Matches(string text) =>
new[] { TimeText, ActionText, Entry.Action, ResultText, ActorText, DetailText, OriginText }
.Any(value => value.Contains(text, StringComparison.CurrentCultureIgnoreCase));
}
/// <summary>Libellés du journal (français) et export CSV.</summary>
public static class AuditTexts
{
public static string Action(string action) => action switch
{
"auth.login" => "Connexion",
"auth.logout" => "Déconnexion",
"admins.enroll" => "Appareil enregistré avec une invitation",
"admins.invite" => "Invitation créée",
"admins.invite_deleted" => "Invitation supprimée",
"admins.key_approve" => "Appareil approuvé",
"admins.key_revoke" => "Appareil révoqué",
"admins.role" => "Rôle modifié",
"admins.revoke" => "Compte révoqué",
"cli.add_owner" => "Propriétaire ajouté sur le VPS",
"cli.add_key" => "Clé ajoutée sur le VPS",
"cli.revoke_admin" => "Compte révoqué sur le VPS",
"cli.revoke_key" => "Clé révoquée sur le VPS",
"catalog.publish" => "Informations des jeux publiées",
"changelog.edit" => "Notes de version modifiées",
"release.start" => "Publication d'une version commencée",
"release.publish" => "Version mise en ligne",
"release.cancel" => "Publication abandonnée",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
"audit.view" => "Journal d'audit consulté",
"audit.export" => "Journal d'audit exporté (CSV)",
"audit.suppressed" => "Tentatives refusées regroupées",
_ => action
};
public static string Result(string result) => result switch
{
AuditResults.Ok => "Réussi",
AuditResults.Denied => "Refusé",
AuditResults.Failed => "Échec",
_ => result
};
/// <summary>
/// Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées ;
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité).
/// </summary>
public static bool InCategory(string action, string? category) => category switch
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
"audit." => action is "audit.view" or "audit.export",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
/// <summary>
/// CSV (séparateur « ; », pour Excel en français). Les valeurs viennent en partie de requêtes anonymes ou
/// de saisies (nom du client, de l'appareil…) : une cellule qui commencerait par = + - @ serait interprétée
/// comme une formule par le tableur ; elle est donc précédée d'une apostrophe, et toutes les cellules sont
/// entre guillemets (un tableur qui sépare par des virgules ne peut pas couper une valeur en deux).
/// </summary>
public static string ToCsv(IEnumerable<AuditItem> items)
{
var csv = new StringBuilder();
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
foreach (var item in items)
{
var e = item.Entry;
csv.AppendLine(string.Join(';', new[]
{
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
item.ActionText, e.Action, item.ResultText, e.ActorName, e.Ip, e.KeyId, e.Target, e.Details
}.Select(Cell)));
}
return csv.ToString();
}
private static string Cell(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
value = value.Replace("\r", " ").Replace("\n", " ");
var first = value.TrimStart(' ');
if (value[0] == '\t' || (first.Length > 0 && first[0] is '=' or '+' or '-' or '@')) value = "'" + value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>
/// Module « Catalogue des jeux » : page « Jeux » du menu d'administration (informations modifiables
/// des jeux déclarés dans launcher.json, publiées dans catalog.json). Permission games.edit.
/// </summary>
public sealed class CatalogAdminModule : ILauncherModule
{
public string Id => "catalog";
public string DisplayName => "Catalogue des jeux";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
GamesPanelViewModel.PanelId,
"Jeux",
"Ce que le launcher affiche pour chaque jeu : nom, accroche, présentation, liens, apparence, ordre. " +
"Les jeux eux-mêmes restent déclarés dans launcher.json.",
"Gamepad",
AdminPermissions.GamesEdit,
Order: 10,
context => new GamesPanelViewModel(context)));
}
@@ -0,0 +1,316 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>Choix d'une liste (null : valeur par défaut de launcher.json).</summary>
public sealed record Choice(string? Value, string Label)
{
/// <summary>Texte affiché par les listes déroulantes.</summary>
public override string ToString() => Label;
}
/// <summary>Partie modifiable de la page « Jeux » (réglages du launcher ou un jeu).</summary>
public abstract class CatalogEditor : ObservableObject
{
private readonly Action _changed;
protected CatalogEditor(Action changed) => _changed = changed;
public abstract string Title { get; }
public abstract string Subtitle { get; }
public abstract string IconKey { get; }
/// <summary>Modifié depuis la dernière publication (pastille dans la liste).</summary>
public bool IsDirty
{
get => _isDirty;
internal set => SetProperty(ref _isDirty, value);
}
private bool _isDirty;
protected bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (!SetProperty(ref field, value, name)) return false;
_changed();
return true;
}
protected void Changed() => _changed();
protected static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
}
/// <summary>Réglages du launcher : nom, jeu affiché au premier lancement.</summary>
public sealed class LauncherEditor : CatalogEditor
{
private string _launcherName = "";
private Choice _defaultGame;
public LauncherEditor(string declaredName, string? declaredDefault, IReadOnlyList<LauncherConfig> games, Action changed) : base(changed)
{
DeclaredName = declaredName;
GameChoices = new[] { new Choice(null, declaredDefault is null ? "Par défaut (premier jeu)" : $"Par défaut ({declaredDefault})") }
.Concat(games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})"))).ToList();
_defaultGame = GameChoices[0];
}
public override string Title => "Launcher";
public override string Subtitle => "Nom, jeu affiché au démarrage, ordre des jeux";
public override string IconKey => "Tune";
public string DeclaredName { get; }
public string DefaultNameHint => $"Vide : « {DeclaredName} » (launcher.json).";
public string LauncherName
{
get => _launcherName;
set => Set(ref _launcherName, value ?? "");
}
public IReadOnlyList<Choice> GameChoices { get; }
public Choice DefaultGame
{
get => _defaultGame;
set
{
if (value is not null) Set(ref _defaultGame, value); // null : liste de l'écran en cours de remplacement
}
}
internal void Load(CatalogLauncher? launcher)
{
_launcherName = launcher?.LauncherName ?? "";
_defaultGame = GameChoices.FirstOrDefault(c => c.Value is not null &&
string.Equals(c.Value, launcher?.DefaultGame, StringComparison.OrdinalIgnoreCase)) ?? GameChoices[0];
OnPropertyChanged(nameof(LauncherName));
OnPropertyChanged(nameof(DefaultGame));
}
internal CatalogLauncher? ToCatalog(IReadOnlyList<string> order, IReadOnlyList<string> declaredOrder)
{
var name = Clean(LauncherName);
var gameOrder = order.SequenceEqual(declaredOrder, StringComparer.OrdinalIgnoreCase) ? null : order.ToList();
return name is null && DefaultGame.Value is null && gameOrder is null
? null
: new CatalogLauncher { LauncherName = name, DefaultGame = DefaultGame.Value, GameOrder = gameOrder };
}
}
/// <summary>Un jeu déclaré dans launcher.json, et ses informations modifiables.</summary>
public sealed class GameEditor : CatalogEditor
{
private string _gameName = "", _tagline = "", _description = "", _websiteUrl = "", _icon = "", _background = "", _interval = "";
private Choice _theme;
private bool _hidden;
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed) : base(changed)
{
Declared = declared;
_builtInThemeChoices = new[] { new Choice(null, $"Par défaut ({ThemeOf(declared)})") }
.Concat(themes.Select(t => new Choice(t, t))).ToList();
ThemeChoices = _builtInThemeChoices;
_theme = ThemeChoices[0];
LinkKinds = CatalogLinkKinds.All.Select(k => new Choice(k, CatalogLinkKinds.DefaultLabel(k))).ToList();
AddLinkCommand = new RelayCommand(AddLink, () => Links.Count < CatalogRules.MaxLinks);
Links.CollectionChanged += (_, _) =>
{
AddLinkCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(HasLinks));
Changed();
};
}
public LauncherConfig Declared { get; }
public string GameId => Declared.GameId;
public override string Title => string.IsNullOrWhiteSpace(GameName) ? Declared.GameName : GameName.Trim();
public override string Subtitle => Hidden ? $"{GameId} · masqué" : GameId;
public override string IconKey => Hidden ? "EyeOffOutline" : "Gamepad";
// ------------------------------------------------------------------ Valeurs (vide : launcher.json)
public string GameName
{
get => _gameName;
set
{
if (Set(ref _gameName, value ?? "")) OnPropertyChanged(nameof(Title));
}
}
public string Tagline { get => _tagline; set => Set(ref _tagline, value ?? ""); }
public string Description { get => _description; set => Set(ref _description, value ?? ""); }
public string WebsiteUrl { get => _websiteUrl; set => Set(ref _websiteUrl, value ?? ""); }
public string Icon { get => _icon; set => Set(ref _icon, value ?? ""); }
public string Background { get => _background; set => Set(ref _background, value ?? ""); }
/// <summary>Minutes entre deux recherches de mise à jour (vide : launcher.json ; 0 : désactivé).</summary>
public string UpdateInterval { get => _interval; set => Set(ref _interval, value ?? ""); }
private readonly IReadOnlyList<Choice> _builtInThemeChoices;
/// <summary>Thèmes intégrés (+ celui du catalogue publié s'il est inconnu de ce launcher, pour ne pas l'effacer en silence).</summary>
public IReadOnlyList<Choice> ThemeChoices { get; private set; }
public Choice Theme
{
get => _theme;
set
{
// null : liste des thèmes remplacée à l'écran (rechargement), pas un choix de l'administrateur.
if (value is not null) Set(ref _theme, value);
}
}
public bool Hidden
{
get => _hidden;
set
{
if (!Set(ref _hidden, value)) return;
OnPropertyChanged(nameof(Subtitle));
OnPropertyChanged(nameof(IconKey));
}
}
public ObservableCollection<LinkEditor> Links { get; } = new();
/// <summary>Au moins un lien : les en-têtes des colonnes (type, texte, adresse) sont affichés.</summary>
public bool HasLinks => Links.Count > 0;
public IReadOnlyList<Choice> LinkKinds { get; }
public RelayCommand AddLinkCommand { get; }
// ------------------------------------------------------------------ Valeurs de launcher.json (rappel)
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci du Bureau prend le nom affiché (au démarrage du launcher, " +
"bouton « Raccourci » ou « Réparer ») ; « Applications installées » garde celui de launcher.json.";
public string TaglineHint => string.IsNullOrWhiteSpace(Declared.Tagline) ? "Vide : aucune accroche." : $"Vide : « {Declared.Tagline} ».";
public string WebsiteHint => string.IsNullOrWhiteSpace(Declared.WebsiteUrl) ? "Adresse https. Vide : aucun site." : $"Adresse https. Vide : {Declared.WebsiteUrl}";
public string IntervalHint => $"0 : désactivé, ou {CatalogRules.MinUpdateInterval} à {CatalogRules.MaxUpdateInterval} minutes. Vide : {Declared.UpdateCheckIntervalMinutes} (launcher.json).";
public string ImageHint => "Image .png ou .jpg (4 Mo au plus) déposée sur le serveur du catalogue, ex. https://…/catalog/images/fond.jpg. " +
"Les launchers gardent l'image en cache : pour la changer, déposez-la sous un NOUVEAU nom. Vide : image de launcher.json.";
private static string ThemeOf(LauncherConfig config) => string.IsNullOrWhiteSpace(config.Theme) ? "default" : config.Theme;
private void AddLink()
{
if (Links.Count >= CatalogRules.MaxLinks) return;
Links.Add(new LinkEditor(LinkKinds, CatalogLinkKinds.Website, "", "", Changed, RemoveLink));
}
private void RemoveLink(LinkEditor link) => Links.Remove(link);
internal void Load(CatalogGame? game)
{
_gameName = game?.GameName ?? "";
_tagline = game?.Tagline ?? "";
_description = game?.Description ?? "";
_websiteUrl = game?.WebsiteUrl ?? "";
_icon = game?.Icon ?? "";
_background = game?.Background ?? "";
_interval = game?.UpdateCheckIntervalMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
_hidden = game?.Hidden == true;
ThemeChoices = _builtInThemeChoices;
_theme = ThemeChoices.FirstOrDefault(c => c.Value is not null && string.Equals(c.Value, game?.Theme, StringComparison.OrdinalIgnoreCase))
?? ThemeChoices[0];
if (_theme.Value is null && game?.Theme is { } unknown)
{
// Publié par une autre version du launcher : conservé (les launchers qui ne le connaissent pas l'ignorent).
_theme = new Choice(unknown, $"{unknown} (inconnu de ce launcher)");
ThemeChoices = _builtInThemeChoices.Append(_theme).ToList();
}
Links.Clear();
foreach (var link in game?.Links ?? new List<CatalogLink>())
Links.Add(new LinkEditor(LinkKinds, link.Kind, link.Label ?? "", link.Url, Changed, RemoveLink));
foreach (var name in new[] { nameof(GameName), nameof(Tagline), nameof(Description), nameof(WebsiteUrl), nameof(Icon), nameof(Background),
nameof(UpdateInterval), nameof(Hidden), nameof(ThemeChoices), nameof(Theme), nameof(Title), nameof(Subtitle), nameof(IconKey) })
OnPropertyChanged(name);
}
/// <summary>Valeurs saisies (contrôlées ensuite par <see cref="CatalogRules"/>) ; null si tout est par défaut.</summary>
internal CatalogGame? ToCatalog(List<string> errors)
{
int? interval = null;
if (!string.IsNullOrWhiteSpace(UpdateInterval))
{
if (int.TryParse(UpdateInterval.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var minutes)) interval = minutes;
else errors.Add($"Jeu « {GameId} » : intervalle de mise à jour : nombre de minutes attendu.");
}
var links = Links.Where(l => !l.IsEmpty)
.Select(l => new CatalogLink { Kind = l.Kind.Value ?? CatalogLinkKinds.Other, Label = Clean(l.Label), Url = l.Url.Trim() })
.ToList();
var game = new CatalogGame
{
GameName = Clean(GameName),
Tagline = Clean(Tagline),
Description = Clean(Description),
WebsiteUrl = Clean(WebsiteUrl),
Links = links.Count == 0 ? null : links,
Theme = Theme.Value,
Icon = Clean(Icon),
Background = Clean(Background),
UpdateCheckIntervalMinutes = interval,
Hidden = Hidden ? true : null
};
return game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null }
? null
: game;
}
}
/// <summary>Lien d'un jeu (Discord, YouTube…).</summary>
public sealed class LinkEditor : ObservableObject
{
private readonly Action _changed;
private Choice _kind;
private string _label, _url;
public LinkEditor(IReadOnlyList<Choice> kinds, string kind, string label, string url, Action changed, Action<LinkEditor> remove)
{
Kinds = kinds;
_kind = kinds.FirstOrDefault(k => k.Value == kind) ?? kinds[^1];
_label = label;
_url = url;
_changed = changed;
RemoveCommand = new RelayCommand(() => remove(this));
}
public IReadOnlyList<Choice> Kinds { get; }
public Choice Kind
{
get => _kind;
set
{
if (value is not null && SetProperty(ref _kind, value)) _changed(); // null : liste de l'écran en cours de remplacement
}
}
public string Label
{
get => _label;
set
{
if (SetProperty(ref _label, value ?? "")) _changed();
}
}
public string Url
{
get => _url;
set
{
if (SetProperty(ref _url, value ?? "")) _changed();
}
}
public RelayCommand RemoveCommand { get; }
internal bool IsEmpty => string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Label);
}
@@ -0,0 +1,273 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>
/// Page « Jeux » : informations modifiables des jeux déclarés dans launcher.json, publiées dans le
/// catalogue (catalog.json) que les launchers des joueurs lisent au démarrage.
/// • une liste : « Launcher » (nom, jeu par défaut) puis les jeux, dans l'ordre d'affichage (↑ ↓) ;
/// • champ vide = valeur de launcher.json (rappelée sous le champ) ;
/// • « Publier » : contrôle complet (mêmes règles que le serveur), requête signée, révision attendue
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé).
/// </summary>
public sealed class GamesPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "catalog.games";
private CatalogState? _state;
private CatalogEditor? _selected;
private string? _statusMessage;
private bool _loading;
private string _publishedSnapshot = "";
private bool _hasChanges;
private bool _hasConflict;
public GamesPanelViewModel(IAdminContext context) : base(context)
{
Launcher = new LauncherEditor(context.LauncherName, context.DefaultGameId, context.Games, OnEdited);
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
RebuildItems(Launcher);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => !IsBusy && HasChanges && PublishingEnabled);
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
}
public LauncherEditor Launcher { get; }
/// <summary>Jeux dans l'ordre d'affichage du launcher.</summary>
public ObservableCollection<GameEditor> Games { get; } = new();
/// <summary>Liste affichée : « Launcher », puis les jeux.</summary>
public ObservableCollection<CatalogEditor> Items { get; } = new();
public CatalogEditor? Selected
{
get => _selected;
set
{
if (!SetProperty(ref _selected, value)) return;
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
}
public bool PublishingEnabled => _state?.PublishingEnabled == true;
public bool ShowPublishingDisabled => _state is not null && !_state.PublishingEnabled;
public string? PublicUrl => _state?.PublicUrl;
/// <summary>« Révision 12, publiée le 31/10/2026 à 08:42 » / « Jamais publié ».</summary>
public string RevisionText => _state?.Catalog is not { } catalog ? "" :
catalog.Revision == 0 ? "Jamais publié : le launcher utilise les valeurs de launcher.json."
: $"Révision {catalog.Revision}" + (catalog.UpdatedAt is { } at ? $", publiée le {at.ToLocalTime():dd/MM/yyyy à HH:mm}" : "") + ".";
public bool HasChanges
{
get => _hasChanges;
private set
{
if (!SetProperty(ref _hasChanges, value)) return;
PublishCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Quelqu'un a publié entre-temps : il faut recharger.</summary>
public bool HasConflict
{
get => _hasConflict;
private set => SetProperty(ref _hasConflict, value);
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand PublishCommand { get; }
public AsyncRelayCommand ReloadCommand { get; }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
/// <summary>Faux pendant un chargement ou une publication : la saisie attend la réponse du serveur.</summary>
public bool IsEditable => !IsBusy;
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
PublishCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
public Task LoadAsync() => RunAsync(async () =>
{
var state = await Context.Session.GetCatalogAsync();
Apply(state);
StatusMessage = null;
});
private async Task ReloadAsync()
{
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les modifications non publiées seront perdues : la page affichera le catalogue actuellement publié.",
"Abandonner et recharger"))
return;
await LoadAsync();
}
/// <summary>Affiche un catalogue (chargé ou publié) et en fait la référence « sans modification ».</summary>
private void Apply(CatalogState state)
{
_loading = true;
try
{
_state = state;
var catalog = state.Catalog;
Launcher.Load(catalog.Launcher);
foreach (var game in Games) game.Load(catalog.Games.TryGetValue(game.GameId, out var entry) ? entry : null);
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
var ordered = new List<GameEditor>();
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
if (Games.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
ordered.Add(game);
ordered.AddRange(Context.Games.Select(d => Games.First(g => g.Declared == d)).Where(g => !ordered.Contains(g)));
var selected = Selected;
Games.Clear();
foreach (var game in ordered) Games.Add(game);
RebuildItems(selected);
}
finally
{
_loading = false;
}
_publishedSnapshot = Snapshot(BuildCatalog(new List<string>()));
HasConflict = false;
RefreshDirty();
foreach (var name in new[] { nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(PublicUrl), nameof(RevisionText) })
OnPropertyChanged(name);
PublishCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Publication
private Task PublishAsync() => RunAsync(async () =>
{
if (_state is not { } state) return;
var errors = new List<string>();
var draft = BuildCatalog(errors);
var imageHost = Uri.TryCreate(state.PublicUrl, UriKind.Absolute, out var url) ? url.Host : null;
var normalized = CatalogRules.Normalize(draft, imageHost, errors);
if (errors.Count == 0 && CatalogRules.PublishedSize(normalized) > CatalogRules.MaxFileBytes)
errors.Add($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko) : raccourcissez les descriptions.");
if (errors.Count > 0)
{
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
return;
}
try
{
// Signature (phase 7) du catalogue tel qu'il sera publié (contrôlé avec les mêmes règles que le serveur).
var signature = await Context.Session.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId,
ContentSigning.CatalogDigest(normalized));
var published = await Context.Session.PublishCatalogAsync(normalized with { Signature = signature }, state.Catalog.Revision);
Apply(published);
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
}
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
Context.Dialogs.ShowMessage("Publié entre-temps",
"Quelqu'un a publié le catalogue pendant vos modifications. Rechargez la page pour partir de sa version " +
"(vos modifications non publiées seront perdues : notez-les si besoin).", ModuleMessageKind.Warning);
}
});
/// <summary>Catalogue saisi (ordre affiché, champs non vides).</summary>
private CatalogDocument BuildCatalog(List<string> errors)
{
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
foreach (var game in Games)
if (game.ToCatalog(errors) is { } entry) games[game.GameId] = entry;
return new CatalogDocument
{
Launcher = Launcher.ToCatalog(Games.Select(g => g.GameId).ToList(), Context.Games.Select(g => g.GameId).ToList()),
Games = games
};
}
// ------------------------------------------------------------------ Modifications
private void OnEdited()
{
if (_loading) return;
RefreshDirty();
}
private void RefreshDirty()
{
var errors = new List<string>();
var current = BuildCatalog(errors);
HasChanges = Snapshot(current) != _publishedSnapshot || errors.Count > 0;
var published = string.IsNullOrEmpty(_publishedSnapshot) ? null : JsonSerializer.Deserialize<CatalogDocument>(_publishedSnapshot, ApiJson.Options);
Launcher.IsDirty = Snapshot(current.Launcher) != Snapshot(published?.Launcher);
foreach (var game in Games)
{
current.Games.TryGetValue(game.GameId, out var now);
CatalogGame? before = null;
published?.Games.TryGetValue(game.GameId, out before);
game.IsDirty = Snapshot(now) != Snapshot(before);
}
}
private bool CanMove(int delta) =>
Selected is GameEditor game && Games.IndexOf(game) is var index and >= 0 && index + delta >= 0 && index + delta < Games.Count;
private void Move(int delta)
{
if (Selected is not GameEditor game || !CanMove(delta)) return;
var index = Games.IndexOf(game);
Games.Move(index, index + delta);
RebuildItems(game);
OnEdited();
}
/// <summary>
/// Reconstruit la liste affichée puis resélectionne <paramref name="select"/> : la liste de l'écran
/// vide sa sélection quand ses éléments sont retirés, la notification la rétablit.
/// </summary>
private void RebuildItems(CatalogEditor? select)
{
Items.Clear();
Items.Add(Launcher);
foreach (var game in Games) Items.Add(game);
_selected = select is not null && Items.Contains(select) ? select : Items.FirstOrDefault();
OnPropertyChanged(nameof(Selected));
MoveUpCommand?.RaiseCanExecuteChanged();
MoveDownCommand?.RaiseCanExecuteChanged();
}
private static string Snapshot(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Module « Notes de version » : page du menu d'administration pour corriger les notes des versions
/// déjà publiées (manifest.json du serveur). Permission changelog.edit.
/// </summary>
public sealed class ChangelogAdminModule : ILauncherModule
{
public string Id => "changelog";
public string DisplayName => "Notes de version";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
ChangelogPanelViewModel.PanelId,
"Notes de version",
"Corrigez les notes des versions déjà publiées : texte, sections, étiquettes, sous-points. " +
"Une nouvelle version et ses notes se publient toujours avec ManifestBuilder (notes.txt).",
"FileDocument",
AdminPermissions.ChangelogEdit,
Order: 20,
context => new ChangelogPanelViewModel(context)));
}
@@ -0,0 +1,401 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Notes d'une version, en cours de modification : en-tête (titre, sous-titre, introduction), puis
/// sections › éléments. <see cref="Preview"/> est l'entrée telle que le launcher l'affichera (mêmes
/// bornes, mêmes modèles d'affichage), recalculée à chaque modification.
/// </summary>
public sealed class VersionEditor : ObservableObject
{
private readonly Action _changed;
private readonly Func<string, string, bool> _confirm;
private string _title = "", _subtitle = "", _introduction = "";
private string _snapshot = "";
private bool _isDirty, _showPreview, _loading;
private ChangelogEntry _preview;
private IReadOnlyList<string> _limitErrors = Array.Empty<string>();
/// <param name="confirm">Confirmation (titre, message) avant de retirer une section qui contient des éléments.</param>
/// <param name="changed">Prévient la page (bouton « Enregistrer », pastilles).</param>
public VersionEditor(ChangelogEntry published, Func<string, string, bool> confirm, Action changed)
{
Version = published.Version;
Date = published.Date;
_confirm = confirm;
_changed = changed;
_preview = new ChangelogEntry { Version = Version, Date = Date };
Sections.CollectionChanged += OnSectionsChanged;
AddSectionCommand = new RelayCommand(AddSection, () => Sections.Count < ChangelogLimits.MaxSectionsPerEntry);
Load(published);
}
public string Version { get; }
public DateTimeOffset Date { get; }
public string DateText => Date == default ? "" : _preview.DateText;
// ------------------------------------------------------------------ En-tête
public string Title { get => _title; set => Set(ref _title, value ?? ""); }
/// <summary>Sous-titre : une ou plusieurs lignes.</summary>
public string Subtitle { get => _subtitle; set => Set(ref _subtitle, value ?? ""); }
/// <summary>Paragraphes d'introduction, séparés par une ligne vide.</summary>
public string Introduction { get => _introduction; set => Set(ref _introduction, value ?? ""); }
public ObservableCollection<SectionEditor> Sections { get; } = new();
public RelayCommand AddSectionCommand { get; }
// ------------------------------------------------------------------ État
/// <summary>Modifié depuis le chargement (ou le dernier enregistrement).</summary>
public bool IsDirty
{
get => _isDirty;
private set => SetProperty(ref _isDirty, value);
}
/// <summary>Affichage : modification (faux) ou aperçu tel que le verront les joueurs (vrai).</summary>
public bool ShowPreview
{
get => _showPreview;
set
{
if (!SetProperty(ref _showPreview, value)) return;
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(IsPreviewMode));
}
}
public bool IsEditMode { get => !_showPreview; set { if (value) ShowPreview = false; } }
public bool IsPreviewMode { get => _showPreview; set { if (value) ShowPreview = true; } }
/// <summary>Notes telles que le launcher les affichera.</summary>
public ChangelogEntry Preview
{
get => _preview;
private set => SetProperty(ref _preview, value);
}
/// <summary>Limites dépassées (rien n'est tronqué : à corriger avant d'enregistrer).</summary>
public IReadOnlyList<string> LimitErrors
{
get => _limitErrors;
private set
{
_limitErrors = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasLimitErrors));
}
}
public bool HasLimitErrors => _limitErrors.Count > 0;
/// <summary>« 3 sections · 14 éléments ».</summary>
public string CountText
{
get
{
var items = Sections.Sum(s => s.Items.Count);
return $"{Sections.Count} section{(Sections.Count > 1 ? "s" : "")} · {items} élément{(items > 1 ? "s" : "")} " +
$"(au plus {ChangelogLimits.MaxItemsPerEntry})";
}
}
// ------------------------------------------------------------------ Chargement, conversion
/// <summary>Affiche des notes publiées et en fait la référence « sans modification ».</summary>
public void Load(ChangelogEntry entry)
{
_loading = true;
try
{
_title = entry.Title ?? "";
_subtitle = entry.Subtitle ?? "";
_introduction = string.Join("\n\n", entry.Introduction ?? new List<string>());
Sections.Clear();
// Ancien format (liste à plat) : présenté en section(s) sans titre, enregistré au format enrichi.
var sections = entry.Sections.Count > 0
? entry.Sections
: (entry.Changes ?? new List<string>())
.Chunk(ChangelogLimits.MaxItemsPerSection)
.Select(chunk => new ChangelogSection { Items = chunk.Select(c => new ChangelogItem { Text = c }).ToList() })
.ToList();
foreach (var section in sections) Sections.Add(new SectionEditor(section, this));
foreach (var name in new[] { nameof(Title), nameof(Subtitle), nameof(Introduction) }) OnPropertyChanged(name);
}
finally
{
_loading = false;
}
_snapshot = Snapshot(Normalized(ToEntry()));
Refresh();
}
/// <summary>Notes saisies (non tronquées) : c'est ce qui est envoyé au serveur.</summary>
public ChangelogEntry ToEntry()
{
var entry = new ChangelogEntry
{
Version = Version,
Date = Date,
Title = Clean(Title),
Subtitle = Clean(Subtitle.Replace("\r\n", "\n")),
Introduction = Paragraphs(Introduction),
Sections = Sections.Select(s => s.ToSection()).ToList()
};
// Liste à plat des anciens launchers : régénérée par le serveur à l'enregistrement (requête allégée).
if (entry.Sections.Count == 0) entry.Changes = new List<string>();
return entry;
}
// ------------------------------------------------------------------ Modifications
internal void Changed()
{
if (_loading) return;
Refresh();
}
private void Refresh()
{
var entry = ToEntry();
var preview = Normalized(entry);
var errors = ChangelogLimits.Check(entry);
if (preview is null) errors.Insert(0, "Les notes ne peuvent pas être vides : gardez au moins un titre ou un élément.");
LimitErrors = errors;
Preview = preview ?? new ChangelogEntry { Version = Version, Date = Date };
IsDirty = Snapshot(preview) != _snapshot;
OnPropertyChanged(nameof(CountText));
AddSectionCommand.RaiseCanExecuteChanged();
_changed();
}
private bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (!SetProperty(ref field, value, name)) return false;
Changed();
return true;
}
private void AddSection()
{
if (Sections.Count >= ChangelogLimits.MaxSectionsPerEntry) return;
var section = new SectionEditor(new ChangelogSection(), this);
section.AddChangeCommand.Execute(null);
Sections.Add(section);
}
internal void MoveSection(SectionEditor section, int delta) => ListMoves.Move(Sections, section, delta);
internal void RemoveSection(SectionEditor section)
{
if (section.Items.Any(i => !i.IsEmpty) &&
!_confirm("Retirer la section ?", $"La section « {(string.IsNullOrWhiteSpace(section.Title) ? "sans titre" : section.Title.Trim())} » " +
$"et ses {section.Items.Count} élément(s) seront retirés de ces notes (rien n'est enregistré avant « Enregistrer »)."))
return;
Sections.Remove(section);
}
private void OnSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
foreach (var section in Sections) section.RefreshMoves();
Changed();
}
// ------------------------------------------------------------------ Outils
private static ChangelogEntry? Normalized(ChangelogEntry entry) => ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault();
private static string Snapshot(ChangelogEntry? entry) => entry is null ? "" : JsonSerializer.Serialize(entry, ApiJson.Options);
internal static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
/// <summary>Paragraphes séparés par une (ou plusieurs) ligne(s) vide(s) ; retours à la ligne simples gardés en espaces.</summary>
private static List<string> Paragraphs(string text) =>
System.Text.RegularExpressions.Regex.Split(text.Replace("\r\n", "\n"), @"\n[ \t]*\n")
.Select(p => Clean(p.Replace('\n', ' ')))
.OfType<string>()
.ToList();
/// <summary>Une ligne par élément (sous-points).</summary>
internal static List<string> Lines(string text) =>
text.Replace("\r\n", "\n").Split('\n').Select(Clean).OfType<string>().ToList();
}
/// <summary>Section des notes (titre facultatif) et ses éléments.</summary>
public sealed class SectionEditor : ObservableObject
{
private readonly VersionEditor _version;
private string _title;
public SectionEditor(ChangelogSection section, VersionEditor version)
{
_version = version;
_title = section.Title ?? "";
AddChangeCommand = new RelayCommand(() => Add(comment: false), CanAdd);
AddCommentCommand = new RelayCommand(() => Add(comment: true), CanAdd);
foreach (var item in section.Items ?? new List<ChangelogItem>()) Items.Add(new ItemEditor(item, this));
Items.CollectionChanged += (_, _) =>
{
foreach (var item in Items) item.RefreshMoves();
OnPropertyChanged(nameof(CountText));
AddChangeCommand.RaiseCanExecuteChanged();
AddCommentCommand.RaiseCanExecuteChanged();
_version.Changed();
};
MoveUpCommand = new RelayCommand(() => _version.MoveSection(this, -1), () => _version.Sections.IndexOf(this) > 0);
MoveDownCommand = new RelayCommand(() => _version.MoveSection(this, +1),
() => _version.Sections.IndexOf(this) is var i && i >= 0 && i < _version.Sections.Count - 1);
RemoveCommand = new RelayCommand(() => _version.RemoveSection(this));
}
/// <summary>Titre de la section (facultatif), ex. « PROGRESSION ».</summary>
public string Title
{
get => _title;
set
{
if (SetProperty(ref _title, value ?? "")) _version.Changed();
}
}
public ObservableCollection<ItemEditor> Items { get; } = new();
public string CountText => $"{Items.Count} élément{(Items.Count > 1 ? "s" : "")}";
public RelayCommand AddChangeCommand { get; }
public RelayCommand AddCommentCommand { get; }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
internal VersionEditor Version => _version;
internal ChangelogSection ToSection() => new()
{
Title = VersionEditor.Clean(Title),
Items = Items.Select(i => i.ToItem()).ToList()
};
internal void RefreshMoves()
{
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
internal void MoveItem(ItemEditor item, int delta) => ListMoves.Move(Items, item, delta);
internal void RemoveItem(ItemEditor item) => Items.Remove(item);
private bool CanAdd() => Items.Count < ChangelogLimits.MaxItemsPerSection;
private void Add(bool comment)
{
if (!CanAdd()) return;
Items.Add(new ItemEditor(new ChangelogItem { Type = comment ? ChangelogItem.CommentType : null }, this));
}
}
/// <summary>Élément d'une section : une modification (étiquette, sous-points, note) ou un commentaire.</summary>
public sealed class ItemEditor : ObservableObject
{
public static readonly IReadOnlyList<Choice> Kinds = new[]
{
new Choice(ChangelogItem.ChangeType, "Modification"),
new Choice(ChangelogItem.CommentType, "Commentaire")
};
private readonly SectionEditor _section;
private Choice _kind;
private string _text, _tag, _details, _note;
public ItemEditor(ChangelogItem item, SectionEditor section)
{
_section = section;
_kind = item.IsComment ? Kinds[1] : Kinds[0];
_text = item.Text ?? "";
_tag = item.Tag ?? "";
_details = string.Join("\n", item.Details ?? new List<string>());
_note = item.Note ?? "";
MoveUpCommand = new RelayCommand(() => _section.MoveItem(this, -1), () => _section.Items.IndexOf(this) > 0);
MoveDownCommand = new RelayCommand(() => _section.MoveItem(this, +1),
() => _section.Items.IndexOf(this) is var i && i >= 0 && i < _section.Items.Count - 1);
RemoveCommand = new RelayCommand(() => _section.RemoveItem(this));
}
public IReadOnlyList<Choice> KindChoices => Kinds;
public Choice Kind
{
get => _kind;
set
{
// null : liste de l'écran en cours de remplacement, pas un choix de l'administrateur.
if (value is null || !SetProperty(ref _kind, value)) return;
OnPropertyChanged(nameof(IsComment));
OnPropertyChanged(nameof(IsChange));
_section.Version.Changed();
}
}
public bool IsComment => _kind.Value == ChangelogItem.CommentType;
public bool IsChange => !IsComment;
public string Text { get => _text; set => Set(ref _text, value ?? ""); }
/// <summary>Étiquette en pastille, ex. « NOUVEAU » (modifications uniquement).</summary>
public string Tag { get => _tag; set => Set(ref _tag, value ?? ""); }
/// <summary>Sous-points : un par ligne (modifications uniquement).</summary>
public string Details { get => _details; set => Set(ref _details, value ?? ""); }
/// <summary>Texte de conclusion après les sous-points (modifications uniquement).</summary>
public string Note { get => _note; set => Set(ref _note, value ?? ""); }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
internal bool IsEmpty => string.IsNullOrWhiteSpace(Text) && string.IsNullOrWhiteSpace(Details);
internal ChangelogItem ToItem() => IsComment
? new ChangelogItem { Type = ChangelogItem.CommentType, Text = Text.Trim() }
: new ChangelogItem
{
Text = Text.Trim(),
Tag = VersionEditor.Clean(Tag),
Details = VersionEditor.Lines(Details),
Note = VersionEditor.Clean(Note)
};
internal void RefreshMoves()
{
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
private void Set(ref string field, string value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (SetProperty(ref field, value, name)) _section.Version.Changed();
}
}
internal static class ListMoves
{
public static void Move<T>(ObservableCollection<T> list, T item, int delta)
{
var index = list.IndexOf(item);
var target = index + delta;
if (index < 0 || target < 0 || target >= list.Count) return;
list.Move(index, target);
}
}
@@ -0,0 +1,275 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
/// • plusieurs jeux déclarés : rien n'est chargé ni affiché avant le choix du jeu (aucun jeu par défaut) ;
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
/// • « Enregistrer » : une requête signée par version modifiée, avec l'empreinte du manifest lu
/// (si le manifest a changé entre-temps, rien n'est écrasé et le rechargement est proposé).
/// </summary>
public sealed class ChangelogPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "changelog.notes";
private ChangelogState? _state;
private Choice? _selectedGame;
private VersionEditor? _selectedVersion;
private bool _hasChanges, _hasConflict;
private string? _statusMessage;
private int _skippedVersions;
public ChangelogPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = GameTile.Choices(context);
_selectedGame = GameTile.Initial(GameChoices);
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
SaveCommand = new AsyncRelayCommand(ScrollingToTop(SaveAsync), () => !IsBusy && HasChanges && !HasConflict && _state is not null);
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy && _selectedGame is not null);
}
// ------------------------------------------------------------------ Jeu
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
public IReadOnlyList<GameTile> GameTiles { get; }
public bool HasSelectedGame => _selectedGame is not null;
public bool ShowGamePicker => _selectedGame is null;
/// <summary>Chargement lancé par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
/// <summary>Pas de changement de jeu pendant un chargement ou un enregistrement.</summary>
public bool IsGameSwitchEnabled => !IsBusy;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez modifier les notes de version.";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame)) return;
if (IsBusy)
{
OnPropertyChanged(); // pas pendant un chargement ou un enregistrement
return;
}
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher) })
OnPropertyChanged(name);
ReloadCommand.RaiseCanExecuteChanged();
ErrorMessage = null;
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
GameLoad = LoadAsync();
}
}
// ------------------------------------------------------------------ Versions
public ObservableCollection<VersionEditor> Versions { get; } = new();
public VersionEditor? SelectedVersion
{
get => _selectedVersion;
set
{
// null : la liste de l'écran se vide lors d'un rechargement ; la sélection est rétablie ensuite.
if (value is not null) SetProperty(ref _selectedVersion, value);
}
}
public bool HasVersions => Versions.Count > 0;
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
public string ManifestText => _state is null ? "" :
$"Version publiée : {_state.ManifestVersion} · {Versions.Count} version{(Versions.Count > 1 ? "s" : "")} avec des notes" +
(_skippedVersions > 0 ? $" ({_skippedVersions} non modifiable{(_skippedVersions > 1 ? "s" : "")} en ligne : numéro avec caractères spéciaux ou en double)" : "");
// ------------------------------------------------------------------ État
public bool HasChanges
{
get => _hasChanges;
private set
{
if (SetProperty(ref _hasChanges, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Le manifest a changé sur le serveur (version publiée, autre administrateur) : il faut recharger.</summary>
public bool HasConflict
{
get => _hasConflict;
private set
{
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Faux pendant un chargement ou un enregistrement.</summary>
public bool IsEditable => !IsBusy;
public AsyncRelayCommand SaveCommand { get; }
public AsyncRelayCommand ReloadCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
SaveCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
public Task LoadAsync() => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId) return;
var state = await Context.Session.GetChangelogAsync(gameId);
if (!string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase)) return; // jeu changé entre-temps
Apply(state);
StatusMessage = null;
});
private async Task ReloadAsync()
{
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes non enregistrées seront perdues : la page affichera celles du manifest publié.", "Abandonner et recharger"))
return;
await LoadAsync();
}
private void Apply(ChangelogState state)
{
var selected = _selectedVersion?.Version;
_state = state;
HasConflict = false;
Versions.Clear();
// Une version présente deux fois n'est modifiable qu'une fois (le serveur modifie la première).
var editable = state.Entries.Where(e => ChangelogLimits.IsEditableVersion(e.Version))
.DistinctBy(e => e.Version, StringComparer.Ordinal).ToList();
_skippedVersions = state.Entries.Count - editable.Count;
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
}
// ------------------------------------------------------------------ Enregistrement
private Task SaveAsync() => RunAsync(async () =>
{
if (_state is null || _selectedGame?.Value is not { } gameId) return;
var dirty = Versions.Where(v => v.IsDirty).ToList();
// Tout est vérifié avant le premier envoi : limites, taille de chaque requête.
foreach (var version in dirty)
{
var entry = version.ToEntry();
var errors = version.LimitErrors; // limites + notes vides
if (errors.Count > 0)
{
SelectedVersion = version;
Context.Dialogs.ShowMessage($"Notes de la version {version.Version} à corriger",
string.Join("\n", errors.Take(12)), ModuleMessageKind.Warning);
return;
}
var size = JsonSerializer.SerializeToUtf8Bytes(new ChangelogUpdateRequest { BaseSha256 = _state.ManifestSha256, Entry = entry }, ApiJson.Options).Length;
if (size > ChangelogLimits.MaxEditRequestBytes)
{
SelectedVersion = version;
Context.Dialogs.ShowMessage("Notes trop longues pour l'édition en ligne",
$"Les notes de la version {version.Version} font {size / 1024} Ko (au plus {ChangelogLimits.MaxEditRequestBytes / 1024} Ko par version). " +
"Raccourcissez-les, ou modifiez notes.txt puis republiez la version.", ModuleMessageKind.Warning);
return;
}
}
var saved = new List<string>();
try
{
foreach (var version in dirty)
{
var state = await Context.Session.SaveChangelogEntryAsync(gameId, version.ToEntry(), _state.ManifestSha256);
_state = state;
if (state.Entries.FirstOrDefault(e => e.Version == version.Version) is { } published) version.Load(published);
saved.Add(version.Version);
}
StatusMessage = $"Notes enregistrées ({string.Join(", ", saved)}). Les launchers les afficheront à leur prochaine recherche de mise à jour.";
Log.Info($"Administration : notes de version modifiées ({gameId} {string.Join(", ", saved)}).");
}
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
if (saved.Count > 0) StatusMessage = $"Notes enregistrées : {string.Join(", ", saved)}.";
Context.Dialogs.ShowMessage("Manifest modifié entre-temps",
"Le manifest du jeu a changé sur le serveur (nouvelle version publiée ou autre administrateur). Rechargez la page " +
"pour partir de la version publiée (les modifications non enregistrées seront perdues : notez-les si besoin).",
ModuleMessageKind.Warning);
}
});
// ------------------------------------------------------------------ Outils
private void Clear()
{
_state = null;
HasConflict = false;
StatusMessage = null;
Versions.Clear();
_selectedVersion = null;
_skippedVersions = 0;
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
SaveCommand.RaiseCanExecuteChanged();
}
private void RefreshChanges() => HasChanges = Versions.Any(v => v.IsDirty);
private bool Confirm(string title, string message) => Context.Dialogs.Confirm(title, message, "Retirer", danger: true);
}
@@ -0,0 +1,26 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Files;
/// <summary>
/// Module « Fichiers du serveur » : parcours du site public (www), suppression des fichiers inutilisés
/// (anciennes archives…) et nettoyage du dossier « objects » des jeux. Permission files.manage
/// (propriétaires, administrateurs).
/// </summary>
public sealed class FilesAdminModule : ILauncherModule
{
public string Id => "files";
public string DisplayName => "Fichiers du serveur";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
FilesPanelViewModel.PanelId,
"Fichiers du serveur",
"Contenu du site public. Libérez de la place en supprimant les fichiers qu'aucun manifest n'utilise plus " +
"(anciennes archives, anciens exécutables…) : le serveur vérifie les manifests avant chaque suppression.",
"Harddisk",
AdminPermissions.FilesManage,
Order: 35,
context => new FilesPanelViewModel(context)));
}
@@ -0,0 +1,402 @@
using System.Globalization;
using GameLauncher.Contracts.Files;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Files;
/// <summary>
/// Page « Fichiers du serveur » (files.manage) : parcours du site public (www), suppression d'un fichier que le
/// serveur juge inutilisé (après confirmation), nettoyage du dossier « objects » d'un jeu (analyse, puis
/// confirmation). Le serveur revérifie tout au moment de supprimer : la page ne fait qu'afficher ses réponses.
/// </summary>
public sealed class FilesPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "files.manage";
private ServerFolder? _folder;
private IReadOnlyList<ServerFileItem> _entries = Array.Empty<ServerFileItem>();
private IReadOnlyList<FolderCrumb> _crumbs = Array.Empty<FolderCrumb>();
private string _path = "";
private bool _showOnlyUnused;
private string? _statusMessage;
private IReadOnlyList<Choice> _pruneGames = Array.Empty<Choice>();
private Choice? _pruneGame;
private ObjectsPrunePreview? _prune;
public FilesPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(_path))), () => !IsBusy);
UpCommand = new AsyncRelayCommand(() => OpenAsync(Parent(_path)), () => !IsBusy && _path.Length > 0);
AnalyzeCommand = new AsyncRelayCommand(AnalyzeAsync, () => !IsBusy && _pruneGame is not null);
PruneCommand = new AsyncRelayCommand(ScrollingToTop(PruneAsync), () => !IsBusy && CanPrune);
}
// ------------------------------------------------------------------ Dossier affiché
public string RootName => _folder?.RootName is { Length: > 0 } name ? name : "www";
/// <summary>Chemin du dossier affiché (« » : racine du site).</summary>
public string CurrentPath => _path;
/// <summary>Fil d'Ariane : racine, puis chaque dossier jusqu'au dossier affiché.</summary>
public IReadOnlyList<FolderCrumb> Crumbs
{
get => _crumbs;
private set => SetProperty(ref _crumbs, value);
}
public IReadOnlyList<ServerFileItem> Entries
{
get => _entries;
private set
{
if (!SetProperty(ref _entries, value)) return;
OnPropertyChanged(nameof(EmptyText));
}
}
/// <summary>N'afficher que les fichiers supprimables (et les dossiers, pour continuer à parcourir).</summary>
public bool ShowOnlyUnused
{
get => _showOnlyUnused;
set
{
if (SetProperty(ref _showOnlyUnused, value)) ApplyFolder();
}
}
public string EmptyText => _folder is null || Entries.Count > 0 ? ""
: _showOnlyUnused ? "Aucun fichier inutilisé dans ce dossier." : "Dossier vide.";
/// <summary>« 12 éléments · 3 fichier(s) inutilisé(s) : 1,2 Go ».</summary>
public string SummaryText
{
get
{
if (_folder is null) return "";
var unused = _folder.Entries.Where(e => e.Status == ServerFileStatus.Unused).ToList();
var text = $"{_folder.Entries.Count} élément(s)";
if (unused.Count > 0) text += $" · {unused.Count} fichier(s) inutilisé(s) : {Bytes(unused.Sum(e => e.Size))}";
return text + (_folder.Truncated ? $" · seuls les {ServerFilesLimits.MaxEntries} premiers sont affichés" : "");
}
}
/// <summary>« Espace libre sur le serveur : 12,4 Go sur 40 Go ».</summary>
public string DiskText => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f
? $"Espace libre sur le serveur : {Bytes(f.FreeBytes)} sur {Bytes(f.TotalBytes)}"
: "";
/// <summary>Part utilisée du disque (0 à 1), pour la jauge.</summary>
public double DiskUsedRatio => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f ? 1 - (double)f.FreeBytes / f.TotalBytes : 0;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand UpCommand { get; }
// ------------------------------------------------------------------ Nettoyage du dossier « objects »
/// <summary>Jeux configurés sur le serveur (seuls leurs dossiers « objects » peuvent être nettoyés).</summary>
public IReadOnlyList<Choice> PruneGames
{
get => _pruneGames;
private set
{
if (!SetProperty(ref _pruneGames, value)) return;
OnPropertyChanged(nameof(HasPruneGames));
}
}
public bool HasPruneGames => _pruneGames.Count > 0;
public Choice? PruneGame
{
get => _pruneGame;
set
{
if (!SetProperty(ref _pruneGame, value)) return;
Prune = null;
AnalyzeCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Dernière analyse (null : pas encore analysé, ou jeu changé).</summary>
public ObjectsPrunePreview? Prune
{
get => _prune;
private set
{
if (!SetProperty(ref _prune, value)) return;
OnPropertyChanged(nameof(HasPrune));
OnPropertyChanged(nameof(PruneText));
OnPropertyChanged(nameof(PruneDetail));
OnPropertyChanged(nameof(CanPrune));
OnPropertyChanged(nameof(PruneButtonText));
PruneCommand.RaiseCanExecuteChanged();
}
}
public bool HasPrune => _prune is not null;
public bool CanPrune => _prune is { OrphanCount: > 0, PublicationInProgress: false };
public string PruneButtonText => _prune is { OrphanCount: > 0 } p
? $"Supprimer {p.OrphanCount} fichier(s) ({Bytes(p.OrphanBytes)})"
: "Supprimer les fichiers inutilisés";
public string PruneText => _prune switch
{
null => "",
{ PublicationInProgress: true } p =>
$"Une publication de {p.GameId} est en cours : nettoyage possible une fois la version en ligne (ou la publication abandonnée).",
{ OrphanCount: 0 } p => $"Rien à nettoyer : les {p.ReferencedCount} fichier(s) du dossier « objects » sont tous utilisés par la version {p.Version}.",
var p => $"{p.OrphanCount} fichier(s) ne sont plus utilisés par la version en ligne ({p.Version}) : {Bytes(p.OrphanBytes)} à libérer."
};
public string PruneDetail => _prune switch
{
null => "",
{ MissingCount: > 0 } p => $"Attention : {p.MissingCount} fichier(s) de la version {p.Version} sont absents du serveur. Republiez la version.",
{ OrphanCount: > 0 } p => "Exemples : " + string.Join(", ", p.Examples.Take(3).Select(e => Shorten(e.Split('/').Last()))),
_ => ""
};
public AsyncRelayCommand AnalyzeCommand { get; }
public AsyncRelayCommand PruneCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
UpCommand.RaiseCanExecuteChanged();
AnalyzeCommand.RaiseCanExecuteChanged();
PruneCommand.RaiseCanExecuteChanged();
foreach (var entry in _entries) entry.RaiseCanExecuteChanged();
foreach (var crumb in _crumbs) crumb.OpenCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync("")) : Task.CompletedTask;
// ------------------------------------------------------------------ Actions
/// <summary>Ouvre un dossier (fil d'Ariane, double-clic, « Remonter »).</summary>
internal async Task OpenAsync(string path)
{
StatusMessage = null;
if (await RunAsync(() => LoadAsync(path))) RequestScrollToTop();
}
private async Task LoadAsync(string path)
{
var folder = await Context.Session.GetServerFolderAsync(path);
_folder = folder;
_path = folder.Path;
Crumbs = BuildCrumbs(folder.RootName, folder.Path);
var games = folder.PrunableGames.Select(id => new Choice(id, GameLabel(id))).ToList();
if (!games.Select(g => g.Value).SequenceEqual(_pruneGames.Select(g => g.Value)))
{
PruneGames = games;
PruneGame = games.Count == 1 ? games[0] : null; // plusieurs jeux : choisi par l'administrateur, jamais par défaut
}
ApplyFolder();
OnPropertyChanged(nameof(CurrentPath));
OnPropertyChanged(nameof(RootName));
OnPropertyChanged(nameof(DiskText));
OnPropertyChanged(nameof(DiskUsedRatio));
UpCommand.RaiseCanExecuteChanged();
}
private void ApplyFolder()
{
if (_folder is null) return;
Entries = _folder.Entries
.Where(e => !_showOnlyUnused || e.IsDirectory || e.Status == ServerFileStatus.Unused)
.Select(e => new ServerFileItem(this, e))
.ToList();
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(EmptyText));
}
internal async Task DeleteAsync(ServerFileItem item)
{
if (!item.CanDelete) return;
var path = Combine(_path, item.Name);
if (!Context.Dialogs.Confirm("Supprimer ce fichier",
$"« {item.Name} » ({item.SizeText}) va être supprimé définitivement du serveur.\n\n" +
"Le serveur vérifie d'abord qu'aucun manifest ne l'utilise : un fichier encore utilisé n'est jamais supprimé.",
"Supprimer", danger: true))
return;
StatusMessage = null;
ServerFileDeleteResult? result = null;
if (await RunAsync(async () => result = await Context.Session.DeleteServerFileAsync(path, item.Entry.Size)))
StatusMessage = $"« {item.Name} » supprimé : {Bytes(result!.FreedBytes)} libéré(s).";
RequestScrollToTop();
await ReloadKeepingErrorAsync(); // la liste reflète l'état réel du serveur
}
/// <summary>Recharge le dossier sans effacer l'erreur de l'action qui précède (refus du serveur).</summary>
private async Task ReloadKeepingErrorAsync()
{
var error = ErrorMessage;
await RunAsync(() => LoadAsync(_path));
if (error is not null && ErrorMessage is null) ErrorMessage = error;
}
private async Task AnalyzeAsync()
{
if (_pruneGame?.Value is not { } gameId) return;
Prune = null;
ObjectsPrunePreview? preview = null;
if (await RunAsync(async () => preview = await Context.Session.GetObjectsPruneAsync(gameId))) Prune = preview;
}
private async Task PruneAsync()
{
if (_prune is not { OrphanCount: > 0 } preview) return;
if (!Context.Dialogs.Confirm("Nettoyer le dossier « objects »",
$"{preview.OrphanCount} fichier(s) que la version en ligne de {preview.GameId} ({preview.Version}) n'utilise plus vont être " +
$"supprimés définitivement ({Bytes(preview.OrphanBytes)}).\n\n" +
"Les joueurs qui ont déjà la version en ligne ne sont pas concernés. Une version plus ancienne ne pourra plus être " +
"republiée sans renvoyer ces fichiers.",
"Supprimer", danger: true))
return;
StatusMessage = null;
ObjectsPruneResult? result = null;
if (await RunAsync(async () => result = await Context.Session.PruneObjectsAsync(preview.GameId, preview.ManifestSha256)))
StatusMessage = $"Dossier « objects » de {result!.GameId} nettoyé : {result.Deleted} fichier(s) supprimé(s), {Bytes(result.FreedBytes)} libéré(s)." +
(result.Failed > 0 ? $" {result.Failed} fichier(s) n'ont pas pu être supprimés (journal du serveur)." : "");
Prune = null;
await ReloadKeepingErrorAsync();
}
// ------------------------------------------------------------------ Outils
private string GameLabel(string gameId) =>
Context.Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)) is { } game
? $"{game.GameName} ({gameId})"
: gameId;
private IReadOnlyList<FolderCrumb> BuildCrumbs(string rootName, string path)
{
var crumbs = new List<FolderCrumb> { new(this, string.IsNullOrEmpty(rootName) ? "www" : rootName, "") };
var current = "";
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
current = Combine(current, segment);
crumbs.Add(new FolderCrumb(this, segment, current));
}
crumbs[^1].IsCurrent = true;
return crumbs;
}
internal static string Combine(string folder, string name) => folder.Length == 0 ? name : folder + "/" + name;
private static string Parent(string path) => path.LastIndexOf('/') is var i and > 0 ? path[..i] : "";
/// <summary>Taille lisible : « 812 o », « 4,2 Ko », « 1,3 Go ».</summary>
public static string Bytes(long bytes)
{
if (bytes < 0) return "?";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return unit == 0 ? $"{bytes} o" : value.ToString(value >= 100 ? "0" : "0.#", CultureInfo.InvariantCulture).Replace('.', ',') + " " + units[unit];
}
private static string Shorten(string name) => name.Length > 16 ? name[..12] + "…" : name;
}
/// <summary>Élément du fil d'Ariane.</summary>
public sealed class FolderCrumb
{
internal FolderCrumb(FilesPanelViewModel page, string label, string path)
{
Label = label;
Path = path;
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(path), () => !page.IsBusy && !IsCurrent);
}
public string Label { get; }
public string Path { get; }
public bool IsCurrent { get; internal set; }
public AsyncRelayCommand OpenCommand { get; }
}
/// <summary>Fichier ou dossier tel qu'affiché.</summary>
public sealed class ServerFileItem
{
internal ServerFileItem(FilesPanelViewModel page, ServerFileEntry entry)
{
Entry = entry;
Name = entry.Name;
IsDirectory = entry.IsDirectory && entry.Status == ServerFileStatus.Directory;
SizeText = entry.IsDirectory
? entry.Size >= 0 ? $"{FilesPanelViewModel.Bytes(entry.Size)} · {entry.FileCount} fichier(s)" : "taille non calculée (trop de fichiers)"
: FilesPanelViewModel.Bytes(entry.Size);
DateText = entry.Modified.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(FilesPanelViewModel.Combine(page.CurrentPath, entry.Name)),
() => IsDirectory && !page.IsBusy);
DeleteCommand = new AsyncRelayCommand(() => page.DeleteAsync(this), () => CanDelete && !page.IsBusy);
}
public ServerFileEntry Entry { get; }
public string Name { get; }
public bool IsDirectory { get; }
public string SizeText { get; }
public string DateText { get; }
public string Status => Entry.Status;
/// <summary>Supprimable : le serveur l'a jugé inutilisé (il le revérifie au moment de supprimer).</summary>
public bool CanDelete => Entry.Status == ServerFileStatus.Unused;
public string StatusText => Entry.Status switch
{
ServerFileStatus.Directory => "",
ServerFileStatus.Protected => "Protégé",
ServerFileStatus.InUse => "Utilisé",
ServerFileStatus.Unused => "Inutilisé",
ServerFileStatus.Link => "Lien",
_ => "Non vérifiable"
};
/// <summary>Explication (infobulle) : pourquoi le fichier est protégé ou utilisé.</summary>
public string ToolTipText => Entry.Status switch
{
ServerFileStatus.Directory => "Ouvrir le dossier",
ServerFileStatus.Unused => "Aucun manifest ni fichier de description n'utilise ce fichier : il peut être supprimé.",
_ => Capitalize(Entry.Reason ?? "Ce fichier ne peut pas être supprimé depuis le menu.")
};
public string IconKey => Entry.IsDirectory ? "Folder"
: Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "PackageVariant"
: Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? "FileDocument"
: Name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "ImageOutline"
: "TextBoxOutline";
public AsyncRelayCommand OpenCommand { get; }
public AsyncRelayCommand DeleteCommand { get; }
internal void RaiseCanExecuteChanged()
{
OpenCommand.RaiseCanExecuteChanged();
DeleteCommand.RaiseCanExecuteChanged();
}
private static string Capitalize(string text) => text.Length > 0 ? char.ToUpper(text[0], CultureInfo.InvariantCulture) + text[1..] : text;
}
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>
/// Module « Publication » : page du menu d'administration pour mettre en ligne une version produite par
/// le .bat (ManifestBuilder), sans WinSCP. Permission releases.publish (propriétaires, administrateurs).
/// </summary>
public sealed class PublishAdminModule : ILauncherModule
{
public string Id => "publishing";
public string DisplayName => "Publication";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
PublishPanelViewModel.PanelId,
"Publier une version",
"Mettez en ligne la version préparée par le .bat de publication (ManifestBuilder) : seuls les fichiers absents " +
"du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est publié en dernier.",
"CloudUpload",
AdminPermissions.ReleasesPublish,
Order: 30,
context => new PublishPanelViewModel(context)));
}
@@ -0,0 +1,505 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>
/// Page « Publier une version » : choix du manifest.json produit par le .bat (ManifestBuilder), contrôle
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète ;
/// • plusieurs jeux déclarés : rien n'est affiché avant le choix du jeu (aucun jeu par défaut) ; un envoi
/// interrompu reste attaché à son jeu (bandeau « Reprendre / Abandonner » quand on revient à ce jeu).
/// </summary>
public sealed class PublishPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "releases.publish";
private Choice? _selectedGame;
private LocalRelease? _release;
private string? _manifestPath, _publishedVersion, _statusMessage, _progressText, _speedText;
private string _publishedText = "";
private ManifestSignatureState? _online;
private IReadOnlyList<string> _warnings = Array.Empty<string>();
private bool _isPublishing, _committing;
private double _progressValue;
private PublicationState? _interrupted;
private readonly Dictionary<string, PublicationState> _interruptedByGame = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _cancellation;
public PublishPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = GameTile.Choices(context);
_selectedGame = GameTile.Initial(GameChoices);
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit && _selectedGame is not null);
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _selectedGame is not null && _manifestPath is not null);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => CanEdit && _release is not null);
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
SignOnlineCommand = new AsyncRelayCommand(ScrollingToTop(SignOnlineAsync), () => CanEdit && CanSignOnline);
AbandonCommand = new AsyncRelayCommand(ScrollingToTop(AbandonAsync), () => CanEdit && _interrupted is not null);
}
// ------------------------------------------------------------------ Jeu, dossier
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
public IReadOnlyList<GameTile> GameTiles { get; }
public bool HasSelectedGame => _selectedGame is not null;
public bool ShowGamePicker => _selectedGame is null;
/// <summary>Lecture de la version en ligne lancée par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
/// <summary>Pas de changement de jeu pendant un contrôle ou un envoi.</summary>
public bool IsGameSwitchEnabled => CanEdit;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez publier une version.";
/// <summary>Nom du jeu choisi (titres, confirmations) : « Exovival Zombie Reborn (EZR) ».</summary>
public string SelectedGameLabel => _selectedGame?.Label ?? "";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame) || _isPublishing || IsBusy)
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher), nameof(SelectedGameLabel) })
OnPropertyChanged(name);
// Rien de l'ancien jeu ne reste affiché ni utilisable : dossier, version en ligne, signature, avancement.
ErrorMessage = null;
StatusMessage = null;
ManifestPath = null;
SetRelease(null);
_publishedVersion = null;
_online = null;
PublishedText = "";
ProgressText = null;
ProgressValue = 0;
SpeedText = null;
SetInterrupted(_interruptedByGame.GetValueOrDefault(value.Value ?? ""), remember: false);
RefreshCommands();
GameLoad = RefreshPublishedAsync();
}
}
public string? ManifestPath
{
get => _manifestPath;
private set
{
if (SetProperty(ref _manifestPath, value)) RecheckCommand.RaiseCanExecuteChanged();
}
}
/// <summary>« Version en ligne : 1.1 » (ou la raison pour laquelle elle est inconnue).</summary>
public string PublishedText
{
get => _publishedText;
private set => SetProperty(ref _publishedText, value);
}
// ------------------------------------------------------------------ Version à publier
public bool HasRelease => _release is not null;
public string VersionText => _release is null ? "" : $"Version {_release.Summary.Version}";
public string? NotesTitle => _release?.Summary.NotesTitle;
public bool HasNotesTitle => !string.IsNullOrWhiteSpace(NotesTitle);
public string FilesText => _release is null ? "" :
$"{_release.Summary.FileCount} fichier(s), {Format.Bytes(_release.Summary.TotalSize)} · exécutable « {_release.Summary.Executable} »";
public string ArchiveText => _release is null ? "" : $"{_release.Summary.ArchiveName} ({Format.Bytes(_release.Summary.ArchiveSize)})";
/// <summary>Points d'attention : republication, retour à une version précédente…</summary>
public IReadOnlyList<string> Warnings
{
get => _warnings;
private set
{
_warnings = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasWarnings));
}
}
public bool HasWarnings => _warnings.Count > 0;
// ------------------------------------------------------------------ Envoi
public bool IsPublishing
{
get => _isPublishing;
private set
{
if (!SetProperty(ref _isPublishing, value)) return;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
}
/// <summary>Faux pendant un contrôle ou un envoi.</summary>
public bool CanEdit => !IsBusy && !_isPublishing;
/// <summary>Barre d'attente du contrôle du dossier (l'envoi a sa propre barre de progression).</summary>
public bool ShowBusyBar => IsBusy && !_isPublishing;
public double ProgressValue
{
get => _progressValue;
private set => SetProperty(ref _progressValue, value);
}
public string? ProgressText
{
get => _progressText;
private set => SetProperty(ref _progressText, value);
}
public string? SpeedText
{
get => _speedText;
private set => SetProperty(ref _speedText, value);
}
/// <summary>
/// Envoi interrompu (annulé, coupure, erreur) : il reprendra à « Publier », ou peut être abandonné (fichiers
/// reçus supprimés). Pas pendant l'envoi lui-même : la publication est alors ouverte, pas interrompue.
/// </summary>
public bool HasInterrupted => _interrupted is not null && !_isPublishing;
public string InterruptedText => _interrupted is null ? "" :
$"L'envoi de la version {_interrupted.Version} a été interrompu. « Publier » le reprend là où il s'est arrêté ; " +
"« Abandonner » supprime ce qui a déjà été reçu par le serveur.";
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand ChooseCommand { get; }
public AsyncRelayCommand RecheckCommand { get; }
public AsyncRelayCommand PublishCommand { get; }
public RelayCommand CancelCommand { get; }
public AsyncRelayCommand AbandonCommand { get; }
// ------------------------------------------------------------------ Signature de la version en ligne (phase 7)
/// <summary>« Version en ligne signée par … » / « non signée ».</summary>
public string OnlineSignatureText => _online is not { Version.Length: > 0 } online ? ""
: online.Signature is { } signature
? $"Version en ligne {online.Version} signée par {online.SignerName ?? $"la clé {signature.KeyId}"} le {Accounts.AccountTexts.Date(signature.SignedAt)}."
: $"Version en ligne {online.Version} non signée : les launchers en mode « require » la refusent.";
public bool HasOnlineSignatureText => OnlineSignatureText.Length > 0;
public bool IsOnlineSigned => _online?.Signature is not null;
/// <summary>Le dossier choisi a exactement le contenu de la version en ligne : elle peut être signée avec lui.</summary>
public bool CanSignOnline => !_isPublishing && _release is { } release && _online is { Digest.Length: > 0 } online &&
string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase);
/// <summary>Même numéro de version que celle en ligne, mais contenu différent : impossible de la signer avec ce dossier.</summary>
public string? OnlineMismatchText => _release is { } release && _online is { Digest.Length: > 0 } online &&
release.Summary.Version == online.Version &&
!string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase)
? "La version en ligne porte ce numéro mais son contenu diffère de ce dossier : elle ne peut pas être signée avec lui."
: null;
public AsyncRelayCommand SignOnlineCommand { get; }
/// <summary>
/// Signe la version déjà en ligne (publiée avant la signature, ou avec WinSCP) à partir du dossier choisi : le
/// serveur n'accepte que si les fichiers en ligne sont exactement ceux de ce manifest.
/// </summary>
private async Task SignOnlineAsync()
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId || _online is not { } online) return;
if (!Context.Dialogs.Confirm($"Signer la version en ligne {online.Version} ?",
$"Jeu : {SelectedGameLabel}.\n\n" +
"Vous attestez que la version en ligne est bien celle de ce dossier de publication (le serveur vérifie que les fichiers, " +
"l'archive et l'exécutable sont identiques). Les launchers des joueurs l'accepteront ensuite.", "Signer"))
return;
StatusMessage = null;
if (await RunAsync(async () =>
{
var signature = await Context.Session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest);
_online = await Context.Session.SignOnlineManifestAsync(gameId,
new ManifestSignRequest { Digest = release.ContentDigest, Signature = signature });
}))
StatusMessage = $"Version en ligne {online.Version} signée.";
NotifySignature();
}
private async Task RefreshSignatureAsync(string gameId)
{
ManifestSignatureState? online;
try
{
online = await Context.Session.GetManifestSignatureAsync(gameId);
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
}
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
_online = online;
NotifySignature();
}
private void NotifySignature()
{
foreach (var name in new[] { nameof(OnlineSignatureText), nameof(HasOnlineSignatureText), nameof(IsOnlineSigned),
nameof(CanSignOnline), nameof(OnlineMismatchText) })
OnPropertyChanged(name);
SignOnlineCommand.RaiseCanExecuteChanged();
}
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshPublishedAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Actions
private async Task ChooseAsync()
{
var path = Context.Dialogs.PickOpenFile("Manifest de la version à publier (dossier produit par le .bat)",
"Manifest de publication (manifest.json)|manifest.json", _manifestPath is null ? null : Path.GetDirectoryName(_manifestPath));
if (path is not null) await InspectAsync(path);
}
/// <summary>Contrôle du dossier choisi (hors du fil de l'interface : lecture de nombreux fichiers).</summary>
public Task InspectAsync(string manifestPath) => RunAsync(async () =>
{
ManifestPath = manifestPath;
StatusMessage = null;
SetRelease(null);
if (_selectedGame?.Value is not { } gameId) return;
var release = await Task.Run(() => ReleaseUploader.Inspect(manifestPath, gameId));
await RefreshPublishedCoreAsync(gameId);
SetRelease(release);
});
private async Task PublishAsync()
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId) return;
var online = _publishedVersion switch
{
null => "version en ligne inconnue",
"" => "aucune version en ligne",
_ => $"version en ligne : {_publishedVersion}"
};
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
$"Jeu : {SelectedGameLabel}.\n\nLes joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
return;
StatusMessage = null;
_committing = false;
IsPublishing = true;
// Créés ici, sur le fil de l'interface : l'envoi les rappelle sur ce fil (boutons, liaisons).
var progress = new Progress<UploadProgress>(OnProgress);
var started = new Progress<PublicationState>(OnStarted);
_cancellation = new CancellationTokenSource();
try
{
using var _ = Context.BeginLongOperation();
await RunAsync(async () =>
{
try
{
var result = await ReleaseUploader.PublishAsync(Context.Session, gameId, release, progress, started, _cancellation.Token);
SetInterrupted(null);
_publishedVersion = result.Version;
PublishedText = $"Version en ligne : {result.Version}";
StatusMessage = $"Version {result.Version} de {SelectedGameLabel} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
(result.ArchiveAdded ? " et l'archive" : "") +
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
UpdateWarnings();
Log.Info($"Administration : version {result.Version} de {gameId} publiée.");
await RefreshSignatureAsync(gameId);
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
{
StatusMessage = null;
ProgressText = "Envoi interrompu.";
}
});
}
finally
{
_cancellation.Dispose();
_cancellation = null;
_committing = false;
IsPublishing = false;
SpeedText = null;
}
}
private Task AbandonAsync() => RunAsync(async () =>
{
if (_interrupted is not { } publication) return;
if (!Context.Dialogs.Confirm("Abandonner la publication ?",
$"Les fichiers de la version {publication.Version} déjà reçus par le serveur seront supprimés. Rien n'a été mis en ligne.",
"Abandonner", danger: true))
return;
try
{
await Context.Session.CancelPublicationAsync(publication.PublicationId);
}
catch (ApiException ex) when (ex.Status == 404)
{
// déjà terminée ou expirée
}
SetInterrupted(null);
ProgressText = null;
ProgressValue = 0;
});
// ------------------------------------------------------------------ Outils
private Task RefreshPublishedAsync() => _selectedGame?.Value is { } gameId ? RefreshPublishedCoreAsync(gameId) : Task.CompletedTask;
/// <summary>Version en ligne (manifest du serveur) ; une erreur ici n'empêche pas de continuer.</summary>
private async Task RefreshPublishedCoreAsync(string gameId)
{
try
{
var state = await Context.Session.GetChangelogAsync(gameId);
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
_publishedVersion = state.ManifestVersion;
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
await RefreshSignatureAsync(gameId);
}
catch (ApiException ex) when (ex.Status == 404)
{
if (!IsSelected(gameId)) return;
_publishedVersion = "";
PublishedText = "Aucune version en ligne pour l'instant.";
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
{
if (!IsSelected(gameId)) return;
_publishedVersion = null;
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
if (!IsSelected(gameId)) return;
_publishedVersion = null;
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
}
UpdateWarnings();
}
private void SetRelease(LocalRelease? release)
{
_release = release;
foreach (var name in new[] { nameof(HasRelease), nameof(VersionText), nameof(NotesTitle), nameof(HasNotesTitle), nameof(FilesText), nameof(ArchiveText) })
OnPropertyChanged(name);
UpdateWarnings();
RefreshCommands();
NotifySignature();
}
private void UpdateWarnings()
{
var warnings = new List<string>();
if (_release is { } release && !string.IsNullOrEmpty(_publishedVersion))
{
if (release.Summary.Version == _publishedVersion)
warnings.Add("Cette version est déjà en ligne : elle sera republiée (fichiers, archive et notes de cette version remplacés).");
else if (VersionUtil.TryParse(release.Summary.Version, out var local) && VersionUtil.TryParse(_publishedVersion, out var online) &&
local < online)
warnings.Add($"Version inférieure à celle en ligne ({_publishedVersion}) : les joueurs reviendront à la version {release.Summary.Version}.");
}
if (_release is { Summary.NotesTitle: null })
warnings.Add("Aucune note pour cette version dans le manifest (celles déjà en ligne pour ce numéro sont conservées).");
Warnings = warnings;
}
private void OnStarted(PublicationState state)
{
SetInterrupted(state);
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
}
/// <param name="remember">Faux au changement de jeu : l'état affiché est celui déjà mémorisé pour ce jeu.</param>
private void SetInterrupted(PublicationState? state, bool remember = true)
{
if (remember && _selectedGame?.Value is { } gameId)
{
if (state is null) _interruptedByGame.Remove(gameId);
else _interruptedByGame[gameId] = state;
}
_interrupted = state;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(InterruptedText));
AbandonCommand.RaiseCanExecuteChanged();
}
private void OnProgress(UploadProgress progress)
{
if (progress.Step == ReleaseUploader.CommitStep && !_committing)
{
_committing = true; // la mise en ligne ne s'interrompt pas
CancelCommand.RaiseCanExecuteChanged();
}
ProgressValue = progress.Total > 0 ? Math.Clamp((double)progress.Done / progress.Total, 0, 1) : 0;
ProgressText = progress.Total > 0
? $"{progress.Step} — {Format.Bytes(progress.Done)} / {Format.Bytes(progress.Total)}"
: progress.Step;
SpeedText = progress.BytesPerSecond > 0 ? Format.Speed(progress.BytesPerSecond) : null;
}
private void RefreshCommands()
{
ChooseCommand.RaiseCanExecuteChanged();
RecheckCommand.RaiseCanExecuteChanged();
PublishCommand.RaiseCanExecuteChanged();
CancelCommand.RaiseCanExecuteChanged();
AbandonCommand.RaiseCanExecuteChanged();
SignOnlineCommand.RaiseCanExecuteChanged();
}
private bool IsSelected(string gameId) => string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase);
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,347 @@
using System.Diagnostics;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>Version prête à publier : dossier produit par ManifestBuilder (le .bat), contrôlé.</summary>
public sealed record LocalRelease(string Folder, string ManifestPath, byte[] ManifestBytes, string ManifestSha256, ManifestSummary Summary)
{
/// <summary>Condensé du contenu signé (phase 7) : fichiers, archive, exécutable… (notes exclues).</summary>
public string ContentDigest { get; } = ContentSigning.ManifestDigest(ManifestBytes);
public string ArchivePath => Path.Combine(Folder, Summary.ArchiveName);
public string ObjectFile(string sha256) =>
Path.Combine(Folder, ManifestCheck.ObjectPath(sha256).Replace('/', Path.DirectorySeparatorChar));
}
/// <summary>Avancement de l'envoi.</summary>
public sealed record UploadProgress(string Step, long Done, long Total, double BytesPerSecond);
/// <summary>Contenu du dossier de publication incorrect (message affiché tel quel).</summary>
public sealed class ReleaseCheckException : InvalidOperationException
{
public ReleaseCheckException(string message) : base(message) { }
}
/// <summary>
/// Publication d'une version : contrôle du dossier local, puis envoi au serveur (manifest, objets absents
/// du serveur, archive) et mise en ligne. Reprise automatique : après une coupure (réseau, serveur
/// occupé, trop de requêtes), l'état est relu sur le serveur et l'envoi continue là où il s'est arrêté ;
/// relancé plus tard avec le même manifest, l'envoi reprend aussi.
/// </summary>
public static class ReleaseUploader
{
private const int MaxAttempts = 8;
/// <summary>Signature réutilisée pour un nouvel essai de mise en ligne pendant ce délai au plus (serveur : ± 10 min).</summary>
private static readonly TimeSpan SignatureReuseLimit = TimeSpan.FromMinutes(5);
/// <summary>Dernière étape (mise en ligne sur le serveur) : elle ne s'interrompt pas.</summary>
public const string CommitStep = "Mise en ligne";
/// <summary>
/// Contrôle du dossier : manifest valide (mêmes règles que le serveur), du bon jeu, archive et fichiers
/// présents avec la bonne taille (empreintes vérifiées pendant l'envoi).
/// </summary>
public static LocalRelease Inspect(string manifestPath, string gameId)
{
var info = new FileInfo(manifestPath);
if (!info.Exists) throw new ReleaseCheckException($"Manifest introuvable : {manifestPath}");
if (info.Length > PublishingLimits.MaxManifestBytes) throw new ReleaseCheckException("Manifest trop volumineux.");
var bytes = File.ReadAllBytes(info.FullName);
var errors = new List<string>();
var summary = ManifestCheck.Check(bytes, gameId, expectedVersion: null, errors)
?? throw new ReleaseCheckException("Ce manifest ne peut pas être publié :\n" + string.Join("\n", errors.Take(8)));
var release = new LocalRelease(info.DirectoryName!, info.FullName, bytes, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), summary);
var archive = new FileInfo(release.ArchivePath);
if (!archive.Exists) errors.Add($"Archive absente : {summary.ArchiveName}");
else if (archive.Length != summary.ArchiveSize) errors.Add($"Archive {summary.ArchiveName} : taille différente de celle du manifest.");
var missing = 0;
foreach (var (sha, size) in summary.Objects)
{
var file = new FileInfo(release.ObjectFile(sha));
if (file.Exists && file.Length == size) continue;
if (++missing <= 5) errors.Add($"{ManifestCheck.ObjectPath(sha)} : {(file.Exists ? "taille différente" : "absent")}.");
}
if (missing > 5) errors.Add($"… et {missing - 5} autre(s) fichier(s) de objects/.");
if (errors.Count > 0)
throw new ReleaseCheckException("Dossier de publication incomplet (relancez le .bat de publication) :\n" + string.Join("\n", errors));
return release;
}
/// <summary>Envoie ce qui manque puis met la version en ligne.</summary>
/// <param name="progress">Avancement ; un <see cref="Progress{T}"/> créé sur le fil de l'interface y est rappelé.</param>
/// <param name="started">Publication ouverte sur le serveur (pour l'abandonner si besoin). Comme <paramref name="progress"/>,
/// passer un <see cref="Progress{T}"/> : l'envoi se poursuit hors du fil de l'interface (ConfigureAwait(false)).</param>
public static async Task<PublicationResult> PublishAsync(AdminSession session, string gameId, LocalRelease release,
IProgress<UploadProgress>? progress, IProgress<PublicationState>? started, CancellationToken ct)
{
var run = new Run(session, release, progress);
var state = await Retry(() => session.StartPublicationAsync(gameId, new PublicationStartRequest
{
Version = release.Summary.Version,
ManifestSha256 = release.ManifestSha256,
ManifestSize = release.ManifestBytes.LongLength
}, ct), ct).ConfigureAwait(false);
started?.Report(state);
var committing = false;
ContentSignature? signature = null;
var signedLocally = DateTime.MinValue;
for (var attempt = 1; ; attempt++)
{
var before = run.Done;
try
{
if (!committing)
{
if (attempt > 1) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
await run.ManifestAsync(state, ct).ConfigureAwait(false);
if (!state.ManifestAccepted) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
run.Plan(state);
await run.ObjectsAsync(state, ct).ConfigureAwait(false);
await run.ArchiveAsync(state, ct).ConfigureAwait(false);
run.Report(CommitStep);
committing = true;
}
// Mise en ligne : ni interrompue (elle se termine sur le serveur de toute façon), ni perdue
// (réponse perdue : le serveur renvoie le même résultat au nouvel essai).
// Signature du contenu (phase 7) juste avant la mise en ligne : le serveur la contrôle (clé de cette
// session, date proche de son heure), puis l'intègre au manifest publié.
// Nouvel essai après plusieurs minutes : signature refaite (le serveur refuse une date trop éloignée).
if (signature is null || DateTime.UtcNow - signedLocally > SignatureReuseLimit)
{
signature = await session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest).ConfigureAwait(false);
signedLocally = DateTime.UtcNow;
}
return await session.CommitPublicationAsync(state.PublicationId, signature, CancellationToken.None).ConfigureAwait(false);
}
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
{
if (run.Done > before) attempt = 1; // l'envoi avance : les essais repartent de zéro
var delay = Backoff(attempt, ex);
if (delay > TimeSpan.Zero) run.Waiting(PauseReason(ex), delay);
await Task.Delay(delay, committing ? CancellationToken.None : ct).ConfigureAwait(false);
}
}
}
// ------------------------------------------------------------------ Envoi
private sealed class Run
{
private readonly AdminSession _session;
private readonly LocalRelease _release;
private readonly IProgress<UploadProgress>? _progress;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private long _total, _done, _sentThisRun;
public long Done => _done;
private string _step = "Préparation";
public Run(AdminSession session, LocalRelease release, IProgress<UploadProgress>? progress)
{
_session = session;
_release = release;
_progress = progress;
}
public async Task ManifestAsync(PublicationState state, CancellationToken ct)
{
if (state.ManifestReceived >= state.ManifestSize) return;
if (_total == 0) _total = state.ManifestSize - state.ManifestReceived;
_step = "Envoi du manifest";
var bytes = _release.ManifestBytes;
for (var offset = state.ManifestReceived; offset < bytes.LongLength;)
{
var length = (int)Math.Min(PublishingLimits.ChunkBytes, bytes.LongLength - offset);
await _session.UploadManifestAsync(state.PublicationId, offset, bytes.AsSpan((int)offset, length).ToArray(), ct).ConfigureAwait(false);
offset += length;
Sent(length);
}
}
/// <summary>Total à envoyer, connu une fois le manifest accepté (calculé une seule fois).</summary>
public void Plan(PublicationState state)
{
if (_total > state.ManifestSize || !state.ManifestAccepted) return;
var objects = state.PendingObjects.Sum(sha => Size(sha) - state.PartialObjects.GetValueOrDefault(sha));
var archive = state.ArchiveComplete ? 0 : state.ArchiveSize - state.ArchiveReceived;
_total = _done + objects + archive;
Report(_step);
}
public async Task ObjectsAsync(PublicationState state, CancellationToken ct)
{
var pending = state.PendingObjects.Select(sha => sha.ToLowerInvariant()).ToList();
if (pending.Count == 0) return;
var count = 0;
void Step() => _step = $"Envoi des fichiers du jeu ({count}/{pending.Count})";
// Petits fichiers : par lots (une requête pour des centaines de fichiers).
var batch = new List<(string Sha, byte[] Data)>();
long batchBytes = 0;
async Task FlushAsync()
{
if (batch.Count == 0) return;
await _session.UploadObjectBatchAsync(state.PublicationId, ObjectBatch.Write(batch), ct).ConfigureAwait(false);
count += batch.Count;
Step();
Sent(batchBytes);
batch.Clear();
batchBytes = 0;
}
foreach (var sha in pending.Where(s => Size(s) <= PublishingLimits.BatchObjectMaxBytes))
{
ct.ThrowIfCancellationRequested();
var data = await File.ReadAllBytesAsync(_release.ObjectFile(sha), ct).ConfigureAwait(false);
if (Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant() != sha) throw Changed(ManifestCheck.ObjectPath(sha));
batch.Add((sha, data));
batchBytes += data.Length;
if (batchBytes >= PublishingLimits.ChunkBytes || batch.Count >= PublishingLimits.MaxObjectsPerBatch) await FlushAsync().ConfigureAwait(false);
}
await FlushAsync().ConfigureAwait(false);
// Gros fichiers : par morceaux, reprise à la position déjà reçue.
foreach (var sha in pending.Where(s => Size(s) > PublishingLimits.BatchObjectMaxBytes))
{
Step();
await FileAsync(_release.ObjectFile(sha), sha, state.PartialObjects.GetValueOrDefault(sha), ManifestCheck.ObjectPath(sha),
(offset, data) => _session.UploadObjectAsync(state.PublicationId, sha, offset, data, ct), ct).ConfigureAwait(false);
count++;
}
}
public async Task ArchiveAsync(PublicationState state, CancellationToken ct)
{
if (state.ArchiveComplete) return;
_step = $"Envoi de l'archive {_release.Summary.ArchiveName}";
await FileAsync(_release.ArchivePath, _release.Summary.ArchiveSha256, state.ArchiveReceived, _release.Summary.ArchiveName,
(offset, data) => _session.UploadArchiveAsync(state.PublicationId, offset, data, ct), ct).ConfigureAwait(false);
}
/// <summary>
/// Envoi d'un fichier à partir de <paramref name="from"/> ; le fichier est relu en entier pour vérifier
/// son empreinte (un fichier modifié depuis la génération est refusé avant la mise en ligne).
/// </summary>
private async Task FileAsync(string path, string sha256, long from, string name, Func<long, byte[], Task<ChunkResult>> upload,
CancellationToken ct)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16, useAsync: true);
var buffer = new byte[PublishingLimits.ChunkBytes];
long offset = 0;
while (true)
{
var read = await ReadFullAsync(stream, buffer, ct).ConfigureAwait(false);
if (read == 0) break;
hash.AppendData(buffer, 0, read);
if (offset + read > from)
{
var skip = (int)Math.Max(0, from - offset);
var chunk = buffer.AsSpan(skip, read - skip).ToArray();
// Dernier morceau : l'empreinte locale est vérifiée AVANT de l'envoyer.
if (offset + read == stream.Length &&
Convert.ToHexString(hash.GetCurrentHash()).ToLowerInvariant() != sha256.ToLowerInvariant())
throw Changed(name);
await upload(offset + skip, chunk).ConfigureAwait(false);
Sent(chunk.Length);
}
offset += read;
}
if (Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant() != sha256.ToLowerInvariant()) throw Changed(name);
}
/// <summary>Pause avant un nouvel essai : affichée (sinon l'envoi semble figé), sans changer d'étape.</summary>
public void Waiting(string reason, TimeSpan delay) =>
_progress?.Report(new UploadProgress($"{_step} — {reason}, reprise dans {Math.Max(1, (int)Math.Ceiling(delay.TotalSeconds))} s…",
Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done), 0));
public void Report(string step)
{
_step = step;
var seconds = _clock.Elapsed.TotalSeconds;
_progress?.Report(new UploadProgress(step, Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done),
seconds > 0.5 ? _sentThisRun / seconds : 0));
}
private void Sent(long bytes)
{
_done += bytes;
_sentThisRun += bytes;
Report(_step);
}
private long Size(string sha) => _release.Summary.Objects.TryGetValue(sha.ToLowerInvariant(), out var size)
? size
: throw new ReleaseCheckException("Le serveur attend un fichier absent de ce manifest : relancez la publication.");
private static ReleaseCheckException Changed(string name) => new(
$"« {name} » a changé depuis la génération de la version (son empreinte ne correspond plus au manifest) : relancez le .bat de publication.");
private static async Task<int> ReadFullAsync(Stream stream, byte[] buffer, CancellationToken ct)
{
var total = 0;
while (total < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(total), ct).ConfigureAwait(false);
if (read == 0) break;
total += read;
}
return total;
}
}
// ------------------------------------------------------------------ Coupures
/// <summary>Coupure réseau, délai dépassé, serveur occupé, trop de requêtes, position décalée : on reprend.</summary>
private static bool IsTransient(ApiException ex) =>
ex.Status is 0 or 429 or 502 or 504 ||
(ex.Status == 503 && ex.Code == ApiErrorCodes.Busy) ||
(ex.Status == 409 && ex.Code == ApiErrorCodes.OffsetMismatch);
/// <summary>
/// Attente avant un nouvel essai. Trop de requêtes : le délai annoncé par le serveur (Retry-After),
/// sinon (limite de nginx, qui se libère en une fraction de seconde) une pause courte qui double à
/// chaque refus consécutif.
/// </summary>
internal static TimeSpan Backoff(int attempt, ApiException ex)
{
if (ex.Code == ApiErrorCodes.OffsetMismatch) return TimeSpan.Zero;
if (ex.Status == 429)
return ex.RetryAfter is { } announced
? TimeSpan.FromSeconds(Math.Clamp(Math.Ceiling(announced.TotalSeconds), 1, 60))
: TimeSpan.FromSeconds(Math.Min(30, 1 << Math.Min(attempt - 1, 5)));
return TimeSpan.FromSeconds(Math.Min(30, 2 * attempt * attempt));
}
private static string PauseReason(ApiException ex) => ex.Status switch
{
429 => "limite de requêtes du serveur atteinte",
0 => "connexion interrompue",
503 => "serveur occupé",
_ => "serveur indisponible"
};
private static async Task<T> Retry<T>(Func<Task<T>> action, CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await action().ConfigureAwait(false);
}
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
{
await Task.Delay(Backoff(attempt, ex), ct).ConfigureAwait(false);
}
}
}
}
@@ -12,14 +12,21 @@ public sealed class AdminLoginService
private readonly LauncherApiClient _client;
private readonly string _clientName;
private readonly IAdminTpm? _tpm;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
/// <param name="rootKeys">
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
/// le nom du fichier (la clé racine reste hors ligne, réservée à tools\sign-keyring.bat et aux mises à jour).
/// </param>
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null,
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null)
{
_client = client;
_clientName = clientName;
_tpm = tpm;
_rootKeys = rootKeys ?? Array.Empty<(string, byte[])>();
}
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
@@ -59,6 +66,14 @@ public sealed class AdminLoginService
key = opened;
}
if (_rootKeys.Any(r => string.Equals(r.KeyId, signer.KeyId, StringComparison.Ordinal) || r.PublicKey.AsSpan().SequenceEqual(signer.PublicKey)))
{
key.Dispose();
throw new InvalidOperationException(
"Cette clé est la clé racine du launcher (signature des mises à jour et du trousseau) : elle ne doit jamais servir " +
"à se connecter. Utilisez votre clé d'administrateur.");
}
try
{
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
@@ -63,6 +63,29 @@ public sealed class AdminSessionGuard : IDisposable
}
}
private int _longOperations;
/// <summary>Opération longue (envoi d'une version) : compte comme une activité jusqu'à sa fin.</summary>
public IDisposable BeginLongOperation()
{
Interlocked.Increment(ref _longOperations);
return new LongOperation(this);
}
private sealed class LongOperation : IDisposable
{
private AdminSessionGuard? _guard;
public LongOperation(AdminSessionGuard guard) => _guard = guard;
public void Dispose()
{
if (Interlocked.Exchange(ref _guard, null) is not { } guard) return;
Interlocked.Decrement(ref guard._longOperations);
guard._lastActivity = guard._time.GetUtcNow(); // 10 minutes à compter de la fin de l'opération
}
}
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
public void NotifyActivity()
{
@@ -76,6 +99,7 @@ public sealed class AdminSessionGuard : IDisposable
{
if (_locked || _disposed) return;
var now = _time.GetUtcNow();
if (Volatile.Read(ref _longOperations) > 0) _lastActivity = now;
if (now >= _connection.LocalExpiresAt)
{
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
@@ -12,6 +12,7 @@ public static class AdminTexts
AdminPermissions.GamesEdit => "Informations des jeux",
AdminPermissions.ChangelogEdit => "Notes de version",
AdminPermissions.ReleasesPublish => "Publication des versions",
AdminPermissions.FilesManage => "Fichiers du serveur",
_ => permission
};
@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
@@ -31,6 +32,50 @@ public sealed record TpmLockoutInfo(int Failures, int MaxTries, TimeSpan Recover
public TimeSpan MaxWait => !IsLockedOut || !HealsWithTime
? TimeSpan.Zero
: RecoveryInterval * Math.Max(1, Failures - MaxTries + 1);
// Propriétés TPM 2.0 lues (TPM_PT_*, partie 2 de la spécification, § 6.13).
private const uint PtPermanent = 0x200, PtLockoutCounter = 0x20E, PtMaxAuthFail = 0x20F, PtLockoutInterval = 0x210;
/// <summary>
/// TPMA_PERMANENT.inLockout (bit 9) : la puce refuse tout code. Ne pas confondre avec le bit 2
/// (lockoutAuthSet), qui indique seulement qu'un mot de passe « lockout » existe — toujours vrai
/// sur un PC dont Windows gère la puce.
/// </summary>
internal const uint PermanentInLockout = 1u << 9;
/// <summary>
/// Lit la réponse à TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES, TPM_PT_PERMANENT…) : en-tête (balise,
/// taille, code retour), moreData, capability, puis la liste (propriété, valeur), en gros-boutiste.
/// Null si la réponse est en erreur, incomplète ou ne contient pas le compteur, le maximum et l'intervalle.
/// </summary>
public static TpmLockoutInfo? FromCapabilityResponse(ReadOnlySpan<byte> response)
{
const int HeaderSize = 10, ListOffset = HeaderSize + 1 + 4 + 4, EntrySize = 8;
if (response.Length < ListOffset) return null;
if (BinaryPrimitives.ReadUInt16BigEndian(response) != 0x8001) return null; // TPM_ST_NO_SESSIONS
if (BinaryPrimitives.ReadUInt32BigEndian(response[6..]) != 0) return null; // code retour TPM
if (BinaryPrimitives.ReadUInt32BigEndian(response[(HeaderSize + 1)..]) != 6) return null; // TPM_CAP_TPM_PROPERTIES
var count = BinaryPrimitives.ReadUInt32BigEndian(response[(HeaderSize + 5)..]);
uint? permanent = null, counter = null, maxTries = null, interval = null;
for (var i = 0L; i < count && ListOffset + (i + 1) * EntrySize <= response.Length; i++)
{
var entry = response.Slice(ListOffset + (int)i * EntrySize, EntrySize);
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
{
case PtPermanent: permanent = value; break;
case PtLockoutCounter: counter = value; break;
case PtMaxAuthFail: maxTries = value; break;
case PtLockoutInterval: interval = value; break; // secondes
}
}
if (counter is null || maxTries is null || interval is null) return null;
var inLockout = permanent is { } attributes && (attributes & PermanentInLockout) != 0;
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
TimeSpan.FromSeconds(interval.Value), inLockout);
}
}
/// <summary>Code PIN refusé par la puce, ou puce bloquée par sa protection anti-essais.</summary>
@@ -0,0 +1,22 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Signing;
/// <summary>Module « Signature » : page du trousseau et des signatures du contenu (propriétaires).</summary>
public sealed class SigningAdminModule : ILauncherModule
{
public string Id => "signing";
public string DisplayName => "Signature";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
SigningPanelViewModel.PanelId,
"Signature",
"Clés autorisées à signer ce que reçoivent les joueurs (trousseau, signé hors ligne par la clé racine) et état des " +
"signatures des versions en ligne et du catalogue.",
"ShieldKey",
AdminPermissions.AdminsManage,
Order: 55,
context => new SigningPanelViewModel(context)));
}
@@ -0,0 +1,352 @@
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Signing;
/// <summary>
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne.
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
/// </summary>
public sealed class SigningPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "signing";
private SigningState? _state;
private List<KeyringKey> _proposed = new();
private IReadOnlyList<KeyringKeyItem> _keys = Array.Empty<KeyringKeyItem>();
private IReadOnlyList<SignedContentItem> _contents = Array.Empty<SignedContentItem>();
private string? _statusMessage;
public SigningPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
ExportRequestCommand = new AsyncRelayCommand(ScrollingToTop(ExportRequestAsync), () => !IsBusy && _state is not null && NeedsNewKeyring);
PublishSignedCommand = new AsyncRelayCommand(ScrollingToTop(PublishSignedAsync), () => !IsBusy && _state is { KeyringPublishingEnabled: true });
}
// ------------------------------------------------------------------ Trousseau publié
public bool HasKeyring => _state?.Keyring is not null;
public bool PublishingEnabled => _state?.KeyringPublishingEnabled == true;
public bool ShowPublishingDisabled => _state is { KeyringPublishingEnabled: false };
public string? KeyringUrl => _state?.KeyringPublicUrl;
public string KeyringText => _state?.Keyring is { } keyring
? $"Trousseau n° {keyring.Sequence} du {AccountTexts.Date(keyring.IssuedAt)} · {keyring.Keys.Count} clé(s)"
: "Aucun trousseau publié : les launchers en mode « require » refusent tout contenu.";
/// <summary>Signature racine vérifiée avec les clés intégrées à CE launcher.</summary>
public string RootText
{
get
{
if (_state?.Keyring is not { } keyring) return "";
if (Context.RootKeys.Count == 0) return "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : vérification impossible.";
return Keyring.VerifyRoot(keyring, Context.RootKeys, out var error)
? $"Signé par la clé racine {keyring.Signature!.RootKeyId} (vérifié par ce launcher)."
: $"Refusé par ce launcher : {error}.";
}
}
public bool RootValid => _state?.Keyring is { } keyring && Keyring.VerifyRoot(keyring, Context.RootKeys, out _);
public string RequireText => _state?.RequireSignatures == true
? "Le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require)."
: "Le serveur accepte encore les publications non signées (LauncherApi:ContentSigning:Require désactivé).";
// ------------------------------------------------------------------ Trousseau proposé
/// <summary>Clés proposées (appareils actifs autorisés à publier) et différences avec le trousseau publié.</summary>
public IReadOnlyList<KeyringKeyItem> Keys
{
get => _keys;
private set => SetProperty(ref _keys, value);
}
public bool HasChanges => _keys.Any(k => k.Change != KeyChange.None);
/// <summary>Nouveau trousseau à signer : clés modifiées, ou trousseau publié refusé par ce launcher (à remplacer).</summary>
public bool NeedsNewKeyring => _proposed.Count > 0 && (HasChanges || (HasKeyring && !RootValid));
public string ChangesText
{
get
{
if (_state is null) return "";
var added = _keys.Count(k => k.Change == KeyChange.Added);
var removed = _keys.Count(k => k.Change == KeyChange.Removed);
var changed = _keys.Count(k => k.Change == KeyChange.ScopesChanged);
if (added + removed + changed == 0)
return HasKeyring && !RootValid
? "Les clés sont à jour, mais le trousseau publié est refusé par ce launcher : exportez une demande pour le remplacer."
: "Le trousseau publié est à jour.";
var parts = new List<string>();
if (added > 0) parts.Add($"{added} clé(s) à ajouter");
if (removed > 0) parts.Add($"{removed} à retirer");
if (changed > 0) parts.Add($"{changed} autorisation(s) à modifier");
return $"À mettre à jour : {string.Join(", ", parts)}.";
}
}
// ------------------------------------------------------------------ Contenu en ligne
public IReadOnlyList<SignedContentItem> Contents
{
get => _contents;
private set => SetProperty(ref _contents, value);
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand ExportRequestCommand { get; }
public AsyncRelayCommand PublishSignedCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
ExportRequestCommand.RaiseCanExecuteChanged();
PublishSignedCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
private async Task ReloadAsync()
{
var state = await Context.Session.GetSigningStateAsync();
var admins = await Context.Session.ListAdminsAsync();
_state = state;
_proposed = Propose(admins);
Keys = Compare(state.Keyring, _proposed, admins);
Contents = BuildContents(state);
foreach (var name in new[] { nameof(HasKeyring), nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(KeyringUrl),
nameof(KeyringText), nameof(RootText), nameof(RootValid), nameof(RequireText), nameof(HasChanges), nameof(NeedsNewKeyring), nameof(ChangesText) })
OnPropertyChanged(name);
ExportRequestCommand.RaiseCanExecuteChanged();
PublishSignedCommand.RaiseCanExecuteChanged();
}
/// <summary>Appareils actifs des comptes actifs dont le rôle permet de publier une version ou le catalogue.</summary>
internal static List<KeyringKey> Propose(IEnumerable<AdminSummary> admins) =>
admins.Where(a => a.Status == AdminStatus.Active)
.SelectMany(a => a.Keys
.Where(k => k.Status == AdminStatus.Active && k.PublicKey is not null)
.Select(k => new KeyringKey
{
KeyId = k.KeyId,
PublicKey = k.PublicKey!,
Name = Truncate($"{a.DisplayName} — {AccountTexts.KeyLabel(k.Label)}"),
Scopes = SigningScopes.FromPermissions(AdminRoles.PermissionsOf(a.Role)).ToList()
}))
.Where(k => k.Scopes.Count > 0)
.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>Nom public du trousseau : caractères invisibles ou de mise en forme retirés (refusés par les launchers), longueur bornée.</summary>
internal static string Truncate(string name)
{
var clean = new string(name.Where(c => Keyring.IsDisplaySafe(c.ToString())).ToArray()).Trim();
if (clean.Length > Keyring.MaxNameLength) clean = clean[..Keyring.MaxNameLength].TrimEnd();
return clean.Length == 0 ? "Appareil" : clean;
}
private static List<KeyringKeyItem> Compare(KeyringDocument? published, List<KeyringKey> proposed, List<AdminSummary> admins)
{
var current = published?.Keys.ToDictionary(k => k.KeyId, StringComparer.Ordinal) ?? new Dictionary<string, KeyringKey>();
var items = proposed.Select(k =>
{
if (!current.TryGetValue(k.KeyId, out var old)) return new KeyringKeyItem(k, KeyChange.Added);
return SameScopes(old, k) ? new KeyringKeyItem(k, KeyChange.None) : new KeyringKeyItem(k, KeyChange.ScopesChanged);
}).ToList();
foreach (var old in current.Values.Where(k => proposed.All(p => p.KeyId != k.KeyId)))
items.Add(new KeyringKeyItem(old, KeyChange.Removed));
return items;
}
private static bool SameScopes(KeyringKey a, KeyringKey b) =>
a.Scopes.OrderBy(s => s, StringComparer.Ordinal).SequenceEqual(b.Scopes.OrderBy(s => s, StringComparer.Ordinal));
private List<SignedContentItem> BuildContents(SigningState state)
{
var proposed = new KeyringDocument { Keys = _proposed };
var items = state.Manifests.Select(m => new SignedContentItem(
$"Version en ligne de {m.GameId}" + (string.IsNullOrEmpty(m.Version) ? "" : $" ({m.Version})"),
ContentKinds.Manifest, m.GameId, m.Digest, m.Signature, m.SignerName, state.Keyring, proposed,
"Re-signez-la depuis la page « Publier une version » (dossier de cette version, « Signer la version en ligne »).")).ToList();
if (state.Catalog is { } catalog)
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
return items;
}
// ------------------------------------------------------------------ Actions
private async Task ExportRequestAsync()
{
if (_state is not { } state) return;
var unsignedContents = Contents.Where(c => c.WillBreak).Select(c => c.Title).ToList();
if (unsignedContents.Count > 0 &&
!Context.Dialogs.Confirm("Contenu à signer d'abord",
"Avec ce trousseau, les launchers en mode « require » refuseraient :\n• " + string.Join("\n• ", unsignedContents) +
"\n\nSignez-les d'abord avec une clé qui restera dans le trousseau, ou exportez quand même (en mode « warn », " +
"ils seront seulement signalés).", "Exporter quand même"))
return;
var request = new KeyringDocument
{
Sequence = (state.Keyring?.Sequence ?? 0) + 1,
IssuedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + Context.Session.ClockOffset),
Keys = _proposed
};
var path = Context.Dialogs.PickSaveFile("Enregistrer la demande de trousseau", "Trousseau (*.json)|*.json",
$"keyring-request-{request.Sequence}.json", null);
if (path is null) return;
if (await RunAsync(() => File.WriteAllTextAsync(path, Keyring.Serialize(request))))
StatusMessage = $"Demande n° {request.Sequence} enregistrée ({request.Keys.Count} clé(s)). Signez-la avec tools\\sign-keyring.bat " +
"(clé racine, hors ligne), puis « Publier le trousseau signé… ».";
}
private async Task PublishSignedAsync()
{
if (_state is not { } state) return;
StatusMessage = null;
var path = Context.Dialogs.PickOpenFile("Trousseau signé (produit par tools\\sign-keyring.bat)", "Trousseau (*.json)|*.json", null);
if (path is null) return;
KeyringDocument? keyring;
try
{
var info = new FileInfo(path);
if (info.Length > Keyring.MaxBytes) throw new InvalidDataException("fichier trop volumineux");
keyring = Keyring.Parse(await File.ReadAllBytesAsync(path)) ?? throw new InvalidDataException("fichier vide");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
ErrorMessage = $"Trousseau illisible : {ex.Message}";
return;
}
// Mêmes contrôles que les launchers : un trousseau qu'ils refuseraient n'est pas publié.
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors))
{
ErrorMessage = "Trousseau invalide : " + string.Join(" ", errors.Take(4));
return;
}
if (Context.RootKeys.Count == 0)
{
ErrorMessage = "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : impossible de vérifier le trousseau.";
return;
}
if (!Keyring.VerifyRoot(keyring, Context.RootKeys, out var rootError))
{
ErrorMessage = $"Trousseau refusé : {rootError}. Signez la demande avec tools\\sign-keyring.bat et la clé racine du launcher.";
return;
}
if (state.Keyring is { } current && keyring.Sequence <= current.Sequence)
{
ErrorMessage = $"Trousseau n° {keyring.Sequence} : le n° {current.Sequence} est déjà publié. Exportez une nouvelle demande.";
return;
}
var names = keyring.Keys.Select(k => $"{k.Name} ({string.Join(", ", k.Scopes.Select(SigningScopes.DisplayName))})");
if (!Context.Dialogs.Confirm($"Publier le trousseau n° {keyring.Sequence} ?",
"Les launchers ne feront plus confiance qu'à ces clés :\n• " + string.Join("\n• ", names), "Publier"))
return;
if (await RunAsync(async () =>
{
await Context.Session.PublishKeyringAsync(keyring);
await ReloadAsync();
}))
StatusMessage = $"Trousseau n° {keyring.Sequence} publié. Les launchers l'utilisent dès leur prochain démarrage.";
}
}
public enum KeyChange
{
None,
Added,
Removed,
ScopesChanged
}
/// <summary>Clé du trousseau proposé (ou retirée du trousseau publié).</summary>
public sealed class KeyringKeyItem
{
internal KeyringKeyItem(KeyringKey key, KeyChange change)
{
Key = key;
Change = change;
ScopesText = string.Join(", ", key.Scopes.Select(SigningScopes.DisplayName));
ChangeText = change switch
{
KeyChange.Added => "À ajouter",
KeyChange.Removed => "À retirer",
KeyChange.ScopesChanged => "Autorisations modifiées",
_ => "Dans le trousseau"
};
}
public KeyringKey Key { get; }
public KeyChange Change { get; }
public string Name => Key.Name;
public string KeyId => Key.KeyId;
public string ScopesText { get; }
public string ChangeText { get; }
public bool IsAdded => Change == KeyChange.Added;
public bool IsRemoved => Change == KeyChange.Removed;
public bool IsChanged => Change == KeyChange.ScopesChanged;
}
/// <summary>Contenu en ligne et sa signature.</summary>
public sealed class SignedContentItem
{
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
KeyringDocument? published, KeyringDocument proposed, string howToSign)
{
Title = title;
if (string.IsNullOrEmpty(digest))
{
Text = "Rien n'est publié.";
IsValid = true;
return;
}
var signer = signature is null ? null : $"{signerName ?? $"clé {signature.KeyId}"}, le {AccountTexts.Date(signature.SignedAt)}";
var now = published is null ? ContentCheck.Fail("aucun trousseau publié") : ContentSigning.Verify(kind, id, digest, signature, published);
var next = ContentSigning.Verify(kind, id, digest, signature, proposed);
IsValid = now.Valid;
WillBreak = !next.Valid;
Text = signature is null
? $"Non signé. {howToSign}"
: now.Valid
? $"Signé par {signer} : valide pour les launchers."
: $"Signé par {signer}, mais refusé par les launchers : {now.Reason}. {howToSign}";
if (signature is not null && now.Valid && !next.Valid)
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
}
public string Title { get; }
public string Text { get; }
/// <summary>Accepté par les launchers avec le trousseau publié.</summary>
public bool IsValid { get; }
/// <summary>Serait refusé avec le trousseau proposé.</summary>
public bool WillBreak { get; }
}
@@ -12,8 +12,18 @@ public sealed class AdminEnvironment
public required ModuleCatalog Modules { get; init; }
/// <summary>Jeux déclarés dans launcher.json (valeurs par défaut, avant le catalogue).</summary>
public required IReadOnlyList<LauncherConfig> Games { get; init; }
/// <summary>Nom du launcher selon launcher.json.</summary>
public string LauncherName { get; init; } = "Launcher";
/// <summary>Jeu par défaut selon launcher.json.</summary>
public string? DefaultGameId { get; init; }
/// <summary>Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</summary>
public IReadOnlyList<string> Themes { get; init; } = new[] { "default" };
public required IModuleDialogs Dialogs { get; init; }
public required IUiDispatcher Ui { get; init; }
@@ -25,6 +35,12 @@ public sealed class AdminEnvironment
public TimeProvider Time { get; init; } = TimeProvider.System;
/// <summary>
/// Clés racine intégrées à l'exe (update-signing.pub) : vérification d'un trousseau signé avant sa publication
/// (page « Signature »). Vide : aucune clé intégrée.
/// </summary>
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; init; } = Array.Empty<(string, byte[])>();
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
public IAdminTpm? Tpm { get; init; }
@@ -120,10 +120,16 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public AdminSession Session => _connection.Session;
public LauncherApiClient Client => _connection.Client;
public IReadOnlyList<LauncherConfig> Games => _shell.Environment.Games;
public string LauncherName => _shell.Environment.LauncherName;
public string? DefaultGameId => _shell.Environment.DefaultGameId;
public IReadOnlyList<string> Themes => _shell.Environment.Themes;
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
public void Navigate(string panelId)
{
if (PanelItems.FirstOrDefault(p => string.Equals(p.Descriptor.Id, panelId, StringComparison.OrdinalIgnoreCase)) is { } item)
@@ -1,4 +1,5 @@
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
@@ -27,9 +28,19 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(RefreshAsync), () => !IsBusy);
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
}
/// <summary>Pages « Administrateurs » et « Mes appareils » présentes (modules déclarés, droits).</summary>
public bool CanOpenAdmins => Context.Panels.Any(p => p.Id == AdminsPanelViewModel.PanelId);
public bool CanOpenDevices => Context.Panels.Any(p => p.Id == MyDevicesPanelViewModel.PanelId);
public RelayCommand OpenAdminsCommand { get; }
public RelayCommand OpenDevicesCommand { get; }
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
@@ -0,0 +1,45 @@
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>
/// Choix du jeu d'une page qui ne concerne qu'un jeu à la fois (« Notes de version », « Publier une version ») :
/// une tuile par jeu déclaré dans launcher.json. Avec plusieurs jeux, la page n'affiche rien d'autre tant qu'aucune
/// tuile n'a été choisie (pas de jeu par défaut : pas de modification ni de publication sur le mauvais jeu par mégarde).
/// </summary>
public sealed class GameTile
{
private GameTile(Choice choice, string name, string detail, Action<Choice> select)
{
Choice = choice;
Name = name;
Detail = detail;
SelectCommand = new RelayCommand(() => select(choice));
}
public Choice Choice { get; }
public string Name { get; }
/// <summary>« Identifiant EZR · serveur cdn.exemple.fr ».</summary>
public string Detail { get; }
public RelayCommand SelectCommand { get; }
/// <summary>Choix « Nom (identifiant) » des jeux déclarés.</summary>
public static IReadOnlyList<Choice> Choices(IAdminContext context) =>
context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
/// <summary>Jeu choisi d'office : le seul déclaré, sinon aucun (l'administrateur choisit).</summary>
public static Choice? Initial(IReadOnlyList<Choice> choices) => choices.Count == 1 ? choices[0] : null;
public static IReadOnlyList<GameTile> For(IAdminContext context, IReadOnlyList<Choice> choices, Action<Choice> select) =>
choices.Select(choice =>
{
var game = context.Games.FirstOrDefault(g => string.Equals(g.GameId, choice.Value, StringComparison.OrdinalIgnoreCase));
var host = game is not null && Uri.TryCreate(game.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : null;
return new GameTile(choice, game?.GameName ?? choice.Label,
$"Identifiant {choice.Value}" + (string.IsNullOrEmpty(host) ? "" : $" · serveur {host}"), select);
}).ToList();
}
@@ -175,7 +175,7 @@ public sealed class LoginViewModel : ObservableObject, IDisposable
try
{
var environment = _shell.Environment;
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm).LoginAsync(key.Path, passphrase);
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm, environment.RootKeys).LoginAsync(key.Path, passphrase);
}
catch (Exception ex)
{
@@ -38,6 +38,33 @@ public abstract class AdminPanelViewModel : ObservableObject
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
/// <summary>
/// Change à chaque demande de retour en haut de la page (la fenêtre fait défiler la page jusqu'en haut) :
/// après « Recharger », « Publier »… le bandeau du résultat, en haut, est visible sans remonter à la main.
/// </summary>
public int ScrollToTopToken
{
get => _scrollToTopToken;
private set => SetProperty(ref _scrollToTopToken, value);
}
private int _scrollToTopToken;
protected void RequestScrollToTop() => ScrollToTopToken = unchecked(_scrollToTopToken + 1);
/// <summary>Action d'un bouton « Recharger », « Publier »… : retour en haut de la page au clic et une fois terminée.</summary>
protected Func<Task> ScrollingToTop(Func<Task> action) => async () =>
{
RequestScrollToTop();
try
{
await action();
}
finally
{
RequestScrollToTop();
}
};
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
public Task ActivateAsync()
{
@@ -67,7 +94,10 @@ public abstract class AdminPanelViewModel : ObservableObject
if (ApiErrorText.IsSessionLost(ex))
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
else
{
ErrorMessage = ApiErrorText.Describe(ex);
RequestScrollToTop(); // bandeau d'erreur en haut de la page
}
return false;
}
finally
@@ -11,13 +11,25 @@ public interface IAdminContext
LauncherApiClient Client { get; }
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier).</summary>
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier), valeurs par défaut.</summary>
IReadOnlyList<LauncherConfig> Games { get; }
/// <summary>Nom du launcher selon launcher.json.</summary>
string LauncherName { get; }
/// <summary>Jeu par défaut selon launcher.json.</summary>
string? DefaultGameId { get; }
/// <summary>Thèmes intégrés au launcher.</summary>
IReadOnlyList<string> Themes { get; }
IModuleDialogs Dialogs { get; }
string LauncherVersion { get; }
/// <summary>Clés racine intégrées à l'exe (vérification du trousseau avant sa publication).</summary>
IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; }
/// <summary>Pages visibles pour cet administrateur.</summary>
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
@@ -25,4 +37,11 @@ public interface IAdminContext
/// <summary>La session n'est plus valable : retour à l'écran de connexion avec ce message.</summary>
void ReportSessionLost(string reason);
/// <summary>
/// Opération longue en cours (envoi d'une version…) : tant que l'objet renvoyé n'est pas libéré,
/// elle compte comme une activité (pas de verrouillage pour inactivité, session prolongée). Le
/// verrouillage manuel, celui de Windows et la durée maximale de la session restent appliqués.
/// </summary>
IDisposable BeginLongOperation();
}
+30 -2
View File
@@ -7,6 +7,7 @@ public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
private readonly OwnerThread _owner = OwnerThread.Current();
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
@@ -23,7 +24,7 @@ public sealed class RelayCommand : ICommand
if (CanExecute(parameter)) _execute();
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
public void RaiseCanExecuteChanged() => _owner.Run(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}
/// <summary>Commande asynchrone : désactivée pendant son exécution (pas de double clic).</summary>
@@ -31,6 +32,7 @@ public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private readonly OwnerThread _owner = OwnerThread.Current();
private bool _isRunning;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
@@ -67,5 +69,31 @@ public sealed class AsyncRelayCommand : ICommand
}
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
public void RaiseCanExecuteChanged() => _owner.Run(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}
/// <summary>
/// Fil qui a créé la commande (celui de l'interface sous WPF). Un bouton n'accepte d'être notifié que
/// depuis ce fil : appelée d'ailleurs (suite d'un ConfigureAwait(false)…), la notification y est renvoyée
/// au lieu de lever « un autre thread en est propriétaire ».
/// </summary>
internal readonly struct OwnerThread
{
private readonly int _threadId;
private readonly SynchronizationContext? _context;
private OwnerThread(int threadId, SynchronizationContext? context)
{
_threadId = threadId;
_context = context;
}
public static OwnerThread Current() => new(Environment.CurrentManagedThreadId, SynchronizationContext.Current);
public void Run(Action action)
{
// Même fil, ou aucun fil d'interface connu (tests, services) : exécution directe.
if (_context is null || Environment.CurrentManagedThreadId == _threadId) action();
else _context.Post(static state => ((Action)state!)(), action);
}
}
+235 -4
View File
@@ -5,12 +5,22 @@ using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.ViewModels;
@@ -46,6 +56,9 @@ public partial class App : Application, ILauncherRestartHost
private HttpDownloader? _http;
private LauncherUpdateService? _updates;
private AdminWindowService? _admin;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
private Task<CatalogDocument?>? _catalogFetch;
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -150,6 +163,16 @@ public partial class App : Application, ILauncherRestartHost
}
}
// Signatures du contenu (phase 7) : trousseau téléchargé dès maintenant, en parallèle du catalogue.
_trust = CreateContentTrust(catalog);
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
var catalogStarted = DateTime.UtcNow;
StartCatalog(catalog, args, download: args.CaptureDirectory is null && !args.Uninstall);
// 3. Mise à jour du launcher lui-même : au démarrage, avant la fenêtre principale
var normalStart = args.CaptureDirectory is null && !args.Uninstall && args.UpdatedFromPid is null;
if (normalStart)
@@ -165,6 +188,8 @@ public partial class App : Application, ILauncherRestartHost
}
}
await ApplyFreshCatalogAsync(catalog, args, catalogStarted);
// 4. Composition des services (un ensemble de services par jeu)
Views.MainWindow window;
try
@@ -277,7 +302,7 @@ public partial class App : Application, ILauncherRestartHost
var settingsPath = catalog.GetGameSettingsPath(config); // GameLauncher\Games\<GameId>\settings.json
var settings = LauncherSettings.Load(settingsPath);
var installer = new GameInstaller(_http!);
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl));
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl), _trust, config.GameId);
// Si le launcher a été déplacé depuis l'installation du jeu, le bouton « Désinstaller »
// de Windows doit pointer vers son nouvel emplacement. (Pas en mode --capture.)
@@ -330,7 +355,8 @@ public partial class App : Application, ILauncherRestartHost
if (!readOnly)
{
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
_admin = new AdminWindowService(window, CreateModules(), catalog, apiUrl, apiUrlError, version);
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
ThemeManager.BuiltInThemes.ToList());
window.AdminRequested += _admin.Open;
}
@@ -340,10 +366,213 @@ public partial class App : Application, ILauncherRestartHost
return window;
}
// ------------------------------------------------------------------ Catalogue des jeux
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
private static readonly TimeSpan CatalogStartupBudget = TimeSpan.FromSeconds(2.5);
private static readonly TimeSpan CatalogImagesBudget = TimeSpan.FromSeconds(1.5);
/// <summary>Applique le catalogue en cache et lance le téléchargement de la version à jour.</summary>
private void StartCatalog(LauncherCatalog catalog, CommandLine args, bool download)
{
var url = ResolveCatalogUrl(catalog);
if (url is null || _http is null) return;
try
{
_catalogService = new CatalogService(_http, catalog.DataDirectory, new Uri(url), _trust);
if (_catalogService.LoadCached() is { } cached) ApplyCatalog(catalog, cached, args);
if (download) _catalogFetch = _catalogService.FetchAsync();
}
catch (Exception ex)
{
Log.Error("Catalogue des jeux", ex);
}
}
/// <summary>
/// Attend brièvement le catalogue à jour (et ses nouvelles images) puis l'applique. Trop lent :
/// le cache reste appliqué, et le téléchargement se termine en arrière-plan pour le démarrage suivant.
/// </summary>
private async Task ApplyFreshCatalogAsync(LauncherCatalog catalog, CommandLine args, DateTime started)
{
if (_catalogFetch is not { } fetch || _catalogService is not { } service) return;
try
{
var remaining = CatalogStartupBudget - (DateTime.UtcNow - started);
if (remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
if (!fetch.IsCompleted)
{
Log.Info("Catalogue des jeux : serveur lent, catalogue en cache utilisé (mise à jour au prochain démarrage).");
_ = FinishCatalogInBackgroundAsync(fetch, service);
return;
}
if (await fetch is not { } fresh) return;
var missing = fresh.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
.Where(u => u is not null && service.CachedImage(u) is null).Distinct().ToList();
if (missing.Count > 0)
await Task.WhenAny(Task.WhenAll(missing.Select(u => service.GetImageAsync(u))), Task.Delay(CatalogImagesBudget));
ApplyCatalog(catalog, fresh, args);
Log.Info($"Catalogue des jeux : révision {fresh.Revision} appliquée.");
_ = FinishCatalogInBackgroundAsync(fetch, service);
}
catch (Exception ex)
{
Log.Error("Catalogue des jeux", ex);
}
}
/// <summary>Fin du téléchargement en arrière-plan : images manquantes, nettoyage du cache d'images.</summary>
private static async Task FinishCatalogInBackgroundAsync(Task<CatalogDocument?> fetch, CatalogService service)
{
try
{
if (await fetch is not { } fresh) return;
foreach (var url in fresh.Games.Values.SelectMany(g => new[] { g.Icon, g.Background }).Where(u => u is not null).Distinct())
await service.GetImageAsync(url);
service.PruneImages(fresh);
}
catch (Exception ex)
{
Log.Warn($"Catalogue des jeux (arrière-plan) : {ex.Message}");
}
}
private void ApplyCatalog(LauncherCatalog catalog, CatalogDocument document, CommandLine args)
{
var requested = new[] { args.GameId, args.UninstallGameId };
CatalogMerger.Apply(catalog, document, ThemeManager.BuiltInThemes.ToList(),
url => _catalogService?.CachedImage(url),
game => requested.Any(id => string.Equals(id, game.GameId, StringComparison.OrdinalIgnoreCase)) || IsInstalled(catalog, game));
}
/// <summary>Jeu installé sur ce PC (registre) : jamais masqué par le catalogue.</summary>
private bool IsInstalled(LauncherCatalog catalog, LauncherConfig game)
{
try
{
return new RegistryService(game, _launcherPath, catalog.DataFolderName, ownsLauncherInstance: false).Read() is not null;
}
catch (Exception ex)
{
Log.Warn($"Lecture du registre ({game.GameId}) : {ex.Message}");
return true; // dans le doute, le jeu reste affiché
}
}
// ------------------------------------------------------------------ Signatures du contenu (phase 7)
/// <summary>
/// Vérification des signatures du manifest et du catalogue (« keyringUrl », « contentSigning »), avec la ou les
/// clés racine intégrées à l'exe (Security\update-signing.pub). Null : mode « off ».
/// </summary>
private ContentTrustService? CreateContentTrust(LauncherCatalog catalog)
{
var (keyringUrl, mode, minSequence) = ResolveContentSigning(catalog);
if (mode == ContentSigningMode.Off) return null;
var roots = LauncherUpdateService.LoadRootKeys();
if (roots.Count == 0)
Log.Error("Signatures du contenu : aucune clé racine intégrée (Security\\update-signing.pub) : aucun trousseau ne peut être vérifié.");
Log.Info($"Signatures du contenu : mode « {mode.ToString().ToLowerInvariant()} », trousseau {keyringUrl ?? "non configuré"}" +
(minSequence > 0 ? $" (n° {minSequence} au minimum)." : "."));
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUrl is null ? null : new Uri(keyringUrl), roots, mode,
minSequence);
}
/// <summary>
/// « keyringUrl », « contentSigning » et « keyringMinSequence ». Comme apiUrl : en Release, seulement ceux du
/// launcher.json intégré à l'exécutable (un fichier posé à côté ne peut ni désactiver la vérification, ni désigner
/// un autre trousseau, ni abaisser le numéro minimal).
/// </summary>
private static (string? KeyringUrl, ContentSigningMode Mode, long MinSequence) ResolveContentSigning(LauncherCatalog catalog)
{
#if DEBUG
if (catalog.KeyringUrlError is not null) Log.Warn(catalog.KeyringUrlError);
if (catalog.ContentSigningError is not null) Log.Warn(catalog.ContentSigningError);
return (catalog.KeyringUrl, catalog.ContentSigning, catalog.KeyringMinSequence);
#else
try
{
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
if ((catalog.KeyringUrl is not null && !string.Equals(catalog.KeyringUrl, integrated?.KeyringUrl, StringComparison.Ordinal)) ||
(catalog.KeyringUrl is not null && catalog.ContentSigning != integrated?.ContentSigning))
Log.Warn("« keyringUrl » / « contentSigning » du launcher.json externe ignorés : seuls ceux intégrés à l'exécutable sont utilisés.");
if (integrated?.KeyringUrlError is { } urlError) Log.Warn(urlError);
if (integrated?.ContentSigningError is { } modeError) Log.Warn(modeError);
return integrated is null ? (null, ContentSigningMode.Off, 0) : (integrated.KeyringUrl, integrated.ContentSigning, integrated.KeyringMinSequence);
}
catch (Exception ex)
{
Log.Error("Signatures du contenu (launcher.json intégré)", ex);
return (null, ContentSigningMode.Off, 0);
}
#endif
}
/// <summary>
/// Adresse du catalogue (« catalogUrl »). Comme apiUrl : en Release, seulement celle du
/// launcher.json intégré à l'exécutable ; en Debug, celle de la configuration utilisée.
/// </summary>
private static string? ResolveCatalogUrl(LauncherCatalog catalog)
{
#if DEBUG
if (catalog.CatalogUrlError is not null) Log.Warn(catalog.CatalogUrlError);
return catalog.CatalogUrl;
#else
try
{
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
if (catalog.CatalogUrl is not null && !string.Equals(catalog.CatalogUrl, integrated?.CatalogUrl, StringComparison.Ordinal))
Log.Warn("« catalogUrl » du launcher.json externe ignorée : seule celle intégrée à l'exécutable est utilisée.");
if (integrated?.CatalogUrlError is { } error) Log.Warn(error);
return integrated?.CatalogUrl;
}
catch (Exception ex)
{
Log.Error("Adresse du catalogue (launcher.json intégré)", ex);
return null;
}
#endif
}
/// <summary>
/// Jeux tels que déclarés dans launcher.json (valeurs par défaut, avant le catalogue), pour le menu
/// d'administration. En Release : le launcher.json INTÉGRÉ à l'exe (un fichier posé à côté ne décide
/// pas des jeux que l'administration modifie) ; en Debug : la configuration utilisée.
/// </summary>
private LauncherCatalog LoadDeclaredCatalog(LauncherCatalog active)
{
try
{
#if DEBUG
return LoadCatalog(_args?.ConfigPath);
#else
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName)
?? throw new FileNotFoundException($"{ConfigFileName} intégré introuvable.");
return LauncherCatalog.Load(embedded);
#endif
}
catch (Exception ex)
{
Log.Error("launcher.json (jeux déclarés, menu d'administration)", ex);
return active;
}
}
/// <summary>Modules du launcher (pages du menu d'administration…). Les futurs modules s'ajoutent ici.</summary>
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
{
new AdminModule()
new AdminModule(),
new CatalogAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
new AccountsAdminModule(),
new SigningAdminModule(),
new AuditAdminModule()
});
/// <summary>
@@ -481,7 +710,9 @@ public partial class App : Application, ILauncherRestartHost
/// <returns>Vrai si le jeu fait partie de ce launcher.</returns>
private bool OnLaunchRequestedByAnotherInstance(string gameId)
{
var known = _catalog?.Find(gameId) is not null;
// Jeux AFFICHÉS uniquement (un jeu masqué par le catalogue n'est pas proposé) : sinon la demande
// serait acceptée sans que le launcher puisse l'afficher.
var known = _catalog?.Games.Any(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)) == true;
Dispatcher.InvokeAsync(() => HandleLaunchRequest(gameId));
return known;
}
+143
View File
@@ -0,0 +1,143 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
namespace GameLauncher.Controls;
/// <summary>
/// Texte indicatif d'un champ vide (« https://… », « jj/mm/aaaa »…), affiché par les styles
/// ModernTextBoxStyle / ModernMultilineTextBoxStyle (Themes/Controls.xaml) tant que le champ est vide.
/// </summary>
public static class TextAssist
{
public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached(
"Placeholder", typeof(string), typeof(TextAssist), new FrameworkPropertyMetadata(null));
public static string? GetPlaceholder(DependencyObject element) => (string?)element.GetValue(PlaceholderProperty);
public static void SetPlaceholder(DependencyObject element, string? value) => element.SetValue(PlaceholderProperty, value);
}
/// <summary>
/// Retour en haut d'un <see cref="ScrollViewer"/> : chaque changement de <c>ScrollToTopToken</c> (demandé par la page
/// après « Recharger », « Publier »…) ou de <c>ResetOn</c> (page affichée) remonte la page, une fois la mise en page faite.
/// </summary>
public static class ScrollAssist
{
public static readonly DependencyProperty ScrollToTopTokenProperty = DependencyProperty.RegisterAttached(
"ScrollToTopToken", typeof(int), typeof(ScrollAssist), new PropertyMetadata(0, OnScrollRequested));
public static int GetScrollToTopToken(DependencyObject element) => (int)element.GetValue(ScrollToTopTokenProperty);
public static void SetScrollToTopToken(DependencyObject element, int value) => element.SetValue(ScrollToTopTokenProperty, value);
public static readonly DependencyProperty ResetOnProperty = DependencyProperty.RegisterAttached(
"ResetOn", typeof(object), typeof(ScrollAssist), new PropertyMetadata(null, OnScrollRequested));
public static object? GetResetOn(DependencyObject element) => element.GetValue(ResetOnProperty);
public static void SetResetOn(DependencyObject element, object? value) => element.SetValue(ResetOnProperty, value);
private static void OnScrollRequested(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not ScrollViewer viewer) return;
viewer.ScrollToTop();
// Contenu remplacé au même moment (rechargement) : encore une fois après la mise en page.
viewer.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(viewer.ScrollToTop));
}
}
/// <summary>
/// Molette de la souris dans le menu d'administration : elle fait défiler ce qui est SOUS le curseur, jamais une
/// liste déroulante fermée (pas de paramètre changé par mégarde en faisant défiler la page), ni un élément qui
/// garderait la molette sans pouvoir défiler (liste, champ texte) : la page défile alors à sa place.
/// Une liste déroulante OUVERTE garde son comportement normal (défilement de ses choix).
/// </summary>
public static class WheelAssist
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled", typeof(bool), typeof(WheelAssist), new PropertyMetadata(false, OnIsEnabledChanged));
public static bool GetIsEnabled(DependencyObject element) => (bool)element.GetValue(IsEnabledProperty);
public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value);
/// <summary>Défilement d'un cran de molette (comme WPF : 3 lignes de 16 pixels par défaut).</summary>
private static double PixelsPerNotch => Math.Max(1, SystemParameters.WheelScrollLines) * 16.0;
private static void OnIsEnabledChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not UIElement root) return;
root.PreviewMouseWheel -= OnPreviewMouseWheel;
if (e.NewValue is true) root.PreviewMouseWheel += OnPreviewMouseWheel;
}
private static void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (e.Handled || sender is not UIElement root || e.Delta == 0) return;
var source = e.OriginalSource as DependencyObject;
// Liste déroulante ouverte (son contenu est dans une fenêtre surgissante) : comportement normal.
if (source is Visual sourceVisual && PresentationSource.FromVisual(sourceVisual) is { } sourcePresentation &&
!ReferenceEquals(sourcePresentation, PresentationSource.FromVisual(root)))
return;
if (Ancestor<ComboBox>(source) is { IsDropDownOpen: true }) return;
// Élément sous le curseur (pas forcément celui qui a le focus clavier).
var hit = root.InputHitTest(e.GetPosition(root)) as DependencyObject;
if (hit is null) return;
if (Ancestor<ComboBox>(hit) is { IsDropDownOpen: true }) return;
var target = ScrollableAncestor(hit, e.Delta);
var overClosedComboBox = Ancestor<ComboBox>(source) is { IsDropDownOpen: false } || Ancestor<ComboBox>(hit) is { IsDropDownOpen: false };
if (!overClosedComboBox && ReferenceEquals(Ancestor<ScrollViewer>(source), target)) return; // comportement normal
e.Handled = true; // la liste déroulante fermée ou l'élément qui ne peut pas défiler ne reçoit pas la molette
if (target is null) return;
if (target.CanContentScroll)
{
for (var i = 0; i < Math.Max(1, SystemParameters.WheelScrollLines); i++)
if (e.Delta > 0) target.LineUp();
else target.LineDown();
}
else
{
target.ScrollToVerticalOffset(target.VerticalOffset - e.Delta * PixelsPerNotch / Mouse.MouseWheelDeltaForOneLine);
}
}
/// <summary>Premier ScrollViewer parent qui peut encore défiler dans le sens de la molette.</summary>
private static ScrollViewer? ScrollableAncestor(DependencyObject? element, int delta)
{
for (var current = element; current is not null; current = Parent(current))
if (current is ScrollViewer viewer && viewer.ScrollableHeight > 0 &&
(delta > 0 ? viewer.VerticalOffset > 0 : viewer.VerticalOffset < viewer.ScrollableHeight))
return viewer;
return null;
}
private static T? Ancestor<T>(DependencyObject? element) where T : DependencyObject
{
for (var current = element; current is not null; current = Parent(current))
if (current is T match)
return match;
return null;
}
private static DependencyObject? Parent(DependencyObject element) =>
element is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(element) ?? LogicalTreeHelper.GetParent(element)
: LogicalTreeHelper.GetParent(element); // Run, Hyperlink… (éléments de texte)
}
/// <summary>
/// Barre d'actions d'une page du menu d'administration, affichée SOUS la zone qui défile (toujours visible) :
/// modèle « AdminFooter.&lt;type de la page&gt; » de Themes/Admin.xaml, sinon rien.
/// </summary>
public sealed class AdminFooterTemplateSelector : DataTemplateSelector
{
public override DataTemplate? SelectTemplate(object? item, DependencyObject container)
{
if (item is null || container is not FrameworkElement element) return null;
return element.TryFindResource("AdminFooter." + item.GetType().Name) as DataTemplate
?? element.TryFindResource("AdminFooter.None") as DataTemplate;
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>1.3.0</Version>
<Version>2.3.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -458,6 +458,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
// ------------------------------------------------------------------ Utilitaires
/// <summary>Clés racine (identifiant, SPKI) : trousseau du contenu signé (phase 7), vérifié avec les mêmes clés.</summary>
internal static IReadOnlyList<(string KeyId, byte[] PublicKey)> LoadRootKeys() =>
LoadTrustedKeys().Select(k => (k.KeyId, k.SubjectPublicKeyInfo)).ToList();
private static IReadOnlyList<UpdateSignature.TrustedKey> LoadTrustedKeys()
{
try
+2 -22
View File
@@ -245,28 +245,8 @@ public sealed class TpmKeyStore : IAdminTpm
var response = new byte[512];
var length = (uint)response.Length;
if (Tbsip_Submit_Command(context, 0, 200, command, (uint)command.Length, response, ref length) != 0) return null;
var data = response.AsSpan(0, (int)Math.Min(length, (uint)response.Length));
if (data.Length < 19 || BinaryPrimitives.ReadUInt32BigEndian(data[6..]) != 0) return null; // code retour TPM
if (BinaryPrimitives.ReadUInt32BigEndian(data[11..]) != 6) return null;
var count = BinaryPrimitives.ReadUInt32BigEndian(data[15..]);
uint? permanent = null, counter = null, maxTries = null, interval = null;
for (var i = 0; i < count && 19 + (i + 1) * 8 <= data.Length; i++)
{
var entry = data.Slice(19 + i * 8, 8);
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
{
case 0x200: permanent = value; break; // TPM_PT_PERMANENT
case 0x20E: counter = value; break; // TPM_PT_LOCKOUT_COUNTER
case 0x20F: maxTries = value; break; // TPM_PT_MAX_AUTH_FAIL
case 0x210: interval = value; break; // TPM_PT_LOCKOUT_INTERVAL (secondes)
}
}
if (counter is null || maxTries is null || interval is null) return null;
var inLockout = permanent is { } attributes && (attributes & (1u << 2)) != 0; // TPMA_PERMANENT.inLockout
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
TimeSpan.FromSeconds(interval.Value), inLockout);
// Analyse dans TpmLockoutInfo (testée) : compteur, maximum, intervalle et TPMA_PERMANENT.inLockout.
return TpmLockoutInfo.FromCapabilityResponse(response.AsSpan(0, (int)Math.Min(length, (uint)response.Length)));
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentOutOfRangeException)
{
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more