Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e4b779be5 | ||
|
|
10558a6e5f | ||
|
|
54941a1009 | ||
|
|
bd8b76015f | ||
|
|
0d3d76a68e | ||
|
|
8862047e36 | ||
|
|
25c87d672e | ||
|
|
d687414872 | ||
|
|
fb71661f17 | ||
|
|
19ccd3bf9a | ||
|
|
370443f3d4 | ||
|
|
213cb9a533 | ||
|
|
dc740e0452 | ||
|
|
72e979afd6 | ||
|
|
1d5f85a77c | ||
|
|
629fb600e9 | ||
|
|
9455277c25 | ||
|
|
f1032df74a | ||
|
|
a341bc641a | ||
|
|
9b544cbc03 | ||
|
|
001ab82fda | ||
|
|
67f4c02efc | ||
|
|
8808fe2615 | ||
|
|
bd5b9d46ca | ||
|
|
688b726e24 | ||
|
|
929b27dbeb | ||
|
|
090d8c64ec | ||
|
|
93f3ba630c |
No files matched your search
@@ -41,9 +41,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
|
||||
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}"
|
||||
|
||||
@@ -35,10 +35,10 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| `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 ; module « Catalogue des jeux » (`Catalog/`, page « Jeux ») (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
@@ -50,7 +50,7 @@ src/GameLauncher/
|
||||
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
|
||||
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont la page « Jeux »)
|
||||
│ 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),
|
||||
@@ -72,12 +72,25 @@ dotnet run --project tools\ManifestBuilder -c Release -- ^
|
||||
--exe MonJeu.exe ^
|
||||
--game-id MonJeu ^
|
||||
--notes notes.txt ^
|
||||
--remote-manifest https://launcher.example/monjeu/manifest.json ^
|
||||
--exclude "*.pdb;*.log"
|
||||
```
|
||||
|
||||
L'historique des notes est conservé d'une version à l'autre (30 versions) et affiché dans le
|
||||
launcher (panneau « Notes de version », bouton **Agrandir** pour les lire en grand).
|
||||
|
||||
`--remote-manifest` (recommandé, déjà dans `tools\publish-example.bat`) : l'historique est repris du
|
||||
manifest **en ligne**, et non du `manifest.json` local. Les notes corrigées depuis le menu
|
||||
d'administration (page « Notes de version », §8) ne sont donc pas écrasées à la publication
|
||||
suivante. Pas de manifest en ligne (première publication) : historique local. Serveur injoignable :
|
||||
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
|
||||
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
**Mise en ligne** : depuis le menu d'administration, page **Publier une version** (§8) : choisissez le
|
||||
`manifest.json` du dossier `--output`, puis « Publier ». Seuls les fichiers absents du serveur sont
|
||||
envoyés, l'envoi reprend après une coupure, et le manifest est mis en ligne en dernier. WinSCP reste
|
||||
possible (objects/ et l'archive d'abord, `manifest.json` en dernier), mais pas les deux en même temps.
|
||||
|
||||
### Format de `notes.txt`
|
||||
|
||||
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
|
||||
@@ -474,8 +487,9 @@ 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).
|
||||
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Les autres pages de gestion
|
||||
arrivent dans les phases suivantes.
|
||||
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**. Les pages « Administrateurs »
|
||||
et « Journal d'audit » arrivent à la phase 6.
|
||||
|
||||
* **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é,
|
||||
@@ -505,6 +519,24 @@ arrivent dans les phases suivantes.
|
||||
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
|
||||
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
|
||||
le nom affiché ne change ni le raccourci, ni le registre, ni les dossiers.
|
||||
* **Page « Notes de version »** (permission `changelog.edit`) : corrige les notes des versions **déjà
|
||||
publiées** dans le `manifest.json` du serveur. Liste des versions, en-tête (titre, sous-titre,
|
||||
introduction), sections › éléments (modification ou commentaire, étiquette, sous-points, note),
|
||||
boutons ↑ ↓ pour l'ordre, **Aperçu** avec la présentation exacte du launcher. Mêmes limites que le
|
||||
launcher, vérifiées avant l'envoi. « Enregistrer » ne change que les notes (fichiers, archive,
|
||||
version et exécutable restent identiques) ; si le manifest a changé entre-temps (nouvelle version
|
||||
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
|
||||
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
|
||||
`server/deploy/LISEZMOI.md`, étape 3 ter.
|
||||
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
|
||||
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
|
||||
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
|
||||
si même numéro ou version inférieure), puis envoi : manifest, fichiers **absents du serveur**
|
||||
seulement (petits fichiers par lots, gros par morceaux), archive par morceaux de 4 Mo, chaque fichier
|
||||
vérifié (SHA-256) des deux côtés, et mise en ligne avec le manifest **en dernier** (historique des
|
||||
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
|
||||
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 quater.
|
||||
* **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 :
|
||||
|
||||
+91
-24
@@ -68,7 +68,7 @@ Conséquences :
|
||||
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques |
|
||||
| `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 |
|
||||
@@ -381,33 +381,100 @@ champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles
|
||||
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)
|
||||
## 10. Notes de version (phase 4 ✅)
|
||||
|
||||
* É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`).
|
||||
* É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.
|
||||
|
||||
**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`).
|
||||
|
||||
**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`.
|
||||
|
||||
**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).
|
||||
|
||||
**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).
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
|
||||
|
||||
@@ -432,8 +499,8 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|
||||
| **2** | Launcher : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
|
||||
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
|
||||
| **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 « 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 |
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ public static class ApiModules
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule()
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule(),
|
||||
new Modules.Publishing.PublishingModule()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,4 +37,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";
|
||||
}
|
||||
@@ -41,6 +41,12 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public CatalogSettings Catalog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
|
||||
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
|
||||
/// </summary>
|
||||
public Dictionary<string, GameSettings> Games { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de validité d'un défi de connexion.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 60;
|
||||
|
||||
@@ -70,6 +76,17 @@ public sealed class LauncherApiOptions
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
|
||||
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
|
||||
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string ManifestFile { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class LimitSettings
|
||||
{
|
||||
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
|
||||
@@ -78,6 +95,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;
|
||||
|
||||
@@ -108,6 +132,10 @@ public sealed class LauncherApiOptions
|
||||
/// </summary>
|
||||
public string CatalogImageHost { get; private set; } = "";
|
||||
|
||||
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
@@ -135,6 +163,7 @@ public sealed class LauncherApiOptions
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
ValidateCatalog();
|
||||
ValidateGames();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
@@ -142,7 +171,8 @@ public sealed class LauncherApiOptions
|
||||
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");
|
||||
}
|
||||
@@ -159,13 +189,7 @@ public sealed class LauncherApiOptions
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
// Ni le fichier public dans les données privées, ni les données privées sous le dossier public
|
||||
// (servi par nginx) ; liens symboliques résolus.
|
||||
var publicDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Catalog:PublicFile : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
|
||||
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
|
||||
CatalogPublicFileFullPath = full;
|
||||
}
|
||||
|
||||
@@ -185,6 +209,43 @@ public sealed class LauncherApiOptions
|
||||
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
|
||||
}
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (gameId, game) in Games)
|
||||
{
|
||||
var name = $"Games:{gameId}:ManifestFile";
|
||||
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
|
||||
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
|
||||
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
|
||||
var file = game.ManifestFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, name);
|
||||
if (files.Values.Contains(full, StringComparer.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
|
||||
files[gameId] = full;
|
||||
}
|
||||
GameManifestFiles = files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
|
||||
/// dans l'autre (liens symboliques résolus).
|
||||
/// </summary>
|
||||
private void EnsureSeparatedFromData(string directory, string name)
|
||||
{
|
||||
var publicDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:{name} : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
|
||||
}
|
||||
|
||||
/// <summary>Chemin absolu, dernier élément résolu s'il s'agit d'un lien symbolique.</summary>
|
||||
private static string RealPath(string directory)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,126 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
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, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
var (result, record) = store.Commit(id, context.Admin().Admin.Id);
|
||||
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]}");
|
||||
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;
|
||||
}
|
||||
@@ -24,15 +24,43 @@ public static class SignedRequests
|
||||
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
|
||||
!HttpMethods.IsOptions(context.Request.Method))
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
var body = buffer.ToArray();
|
||||
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
|
||||
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
|
||||
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
|
||||
feature.MaxRequestBodySize = limit.MaxBytes;
|
||||
|
||||
byte[] body;
|
||||
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
|
||||
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
|
||||
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
|
||||
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
|
||||
{
|
||||
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
|
||||
body = new byte[length];
|
||||
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
|
||||
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
|
||||
throw new BadHttpRequestException("Corps plus long que annoncé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
body = buffer.ToArray();
|
||||
}
|
||||
context.Items[BodyKey] = body;
|
||||
context.Request.Body = new MemoryStream(body, writable: false);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
|
||||
public static byte[] CapturedBody(this HttpContext context) =>
|
||||
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
|
||||
|
||||
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
|
||||
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.WithMetadata(new RequestBodyLimit(maxBytes));
|
||||
|
||||
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
|
||||
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.AddEndpointFilter(async (invocation, next) =>
|
||||
@@ -94,3 +122,6 @@ public static class SignedRequests
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
|
||||
public sealed record RequestBodyLimit(long MaxBytes);
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version des jeux, dans le manifest.json PUBLIÉ de chaque jeu (LauncherApi:Games:<id>:ManifestFile).
|
||||
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
|
||||
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
|
||||
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
|
||||
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
|
||||
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
|
||||
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
|
||||
/// </summary>
|
||||
public sealed class ChangelogStore
|
||||
{
|
||||
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
|
||||
public const long MaxManifestBytes = PublishedFiles.MaxManifestBytes;
|
||||
|
||||
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
|
||||
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public ChangelogStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
|
||||
|
||||
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
/// <summary>Notes de version publiées du jeu.</summary>
|
||||
public ChangelogState Read(string gameId)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
var bytes = ReadManifest(path);
|
||||
var root = Parse(bytes, key);
|
||||
return State(key, root, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
|
||||
/// </summary>
|
||||
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
|
||||
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
|
||||
{
|
||||
var (path, key) = Locate(gameId);
|
||||
lock (_writeLock)
|
||||
using (PublishedFiles.LockGame(_dataDirectory, key))
|
||||
{
|
||||
var bytes = ReadManifest(path);
|
||||
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw Conflict();
|
||||
|
||||
var root = Parse(bytes, key);
|
||||
var changelog = ChangelogArray(root)
|
||||
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
|
||||
var index = -1;
|
||||
for (var i = 0; i < changelog.Count; i++)
|
||||
if (changelog[i] is JsonObject candidate && string.Equals(PublishedFiles.StringValue(candidate, "version"), version, StringComparison.Ordinal))
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
if (index < 0)
|
||||
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
|
||||
|
||||
var old = (JsonObject)changelog[index]!;
|
||||
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
|
||||
|
||||
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
|
||||
// anciens launchers ; numéro et date de la version repris du manifest.
|
||||
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
|
||||
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
|
||||
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
|
||||
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
|
||||
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
|
||||
var replacement = old.DeepClone().AsObject();
|
||||
var edited = JsonSerializer.SerializeToNode(clean, PublishedFiles.ManifestJson)!.AsObject();
|
||||
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.DeepClone());
|
||||
changelog[index] = replacement;
|
||||
|
||||
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
|
||||
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
|
||||
if (total > ChangelogLimits.MaxTotalItems)
|
||||
throw ApiRejection.BadRequest(
|
||||
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
|
||||
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(_dataDirectory, key, bytes);
|
||||
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
|
||||
return (State(key, root, newBytes), previous);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private (string Path, string GameId) Locate(string gameId)
|
||||
{
|
||||
foreach (var (key, path) in _manifests)
|
||||
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
return (path, key);
|
||||
throw PublishedFiles.NotConfigured(gameId, "Modification des notes de version");
|
||||
}
|
||||
|
||||
private static byte[] ReadManifest(string path) =>
|
||||
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
|
||||
|
||||
internal static JsonObject Parse(byte[] bytes, string gameId)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
|
||||
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
|
||||
var manifestGameId = PublishedFiles.StringValue(root, "gameId");
|
||||
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
|
||||
{
|
||||
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
|
||||
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
|
||||
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
|
||||
return new ChangelogState
|
||||
{
|
||||
GameId = gameId,
|
||||
ManifestVersion = PublishedFiles.StringValue(root, "version") ?? "",
|
||||
ManifestSha256 = Sha256(bytes),
|
||||
Entries = entries
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
|
||||
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
|
||||
{
|
||||
try
|
||||
{
|
||||
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.ManifestJson) ?? new List<ChangelogEntry?>();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
|
||||
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
|
||||
|
||||
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
|
||||
|
||||
private static ApiRejection Conflict() => ApiRejection.Conflict(
|
||||
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
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.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version depuis le menu d'administration (phase 5).
|
||||
/// • Dépôt : dossier CACHÉ « .publish-<id> » dans le dossier du jeu (même disque : la mise en ligne
|
||||
/// finale n'est qu'une suite de renommages ; nginx refuse les chemins commençant par un point), droits
|
||||
/// 0700 ; description (version, plan) dans les données privées.
|
||||
/// • Envoi : manifest, puis objets absents du serveur (par lots, ou par morceaux pour les gros), puis
|
||||
/// archive par morceaux ; chaque fichier est reçu à la suite (position vérifiée : reprise possible
|
||||
/// après une coupure) puis vérifié (SHA-256) avant d'être accepté.
|
||||
/// • Mise en ligne (commit) : tout est revérifié AVANT de déplacer quoi que ce soit ; objets puis archive
|
||||
/// installés, manifest en DERNIER (comme avec WinSCP), historique des notes du serveur conservé ; si
|
||||
/// l'écriture du manifest échoue, l'archive remplacée est remise en place. Jamais si les fichiers en
|
||||
/// ligne ont changé pendant l'envoi (autre publication, WinSCP).
|
||||
/// • Une publication par jeu ; abandonnée au bout de 48 h sans envoi ; résultat conservé 24 h (une
|
||||
/// réponse perdue n'empêche pas de constater que la version est en ligne).
|
||||
/// </summary>
|
||||
public sealed class PublicationStore
|
||||
{
|
||||
private const string RecordsFolder = "publications";
|
||||
private const string StagingPrefix = ".publish-";
|
||||
private const string DoneSuffix = ".done";
|
||||
private const long FreeSpaceMargin = 256L * 1024 * 1024;
|
||||
private static readonly TimeSpan DoneLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan CleanUpEvery = TimeSpan.FromMinutes(10);
|
||||
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string _recordsDirectory;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _startLock = new();
|
||||
private readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, HashProgress> _hashes = new(StringComparer.Ordinal);
|
||||
private DateTimeOffset _lastCleanUp = DateTimeOffset.MinValue;
|
||||
|
||||
public PublicationStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath, time) { }
|
||||
|
||||
public PublicationStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory, TimeProvider time)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
_recordsDirectory = Path.Combine(dataDirectory, RecordsFolder);
|
||||
_time = time;
|
||||
}
|
||||
|
||||
// ================================================================== Début, état, abandon
|
||||
|
||||
/// <summary>
|
||||
/// Début d'une publication, ou reprise si le même manifest était en cours d'envoi. Une publication
|
||||
/// d'un autre administrateur, active depuis moins de 30 min, n'est pas remplacée (409).
|
||||
/// </summary>
|
||||
/// <returns>L'état, et la publication remplacée (audit) le cas échéant.</returns>
|
||||
public (PublicationState State, PublicationRecord? Replaced) Start(string gameId, PublicationStartRequest request, Guid adminId, string adminName)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
if (!ManifestCheck.IsValidVersion(request.Version)) throw ApiRejection.BadRequest("Numéro de version invalide.");
|
||||
if (!PublishingLimits.IsValidSha256(request.ManifestSha256)) throw ApiRejection.BadRequest("Empreinte du manifest invalide.");
|
||||
if (request.ManifestSize is <= 0 or > PublishingLimits.MaxManifestBytes) throw ApiRejection.BadRequest("Taille du manifest invalide.");
|
||||
|
||||
lock (_startLock)
|
||||
{
|
||||
CleanUp(force: true);
|
||||
PublicationRecord? replaced = null;
|
||||
foreach (var existing in Records().Where(r => string.Equals(r.GameId, key, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
{
|
||||
if (existing.AdminId == adminId && existing.Version == request.Version &&
|
||||
string.Equals(existing.ManifestSha256, request.ManifestSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Touch(existing.Id);
|
||||
return (Lock(existing.Id, () => State(Current(existing.Id), resumed: true)), null);
|
||||
}
|
||||
var idle = _time.GetUtcNow() - LastActivity(existing.Id);
|
||||
if (existing.AdminId != adminId && idle < PublishingLimits.TakeoverAfter)
|
||||
throw ApiRejection.Conflict(
|
||||
$"{existing.AdminName} publie la version {existing.Version} de ce jeu (dernier envoi il y a " +
|
||||
$"{Math.Max(1, (int)idle.TotalMinutes)} min). Réessayez plus tard.", ApiErrorCodes.PublicationInProgress);
|
||||
Lock(existing.Id, () =>
|
||||
{
|
||||
Delete(existing);
|
||||
return 0;
|
||||
});
|
||||
replaced = existing;
|
||||
}
|
||||
|
||||
var current = PublishedFiles.ReadManifest(manifestPath);
|
||||
var currentRoot = current is null ? null : ChangelogStore.Parse(current, key);
|
||||
var record = new PublicationRecord
|
||||
{
|
||||
Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
GameId = key,
|
||||
Version = request.Version,
|
||||
ManifestSha256 = request.ManifestSha256.ToLowerInvariant(),
|
||||
ManifestSize = request.ManifestSize,
|
||||
AdminId = adminId,
|
||||
AdminName = adminName,
|
||||
CreatedAt = _time.GetUtcNow(),
|
||||
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
|
||||
PublishedContentSha256 = ContentSignature(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>
|
||||
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId)
|
||||
{
|
||||
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 (ContentSignature(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);
|
||||
|
||||
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne).
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
|
||||
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 du manifest en ligne SANS les notes (fichiers, archive, version…) ; vide s'il n'existe pas.</summary>
|
||||
private static string ContentSignature(JsonObject? root)
|
||||
{
|
||||
if (root is null) return "";
|
||||
var copy = root.DeepClone().AsObject();
|
||||
PublishedFiles.SetProperty(copy, "changelog", 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) { }
|
||||
}
|
||||
@@ -31,8 +31,14 @@
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
|
||||
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
|
||||
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
|
||||
"Games": {},
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
// 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,
|
||||
|
||||
+107
-1
@@ -124,6 +124,109 @@ 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.
|
||||
|
||||
## 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 :
|
||||
@@ -191,7 +294,7 @@ d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
conservés, ainsi que le complément systemd du catalogue (`launcher-api.service.d/catalog.conf`) ;
|
||||
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.
|
||||
|
||||
@@ -243,10 +346,13 @@ recréer les administrateurs.
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
|
||||
├─ catalog-history/ 20 dernières versions publiées
|
||||
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
|
||||
├─ publications/ publications en cours (version, fichiers restant à envoyer)
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
|
||||
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
|
||||
└─ images/ images du catalogue (envoyées en SFTP)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
|
||||
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
|
||||
```
|
||||
@@ -0,0 +1,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
|
||||
@@ -8,6 +8,8 @@
|
||||
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
|
||||
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
|
||||
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
|
||||
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
|
||||
#
|
||||
# Vérifier le durcissement : systemd-analyze security launcher-api
|
||||
# Journal : journalctl -u launcher-api -f
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# Publication d'une version depuis le launcher — deux ajouts (voir LISEZMOI.md, étape 3 quater) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf, à côté de la zone « api_par_ip » :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
|
||||
#
|
||||
# Un envoi enchaîne plusieurs morceaux de 4 Mo par seconde : la zone « api_par_ip » (5 par seconde)
|
||||
# briderait l'envoi vers 20 Mo/s et provoquerait des pauses. 25 par seconde ≈ 100 Mo/s ; ces routes
|
||||
# exigent une session d'administrateur et le droit de publier.
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival, à côté du bloc
|
||||
# « location ^~ /api/ { … } » (nginx-api.conf) : (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Seules ces routes acceptent de gros envois (morceaux de 4 Mo, 8 Mo au plus) ; toutes les autres
|
||||
# routes de l'API restent limitées à 64 Ko. Le préfixe le plus long l'emporte sur « /api/ ».
|
||||
# Les fichiers en cours d'envoi sont rangés dans un dossier caché (.publish-…) du dossier du jeu :
|
||||
# la règle « location ~ /\. { deny all; } » du site doit être présente (elle l'est dans le guide).
|
||||
# =============================================================================
|
||||
|
||||
location ^~ /api/v1/publications/ {
|
||||
limit_req zone=publication_par_ip burst=50 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 9m;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 180s;
|
||||
proxy_read_timeout 180s;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
@@ -118,6 +120,67 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
|
||||
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
|
||||
|
||||
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
|
||||
public Task<ChangelogState> GetChangelogAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ChangelogState>(ApiRoutes.GameChangelog(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (requête signée). <paramref name="baseSha256"/> : empreinte
|
||||
/// du manifest sur lequel la modification a été préparée (409 revision_conflict s'il a changé).
|
||||
/// </summary>
|
||||
public Task<ChangelogState> SaveChangelogEntryAsync(string gameId, ChangelogEntry entry, string baseSha256, CancellationToken ct = default)
|
||||
{
|
||||
if (!ChangelogLimits.IsEditableVersion(entry.Version))
|
||||
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
|
||||
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
|
||||
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
|
||||
/// <summary>Délai d'une requête d'envoi de fichier (morceau de 4 Mo sur une connexion lente).</summary>
|
||||
public static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(3);
|
||||
|
||||
public Task<PublicationState> StartPublicationAsync(string gameId, PublicationStartRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<PublicationState>(HttpMethod.Post, ApiRoutes.PublicationStart(CheckGameId(gameId)), request, ct);
|
||||
|
||||
public Task<PublicationState> GetPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
GetAsync<PublicationState>(ApiRoutes.Publication(CheckPublicationId(id)), ct);
|
||||
|
||||
public Task CancelPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Publication(CheckPublicationId(id)), null, ct);
|
||||
|
||||
public Task<ChunkResult> UploadManifestAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationManifest(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadArchiveAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationArchive(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectAsync(string id, string sha256, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put,
|
||||
ApiRoutes.PublicationObject(CheckPublicationId(id), PublishingLimits.IsValidSha256(sha256) ? sha256.ToLowerInvariant()
|
||||
: throw new ArgumentException("Empreinte invalide.", nameof(sha256)), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectBatchAsync(string id, byte[] batch, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Post, ApiRoutes.PublicationObjects(CheckPublicationId(id)), batch,
|
||||
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
|
||||
|
||||
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
|
||||
public Task<PublicationResult> CommitPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)), null, signed: true, ct,
|
||||
timeout: UploadTimeout);
|
||||
|
||||
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) =>
|
||||
@@ -127,7 +190,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.");
|
||||
|
||||
@@ -153,7 +222,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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -39,4 +39,33 @@ public static class ApiRoutes
|
||||
|
||||
// ------------------------------------------------------------------ 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}";
|
||||
|
||||
// ------------------------------------------------------------------ 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";
|
||||
}
|
||||
+73
-4
@@ -1,11 +1,14 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Core.Utils;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version du jeu (manifest.json → "changelog").
|
||||
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
|
||||
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
|
||||
///
|
||||
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
|
||||
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
|
||||
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
|
||||
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
|
||||
public List<string> Changes { get; set; } = new();
|
||||
|
||||
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
|
||||
|
||||
[JsonIgnore]
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
|
||||
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
|
||||
@@ -142,6 +147,69 @@ public static class ChangelogLimits
|
||||
public const int MaxTitleLength = 200;
|
||||
public const int MaxTagLength = 24;
|
||||
public const int MaxTextLength = 2000;
|
||||
public const int MaxVersionLength = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
|
||||
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
|
||||
/// </summary>
|
||||
public const int MaxEditRequestBytes = 60 * 1024;
|
||||
|
||||
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
|
||||
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
|
||||
|
||||
/// <summary>
|
||||
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
|
||||
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
|
||||
/// </summary>
|
||||
public static List<string> Check(ChangelogEntry entry)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
void Length(string? value, int max, string what)
|
||||
{
|
||||
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
|
||||
}
|
||||
|
||||
Length(entry.Title, MaxTitleLength, "Titre");
|
||||
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
|
||||
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
|
||||
if (introduction.Count > MaxIntroductionParagraphs)
|
||||
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
|
||||
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
|
||||
|
||||
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
|
||||
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
|
||||
var items = 0;
|
||||
for (var s = 0; s < sections.Count; s++)
|
||||
{
|
||||
var section = sections[s];
|
||||
var where = $"Section {s + 1}";
|
||||
Length(section.Title, MaxTitleLength, $"{where} (titre)");
|
||||
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
|
||||
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
|
||||
items += sectionItems.Count;
|
||||
for (var i = 0; i < sectionItems.Count; i++)
|
||||
{
|
||||
var item = sectionItems[i];
|
||||
var at = $"{where}, élément {i + 1}";
|
||||
Length(item.Text, MaxTextLength, at);
|
||||
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
|
||||
Length(item.Note, MaxTextLength, $"{at} (note)");
|
||||
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
|
||||
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
|
||||
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
|
||||
}
|
||||
}
|
||||
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
|
||||
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
|
||||
errors.Add($"{MaxFlatChanges} modifications au plus.");
|
||||
return errors.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
|
||||
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
|
||||
|
||||
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
|
||||
{
|
||||
@@ -229,7 +297,8 @@ public static class ChangelogLimits
|
||||
|
||||
var text = sb.ToString().Trim();
|
||||
if (text.Length == 0) return null;
|
||||
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
|
||||
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
|
||||
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GameLauncher.Contracts.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
|
||||
/// l'éditeur du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed record ChangelogState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
|
||||
public string ManifestVersion { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
|
||||
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
|
||||
/// </summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
|
||||
public List<ChangelogEntry> Entries { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
|
||||
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
|
||||
/// </summary>
|
||||
public sealed record ChangelogUpdateRequest
|
||||
{
|
||||
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
|
||||
public string BaseSha256 { get; init; } = "";
|
||||
|
||||
public ChangelogEntry? Entry { get; init; }
|
||||
}
|
||||
@@ -59,4 +59,13 @@ public static class ApiErrorCodes
|
||||
|
||||
/// <summary>Fonction non configurée sur le serveur.</summary>
|
||||
public const string NotConfigured = "not_configured";
|
||||
|
||||
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
|
||||
public const string OffsetMismatch = "offset_mismatch";
|
||||
|
||||
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
|
||||
public const string PublicationInProgress = "publication_in_progress";
|
||||
|
||||
/// <summary>Espace disque insuffisant sur le serveur.</summary>
|
||||
public const string InsufficientStorage = "insufficient_storage";
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>Ce que contient un manifest.json contrôlé.</summary>
|
||||
/// <param name="Objects">Objets à servir (empreinte en minuscules → taille), une fois chacun.</param>
|
||||
public sealed record ManifestSummary(
|
||||
string GameId,
|
||||
string Version,
|
||||
string Executable,
|
||||
string ArchiveName,
|
||||
long ArchiveSize,
|
||||
string ArchiveSha256,
|
||||
IReadOnlyDictionary<string, long> Objects,
|
||||
int FileCount,
|
||||
long TotalSize,
|
||||
string? NotesTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un manifest.json produit par ManifestBuilder, AVANT de le publier : par le launcher
|
||||
/// (dossier choisi) et par le serveur (manifest reçu). Mêmes règles des deux côtés ; le serveur ne fait
|
||||
/// confiance à rien de ce qu'il reçoit.
|
||||
/// • identifiant de jeu et version attendus ;
|
||||
/// • archive : simple nom de fichier .zip (jamais un chemin), taille et empreinte valides ;
|
||||
/// • objets dans « objects/ » (seul dossier écrit par la publication) ;
|
||||
/// • chemins des fichiers relatifs et sûrs (ni « .. », ni chemin absolu, ni « : »), sans doublon ;
|
||||
/// • exécutable présent parmi les fichiers ; tailles et nombre de fichiers bornés.
|
||||
/// </summary>
|
||||
public static partial class ManifestCheck
|
||||
{
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9._+\-]{1,64}\z")]
|
||||
private static partial Regex VersionRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}\.zip\z", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ArchiveNameRegex();
|
||||
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
public static bool IsValidVersion(string? version) => version is not null && VersionRegex().IsMatch(version);
|
||||
|
||||
/// <summary>Contrôle ; null (et <paramref name="errors"/> rempli) si le manifest n'est pas publiable.</summary>
|
||||
public static ManifestSummary? Check(ReadOnlySpan<byte> json, string expectedGameId, string? expectedVersion, List<string> errors)
|
||||
{
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
if (json.Length > PublishingLimits.MaxManifestBytes)
|
||||
{
|
||||
errors.Add("Manifest trop volumineux.");
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(json.ToArray(), ReadOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"Manifest illisible : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add("Manifest illisible : objet JSON attendu.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Get(root, "formatVersion") is { } format && (format.ValueKind != JsonValueKind.Number || format.GetDouble() != 1))
|
||||
errors.Add("Format de manifest non pris en charge (formatVersion 1 attendu).");
|
||||
|
||||
var gameId = Text(root, "gameId");
|
||||
if (!string.Equals(gameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add($"Ce manifest est celui du jeu « {gameId} », pas de « {expectedGameId} ».");
|
||||
|
||||
var version = Text(root, "version");
|
||||
if (!IsValidVersion(version))
|
||||
errors.Add($"Version « {version} » invalide (lettres, chiffres, « . », « _ », « + », « - »).");
|
||||
else if (expectedVersion is not null && !string.Equals(version, expectedVersion, StringComparison.Ordinal))
|
||||
errors.Add($"Le manifest est celui de la version {version}, pas {expectedVersion}.");
|
||||
|
||||
var objectsBase = Text(root, "objectsBaseUrl");
|
||||
if (!string.IsNullOrEmpty(objectsBase) && objectsBase is not ("objects/" or "objects"))
|
||||
errors.Add($"« objectsBaseUrl » doit être « objects/ » (manifest produit par ManifestBuilder), pas « {objectsBase} ».");
|
||||
|
||||
// Archive (installation initiale)
|
||||
string archiveName = "", archiveSha = "";
|
||||
long archiveSize = 0;
|
||||
if (Get(root, "archive") is { ValueKind: JsonValueKind.Object } archive)
|
||||
{
|
||||
archiveName = Text(archive, "url") ?? "";
|
||||
archiveSha = (Text(archive, "sha256") ?? "").ToLowerInvariant();
|
||||
archiveSize = Number(archive, "size");
|
||||
if (!ArchiveNameRegex().IsMatch(archiveName))
|
||||
errors.Add($"Archive « {archiveName} » : simple nom de fichier .zip attendu (à côté du manifest).");
|
||||
if (!PublishingLimits.IsValidSha256(archiveSha)) errors.Add("Archive : empreinte SHA-256 invalide.");
|
||||
if (archiveSize <= 0 || archiveSize > PublishingLimits.MaxArchiveBytes) errors.Add("Archive : taille invalide.");
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Archive absente du manifest.");
|
||||
}
|
||||
|
||||
// Fichiers du jeu
|
||||
var objects = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalSize = 0;
|
||||
var fileCount = 0;
|
||||
if (Get(root, "files") is { ValueKind: JsonValueKind.Array } files)
|
||||
{
|
||||
fileCount = files.GetArrayLength();
|
||||
if (fileCount == 0) errors.Add("Aucun fichier dans le manifest.");
|
||||
if (fileCount > PublishingLimits.MaxFiles) errors.Add($"Trop de fichiers ({fileCount} ; {PublishingLimits.MaxFiles} au plus).");
|
||||
else
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var file in files.EnumerateArray())
|
||||
{
|
||||
index++;
|
||||
if (errors.Count > 20) break;
|
||||
if (file.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add($"Fichier n° {index} invalide.");
|
||||
continue;
|
||||
}
|
||||
var path = Text(file, "path") ?? "";
|
||||
var sha = (Text(file, "sha256") ?? "").ToLowerInvariant();
|
||||
var size = Number(file, "size");
|
||||
if (!IsSafeRelativePath(path)) errors.Add($"Chemin non autorisé : « {path} ».");
|
||||
else if (!paths.Add(path.Replace('\\', '/'))) errors.Add($"Fichier en double : « {path} ».");
|
||||
if (!PublishingLimits.IsValidSha256(sha)) errors.Add($"« {path} » : empreinte SHA-256 invalide.");
|
||||
else if (size < 0 || size > PublishingLimits.MaxObjectBytes) errors.Add($"« {path} » : taille invalide.");
|
||||
else if (objects.TryGetValue(sha, out var known) && known != size)
|
||||
errors.Add($"« {path} » : même empreinte qu'un autre fichier mais taille différente.");
|
||||
else
|
||||
{
|
||||
objects[sha] = size;
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Liste des fichiers absente du manifest.");
|
||||
}
|
||||
|
||||
var executable = (Text(root, "executable") ?? "").Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(executable)) errors.Add("Exécutable absent du manifest.");
|
||||
else if (!paths.Contains(executable)) errors.Add($"L'exécutable « {executable} » ne fait pas partie des fichiers.");
|
||||
|
||||
if (Get(root, "changelog") is { } changelog && changelog.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null))
|
||||
errors.Add("Notes de version (« changelog ») : liste attendue.");
|
||||
|
||||
if (errors.Count > 0) return null;
|
||||
return new ManifestSummary(gameId!, version!, executable, archiveName, archiveSize, archiveSha, objects, fileCount, totalSize,
|
||||
NotesTitle(root, version!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chemin relatif sûr (mêmes règles que le launcher pour l'installation).</summary>
|
||||
public static bool IsSafeRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path.Length > 1024 || path.Any(char.IsControl)) return false;
|
||||
var p = path.Replace('\\', '/');
|
||||
if (p.StartsWith('/') || p.Contains(':')) return false;
|
||||
return p.Split('/').All(s => s.Length > 0 && s != "." && s != "..");
|
||||
}
|
||||
|
||||
/// <summary>Chemin d'un objet sous le dossier du manifest : « objects/ab/abcdef….bin ».</summary>
|
||||
public static string ObjectPath(string sha256)
|
||||
{
|
||||
var h = sha256.ToLowerInvariant();
|
||||
return $"objects/{h[..2]}/{h}.bin";
|
||||
}
|
||||
|
||||
private static string? NotesTitle(JsonElement root, string version)
|
||||
{
|
||||
if (Get(root, "changelog") is not { ValueKind: JsonValueKind.Array } changelog) return null;
|
||||
foreach (var entry in changelog.EnumerateArray())
|
||||
if (entry.ValueKind == JsonValueKind.Object && Text(entry, "version") == version)
|
||||
return Text(entry, "title");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement? Get(JsonElement element, string name)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Text(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null;
|
||||
|
||||
private static long Number(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : -1;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Bornes de la publication d'une version depuis le menu d'administration. Les requêtes d'envoi
|
||||
/// (morceaux de fichiers) sont les seules à dépasser 64 Ko : elles ont leur propre limite, côté
|
||||
/// serveur et côté nginx (server/deploy/nginx-publications.conf).
|
||||
/// </summary>
|
||||
public static partial class PublishingLimits
|
||||
{
|
||||
/// <summary>Taille d'un morceau envoyé par le launcher.</summary>
|
||||
public const int ChunkBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Taille maximale d'un corps de requête d'envoi accepté par le serveur (morceau ou lot d'objets).</summary>
|
||||
public const long MaxUploadRequestBytes = 8L * 1024 * 1024 + 64 * 1024;
|
||||
|
||||
/// <summary>Objets de cette taille au plus : envoyés par lots ; au-delà, en morceaux.</summary>
|
||||
public const int BatchObjectMaxBytes = 2 * 1024 * 1024;
|
||||
|
||||
public const int MaxObjectsPerBatch = 500;
|
||||
public const int MaxFiles = 10_000;
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
public const long MaxObjectBytes = 32L * 1024 * 1024 * 1024;
|
||||
public const long MaxArchiveBytes = 128L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>Publication abandonnée (aucun envoi) au-delà de cette durée : supprimée par le serveur.</summary>
|
||||
public static readonly TimeSpan IdleExpiry = TimeSpan.FromHours(48);
|
||||
|
||||
/// <summary>Publication d'un autre administrateur sans envoi depuis cette durée : peut être remplacée.</summary>
|
||||
public static readonly TimeSpan TakeoverAfter = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>Type du corps d'un lot d'objets (voir <see cref="ObjectBatch"/>).</summary>
|
||||
public const string ObjectBatchContentType = "application/vnd.gamelauncher.objects";
|
||||
|
||||
public const string BinaryContentType = "application/octet-stream";
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
|
||||
private static partial Regex PublicationIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-fA-F]{64}\z")]
|
||||
private static partial Regex Sha256Regex();
|
||||
|
||||
public static bool IsValidPublicationId(string? id) => id is not null && PublicationIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidSha256(string? value) => value is not null && Sha256Regex().IsMatch(value);
|
||||
}
|
||||
|
||||
/// <summary>Début (ou reprise) de la publication d'une version : le manifest est envoyé ensuite.</summary>
|
||||
public sealed record PublicationStartRequest
|
||||
{
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest.json à publier : même manifest = reprise d'un envoi interrompu.</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État d'une publication en cours (ce qui reste à envoyer).</summary>
|
||||
public sealed record PublicationState
|
||||
{
|
||||
public string PublicationId { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne au début de la publication (vide : aucune).</summary>
|
||||
public string PublishedVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Vrai si un envoi interrompu de ce même manifest est repris.</summary>
|
||||
public bool Resumed { get; init; }
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
public long ManifestReceived { get; init; }
|
||||
|
||||
/// <summary>Manifest reçu et contrôlé : la liste des objets à envoyer est connue.</summary>
|
||||
public bool ManifestAccepted { get; init; }
|
||||
|
||||
/// <summary>Fichiers (objets) du manifest ; ceux déjà présents sur le serveur ne sont pas renvoyés.</summary>
|
||||
public int ObjectCount { get; init; }
|
||||
public int ObjectsOnServer { get; init; }
|
||||
|
||||
/// <summary>Empreintes des objets restant à envoyer.</summary>
|
||||
public List<string> PendingObjects { get; init; } = new();
|
||||
|
||||
/// <summary>Objets envoyés en partie (empreinte → octets reçus) : l'envoi reprend à cet endroit.</summary>
|
||||
public Dictionary<string, long> PartialObjects { get; init; } = new();
|
||||
|
||||
public string? ArchiveName { get; init; }
|
||||
public long ArchiveSize { get; init; }
|
||||
public long ArchiveReceived { get; init; }
|
||||
public bool ArchiveComplete { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réponse à l'envoi d'un morceau (ou d'un lot d'objets).</summary>
|
||||
public sealed record ChunkResult
|
||||
{
|
||||
/// <summary>Octets reçus pour ce fichier (ou nombre d'objets enregistrés pour un lot).</summary>
|
||||
public long Received { get; init; }
|
||||
|
||||
/// <summary>Fichier complet et vérifié (SHA-256).</summary>
|
||||
public bool Complete { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Version publiée.</summary>
|
||||
public sealed record PublicationResult
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string PreviousVersion { get; init; } = "";
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
public int ObjectsAdded { get; init; }
|
||||
public bool ArchiveAdded { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lot d'objets dans un seul corps de requête (petits fichiers : une requête pour des centaines de
|
||||
/// fichiers) : pour chaque objet, empreinte SHA-256 (32 octets bruts), taille (8 octets, gros-boutiste)
|
||||
/// puis contenu.
|
||||
/// </summary>
|
||||
public static class ObjectBatch
|
||||
{
|
||||
public const int HeaderBytes = 32 + 8;
|
||||
|
||||
public static byte[] Write(IReadOnlyList<(string Sha256, byte[] Data)> objects)
|
||||
{
|
||||
var total = objects.Sum(o => (long)HeaderBytes + o.Data.Length);
|
||||
var buffer = new byte[total];
|
||||
var position = 0;
|
||||
foreach (var (sha, data) in objects)
|
||||
{
|
||||
Convert.FromHexString(sha).CopyTo(buffer, position);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(buffer.AsSpan(position + 32, 8), data.Length);
|
||||
data.CopyTo(buffer, position + HeaderBytes);
|
||||
position += HeaderBytes + data.Length;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>Lecture bornée ; <see cref="InvalidDataException"/> si le lot est mal formé.</summary>
|
||||
public static List<(string Sha256, ReadOnlyMemory<byte> Data)> Read(ReadOnlyMemory<byte> body, int maxObjects)
|
||||
{
|
||||
var result = new List<(string, ReadOnlyMemory<byte>)>();
|
||||
var position = 0;
|
||||
while (position < body.Length)
|
||||
{
|
||||
if (result.Count >= maxObjects) throw new InvalidDataException($"Lot trop grand ({maxObjects} objets au plus).");
|
||||
if (body.Length - position < HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
var span = body.Span;
|
||||
var sha = Convert.ToHexString(span.Slice(position, 32)).ToLowerInvariant();
|
||||
var length = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(span.Slice(position + 32, 8));
|
||||
if (length < 0 || length > body.Length - position - HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
result.Add((sha, body.Slice(position + HeaderBytes, (int)length)));
|
||||
position += HeaderBytes + (int)length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Notes de version » : page du menu d'administration pour corriger les notes des versions
|
||||
/// déjà publiées (manifest.json du serveur). Permission changelog.edit.
|
||||
/// </summary>
|
||||
public sealed class ChangelogAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "changelog";
|
||||
|
||||
public string DisplayName => "Notes de version";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
ChangelogPanelViewModel.PanelId,
|
||||
"Notes de version",
|
||||
"Corrigez les notes des versions déjà publiées : texte, sections, étiquettes, sous-points. " +
|
||||
"Une nouvelle version et ses notes se publient toujours avec ManifestBuilder (notes.txt).",
|
||||
"FileDocument",
|
||||
AdminPermissions.ChangelogEdit,
|
||||
Order: 20,
|
||||
context => new ChangelogPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version, en cours de modification : en-tête (titre, sous-titre, introduction), puis
|
||||
/// sections › éléments. <see cref="Preview"/> est l'entrée telle que le launcher l'affichera (mêmes
|
||||
/// bornes, mêmes modèles d'affichage), recalculée à chaque modification.
|
||||
/// </summary>
|
||||
public sealed class VersionEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private readonly Func<string, string, bool> _confirm;
|
||||
private string _title = "", _subtitle = "", _introduction = "";
|
||||
private string _snapshot = "";
|
||||
private bool _isDirty, _showPreview, _loading;
|
||||
private ChangelogEntry _preview;
|
||||
private IReadOnlyList<string> _limitErrors = Array.Empty<string>();
|
||||
|
||||
/// <param name="confirm">Confirmation (titre, message) avant de retirer une section qui contient des éléments.</param>
|
||||
/// <param name="changed">Prévient la page (bouton « Enregistrer », pastilles).</param>
|
||||
public VersionEditor(ChangelogEntry published, Func<string, string, bool> confirm, Action changed)
|
||||
{
|
||||
Version = published.Version;
|
||||
Date = published.Date;
|
||||
_confirm = confirm;
|
||||
_changed = changed;
|
||||
_preview = new ChangelogEntry { Version = Version, Date = Date };
|
||||
Sections.CollectionChanged += OnSectionsChanged;
|
||||
AddSectionCommand = new RelayCommand(AddSection, () => Sections.Count < ChangelogLimits.MaxSectionsPerEntry);
|
||||
Load(published);
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
public DateTimeOffset Date { get; }
|
||||
public string DateText => Date == default ? "" : _preview.DateText;
|
||||
|
||||
// ------------------------------------------------------------------ En-tête
|
||||
|
||||
public string Title { get => _title; set => Set(ref _title, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-titre : une ou plusieurs lignes.</summary>
|
||||
public string Subtitle { get => _subtitle; set => Set(ref _subtitle, value ?? ""); }
|
||||
|
||||
/// <summary>Paragraphes d'introduction, séparés par une ligne vide.</summary>
|
||||
public string Introduction { get => _introduction; set => Set(ref _introduction, value ?? ""); }
|
||||
|
||||
public ObservableCollection<SectionEditor> Sections { get; } = new();
|
||||
public RelayCommand AddSectionCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
/// <summary>Modifié depuis le chargement (ou le dernier enregistrement).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
private set => SetProperty(ref _isDirty, value);
|
||||
}
|
||||
|
||||
/// <summary>Affichage : modification (faux) ou aperçu tel que le verront les joueurs (vrai).</summary>
|
||||
public bool ShowPreview
|
||||
{
|
||||
get => _showPreview;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showPreview, value)) return;
|
||||
OnPropertyChanged(nameof(IsEditMode));
|
||||
OnPropertyChanged(nameof(IsPreviewMode));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEditMode { get => !_showPreview; set { if (value) ShowPreview = false; } }
|
||||
public bool IsPreviewMode { get => _showPreview; set { if (value) ShowPreview = true; } }
|
||||
|
||||
/// <summary>Notes telles que le launcher les affichera.</summary>
|
||||
public ChangelogEntry Preview
|
||||
{
|
||||
get => _preview;
|
||||
private set => SetProperty(ref _preview, value);
|
||||
}
|
||||
|
||||
/// <summary>Limites dépassées (rien n'est tronqué : à corriger avant d'enregistrer).</summary>
|
||||
public IReadOnlyList<string> LimitErrors
|
||||
{
|
||||
get => _limitErrors;
|
||||
private set
|
||||
{
|
||||
_limitErrors = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasLimitErrors));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasLimitErrors => _limitErrors.Count > 0;
|
||||
|
||||
/// <summary>« 3 sections · 14 éléments ».</summary>
|
||||
public string CountText
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = Sections.Sum(s => s.Items.Count);
|
||||
return $"{Sections.Count} section{(Sections.Count > 1 ? "s" : "")} · {items} élément{(items > 1 ? "s" : "")} " +
|
||||
$"(au plus {ChangelogLimits.MaxItemsPerEntry})";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chargement, conversion
|
||||
|
||||
/// <summary>Affiche des notes publiées et en fait la référence « sans modification ».</summary>
|
||||
public void Load(ChangelogEntry entry)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_title = entry.Title ?? "";
|
||||
_subtitle = entry.Subtitle ?? "";
|
||||
_introduction = string.Join("\n\n", entry.Introduction ?? new List<string>());
|
||||
Sections.Clear();
|
||||
// Ancien format (liste à plat) : présenté en section(s) sans titre, enregistré au format enrichi.
|
||||
var sections = entry.Sections.Count > 0
|
||||
? entry.Sections
|
||||
: (entry.Changes ?? new List<string>())
|
||||
.Chunk(ChangelogLimits.MaxItemsPerSection)
|
||||
.Select(chunk => new ChangelogSection { Items = chunk.Select(c => new ChangelogItem { Text = c }).ToList() })
|
||||
.ToList();
|
||||
foreach (var section in sections) Sections.Add(new SectionEditor(section, this));
|
||||
foreach (var name in new[] { nameof(Title), nameof(Subtitle), nameof(Introduction) }) OnPropertyChanged(name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
_snapshot = Snapshot(Normalized(ToEntry()));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>Notes saisies (non tronquées) : c'est ce qui est envoyé au serveur.</summary>
|
||||
public ChangelogEntry ToEntry()
|
||||
{
|
||||
var entry = new ChangelogEntry
|
||||
{
|
||||
Version = Version,
|
||||
Date = Date,
|
||||
Title = Clean(Title),
|
||||
Subtitle = Clean(Subtitle.Replace("\r\n", "\n")),
|
||||
Introduction = Paragraphs(Introduction),
|
||||
Sections = Sections.Select(s => s.ToSection()).ToList()
|
||||
};
|
||||
// Liste à plat des anciens launchers : régénérée par le serveur à l'enregistrement (requête allégée).
|
||||
if (entry.Sections.Count == 0) entry.Changes = new List<string>();
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
internal void Changed()
|
||||
{
|
||||
if (_loading) return;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
var entry = ToEntry();
|
||||
var preview = Normalized(entry);
|
||||
var errors = ChangelogLimits.Check(entry);
|
||||
if (preview is null) errors.Insert(0, "Les notes ne peuvent pas être vides : gardez au moins un titre ou un élément.");
|
||||
LimitErrors = errors;
|
||||
Preview = preview ?? new ChangelogEntry { Version = Version, Date = Date };
|
||||
IsDirty = Snapshot(preview) != _snapshot;
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddSectionCommand.RaiseCanExecuteChanged();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (!SetProperty(ref field, value, name)) return false;
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddSection()
|
||||
{
|
||||
if (Sections.Count >= ChangelogLimits.MaxSectionsPerEntry) return;
|
||||
var section = new SectionEditor(new ChangelogSection(), this);
|
||||
section.AddChangeCommand.Execute(null);
|
||||
Sections.Add(section);
|
||||
}
|
||||
|
||||
internal void MoveSection(SectionEditor section, int delta) => ListMoves.Move(Sections, section, delta);
|
||||
|
||||
internal void RemoveSection(SectionEditor section)
|
||||
{
|
||||
if (section.Items.Any(i => !i.IsEmpty) &&
|
||||
!_confirm("Retirer la section ?", $"La section « {(string.IsNullOrWhiteSpace(section.Title) ? "sans titre" : section.Title.Trim())} » " +
|
||||
$"et ses {section.Items.Count} élément(s) seront retirés de ces notes (rien n'est enregistré avant « Enregistrer »)."))
|
||||
return;
|
||||
Sections.Remove(section);
|
||||
}
|
||||
|
||||
private void OnSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
foreach (var section in Sections) section.RefreshMoves();
|
||||
Changed();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static ChangelogEntry? Normalized(ChangelogEntry entry) => ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault();
|
||||
|
||||
private static string Snapshot(ChangelogEntry? entry) => entry is null ? "" : JsonSerializer.Serialize(entry, ApiJson.Options);
|
||||
|
||||
internal static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
|
||||
/// <summary>Paragraphes séparés par une (ou plusieurs) ligne(s) vide(s) ; retours à la ligne simples gardés en espaces.</summary>
|
||||
private static List<string> Paragraphs(string text) =>
|
||||
System.Text.RegularExpressions.Regex.Split(text.Replace("\r\n", "\n"), @"\n[ \t]*\n")
|
||||
.Select(p => Clean(p.Replace('\n', ' ')))
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
/// <summary>Une ligne par élément (sous-points).</summary>
|
||||
internal static List<string> Lines(string text) =>
|
||||
text.Replace("\r\n", "\n").Split('\n').Select(Clean).OfType<string>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Section des notes (titre facultatif) et ses éléments.</summary>
|
||||
public sealed class SectionEditor : ObservableObject
|
||||
{
|
||||
private readonly VersionEditor _version;
|
||||
private string _title;
|
||||
|
||||
public SectionEditor(ChangelogSection section, VersionEditor version)
|
||||
{
|
||||
_version = version;
|
||||
_title = section.Title ?? "";
|
||||
AddChangeCommand = new RelayCommand(() => Add(comment: false), CanAdd);
|
||||
AddCommentCommand = new RelayCommand(() => Add(comment: true), CanAdd);
|
||||
foreach (var item in section.Items ?? new List<ChangelogItem>()) Items.Add(new ItemEditor(item, this));
|
||||
Items.CollectionChanged += (_, _) =>
|
||||
{
|
||||
foreach (var item in Items) item.RefreshMoves();
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddChangeCommand.RaiseCanExecuteChanged();
|
||||
AddCommentCommand.RaiseCanExecuteChanged();
|
||||
_version.Changed();
|
||||
};
|
||||
MoveUpCommand = new RelayCommand(() => _version.MoveSection(this, -1), () => _version.Sections.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _version.MoveSection(this, +1),
|
||||
() => _version.Sections.IndexOf(this) is var i && i >= 0 && i < _version.Sections.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _version.RemoveSection(this));
|
||||
}
|
||||
|
||||
/// <summary>Titre de la section (facultatif), ex. « PROGRESSION ».</summary>
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _title, value ?? "")) _version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ItemEditor> Items { get; } = new();
|
||||
|
||||
public string CountText => $"{Items.Count} élément{(Items.Count > 1 ? "s" : "")}";
|
||||
|
||||
public RelayCommand AddChangeCommand { get; }
|
||||
public RelayCommand AddCommentCommand { get; }
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal VersionEditor Version => _version;
|
||||
|
||||
internal ChangelogSection ToSection() => new()
|
||||
{
|
||||
Title = VersionEditor.Clean(Title),
|
||||
Items = Items.Select(i => i.ToItem()).ToList()
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void MoveItem(ItemEditor item, int delta) => ListMoves.Move(Items, item, delta);
|
||||
|
||||
internal void RemoveItem(ItemEditor item) => Items.Remove(item);
|
||||
|
||||
private bool CanAdd() => Items.Count < ChangelogLimits.MaxItemsPerSection;
|
||||
|
||||
private void Add(bool comment)
|
||||
{
|
||||
if (!CanAdd()) return;
|
||||
Items.Add(new ItemEditor(new ChangelogItem { Type = comment ? ChangelogItem.CommentType : null }, this));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Élément d'une section : une modification (étiquette, sous-points, note) ou un commentaire.</summary>
|
||||
public sealed class ItemEditor : ObservableObject
|
||||
{
|
||||
public static readonly IReadOnlyList<Choice> Kinds = new[]
|
||||
{
|
||||
new Choice(ChangelogItem.ChangeType, "Modification"),
|
||||
new Choice(ChangelogItem.CommentType, "Commentaire")
|
||||
};
|
||||
|
||||
private readonly SectionEditor _section;
|
||||
private Choice _kind;
|
||||
private string _text, _tag, _details, _note;
|
||||
|
||||
public ItemEditor(ChangelogItem item, SectionEditor section)
|
||||
{
|
||||
_section = section;
|
||||
_kind = item.IsComment ? Kinds[1] : Kinds[0];
|
||||
_text = item.Text ?? "";
|
||||
_tag = item.Tag ?? "";
|
||||
_details = string.Join("\n", item.Details ?? new List<string>());
|
||||
_note = item.Note ?? "";
|
||||
MoveUpCommand = new RelayCommand(() => _section.MoveItem(this, -1), () => _section.Items.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _section.MoveItem(this, +1),
|
||||
() => _section.Items.IndexOf(this) is var i && i >= 0 && i < _section.Items.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _section.RemoveItem(this));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> KindChoices => Kinds;
|
||||
|
||||
public Choice Kind
|
||||
{
|
||||
get => _kind;
|
||||
set
|
||||
{
|
||||
// null : liste de l'écran en cours de remplacement, pas un choix de l'administrateur.
|
||||
if (value is null || !SetProperty(ref _kind, value)) return;
|
||||
OnPropertyChanged(nameof(IsComment));
|
||||
OnPropertyChanged(nameof(IsChange));
|
||||
_section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsComment => _kind.Value == ChangelogItem.CommentType;
|
||||
public bool IsChange => !IsComment;
|
||||
|
||||
public string Text { get => _text; set => Set(ref _text, value ?? ""); }
|
||||
|
||||
/// <summary>Étiquette en pastille, ex. « NOUVEAU » (modifications uniquement).</summary>
|
||||
public string Tag { get => _tag; set => Set(ref _tag, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-points : un par ligne (modifications uniquement).</summary>
|
||||
public string Details { get => _details; set => Set(ref _details, value ?? ""); }
|
||||
|
||||
/// <summary>Texte de conclusion après les sous-points (modifications uniquement).</summary>
|
||||
public string Note { get => _note; set => Set(ref _note, value ?? ""); }
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal bool IsEmpty => string.IsNullOrWhiteSpace(Text) && string.IsNullOrWhiteSpace(Details);
|
||||
|
||||
internal ChangelogItem ToItem() => IsComment
|
||||
? new ChangelogItem { Type = ChangelogItem.CommentType, Text = Text.Trim() }
|
||||
: new ChangelogItem
|
||||
{
|
||||
Text = Text.Trim(),
|
||||
Tag = VersionEditor.Clean(Tag),
|
||||
Details = VersionEditor.Lines(Details),
|
||||
Note = VersionEditor.Clean(Note)
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void Set(ref string field, string value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (SetProperty(ref field, value, name)) _section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ListMoves
|
||||
{
|
||||
public static void Move<T>(ObservableCollection<T> list, T item, int delta)
|
||||
{
|
||||
var index = list.IndexOf(item);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= list.Count) return;
|
||||
list.Move(index, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
|
||||
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
|
||||
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
|
||||
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
|
||||
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
|
||||
/// • « Enregistrer » : une requête signée par version modifiée, avec l'empreinte du manifest lu
|
||||
/// (si le manifest a changé entre-temps, rien n'est écrasé et le rechargement est proposé).
|
||||
/// </summary>
|
||||
public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "changelog.notes";
|
||||
|
||||
private ChangelogState? _state;
|
||||
private Choice? _selectedGame;
|
||||
private VersionEditor? _selectedVersion;
|
||||
private bool _hasChanges, _hasConflict;
|
||||
private string? _statusMessage;
|
||||
private int _skippedVersions;
|
||||
|
||||
public ChangelogPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame)) return;
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
|
||||
{
|
||||
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
|
||||
return;
|
||||
}
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
|
||||
_ = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Versions
|
||||
|
||||
public ObservableCollection<VersionEditor> Versions { get; } = new();
|
||||
|
||||
public VersionEditor? SelectedVersion
|
||||
{
|
||||
get => _selectedVersion;
|
||||
set
|
||||
{
|
||||
// null : la liste de l'écran se vide lors d'un rechargement ; la sélection est rétablie ensuite.
|
||||
if (value is not null) SetProperty(ref _selectedVersion, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasVersions => Versions.Count > 0;
|
||||
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
|
||||
|
||||
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
|
||||
public string ManifestText => _state is null ? "" :
|
||||
$"Version publiée : {_state.ManifestVersion} · {Versions.Count} version{(Versions.Count > 1 ? "s" : "")} avec des notes" +
|
||||
(_skippedVersions > 0 ? $" ({_skippedVersions} non modifiable{(_skippedVersions > 1 ? "s" : "")} en ligne : numéro avec caractères spéciaux ou en double)" : "");
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
public bool HasChanges
|
||||
{
|
||||
get => _hasChanges;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasChanges, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Le manifest a changé sur le serveur (version publiée, autre administrateur) : il faut recharger.</summary>
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
/// <summary>Faux pendant un chargement ou un enregistrement.</summary>
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
public AsyncRelayCommand SaveCommand { get; }
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
public Task LoadAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
Apply(state);
|
||||
StatusMessage = null;
|
||||
});
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes non enregistrées seront perdues : la page affichera celles du manifest publié.", "Abandonner et recharger"))
|
||||
return;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private void Apply(ChangelogState state)
|
||||
{
|
||||
var selected = _selectedVersion?.Version;
|
||||
_state = state;
|
||||
HasConflict = false;
|
||||
Versions.Clear();
|
||||
// Une version présente deux fois n'est modifiable qu'une fois (le serveur modifie la première).
|
||||
var editable = state.Entries.Where(e => ChangelogLimits.IsEditableVersion(e.Version))
|
||||
.DistinctBy(e => e.Version, StringComparer.Ordinal).ToList();
|
||||
_skippedVersions = state.Entries.Count - editable.Count;
|
||||
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
|
||||
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Enregistrement
|
||||
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_state is null || _selectedGame?.Value is not { } gameId) return;
|
||||
var dirty = Versions.Where(v => v.IsDirty).ToList();
|
||||
|
||||
// Tout est vérifié avant le premier envoi : limites, taille de chaque requête.
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var entry = version.ToEntry();
|
||||
var errors = version.LimitErrors; // limites + notes vides
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage($"Notes de la version {version.Version} à corriger",
|
||||
string.Join("\n", errors.Take(12)), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
var size = JsonSerializer.SerializeToUtf8Bytes(new ChangelogUpdateRequest { BaseSha256 = _state.ManifestSha256, Entry = entry }, ApiJson.Options).Length;
|
||||
if (size > ChangelogLimits.MaxEditRequestBytes)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage("Notes trop longues pour l'édition en ligne",
|
||||
$"Les notes de la version {version.Version} font {size / 1024} Ko (au plus {ChangelogLimits.MaxEditRequestBytes / 1024} Ko par version). " +
|
||||
"Raccourcissez-les, ou modifiez notes.txt puis republiez la version.", ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var saved = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var state = await Context.Session.SaveChangelogEntryAsync(gameId, version.ToEntry(), _state.ManifestSha256);
|
||||
_state = state;
|
||||
if (state.Entries.FirstOrDefault(e => e.Version == version.Version) is { } published) version.Load(published);
|
||||
saved.Add(version.Version);
|
||||
}
|
||||
StatusMessage = $"Notes enregistrées ({string.Join(", ", saved)}). Les launchers les afficheront à leur prochaine recherche de mise à jour.";
|
||||
Log.Info($"Administration : notes de version modifiées ({gameId} {string.Join(", ", saved)}).");
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
if (saved.Count > 0) StatusMessage = $"Notes enregistrées : {string.Join(", ", saved)}.";
|
||||
Context.Dialogs.ShowMessage("Manifest modifié entre-temps",
|
||||
"Le manifest du jeu a changé sur le serveur (nouvelle version publiée ou autre administrateur). Rechargez la page " +
|
||||
"pour partir de la version publiée (les modifications non enregistrées seront perdues : notez-les si besoin).",
|
||||
ModuleMessageKind.Warning);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
_state = null;
|
||||
HasConflict = false;
|
||||
StatusMessage = null;
|
||||
Versions.Clear();
|
||||
_selectedVersion = null;
|
||||
_skippedVersions = 0;
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void RefreshChanges() => HasChanges = Versions.Any(v => v.IsDirty);
|
||||
|
||||
private bool Confirm(string title, string message) => Context.Dialogs.Confirm(title, message, "Retirer", danger: true);
|
||||
}
|
||||
@@ -0,0 +1,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,369 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Publier une version » : choix du manifest.json produit par le .bat (ManifestBuilder), contrôle
|
||||
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
|
||||
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
|
||||
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
|
||||
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète.
|
||||
/// </summary>
|
||||
public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "releases.publish";
|
||||
|
||||
private Choice? _selectedGame;
|
||||
private LocalRelease? _release;
|
||||
private string? _manifestPath, _publishedVersion, _statusMessage, _progressText, _speedText;
|
||||
private string _publishedText = "";
|
||||
private IReadOnlyList<string> _warnings = Array.Empty<string>();
|
||||
private bool _isPublishing, _committing;
|
||||
private double _progressValue;
|
||||
private PublicationState? _interrupted;
|
||||
private CancellationTokenSource? _cancellation;
|
||||
|
||||
public PublishPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit);
|
||||
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _manifestPath is not null);
|
||||
PublishCommand = new AsyncRelayCommand(PublishAsync, () => CanEdit && _release is not null);
|
||||
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
|
||||
AbandonCommand = new AsyncRelayCommand(AbandonAsync, () => CanEdit && _interrupted is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu, dossier
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame) || _isPublishing) return;
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
SetRelease(null);
|
||||
_ = RefreshPublishedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public string? ManifestPath
|
||||
{
|
||||
get => _manifestPath;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _manifestPath, value)) RecheckCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>« Version en ligne : 1.1 » (ou la raison pour laquelle elle est inconnue).</summary>
|
||||
public string PublishedText
|
||||
{
|
||||
get => _publishedText;
|
||||
private set => SetProperty(ref _publishedText, value);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Version à publier
|
||||
|
||||
public bool HasRelease => _release is not null;
|
||||
public string VersionText => _release is null ? "" : $"Version {_release.Summary.Version}";
|
||||
public string? NotesTitle => _release?.Summary.NotesTitle;
|
||||
public bool HasNotesTitle => !string.IsNullOrWhiteSpace(NotesTitle);
|
||||
|
||||
public string FilesText => _release is null ? "" :
|
||||
$"{_release.Summary.FileCount} fichier(s), {Format.Bytes(_release.Summary.TotalSize)} · exécutable « {_release.Summary.Executable} »";
|
||||
|
||||
public string ArchiveText => _release is null ? "" : $"{_release.Summary.ArchiveName} ({Format.Bytes(_release.Summary.ArchiveSize)})";
|
||||
|
||||
/// <summary>Points d'attention : republication, retour à une version précédente…</summary>
|
||||
public IReadOnlyList<string> Warnings
|
||||
{
|
||||
get => _warnings;
|
||||
private set
|
||||
{
|
||||
_warnings = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasWarnings => _warnings.Count > 0;
|
||||
|
||||
// ------------------------------------------------------------------ Envoi
|
||||
|
||||
public bool IsPublishing
|
||||
{
|
||||
get => _isPublishing;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isPublishing, value)) return;
|
||||
OnPropertyChanged(nameof(HasInterrupted));
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
OnPropertyChanged(nameof(ShowBusyBar));
|
||||
RefreshCommands();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Faux pendant un contrôle ou un envoi.</summary>
|
||||
public bool CanEdit => !IsBusy && !_isPublishing;
|
||||
|
||||
/// <summary>Barre d'attente du contrôle du dossier (l'envoi a sa propre barre de progression).</summary>
|
||||
public bool ShowBusyBar => IsBusy && !_isPublishing;
|
||||
|
||||
public double ProgressValue
|
||||
{
|
||||
get => _progressValue;
|
||||
private set => SetProperty(ref _progressValue, value);
|
||||
}
|
||||
|
||||
public string? ProgressText
|
||||
{
|
||||
get => _progressText;
|
||||
private set => SetProperty(ref _progressText, value);
|
||||
}
|
||||
|
||||
public string? SpeedText
|
||||
{
|
||||
get => _speedText;
|
||||
private set => SetProperty(ref _speedText, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Envoi interrompu (annulé, coupure, erreur) : il reprendra à « Publier », ou peut être abandonné (fichiers
|
||||
/// reçus supprimés). Pas pendant l'envoi lui-même : la publication est alors ouverte, pas interrompue.
|
||||
/// </summary>
|
||||
public bool HasInterrupted => _interrupted is not null && !_isPublishing;
|
||||
|
||||
public string InterruptedText => _interrupted is null ? "" :
|
||||
$"L'envoi de la version {_interrupted.Version} a été interrompu. « Publier » le reprend là où il s'est arrêté ; " +
|
||||
"« Abandonner » supprime ce qui a déjà été reçu par le serveur.";
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public AsyncRelayCommand ChooseCommand { get; }
|
||||
public AsyncRelayCommand RecheckCommand { get; }
|
||||
public AsyncRelayCommand PublishCommand { get; }
|
||||
public RelayCommand CancelCommand { get; }
|
||||
public AsyncRelayCommand AbandonCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
OnPropertyChanged(nameof(ShowBusyBar));
|
||||
RefreshCommands();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshPublishedAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
private async Task ChooseAsync()
|
||||
{
|
||||
var path = Context.Dialogs.PickOpenFile("Manifest de la version à publier (dossier produit par le .bat)",
|
||||
"Manifest de publication (manifest.json)|manifest.json", _manifestPath is null ? null : Path.GetDirectoryName(_manifestPath));
|
||||
if (path is not null) await InspectAsync(path);
|
||||
}
|
||||
|
||||
/// <summary>Contrôle du dossier choisi (hors du fil de l'interface : lecture de nombreux fichiers).</summary>
|
||||
public Task InspectAsync(string manifestPath) => RunAsync(async () =>
|
||||
{
|
||||
ManifestPath = manifestPath;
|
||||
StatusMessage = null;
|
||||
SetRelease(null);
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var release = await Task.Run(() => ReleaseUploader.Inspect(manifestPath, gameId));
|
||||
await RefreshPublishedCoreAsync(gameId);
|
||||
SetRelease(release);
|
||||
});
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
if (_release is not { } release || _selectedGame?.Value is not { } gameId) return;
|
||||
var online = _publishedVersion switch
|
||||
{
|
||||
null => "version en ligne inconnue",
|
||||
"" => "aucune version en ligne",
|
||||
_ => $"version en ligne : {_publishedVersion}"
|
||||
};
|
||||
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
|
||||
$"Les joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
|
||||
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
|
||||
return;
|
||||
|
||||
StatusMessage = null;
|
||||
_committing = false;
|
||||
IsPublishing = true;
|
||||
// Créés ici, sur le fil de l'interface : l'envoi les rappelle sur ce fil (boutons, liaisons).
|
||||
var progress = new Progress<UploadProgress>(OnProgress);
|
||||
var started = new Progress<PublicationState>(OnStarted);
|
||||
_cancellation = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
using var _ = Context.BeginLongOperation();
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ReleaseUploader.PublishAsync(Context.Session, gameId, release, progress, started, _cancellation.Token);
|
||||
SetInterrupted(null);
|
||||
_publishedVersion = result.Version;
|
||||
PublishedText = $"Version en ligne : {result.Version}";
|
||||
StatusMessage = $"Version {result.Version} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
|
||||
(result.ArchiveAdded ? " et l'archive" : "") +
|
||||
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
|
||||
UpdateWarnings();
|
||||
Log.Info($"Administration : version {result.Version} de {gameId} publiée.");
|
||||
}
|
||||
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
|
||||
{
|
||||
StatusMessage = null;
|
||||
ProgressText = "Envoi interrompu.";
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cancellation.Dispose();
|
||||
_cancellation = null;
|
||||
_committing = false;
|
||||
IsPublishing = false;
|
||||
SpeedText = null;
|
||||
}
|
||||
}
|
||||
|
||||
private Task AbandonAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_interrupted is not { } publication) return;
|
||||
if (!Context.Dialogs.Confirm("Abandonner la publication ?",
|
||||
$"Les fichiers de la version {publication.Version} déjà reçus par le serveur seront supprimés. Rien n'a été mis en ligne.",
|
||||
"Abandonner", danger: true))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await Context.Session.CancelPublicationAsync(publication.PublicationId);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
// déjà terminée ou expirée
|
||||
}
|
||||
SetInterrupted(null);
|
||||
ProgressText = null;
|
||||
ProgressValue = 0;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private Task RefreshPublishedAsync() => _selectedGame?.Value is { } gameId ? RefreshPublishedCoreAsync(gameId) : Task.CompletedTask;
|
||||
|
||||
/// <summary>Version en ligne (manifest du serveur) ; une erreur ici n'empêche pas de continuer.</summary>
|
||||
private async Task RefreshPublishedCoreAsync(string gameId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
_publishedVersion = state.ManifestVersion;
|
||||
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
_publishedVersion = "";
|
||||
PublishedText = "Aucune version en ligne pour l'instant.";
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
|
||||
{
|
||||
_publishedVersion = null;
|
||||
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
|
||||
}
|
||||
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
_publishedVersion = null;
|
||||
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
|
||||
}
|
||||
UpdateWarnings();
|
||||
}
|
||||
|
||||
private void SetRelease(LocalRelease? release)
|
||||
{
|
||||
_release = release;
|
||||
foreach (var name in new[] { nameof(HasRelease), nameof(VersionText), nameof(NotesTitle), nameof(HasNotesTitle), nameof(FilesText), nameof(ArchiveText) })
|
||||
OnPropertyChanged(name);
|
||||
UpdateWarnings();
|
||||
RefreshCommands();
|
||||
}
|
||||
|
||||
private void UpdateWarnings()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (_release is { } release && !string.IsNullOrEmpty(_publishedVersion))
|
||||
{
|
||||
if (release.Summary.Version == _publishedVersion)
|
||||
warnings.Add("Cette version est déjà en ligne : elle sera republiée (fichiers, archive et notes de cette version remplacés).");
|
||||
else if (VersionUtil.TryParse(release.Summary.Version, out var local) && VersionUtil.TryParse(_publishedVersion, out var online) &&
|
||||
local < online)
|
||||
warnings.Add($"Version inférieure à celle en ligne ({_publishedVersion}) : les joueurs reviendront à la version {release.Summary.Version}.");
|
||||
}
|
||||
if (_release is { Summary.NotesTitle: null })
|
||||
warnings.Add("Aucune note pour cette version dans le manifest (celles déjà en ligne pour ce numéro sont conservées).");
|
||||
Warnings = warnings;
|
||||
}
|
||||
|
||||
private void OnStarted(PublicationState state)
|
||||
{
|
||||
SetInterrupted(state);
|
||||
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
|
||||
}
|
||||
|
||||
private void SetInterrupted(PublicationState? state)
|
||||
{
|
||||
_interrupted = state;
|
||||
OnPropertyChanged(nameof(HasInterrupted));
|
||||
OnPropertyChanged(nameof(InterruptedText));
|
||||
AbandonCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void OnProgress(UploadProgress progress)
|
||||
{
|
||||
if (progress.Step == ReleaseUploader.CommitStep && !_committing)
|
||||
{
|
||||
_committing = true; // la mise en ligne ne s'interrompt pas
|
||||
CancelCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
ProgressValue = progress.Total > 0 ? Math.Clamp((double)progress.Done / progress.Total, 0, 1) : 0;
|
||||
ProgressText = progress.Total > 0
|
||||
? $"{progress.Step} — {Format.Bytes(progress.Done)} / {Format.Bytes(progress.Total)}"
|
||||
: progress.Step;
|
||||
SpeedText = progress.BytesPerSecond > 0 ? Format.Speed(progress.BytesPerSecond) : null;
|
||||
}
|
||||
|
||||
private void RefreshCommands()
|
||||
{
|
||||
ChooseCommand.RaiseCanExecuteChanged();
|
||||
RecheckCommand.RaiseCanExecuteChanged();
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
CancelCommand.RaiseCanExecuteChanged();
|
||||
AbandonCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
|
||||
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)
|
||||
{
|
||||
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>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;
|
||||
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).
|
||||
return await session.CommitPublicationAsync(state.PublicationId, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -127,6 +127,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
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)
|
||||
|
||||
@@ -34,4 +34,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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ 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.Publishing;
|
||||
using GameLauncher.Platform;
|
||||
using GameLauncher.Services;
|
||||
using GameLauncher.ViewModels;
|
||||
@@ -504,7 +506,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
|
||||
{
|
||||
new AdminModule(),
|
||||
new CatalogAdminModule()
|
||||
new CatalogAdminModule(),
|
||||
new ChangelogAdminModule(),
|
||||
new PublishAdminModule()
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls"
|
||||
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin">
|
||||
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:achl="clr-namespace:GameLauncher.Modules.Admin.Changelog;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:apub="clr-namespace:GameLauncher.Modules.Admin.Publishing;assembly=GameLauncher.Modules.Admin">
|
||||
|
||||
<!--
|
||||
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
|
||||
@@ -694,4 +696,466 @@
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Notes de version » -->
|
||||
<!-- Version de la liste : numéro, date, pastille « modifié ». -->
|
||||
<Style x:Key="AdminVersionItemStyle" TargetType="ListBoxItem">
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd" CornerRadius="12" Padding="12,8" BorderThickness="1"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="Number" Text="{Binding Version}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding DateText}" FontSize="11.5" Margin="0,1,0,0" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
<Ellipse x:Name="Dirty" Grid.Column="1" Width="8" Height="8" Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource WarningBrush}" Visibility="Collapsed" ToolTip="Modifié, non enregistré" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
|
||||
<Setter TargetName="Number" Property="Foreground" Value="{DynamicResource Accent2Brush}" />
|
||||
</Trigger>
|
||||
<DataTrigger Binding="{Binding IsDirty}" Value="True">
|
||||
<Setter TargetName="Dirty" Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Bouton d'un sélecteur segmenté (« Modifier » / « Aperçu ») : RadioButton, ui:IconAssist.Icon = icône. -->
|
||||
<Style x:Key="AdminSegmentStyle" TargetType="RadioButton">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RadioButton">
|
||||
<Border x:Name="Bd" CornerRadius="9" Padding="12,6" Background="Transparent">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path x:Name="Icon" Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Data="{Binding Path=(ui:IconAssist.Icon), RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
<ContentPresenter VerticalAlignment="Center" TextElement.FontWeight="SemiBold" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentGradientBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type achl:ChangelogPanelViewModel}">
|
||||
<StackPanel>
|
||||
<!-- Erreur (chargement, jeu non configuré sur le serveur, refus…) -->
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Manifest modifié entre-temps -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding HasConflict, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Recharger" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}" />
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.History}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center"
|
||||
Text="Le manifest du jeu a changé sur le serveur pendant vos modifications : rechargez avant d'enregistrer." />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Résultat du dernier enregistrement -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
|
||||
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Jeu (plusieurs jeux déclarés dans launcher.json) -->
|
||||
<DockPanel Margin="0,0,0,14" Visibility="{Binding HasSeveralGames, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="340" HorizontalAlignment="Left"
|
||||
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
|
||||
IsEnabled="{Binding IsEditable}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Aucune note dans le manifest publié -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding ShowNoVersions, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Aucune note de version" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
|
||||
Text="Le manifest publié ne contient aucune note. Les notes d'une version se publient avec ManifestBuilder (notes.txt), puis se corrigent ici." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Saisie suspendue pendant un chargement ou un enregistrement -->
|
||||
<Grid IsEnabled="{Binding IsEditable}" Visibility="{Binding HasVersions, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="VERSIONS" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
|
||||
<ListBox ItemsSource="{Binding Versions}" SelectedItem="{Binding SelectedVersion, Mode=TwoWay}"
|
||||
ItemContainerStyle="{StaticResource AdminVersionItemStyle}"
|
||||
Background="Transparent" BorderThickness="0" Padding="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Disabled" />
|
||||
</StackPanel>
|
||||
<!-- Notes de la version sélectionnée (modèle VersionEditor ci-dessous) -->
|
||||
<ContentControl Grid.Column="1" Margin="18,0,0,0" Content="{Binding SelectedVersion}" Focusable="False" />
|
||||
</Grid>
|
||||
|
||||
<!-- Enregistrement -->
|
||||
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,18,0,14" />
|
||||
<DockPanel Margin="0,0,0,8">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
|
||||
ToolTip="Afficher les notes du manifest publié" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding SaveCommand}"
|
||||
ToolTip="Contrôle les notes puis les enregistre dans le manifest publié (requête signée par votre clé)" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ManifestText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Notes d'une version : modification ou aperçu -->
|
||||
<DataTemplate DataType="{x:Type achl:VersionEditor}">
|
||||
<StackPanel>
|
||||
<DockPanel Margin="0,0,0,14">
|
||||
<Border DockPanel.Dock="Right" CornerRadius="11" Padding="3" VerticalAlignment="Center"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton Style="{StaticResource AdminSegmentStyle}" Content="Modifier" IsChecked="{Binding IsEditMode, Mode=TwoWay}"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Wrench}" />
|
||||
<RadioButton Style="{StaticResource AdminSegmentStyle}" Content="Aperçu" IsChecked="{Binding IsPreviewMode, Mode=TwoWay}"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Play}" Margin="2,0,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock FontSize="16" FontWeight="SemiBold">
|
||||
<Run Text="Version" />
|
||||
<Run Text="{Binding Version, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0">
|
||||
<Run Text="{Binding DateText, Mode=OneWay}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding CountText, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Limites dépassées : à corriger avant d'enregistrer -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding HasLimitErrors, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="À corriger avant d'enregistrer" FontSize="12.5" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding LimitErrors}" Margin="0,4,0,0" Focusable="False">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontSize="12" TextWrapping="Wrap" Margin="0,1,0,0" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Modification -->
|
||||
<StackPanel Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVisibility}}">
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="En-tête" Style="{StaticResource AdminCardTitleStyle}" Margin="0,0,0,12" />
|
||||
<TextBlock Text="Titre" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" MaxLength="200" />
|
||||
<TextBlock Text="Sous-titre (une ou plusieurs lignes)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,6" />
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="58"
|
||||
Text="{Binding Subtitle, UpdateSourceTrigger=PropertyChanged}" MaxLength="400" />
|
||||
<TextBlock Text="Introduction" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,6" />
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" Text="{Binding Introduction, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0"
|
||||
Text="Paragraphes séparés par une ligne vide (20 au plus), affichés avant les sections." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Sections}" Focusable="False" />
|
||||
|
||||
<Button Style="{StaticResource SmallButtonStyle}" HorizontalAlignment="Left" Content="Ajouter une section"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddSectionCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Aperçu : modèles d'affichage du launcher (Themes/Changelog.xaml), mêmes bornes -->
|
||||
<StackPanel Visibility="{Binding IsPreviewMode, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,0,0,10"
|
||||
Text="Tel que les joueurs le verront dans le launcher (textes nettoyés et bornés de la même façon)." />
|
||||
<ContentControl Content="{Binding Preview}" ContentTemplate="{StaticResource ChangelogEntryTemplate}" Focusable="False" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Une section : titre, éléments -->
|
||||
<DataTemplate DataType="{x:Type achl:SectionEditor}">
|
||||
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource AdminFieldLabelStyle}">
|
||||
<Run Text="Section" />
|
||||
<Run Text="·" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<Run Text="{Binding CountText, Mode=OneWay}" Foreground="{DynamicResource TextMutedBrush}" FontWeight="Normal" />
|
||||
</TextBlock>
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}"
|
||||
MaxLength="200" ToolTip="Titre de la section (facultatif), ex. « PROGRESSION »" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Bottom" Margin="8,0,0,5">
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" ToolTip="Monter la section" Command="{Binding MoveUpCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" ToolTip="Descendre la section" Command="{Binding MoveDownCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Delete}" ToolTip="Retirer la section" Command="{Binding RemoveCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Items}" Focusable="False" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Modification" Margin="0,0,8,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddChangeCommand}" ToolTip="Ajouter une modification" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Commentaire"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddCommentCommand}" ToolTip="Ajouter un commentaire (texte en italique)" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Un élément : modification (étiquette, sous-points, note) ou commentaire -->
|
||||
<DataTemplate DataType="{x:Type achl:ItemEditor}">
|
||||
<Border CornerRadius="12" Padding="12,10" Margin="0,0,0,8" BorderThickness="1"
|
||||
Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Height="34" ItemsSource="{Binding KindChoices}"
|
||||
SelectedItem="{Binding Kind, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Type d'élément" />
|
||||
<TextBox Grid.Column="1" Style="{StaticResource ModernTextBoxStyle}" Height="34" Width="130" Margin="8,0,0,0" MaxLength="24"
|
||||
Text="{Binding Tag, UpdateSourceTrigger=PropertyChanged}" ToolTip="Étiquette en pastille (facultatif), ex. « NOUVEAU »"
|
||||
Visibility="{Binding IsChange, Converter={StaticResource BoolToVisibility}}" />
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" ToolTip="Monter" Command="{Binding MoveUpCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" ToolTip="Descendre" Command="{Binding MoveDownCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="8"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Delete}" ToolTip="Retirer" Command="{Binding RemoveCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="40" AcceptsReturn="False" MaxLength="2000"
|
||||
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" ToolTip="Texte" />
|
||||
|
||||
<StackPanel Visibility="{Binding IsChange, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Sous-points (un par ligne)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="40"
|
||||
Text="{Binding Details, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock Text="Note de fin (facultatif)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Height="34" MaxLength="2000"
|
||||
Text="{Binding Note, UpdateSourceTrigger=PropertyChanged}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Publier une version » -->
|
||||
<DataTemplate DataType="{x:Type apub:PublishPanelViewModel}">
|
||||
<StackPanel>
|
||||
<!-- Erreur (contrôle du dossier, refus du serveur…) -->
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
|
||||
VerticalAlignment="Top" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Version publiée -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
|
||||
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Envoi interrompu : reprise ou abandon -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding HasInterrupted, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Abandonner" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" Foreground="{DynamicResource DangerBrush}"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding AbandonCommand}" />
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Pause}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding InterruptedText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Jeu (plusieurs jeux déclarés dans launcher.json) -->
|
||||
<DockPanel Margin="0,0,0,14" Visibility="{Binding HasSeveralGames, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="340" HorizontalAlignment="Left"
|
||||
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
|
||||
IsEnabled="{Binding CanEdit}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Dossier de publication -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Style="{StaticResource AdminIconBadgeStyle}" VerticalAlignment="Top">
|
||||
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.PackageVariant}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="14,0,0,0">
|
||||
<TextBlock Text="Dossier de publication" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Text="{Binding PublishedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12" />
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="0,0,6,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" ToolTip="Contrôler de nouveau le dossier"
|
||||
Command="{Binding RecheckCommand}" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Choisir le manifest.json…"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding ChooseCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock VerticalAlignment="Center" FontSize="12.5" TextTrimming="CharacterEllipsis" ToolTip="{Binding ManifestPath}"
|
||||
Text="{Binding ManifestPath, TargetNullValue='Choisissez le manifest.json produit par le .bat de publication (dossier --output).'}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Version à publier (dossier contrôlé) -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasRelease, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Right" Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="11" Height="11" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.Check}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Dossier vérifié" FontSize="11.5" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding VersionText}" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding NotesTitle}" FontSize="13" Margin="0,4,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding HasNotesTitle, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Fichiers" Style="{StaticResource AdminLabelStyle}" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding FilesText}" Style="{StaticResource AdminValueStyle}" />
|
||||
<TextBlock Grid.Row="1" Text="Archive" Style="{StaticResource AdminLabelStyle}" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ArchiveText}" Style="{StaticResource AdminValueStyle}" />
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding Warnings}" Focusable="False" Margin="0,4,0,0"
|
||||
Visibility="{Binding HasWarnings, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
|
||||
VerticalAlignment="Top" Margin="0,2,0,0" />
|
||||
<TextBlock Text="{Binding}" Margin="8,0,0,0" FontSize="12.5" TextWrapping="Wrap" MaxWidth="600" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Avancement de l'envoi -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding ProgressText, Converter={StaticResource StringToVisibility}}">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Interrompre" Margin="12,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Pause}" Command="{Binding CancelCommand}"
|
||||
ToolTip="Arrête l'envoi ; « Publier » le reprendra là où il s'est arrêté"
|
||||
Visibility="{Binding IsPublishing, Converter={StaticResource BoolToVisibility}}" />
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SpeedText}" FontSize="12" Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding ProgressText}" FontSize="12.5" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Margin="0,12,0,0" Maximum="1" Value="{Binding ProgressValue, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Publication -->
|
||||
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,8,0,14" />
|
||||
<DockPanel Margin="0,0,0,8">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Publier"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishCommand}"
|
||||
ToolTip="Envoie les fichiers absents du serveur puis met la version en ligne (requêtes signées par votre clé)" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" Margin="0,0,16,0"
|
||||
Text="L'envoi peut être interrompu et repris. Le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète." />
|
||||
</DockPanel>
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
|
||||
Visibility="{Binding ShowBusyBar, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -86,4 +86,9 @@
|
||||
<Geometry x:Key="Icon.Restore">M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3Z</Geometry>
|
||||
<Geometry x:Key="Icon.ContentSaveOutline">M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3M19 19H5V5H16.17L19 7.83V19M12 12C10.34 12 9 13.34 9 15S10.34 18 12 18 15 16.66 15 15 13.66 12 12 12M6 6H15V10H6V6Z</Geometry>
|
||||
|
||||
<!-- Publication d'une version (phase 5) -->
|
||||
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
|
||||
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
|
||||
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Views;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Changelog;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Server;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Page « Notes de version » du menu d'administration.</summary>
|
||||
public class ChangelogPanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new ChangelogAdminModule() });
|
||||
|
||||
private static async Task<(TestApi Api, TempFolder Folder, FakeDialogs Dialogs, AdminShellViewModel Shell, ChangelogPanelViewModel Page)>
|
||||
OpenAsync(string? manifest)
|
||||
{
|
||||
var api = await TestApi.StartAsync(manifest is null
|
||||
? null
|
||||
: new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
signer.Dispose();
|
||||
var dialogs = new FakeDialogs();
|
||||
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(ChangelogPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
return (api, folder, dialogs, shell, Assert.IsType<ChangelogPanelViewModel>(workspace.SelectedPanel!.ViewModel));
|
||||
}
|
||||
|
||||
private static GameManifest ReadManifest(string path) =>
|
||||
JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(path), JsonDefaults.Options)!;
|
||||
|
||||
[Fact]
|
||||
public async Task Notes_are_edited_previewed_checked_and_saved_version_by_version()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
|
||||
Assert.StartsWith("Version publiée : 1.1 · 2 versions", page.ManifestText);
|
||||
Assert.False(page.HasSeveralGames);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(page.SaveCommand.CanExecute(null));
|
||||
|
||||
// Version 1.1 : titre, nouvel élément avec étiquette et sous-points ; aperçu à jour.
|
||||
var v11 = page.SelectedVersion!;
|
||||
Assert.Equal("1.1", v11.Version);
|
||||
v11.Title = "Version 1.1 — corrigée";
|
||||
var section = Assert.Single(v11.Sections);
|
||||
section.AddChangeCommand.Execute(null);
|
||||
var item = section.Items[^1];
|
||||
item.Text = "Les portes se ferment correctement.";
|
||||
item.Tag = "CORRIGÉ";
|
||||
item.Details = "salle 2\n\nsalle 3\n";
|
||||
Assert.True(v11.IsDirty);
|
||||
Assert.True(page.HasChanges);
|
||||
Assert.Equal("Version 1.1 — corrigée", v11.Preview.Title);
|
||||
Assert.Equal(new[] { "salle 2", "salle 3" }, v11.Preview.Sections[0].Items[1].Details);
|
||||
Assert.True(section.Items[1].MoveUpCommand.CanExecute(null));
|
||||
Assert.False(section.Items[1].MoveDownCommand.CanExecute(null));
|
||||
|
||||
// Version 1.0 (ancien format : liste à plat) : présentée comme une section sans titre.
|
||||
var v10 = page.Versions[1];
|
||||
var legacy = Assert.Single(v10.Sections);
|
||||
Assert.Equal("", legacy.Title);
|
||||
Assert.Equal("Sortie du niveau 1.", Assert.Single(legacy.Items).Text);
|
||||
legacy.Items[0].Text = "Sortie du niveau 1 « L'école ».";
|
||||
|
||||
// Limite dépassée : signalée, rien n'est envoyé.
|
||||
item.Tag = new string('X', ChangelogLimits.MaxTagLength + 1);
|
||||
Assert.True(v11.HasLimitErrors);
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Notes de la version 1.1 à corriger"));
|
||||
Assert.Equal("Version 1.1", ReadManifest(manifest).Changelog[0].Title);
|
||||
|
||||
item.Tag = "CORRIGÉ";
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Contains("1.1, 1.0", page.StatusMessage);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(v11.IsDirty);
|
||||
Assert.False(v10.IsDirty);
|
||||
|
||||
var saved = ReadManifest(manifest);
|
||||
Assert.Equal("Version 1.1 — corrigée", saved.Changelog[0].Title);
|
||||
Assert.Equal("CORRIGÉ", saved.Changelog[0].Sections[0].Items[1].Tag);
|
||||
Assert.Equal("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Sections[0].Items[0].Text);
|
||||
Assert.Contains("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Changes); // anciens launchers
|
||||
Assert.Equal("1.1", saved.Version);
|
||||
saved.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_manifest_changed_on_the_server_is_reported_and_reloaded()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
File.WriteAllText(manifest, File.ReadAllText(manifest).Replace("Première version", "Première version (WinSCP)"));
|
||||
page.SelectedVersion!.Title = "Ma correction";
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.True(page.HasConflict);
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Manifest modifié entre-temps"));
|
||||
Assert.DoesNotContain("Ma correction", File.ReadAllText(manifest));
|
||||
|
||||
// Recharger : confirmation (modifications perdues), puis le manifest publié est affiché.
|
||||
dialogs.ConfirmAnswer = false;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.True(page.HasChanges);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.False(page.HasConflict);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.Equal("1.1", page.SelectedVersion!.Version); // même version resélectionnée
|
||||
Assert.Equal("Première version (WinSCP)", page.Versions[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_a_section_with_items_asks_first()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
var version = page.SelectedVersion!;
|
||||
version.AddSectionCommand.Execute(null);
|
||||
Assert.Equal(2, version.Sections.Count);
|
||||
version.Sections[1].RemoveCommand.Execute(null); // vide : retirée sans question
|
||||
Assert.Single(version.Sections);
|
||||
Assert.Empty(dialogs.Confirmations);
|
||||
|
||||
dialogs.ConfirmAnswer = false;
|
||||
version.Sections[0].RemoveCommand.Execute(null);
|
||||
Assert.Single(version.Sections);
|
||||
Assert.Single(dialogs.Confirmations);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
version.Sections[0].RemoveCommand.Execute(null);
|
||||
Assert.Empty(version.Sections);
|
||||
Assert.True(version.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_game_without_configured_manifest_shows_why()
|
||||
{
|
||||
var (api, folder, _, shell, page) = await OpenAsync(manifest: null);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
Assert.True(page.HasError);
|
||||
Assert.Contains("non configuré", page.ErrorMessage);
|
||||
Assert.False(page.HasVersions);
|
||||
Assert.False(page.SaveCommand.CanExecute(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Mvvm;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Publishing;
|
||||
using GameLauncher.Tests.Server;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Page « Publier une version » du menu d'administration.</summary>
|
||||
public class PublishPanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new PublishAdminModule() });
|
||||
|
||||
[Fact]
|
||||
public async Task The_page_checks_the_folder_warns_and_publishes()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 4, bigFileBytes: 3 * 1024 * 1024, archiveBytes: 5000);
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
var dialogs = new FakeDialogs();
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(PublishPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
|
||||
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
|
||||
Assert.False(page.PublishCommand.CanExecute(null));
|
||||
|
||||
// Dossier choisi : contrôlé ; même numéro que la version en ligne → avertissement.
|
||||
dialogs.NextOpenFile = release.ManifestPath;
|
||||
await page.ChooseCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.True(page.HasRelease);
|
||||
Assert.Equal("Version 1.1", page.VersionText);
|
||||
Assert.Contains(page.Warnings, w => w.Contains("déjà en ligne"));
|
||||
Assert.True(page.PublishCommand.CanExecute(null));
|
||||
|
||||
// Refus de la confirmation : rien n'est envoyé.
|
||||
dialogs.ConfirmAnswer = false;
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
|
||||
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Contains("Version 1.1 publiée", page.StatusMessage);
|
||||
Assert.False(page.IsPublishing);
|
||||
Assert.False(page.HasInterrupted);
|
||||
Assert.Equal("Nouveautés.", JsonNode.Parse(File.ReadAllText(manifest))!["changelog"]![0]!["changes"]![0]!.GetValue<string>());
|
||||
|
||||
// Dossier incomplet : message clair.
|
||||
File.Delete(Path.Combine(release.Folder, "EZR-1.1.zip"));
|
||||
await page.RecheckCommand.ExecuteAsync();
|
||||
Assert.True(page.HasError);
|
||||
Assert.Contains("Archive absente", page.ErrorMessage);
|
||||
Assert.False(page.HasRelease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publishing_from_the_interface_only_notifies_it_from_its_own_thread()
|
||||
{
|
||||
// Comme sous WPF : la page vit sur le fil de l'interface, et tout ce qui notifie les boutons
|
||||
// ou les liaisons doit s'y exécuter (sinon « un autre thread en est propriétaire »).
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 4, bigFileBytes: 3 * 1024 * 1024, archiveBytes: 5000);
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
var dialogs = new FakeDialogs { ConfirmAnswer = true, NextOpenFile = release.ManifestPath };
|
||||
using var ui = new UiThread();
|
||||
var offThread = new ConcurrentQueue<string>();
|
||||
|
||||
await ui.RunAsync(async () =>
|
||||
{
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(PublishPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
|
||||
page.PropertyChanged += (_, e) => { if (!ui.IsCurrent) offThread.Enqueue($"PropertyChanged({e.PropertyName})"); };
|
||||
foreach (var (name, command) in new (string, ICommand)[]
|
||||
{
|
||||
("Choose", page.ChooseCommand), ("Recheck", page.RecheckCommand), ("Publish", page.PublishCommand),
|
||||
("Cancel", page.CancelCommand), ("Abandon", page.AbandonCommand)
|
||||
})
|
||||
command.CanExecuteChanged += (_, _) => { if (!ui.IsCurrent) offThread.Enqueue($"{name}.CanExecuteChanged"); };
|
||||
|
||||
await page.ChooseCommand.ExecuteAsync();
|
||||
Assert.True(page.HasRelease, page.ErrorMessage);
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Contains("Version 1.2 publiée", page.StatusMessage);
|
||||
Assert.False(page.HasInterrupted);
|
||||
});
|
||||
|
||||
Assert.Empty(offThread);
|
||||
Assert.Empty(ui.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_interrupted_banner_only_appears_once_the_upload_has_stopped()
|
||||
{
|
||||
// Limite des morceaux à 2 par minute : manifest, lot de fichiers, puis l'archive est refusée (429 +
|
||||
// Retry-After) et l'envoi attend ; la page affiche la pause, puis l'administrateur interrompt l'envoi
|
||||
// pendant l'attente. (Petits envois : sans nginx devant, Kestrel coupe la connexion au lieu de répondre
|
||||
// 429 quand il refuse un gros corps qu'il n'a pas lu.)
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 4, bigFileBytes: 100_000, archiveBytes: 5000);
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Games:EZR:ManifestFile"] = manifest,
|
||||
["LauncherApi:Limits:PublicationUploadsPerMinute"] = "2"
|
||||
});
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
var dialogs = new FakeDialogs { ConfirmAnswer = true, NextOpenFile = release.ManifestPath };
|
||||
using var ui = new UiThread();
|
||||
|
||||
await ui.RunAsync(async () =>
|
||||
{
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(PublishPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
await page.ChooseCommand.ExecuteAsync();
|
||||
Assert.True(page.HasRelease, page.ErrorMessage);
|
||||
|
||||
var bannerDuringUpload = false;
|
||||
string? pause = null;
|
||||
page.PropertyChanged += (_, _) =>
|
||||
{
|
||||
if (page.IsPublishing && page.HasInterrupted) bannerDuringUpload = true;
|
||||
if (pause is null && page.IsPublishing && page.ProgressText?.Contains("reprise dans") == true)
|
||||
{
|
||||
pause = page.ProgressText;
|
||||
page.CancelCommand.Execute(null);
|
||||
}
|
||||
};
|
||||
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.False(bannerDuringUpload);
|
||||
Assert.NotNull(pause);
|
||||
Assert.Contains("limite de requêtes du serveur atteinte", pause);
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.False(page.IsPublishing);
|
||||
Assert.True(page.HasInterrupted); // arrêté : « Publier » reprendra, « Abandonner » nettoie
|
||||
Assert.Contains("version 1.2", page.InterruptedText);
|
||||
Assert.Equal("Envoi interrompu.", page.ProgressText);
|
||||
|
||||
await page.AbandonCommand.ExecuteAsync();
|
||||
Assert.False(page.HasInterrupted);
|
||||
});
|
||||
|
||||
Assert.Empty(ui.Errors);
|
||||
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_command_raised_from_a_background_thread_notifies_on_the_interface_thread()
|
||||
{
|
||||
using var ui = new UiThread();
|
||||
RelayCommand? command = null;
|
||||
AsyncRelayCommand? asyncCommand = null;
|
||||
await ui.RunAsync(() =>
|
||||
{
|
||||
command = new RelayCommand(() => { });
|
||||
asyncCommand = new AsyncRelayCommand(() => Task.CompletedTask);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var threads = new ConcurrentQueue<int>();
|
||||
var both = new CountdownEvent(2);
|
||||
command!.CanExecuteChanged += (_, _) => { threads.Enqueue(Environment.CurrentManagedThreadId); both.Signal(); };
|
||||
asyncCommand!.CanExecuteChanged += (_, _) => { threads.Enqueue(Environment.CurrentManagedThreadId); both.Signal(); };
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
command.RaiseCanExecuteChanged();
|
||||
asyncCommand.RaiseCanExecuteChanged();
|
||||
});
|
||||
|
||||
Assert.True(both.Wait(TimeSpan.FromSeconds(10)));
|
||||
Assert.All(threads, id => Assert.Equal(ui.ThreadId, id));
|
||||
|
||||
// Hors de toute interface (tests, services) : notification directe, comme avant.
|
||||
var direct = new RelayCommand(() => { });
|
||||
var raised = false;
|
||||
direct.CanExecuteChanged += (_, _) => raised = true;
|
||||
direct.RaiseCanExecuteChanged();
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_long_operation_postpones_the_inactivity_lock()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
|
||||
using (workspace.BeginLongOperation())
|
||||
{
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
api.Time.Advance(TimeSpan.FromMinutes(4));
|
||||
await workspace.Guard.TickAsync();
|
||||
}
|
||||
Assert.Same(workspace, shell.Screen); // 24 minutes d'envoi sans souris ni clavier : pas de verrouillage
|
||||
}
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(11));
|
||||
await workspace.Guard.TickAsync();
|
||||
Assert.IsType<LoginViewModel>(shell.Screen); // 10 minutes après la fin de l'opération : verrouillé
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Server;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GameLauncher.Tests.Changelog;
|
||||
|
||||
/// <summary>ManifestBuilder --remote-manifest et bornes communes des notes de version.</summary>
|
||||
public class ChangelogHistoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void New_notes_go_first_and_a_republished_version_replaces_its_old_notes()
|
||||
{
|
||||
var history = new List<ChangelogEntry?>
|
||||
{
|
||||
new() { Version = "1.1", Title = "Corrigé en ligne" },
|
||||
null,
|
||||
new() { Version = "1.0", Title = "Première version" }
|
||||
};
|
||||
|
||||
var republished = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.1", Title = "Notes de notes.txt" });
|
||||
Assert.Equal(new[] { "1.1", "1.0" }, republished.Select(e => e.Version));
|
||||
Assert.Equal("Notes de notes.txt", republished[0].Title);
|
||||
|
||||
var next = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.2", Title = "Nouveautés" });
|
||||
Assert.Equal(new[] { "1.2", "1.1", "1.0" }, next.Select(e => e.Version));
|
||||
Assert.Equal("Corrigé en ligne", next[1].Title); // correction en ligne conservée
|
||||
|
||||
var withoutNotes = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.3" });
|
||||
Assert.Equal(new[] { "1.1", "1.0" }, withoutNotes.Select(e => e.Version));
|
||||
|
||||
// Version republiée sans notes.txt : ses notes (corrigées en ligne) sont gardées.
|
||||
var sameWithoutNotes = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.1" });
|
||||
Assert.Equal("Corrigé en ligne", sameWithoutNotes[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_published_manifest_is_read_and_a_missing_one_means_first_publication()
|
||||
{
|
||||
var manifest = File.ReadAllText(ChangelogModuleTests.PublishedManifest());
|
||||
var port = TestApi.FreePort();
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
||||
builder.Logging.ClearProviders();
|
||||
await using var app = builder.Build();
|
||||
app.MapGet("/exovivalzombie/manifest.json", () => Results.Text(manifest, "application/json"));
|
||||
app.MapGet("/casse/manifest.json", () => Results.Text("{ pas du json", "application/json"));
|
||||
await app.StartAsync();
|
||||
|
||||
using var http = new HttpDownloader("GameLauncher.Tests");
|
||||
var root = new Uri($"http://127.0.0.1:{port}/");
|
||||
var published = await ChangelogHistory.FetchPublishedAsync(http, new Uri(root, "exovivalzombie/manifest.json"));
|
||||
Assert.Equal("EZR", published!.GameId);
|
||||
Assert.Equal(new[] { "1.1", "1.0" }, published.Changelog.Select(e => e.Version));
|
||||
|
||||
Assert.Null(await ChangelogHistory.FetchPublishedAsync(http, new Uri(root, "absent/manifest.json")));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => ChangelogHistory.FetchPublishedAsync(http, new Uri(root, "casse/manifest.json")));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => ChangelogHistory.FetchPublishedAsync(http, new Uri("http://launcher.example/m.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Limits_are_reported_without_truncating()
|
||||
{
|
||||
var entry = new ChangelogEntry
|
||||
{
|
||||
Version = "1.1",
|
||||
Title = new string('t', ChangelogLimits.MaxTitleLength + 1),
|
||||
Sections = new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Items = new()
|
||||
{
|
||||
new() { Text = "ok", Tag = new string('T', ChangelogLimits.MaxTagLength + 1) },
|
||||
new() { Text = "ok", Details = Enumerable.Repeat("d", ChangelogLimits.MaxDetailsPerItem + 1).ToList() }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var errors = ChangelogLimits.Check(entry);
|
||||
Assert.Equal(3, errors.Count);
|
||||
Assert.Contains(errors, e => e.StartsWith("Titre"));
|
||||
Assert.Contains(errors, e => e.Contains("élément 1 (étiquette)"));
|
||||
Assert.Contains(errors, e => e.Contains("sous-points"));
|
||||
Assert.Equal(ChangelogLimits.MaxTitleLength + 1, entry.Title!.Length); // rien n'est modifié
|
||||
|
||||
// Un texte tronqué par Normalize (ManifestBuilder, launcher) reste dans les limites de Check.
|
||||
var truncated = ChangelogLimits.Normalize(new[] { entry }).Single();
|
||||
Assert.Equal(ChangelogLimits.MaxTitleLength, truncated.Title!.Length);
|
||||
Assert.EndsWith("…", truncated.Title);
|
||||
Assert.DoesNotContain(ChangelogLimits.Check(truncated), e => e.StartsWith("Titre"));
|
||||
Assert.Empty(ChangelogLimits.Check(new ChangelogEntry { Version = "1", Sections = new() { null!, new() { Items = new() { null! } } } }));
|
||||
|
||||
Assert.True(ChangelogLimits.IsEditableVersion("1.0.3-beta_2"));
|
||||
Assert.False(ChangelogLimits.IsEditableVersion("1.0/../x"));
|
||||
Assert.False(ChangelogLimits.IsEditableVersion("1.0+build"));
|
||||
Assert.False(ChangelogLimits.IsEditableVersion(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changelog_model_keeps_its_json_format_after_moving_to_contracts()
|
||||
{
|
||||
var json = """{ "version": "1.0", "date": "2026-09-01T12:00:00+02:00", "sections": [ { "title": "A", "items": [ { "type": "comment", "text": "c" } ] } ], "changes": [] }""";
|
||||
var entry = JsonSerializer.Deserialize<ChangelogEntry>(json, JsonDefaults.Options)!;
|
||||
Assert.True(entry.Sections[0].Items[0].IsComment);
|
||||
Assert.Equal("1 septembre 2026", entry.DateText);
|
||||
Assert.DoesNotContain("dateText", JsonSerializer.Serialize(entry, JsonDefaults.Options));
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ public sealed class TestApi : IAsyncDisposable
|
||||
["LauncherApi:DataDirectory"] = dataDirectory,
|
||||
["LauncherApi:RequireHttps"] = "false",
|
||||
["LauncherApi:Limits:RequestsPerMinute"] = "100000",
|
||||
["LauncherApi:Limits:PublicationUploadsPerMinute"] = "100000",
|
||||
["LauncherApi:Limits:AuthPerMinute"] = "100000",
|
||||
["LauncherApi:Limits:EnrollmentsPerHour"] = "100000"
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace GameLauncher.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Fil « interface » de test : un seul thread avec son SynchronizationContext, comme le Dispatcher WPF.
|
||||
/// Permet de vérifier qu'un ViewModel ne notifie l'interface que depuis ce fil.
|
||||
/// </summary>
|
||||
public sealed class UiThread : IDisposable
|
||||
{
|
||||
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
|
||||
private readonly ConcurrentQueue<Exception> _errors = new();
|
||||
|
||||
public UiThread()
|
||||
{
|
||||
using var ready = new ManualResetEventSlim();
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(new Context(this));
|
||||
ThreadId = Environment.CurrentManagedThreadId;
|
||||
ready.Set();
|
||||
foreach (var (callback, state) in _queue.GetConsumingEnumerable())
|
||||
{
|
||||
try
|
||||
{
|
||||
callback(state);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errors.Enqueue(ex); // comme une exception non gérée du Dispatcher
|
||||
}
|
||||
}
|
||||
}) { IsBackground = true, Name = "UI (tests)" };
|
||||
thread.Start();
|
||||
ready.Wait();
|
||||
}
|
||||
|
||||
public int ThreadId { get; private set; }
|
||||
|
||||
/// <summary>Vrai si l'appelant est sur le fil « interface ».</summary>
|
||||
public bool IsCurrent => Environment.CurrentManagedThreadId == ThreadId;
|
||||
|
||||
/// <summary>Exceptions levées par les rappels exécutés sur le fil « interface ».</summary>
|
||||
public IReadOnlyCollection<Exception> Errors => _errors;
|
||||
|
||||
/// <summary>Exécute <paramref name="action"/> sur le fil « interface » (ses <c>await</c> y reviennent).</summary>
|
||||
public Task RunAsync(Func<Task> action)
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Post(async _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await action();
|
||||
done.SetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
done.SetException(ex);
|
||||
}
|
||||
}, null);
|
||||
return done.Task;
|
||||
}
|
||||
|
||||
private void Post(SendOrPostCallback callback, object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
_queue.Add((callback, state));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// fil arrêté (fin du test) : rappel ignoré
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _queue.CompleteAdding();
|
||||
|
||||
private sealed class Context : SynchronizationContext
|
||||
{
|
||||
private readonly UiThread _ui;
|
||||
public Context(UiThread ui) => _ui = ui;
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state) => _ui.Post(d, state);
|
||||
|
||||
public override void Send(SendOrPostCallback d, object? state)
|
||||
{
|
||||
if (_ui.IsCurrent)
|
||||
{
|
||||
d(state);
|
||||
return;
|
||||
}
|
||||
using var done = new ManualResetEventSlim();
|
||||
_ui.Post(s =>
|
||||
{
|
||||
try
|
||||
{
|
||||
d(s);
|
||||
}
|
||||
finally
|
||||
{
|
||||
done.Set();
|
||||
}
|
||||
}, state);
|
||||
done.Wait();
|
||||
}
|
||||
|
||||
public override SynchronizationContext CreateCopy() => this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Server;
|
||||
|
||||
namespace GameLauncher.Tests.Publishing;
|
||||
|
||||
/// <summary>Publication d'une version depuis le menu d'administration (envoi, reprise, mise en ligne).</summary>
|
||||
public class PublishingTests
|
||||
{
|
||||
/// <summary>Dossier produit par ManifestBuilder (--output) : manifest, archive, objects/.</summary>
|
||||
public sealed class ReleaseFolder
|
||||
{
|
||||
public string Folder { get; } = Path.Combine(Path.GetTempPath(), "launcher-publish-tests", Guid.NewGuid().ToString("N"));
|
||||
public string ManifestPath => Path.Combine(Folder, "manifest.json");
|
||||
public GameManifest Manifest { get; private set; } = new();
|
||||
public List<(string Path, byte[] Data)> Files { get; } = new();
|
||||
public byte[] Archive { get; private set; } = Array.Empty<byte>();
|
||||
|
||||
public static ReleaseFolder Create(string version = "1.2", int smallFiles = 30, int bigFileBytes = 5 * 1024 * 1024,
|
||||
int archiveBytes = 9 * 1024 * 1024 + 123, byte[]? shared = null)
|
||||
{
|
||||
var release = new ReleaseFolder();
|
||||
Directory.CreateDirectory(release.Folder);
|
||||
release.Files.Add(("Exovival Zombie Reborn.exe", RandomNumberGenerator.GetBytes(40_000)));
|
||||
for (var i = 0; i < smallFiles; i++) release.Files.Add(($"Data/level{i}.pak", RandomNumberGenerator.GetBytes(1000 + i * 137)));
|
||||
release.Files.Add(("Data/empty.txt", Array.Empty<byte>()));
|
||||
release.Files.Add(("Data/big.assets", RandomNumberGenerator.GetBytes(bigFileBytes)));
|
||||
if (shared is not null) release.Files.Add(("Data/shared.pak", shared));
|
||||
release.Archive = RandomNumberGenerator.GetBytes(archiveBytes);
|
||||
release.Write(version);
|
||||
return release;
|
||||
}
|
||||
|
||||
public void Write(string version, Action<GameManifest>? edit = null)
|
||||
{
|
||||
foreach (var (_, data) in Files)
|
||||
{
|
||||
var path = Path.Combine(Folder, ManifestCheck.ObjectPath(Sha(data)));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllBytes(path, data);
|
||||
}
|
||||
var archiveName = $"EZR-{version}.zip";
|
||||
File.WriteAllBytes(Path.Combine(Folder, archiveName), Archive);
|
||||
Manifest = new GameManifest
|
||||
{
|
||||
GameId = "EZR",
|
||||
Version = version,
|
||||
ReleaseDate = DateTimeOffset.Now,
|
||||
Executable = "Exovival Zombie Reborn.exe",
|
||||
Archive = new ArchiveInfo { Url = archiveName, Size = Archive.Length, Sha256 = Sha(Archive) },
|
||||
Files = Files.Select(f => new ManifestFile { Path = f.Path, Size = f.Data.Length, Sha256 = Sha(f.Data) }).ToList(),
|
||||
Changelog = new List<ChangelogEntry>
|
||||
{
|
||||
new() { Version = version, Date = DateTimeOffset.Now, Title = $"Version {version}", Changes = new() { "Nouveautés." } },
|
||||
new() { Version = "1.1", Title = "Notes locales de la 1.1 (pas celles du serveur)", Changes = new() { "ancien" } }
|
||||
}
|
||||
};
|
||||
edit?.Invoke(Manifest);
|
||||
File.WriteAllText(ManifestPath, JsonSerializer.Serialize(Manifest, JsonDefaults.Options));
|
||||
}
|
||||
}
|
||||
|
||||
public static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
|
||||
private static Dictionary<string, string?> Settings(string manifest) => new() { ["LauncherApi:Games:EZR:ManifestFile"] = manifest };
|
||||
|
||||
/// <summary>Avancement transmis immédiatement (Progress<T> passerait par le pool de threads).</summary>
|
||||
private sealed class Sync<T> : IProgress<T>
|
||||
{
|
||||
private readonly Action<T> _action;
|
||||
public Sync(Action<T> action) => _action = action;
|
||||
public void Report(T value) => _action(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_version_is_uploaded_and_put_online_with_the_server_notes_history()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var gameDirectory = Path.GetDirectoryName(manifest)!;
|
||||
// Un fichier est déjà sur le serveur (publication précédente) : il n'est pas renvoyé.
|
||||
var shared = RandomNumberGenerator.GetBytes(3000);
|
||||
var sharedPath = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(shared)));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(sharedPath)!);
|
||||
File.WriteAllBytes(sharedPath, shared);
|
||||
var release = ReleaseFolder.Create(shared: shared);
|
||||
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
|
||||
Assert.Equal("1.2", local.Summary.Version);
|
||||
Assert.Equal("Version 1.2", local.Summary.NotesTitle);
|
||||
|
||||
var reports = new List<UploadProgress>();
|
||||
PublicationState? started = null;
|
||||
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, new Sync<UploadProgress>(reports.Add), new Sync<PublicationState>(s => started = s), default);
|
||||
|
||||
Assert.Equal("1.2", result.Version);
|
||||
Assert.Equal("1.1", result.PreviousVersion);
|
||||
Assert.Equal(release.Files.Count - 1, result.ObjectsAdded); // le fichier partagé était déjà là
|
||||
Assert.True(result.ArchiveAdded);
|
||||
Assert.Equal("1.1", started!.PublishedVersion);
|
||||
Assert.Equal(reports[^1].Total, reports.Where(r => r.Step.StartsWith("Envoi")).Max(r => r.Done));
|
||||
Assert.Contains(reports, r => r.Step == "Mise en ligne");
|
||||
|
||||
// Fichiers en ligne, identiques à ceux du dossier ; aucun dépôt laissé.
|
||||
foreach (var (_, data) in release.Files)
|
||||
Assert.Equal(data, File.ReadAllBytes(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(data)))));
|
||||
Assert.Equal(release.Archive, File.ReadAllBytes(Path.Combine(gameDirectory, "EZR-1.2.zip")));
|
||||
Assert.Empty(Directory.GetDirectories(gameDirectory, ".publish-*"));
|
||||
Assert.Empty(Directory.GetFiles(Path.Combine(api.DataDirectory, "publications")).Where(f => !f.EndsWith(".done.json")));
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead; // comme le manifest en ligne
|
||||
Assert.Equal(expected, File.GetUnixFileMode(Path.Combine(gameDirectory, "EZR-1.2.zip")));
|
||||
Assert.True(File.GetUnixFileMode(Path.GetDirectoryName(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(release.Files[0].Data))))!)
|
||||
.HasFlag(UnixFileMode.GroupExecute)); // dossiers objects/xx traversables par nginx
|
||||
}
|
||||
|
||||
// Manifest : celui de la version, avec l'historique des notes du SERVEUR (pas les notes locales de la 1.1).
|
||||
var online = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
|
||||
online.Validate();
|
||||
Assert.Equal("1.2", online.Version);
|
||||
Assert.Equal(new[] { "1.2", "1.1", "1.0" }, online.Changelog.Select(e => e.Version));
|
||||
Assert.Equal("Version 1.1", online.Changelog[1].Title);
|
||||
Assert.Equal(release.Manifest.Files.Count, online.Files.Count);
|
||||
|
||||
var audit = await owner.GetAuditAsync();
|
||||
Assert.Contains(audit.Entries, e => e.Action == "release.start" && e.Target == "EZR 1.2");
|
||||
Assert.Contains(audit.Entries, e => e.Action == "release.publish" && e.Target == "EZR 1.2" && e.Details!.Contains("1.1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notes_edited_online_during_the_upload_are_kept_and_a_republication_sends_nothing_again()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = ReleaseFolder.Create(smallFiles: 3, bigFileBytes: 10, archiveBytes: 2000);
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
|
||||
|
||||
var started = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
|
||||
});
|
||||
// Correction des notes de la 1.1 (page Notes de version) pendant l'envoi : permise, et conservée.
|
||||
var notes = await owner.GetChangelogAsync("EZR");
|
||||
notes.Entries[0].Title = "1.1 corrigée en ligne";
|
||||
await owner.SaveChangelogEntryAsync("EZR", notes.Entries[0], notes.ManifestSha256);
|
||||
|
||||
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default);
|
||||
var online = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
|
||||
Assert.Equal("1.1 corrigée en ligne", online.Changelog[1].Title);
|
||||
|
||||
// Réponse de mise en ligne perdue : le nouvel essai renvoie le même résultat, sans republier.
|
||||
var again = await owner.CommitPublicationAsync(started.PublicationId);
|
||||
Assert.Equal(result.ManifestSha256, again.ManifestSha256);
|
||||
Assert.Single((await owner.GetAuditAsync()).Entries, e => e.Action == "release.publish");
|
||||
|
||||
// Même version republiée : fichiers et archive déjà en ligne, rien n'est renvoyé.
|
||||
var republished = await ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default);
|
||||
Assert.Equal(0, republished.ObjectsAdded);
|
||||
Assert.False(republished.ArchiveAdded);
|
||||
Assert.Equal("1.2", republished.PreviousVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_abandoned_publication_can_be_taken_over_after_30_minutes()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var first = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.2", ManifestSha256 = new string('a', 64), ManifestSize = 100
|
||||
});
|
||||
|
||||
var (otherKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
|
||||
using var __ = otherKey;
|
||||
api.Time.Advance(TimeSpan.FromMinutes(31));
|
||||
await using var other = await api.LoginAsync(otherKey);
|
||||
var second = await other.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.3", ManifestSha256 = new string('b', 64), ManifestSize = 100
|
||||
});
|
||||
Assert.NotEqual(first.PublicationId, second.PublicationId);
|
||||
Assert.Single(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
|
||||
await using var ownerAgain = await api.LoginAsync(ownerKey); // session précédente expirée (31 min)
|
||||
var audit = await ownerAgain.GetAuditAsync();
|
||||
Assert.Contains(audit.Entries, e => e.Action == "release.cancel" && e.Target == "EZR 1.2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_interrupted_upload_resumes_where_it_stopped()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = ReleaseFolder.Create();
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
|
||||
|
||||
// Interruption pendant l'envoi de l'archive (après son premier morceau).
|
||||
using var cancel = new CancellationTokenSource();
|
||||
var progress = new Sync<UploadProgress>(p =>
|
||||
{
|
||||
if (p.Step.StartsWith("Envoi de l'archive") && p.Done >= p.Total - release.Archive.Length + PublishingLimits.ChunkBytes) cancel.Cancel();
|
||||
});
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ReleaseUploader.PublishAsync(owner, "EZR", local, progress, null, cancel.Token));
|
||||
|
||||
var directory = Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*").Single();
|
||||
var id = Path.GetFileName(directory)[".publish-".Length..];
|
||||
var state = await owner.GetPublicationAsync(id);
|
||||
Assert.True(state.ManifestAccepted);
|
||||
Assert.Empty(state.PendingObjects);
|
||||
Assert.False(state.ArchiveComplete);
|
||||
Assert.Equal(PublishingLimits.ChunkBytes, state.ArchiveReceived);
|
||||
Assert.Equal("1.1", JsonNode.Parse(File.ReadAllText(manifest))!["version"]!.GetValue<string>()); // rien en ligne
|
||||
|
||||
// Reprise : seul le reste de l'archive est envoyé.
|
||||
var reports = new List<UploadProgress>();
|
||||
PublicationState? resumed = null;
|
||||
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, new Sync<UploadProgress>(reports.Add), new Sync<PublicationState>(s => resumed = s), default);
|
||||
Assert.True(resumed!.Resumed);
|
||||
Assert.Equal(id, resumed.PublicationId);
|
||||
Assert.Equal(release.Archive.Length - PublishingLimits.ChunkBytes, reports.Max(r => r.Total));
|
||||
Assert.Equal("1.2", result.Version);
|
||||
Assert.Equal(release.Archive, File.ReadAllBytes(Path.Combine(Path.GetDirectoryName(manifest)!, "EZR-1.2.zip")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_version_published_during_the_upload_is_never_overwritten()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = ReleaseFolder.Create(smallFiles: 3, bigFileBytes: 10, archiveBytes: 1000);
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
|
||||
|
||||
await owner.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
|
||||
});
|
||||
// Une version 1.5 est envoyée avec WinSCP pendant ce temps.
|
||||
var winscp = File.ReadAllText(manifest).Replace("\"version\": \"1.1\"", "\"version\": \"1.5\"");
|
||||
File.WriteAllText(manifest, winscp);
|
||||
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default));
|
||||
Assert.Equal(409, refused.Status);
|
||||
Assert.Contains("1.5", refused.Message);
|
||||
Assert.Equal(winscp, File.ReadAllText(manifest));
|
||||
Assert.False(File.Exists(Path.Combine(Path.GetDirectoryName(manifest)!, "EZR-1.2.zip")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsafe_or_inconsistent_publications_are_refused()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
// Manifest dangereux, contrôlé par le launcher ET par le serveur.
|
||||
var release = ReleaseFolder.Create(smallFiles: 2, bigFileBytes: 10, archiveBytes: 100);
|
||||
release.Write("1.2", m =>
|
||||
{
|
||||
m.Archive.Url = "../../etc/EZR.zip";
|
||||
m.Files.Add(new ManifestFile { Path = "../outside.dll", Size = 1, Sha256 = new string('c', 64) });
|
||||
});
|
||||
var local = Assert.Throws<ReleaseCheckException>(() => ReleaseUploader.Inspect(release.ManifestPath, "EZR"));
|
||||
Assert.Contains("../outside.dll", local.Message);
|
||||
Assert.Contains("simple nom de fichier", local.Message);
|
||||
|
||||
var bytes = File.ReadAllBytes(release.ManifestPath);
|
||||
var state = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.2", ManifestSha256 = Sha(bytes), ManifestSize = bytes.Length
|
||||
});
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.UploadManifestAsync(state.PublicationId, 0, bytes));
|
||||
Assert.Equal(400, refused.Status);
|
||||
Assert.Contains("Manifest refusé", refused.Message);
|
||||
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*")); // publication abandonnée
|
||||
|
||||
// Fichier modifié après la génération : refusé avant la mise en ligne.
|
||||
var changed = ReleaseFolder.Create(smallFiles: 2, bigFileBytes: 10, archiveBytes: 100);
|
||||
var victim = Path.Combine(changed.Folder, ManifestCheck.ObjectPath(Sha(changed.Files[1].Data)));
|
||||
var tampered = File.ReadAllBytes(victim);
|
||||
tampered[0] ^= 0xFF;
|
||||
File.WriteAllBytes(victim, tampered);
|
||||
var inspected = ReleaseUploader.Inspect(changed.ManifestPath, "EZR"); // même taille : vu seulement à l'envoi
|
||||
var error = await Assert.ThrowsAsync<ReleaseCheckException>(() => ReleaseUploader.PublishAsync(owner, "EZR", inspected, null, null, default));
|
||||
Assert.Contains("a changé depuis la génération", error.Message);
|
||||
|
||||
// Le serveur vérifie aussi chaque objet reçu.
|
||||
var publication = (await owner.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.2", ManifestSha256 = inspected.ManifestSha256, ManifestSize = inspected.ManifestBytes.Length
|
||||
}));
|
||||
await owner.UploadManifestAsync(publication.PublicationId, 0, inspected.ManifestBytes);
|
||||
var sha = Sha(changed.Files[1].Data);
|
||||
var bad = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.UploadObjectBatchAsync(publication.PublicationId, ObjectBatch.Write(new[] { (sha, tampered) })));
|
||||
Assert.Equal(400, bad.Status);
|
||||
var notExpected = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.UploadObjectBatchAsync(publication.PublicationId, ObjectBatch.Write(new[] { (Sha(new byte[] { 1 }), new byte[] { 1 }) })));
|
||||
Assert.Contains("pas attendu", notExpected.Message);
|
||||
var offset = await Assert.ThrowsAsync<ApiException>(() => owner.UploadArchiveAsync(publication.PublicationId, 50, new byte[10]));
|
||||
Assert.Equal(ApiErrorCodes.OffsetMismatch, offset.Code);
|
||||
var incomplete = await Assert.ThrowsAsync<ApiException>(() => owner.CommitPublicationAsync(publication.PublicationId));
|
||||
Assert.Contains("Envoi incomplet", incomplete.Message);
|
||||
|
||||
// Un autre administrateur ne peut ni la poursuivre, ni la remplacer tant qu'elle est active.
|
||||
var (otherKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
|
||||
using var __ = otherKey;
|
||||
await using var other = await api.LoginAsync(otherKey);
|
||||
var forbidden = await Assert.ThrowsAsync<ApiException>(() => other.GetPublicationAsync(publication.PublicationId));
|
||||
Assert.Equal(403, forbidden.Status);
|
||||
var busy = await Assert.ThrowsAsync<ApiException>(() => other.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.3", ManifestSha256 = new string('d', 64), ManifestSize = 10
|
||||
}));
|
||||
Assert.Equal(ApiErrorCodes.PublicationInProgress, busy.Code);
|
||||
|
||||
// Abandon : dépôt supprimé.
|
||||
await owner.CancelPublicationAsync(publication.PublicationId);
|
||||
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
|
||||
var gone = await Assert.ThrowsAsync<ApiException>(() => owner.GetPublicationAsync(publication.PublicationId));
|
||||
Assert.Equal(404, gone.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_upload_routes_accept_large_bodies_and_editors_cannot_publish()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var release = ReleaseFolder.Create(smallFiles: 1, bigFileBytes: 10, archiveBytes: 9 * 1024 * 1024);
|
||||
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
|
||||
var state = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
|
||||
});
|
||||
await owner.UploadManifestAsync(state.PublicationId, 0, local.ManifestBytes);
|
||||
|
||||
// 8 Mo : accepté sur une route d'envoi ; au-delà : refusé.
|
||||
var ok = await owner.UploadArchiveAsync(state.PublicationId, 0, release.Archive.AsSpan(0, 8 * 1024 * 1024).ToArray());
|
||||
Assert.Equal(8 * 1024 * 1024, ok.Received);
|
||||
// Requêtes trop grosses : chacune sur sa propre connexion (Kestrel ferme la connexion après un refus).
|
||||
await using (var oversize = await api.LoginAsync(ownerKey))
|
||||
{
|
||||
var tooLarge = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
oversize.UploadArchiveAsync(state.PublicationId, ok.Received, new byte[(int)PublishingLimits.MaxUploadRequestBytes + 1]));
|
||||
Assert.True(tooLarge.Status is 413 or 0, tooLarge.Message); // 413, ou connexion coupée par Kestrel pendant l'envoi
|
||||
}
|
||||
|
||||
// Les autres routes gardent la limite de 64 Ko.
|
||||
await using (var oversize = await api.LoginAsync(ownerKey))
|
||||
{
|
||||
var bigJson = await Assert.ThrowsAsync<ApiException>(() => oversize.SaveChangelogEntryAsync("EZR",
|
||||
new ChangelogEntry { Version = "1.1", Title = new string('x', 70_000) }, new string('a', 64)));
|
||||
Assert.True(bigJson.Status is 413 or 0, bigJson.Message);
|
||||
}
|
||||
|
||||
// Un éditeur (contenu) ne peut pas publier.
|
||||
var (editorKey, _) = await api.AddAdminAsync(owner, "Léa", AdminRoles.Editor);
|
||||
using var __ = editorKey;
|
||||
await using var editor = await api.LoginAsync(editorKey);
|
||||
var denied = await Assert.ThrowsAsync<ApiException>(() => editor.StartPublicationAsync("EZR", new PublicationStartRequest
|
||||
{
|
||||
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
|
||||
}));
|
||||
Assert.Equal(403, denied.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manifest_rules_are_shared_by_the_launcher_and_the_server()
|
||||
{
|
||||
var release = ReleaseFolder.Create(smallFiles: 1, bigFileBytes: 10, archiveBytes: 10);
|
||||
var bytes = File.ReadAllBytes(release.ManifestPath);
|
||||
var errors = new List<string>();
|
||||
Assert.NotNull(ManifestCheck.Check(bytes, "ezr", "1.2", errors));
|
||||
Assert.Null(ManifestCheck.Check(bytes, "AUTRE", "1.2", errors));
|
||||
Assert.Contains(errors, e => e.Contains("AUTRE"));
|
||||
|
||||
foreach (var (edit, expected) in new (Action<JsonObject>, string)[]
|
||||
{
|
||||
(m => m["objectsBaseUrl"] = "https://ailleurs.example/objects/", "objectsBaseUrl"),
|
||||
(m => m["version"] = "1.2/../x", "Version"),
|
||||
(m => m["executable"] = "absent.exe", "exécutable"),
|
||||
(m => m["files"]![0]!["path"] = "C:/Windows/x.dll", "Chemin non autorisé"),
|
||||
(m => m["files"]![0]!["sha256"] = "zz", "empreinte"),
|
||||
(m => m["formatVersion"] = 2, "formatVersion")
|
||||
})
|
||||
{
|
||||
var node = JsonNode.Parse(bytes)!.AsObject();
|
||||
edit(node);
|
||||
errors.Clear();
|
||||
Assert.Null(ManifestCheck.Check(System.Text.Encoding.UTF8.GetBytes(node.ToJsonString()), "EZR", null, errors));
|
||||
Assert.Contains(errors, e => e.Contains(expected, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chunk_uploads_have_their_own_rate_limit_and_a_refusal_says_when_to_retry()
|
||||
{
|
||||
// Limite générale très basse : un envoi de 7 morceaux la dépasserait. Les morceaux ont leur propre
|
||||
// limite (haute) : aucune pause pendant l'envoi.
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = ReleaseFolder.Create();
|
||||
var settings = Settings(manifest);
|
||||
settings["LauncherApi:Limits:RequestsPerMinute"] = "8";
|
||||
await using var api = await TestApi.StartAsync(settings);
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var reports = new List<UploadProgress>();
|
||||
var result = await ReleaseUploader.PublishAsync(owner, "EZR", ReleaseUploader.Inspect(release.ManifestPath, "EZR"),
|
||||
new Sync<UploadProgress>(reports.Add), null, default);
|
||||
Assert.Equal("1.2", result.Version);
|
||||
Assert.DoesNotContain(reports, r => r.Step.Contains("reprise dans"));
|
||||
|
||||
// La limite générale s'applique toujours au reste de l'API, et le refus annonce le délai (Retry-After).
|
||||
HttpResponseMessage? limited = null;
|
||||
for (var i = 0; i < 20 && limited is null; i++)
|
||||
{
|
||||
var response = await api.Http.GetAsync(ApiRoutes.Info);
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests) limited = response;
|
||||
else response.Dispose();
|
||||
}
|
||||
Assert.NotNull(limited);
|
||||
using (limited)
|
||||
{
|
||||
Assert.Equal(ApiErrorCodes.RateLimited, (await TestApi.ReadErrorAsync(limited!)).Code);
|
||||
Assert.InRange(limited!.Headers.RetryAfter!.Delta!.Value, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().GetInfoAsync());
|
||||
Assert.Equal(429, error.Status);
|
||||
Assert.InRange(error.RetryAfter!.Value, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_a_refusal_the_upload_waits_just_as_long_as_needed()
|
||||
{
|
||||
static ApiException Limited(TimeSpan? retryAfter) => new(429, ApiErrorCodes.RateLimited, "") { RetryAfter = retryAfter };
|
||||
|
||||
// Délai annoncé par le serveur, arrondi à la seconde, entre 1 et 60 s.
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), ReleaseUploader.Backoff(1, Limited(TimeSpan.FromSeconds(11.2))));
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), ReleaseUploader.Backoff(1, Limited(TimeSpan.Zero)));
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), ReleaseUploader.Backoff(1, Limited(TimeSpan.FromMinutes(10))));
|
||||
// Limite de nginx (pas de délai annoncé) : 1 s, puis le double à chaque refus consécutif, 30 s au plus.
|
||||
Assert.Equal(new[] { 1, 2, 4, 8, 16, 30, 30, 30 },
|
||||
Enumerable.Range(1, 8).Select(a => (int)ReleaseUploader.Backoff(a, Limited(null)).TotalSeconds).ToArray());
|
||||
// Position décalée : reprise immédiate ; coupure réseau : attente croissante.
|
||||
Assert.Equal(TimeSpan.Zero, ReleaseUploader.Backoff(3, new ApiException(409, ApiErrorCodes.OffsetMismatch, "")));
|
||||
Assert.Equal(TimeSpan.FromSeconds(8), ReleaseUploader.Backoff(2, new ApiException(0, "unreachable", "")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Object_folders_are_readable_by_nginx_without_asking_for_special_bits()
|
||||
{
|
||||
// Sur le VPS, systemd refuse au service toute demande de bit setgid (RestrictSUIDSGID=yes) : les dossiers
|
||||
// sont créés sans bit spécial, le setgid et le groupe venant du dossier parent.
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var game = Path.GetDirectoryName(ChangelogModuleTests.PublishedManifest())!;
|
||||
const UnixFileMode special = UnixFileMode.SetUser | UnixFileMode.SetGroup | UnixFileMode.StickyBit;
|
||||
var permissions = File.GetUnixFileMode(game) & ~special;
|
||||
|
||||
var objects = Path.Combine(game, "objects");
|
||||
PublishedFiles.EnsurePublicDirectory(objects, game, keepSetGid: true);
|
||||
Assert.Equal(permissions | UnixFileMode.SetGroup, File.GetUnixFileMode(objects)); // ses sous-dossiers auront le groupe de nginx
|
||||
|
||||
var leaf = Path.Combine(objects, "ab");
|
||||
PublishedFiles.EnsurePublicDirectory(leaf, game, keepSetGid: false);
|
||||
Assert.Equal(permissions, File.GetUnixFileMode(leaf) & ~special);
|
||||
PublishedFiles.EnsurePublicDirectory(leaf, game, keepSetGid: false); // déjà là : rien ne change
|
||||
Assert.Equal(permissions, File.GetUnixFileMode(leaf) & ~special);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Notes de version : modification d'une version existante dans le manifest.json publié.</summary>
|
||||
public class ChangelogModuleTests
|
||||
{
|
||||
private static readonly string Hash = new('a', 64);
|
||||
|
||||
/// <summary>Dossier du jeu (comme sur le VPS) avec un manifest écrit par ManifestBuilder.</summary>
|
||||
public static string PublishedManifest(string gameId = "EZR")
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-changelog-tests", Guid.NewGuid().ToString("N"), "exovivalzombie");
|
||||
Directory.CreateDirectory(directory);
|
||||
// Comme sur le VPS : dossier setgid (groupe www-data transmis aux nouveaux fichiers). Déjà hérité si le
|
||||
// dossier des tests est lui-même setgid (tests lancés sous le filtre de RestrictSUIDSGID=yes).
|
||||
if (!OperatingSystem.IsWindows() && !File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
|
||||
File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) | UnixFileMode.SetGroup);
|
||||
var manifest = new GameManifest
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = "1.1",
|
||||
ReleaseDate = new DateTimeOffset(2026, 10, 17, 18, 0, 0, TimeSpan.FromHours(2)),
|
||||
Executable = "Exovival Zombie Reborn.exe",
|
||||
Archive = new ArchiveInfo { Url = "EZR-1.1.zip", Size = 1234, Sha256 = Hash },
|
||||
Files = new List<ManifestFile>
|
||||
{
|
||||
new() { Path = "Exovival Zombie Reborn.exe", Size = 1000, Sha256 = Hash },
|
||||
new() { Path = "Data/level1.pak", Size = 234, Sha256 = new string('b', 64) }
|
||||
},
|
||||
Changelog = new List<ChangelogEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Version = "1.1",
|
||||
Date = new DateTimeOffset(2026, 10, 17, 18, 0, 0, TimeSpan.FromHours(2)),
|
||||
Title = "Version 1.1",
|
||||
Subtitle = "Niveau 1 « L'école »",
|
||||
Sections = new List<ChangelogSection>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = "PROGRESSION",
|
||||
Items = new List<ChangelogItem> { new() { Text = "La petite clé peut être ramasée.", Tag = "CORRIGÉ" } }
|
||||
}
|
||||
},
|
||||
Changes = new List<string> { "— PROGRESSION —", "CORRIGÉ : La petite clé peut être ramasée." }
|
||||
},
|
||||
new()
|
||||
{
|
||||
Version = "1.0",
|
||||
Date = new DateTimeOffset(2026, 9, 1, 12, 0, 0, TimeSpan.FromHours(2)),
|
||||
Title = "Première version",
|
||||
Changes = new List<string> { "Sortie du niveau 1." }
|
||||
}
|
||||
}
|
||||
};
|
||||
var path = Path.Combine(directory, "manifest.json");
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(manifest, JsonDefaults.Options));
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> Settings(string manifest) => new() { ["LauncherApi:Games:EZR:ManifestFile"] = manifest };
|
||||
|
||||
private static JsonObject WithoutChangelog(string path)
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(path))!.AsObject();
|
||||
root.Remove("changelog");
|
||||
return root;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editing_a_version_changes_only_its_notes_in_the_published_manifest()
|
||||
{
|
||||
var manifest = PublishedManifest();
|
||||
var before = WithoutChangelog(manifest);
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var state = await owner.GetChangelogAsync("ezr");
|
||||
Assert.Equal("EZR", state.GameId);
|
||||
Assert.Equal("1.1", state.ManifestVersion);
|
||||
Assert.Equal(ChangelogStoreHash(manifest), state.ManifestSha256);
|
||||
Assert.Equal(new[] { "1.1", "1.0" }, state.Entries.Select(e => e.Version));
|
||||
|
||||
// Faute corrigée, élément ajouté ; la date envoyée est ignorée (celle du manifest est conservée).
|
||||
var edited = state.Entries[0];
|
||||
edited.Date = DateTimeOffset.UnixEpoch;
|
||||
edited.Sections[0].Items[0].Text = "La petite clé peut être ramassée.";
|
||||
edited.Sections[0].Items.Add(new ChangelogItem { Text = "Les portes se ferment correctement.", Details = new() { "salle 2", "salle 3" } });
|
||||
var saved = await owner.SaveChangelogEntryAsync("EZR", edited, state.ManifestSha256);
|
||||
|
||||
Assert.NotEqual(state.ManifestSha256, saved.ManifestSha256);
|
||||
Assert.Equal(ChangelogStoreHash(manifest), saved.ManifestSha256);
|
||||
var entry = saved.Entries[0];
|
||||
Assert.Equal("La petite clé peut être ramassée.", entry.Sections[0].Items[0].Text);
|
||||
Assert.Equal(2, entry.Sections[0].Items.Count);
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 17, 18, 0, 0, TimeSpan.FromHours(2)), entry.Date);
|
||||
var flat = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!.Changelog[0].Changes;
|
||||
Assert.Contains(" · salle 2", flat); // liste à plat régénérée dans le manifest pour les anciens launchers
|
||||
Assert.Equal("Sortie du niveau 1.", Assert.Single(saved.Entries[1].Changes)); // autre version intacte
|
||||
|
||||
// Le reste du manifest est identique : fichiers, archive, version, exécutable…
|
||||
Assert.True(JsonNode.DeepEquals(before, WithoutChangelog(manifest)));
|
||||
var reread = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
|
||||
reread.Validate();
|
||||
Assert.Contains("ramassée", File.ReadAllText(manifest)); // accents lisibles, comme ManifestBuilder
|
||||
if (!OperatingSystem.IsWindows())
|
||||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead, File.GetUnixFileMode(manifest));
|
||||
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(manifest)!, "*.tmp"));
|
||||
|
||||
// Ancienne version gardée dans les données privées ; action inscrite dans l'audit.
|
||||
Assert.Single(Directory.GetFiles(Path.Combine(api.DataDirectory, "manifest-history", "ezr")));
|
||||
var audit = await owner.GetAuditAsync();
|
||||
Assert.Contains(audit.Entries, e => e.Action == "changelog.edit" && e.Target == "EZR 1.1");
|
||||
|
||||
// Modification fondée sur l'ancienne empreinte : refusée, rien n'est écrasé.
|
||||
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", edited, state.ManifestSha256));
|
||||
Assert.Equal(409, conflict.Status);
|
||||
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_fields_and_other_name_casing_are_preserved()
|
||||
{
|
||||
var manifest = PublishedManifest();
|
||||
// Manifest édité à la main : noms en PascalCase, champ inconnu d'une version future de ManifestBuilder.
|
||||
var root = JsonNode.Parse(File.ReadAllText(manifest))!.AsObject();
|
||||
var first = root["changelog"]![0]!.AsObject();
|
||||
var version = first["version"]!.DeepClone();
|
||||
var date = first["date"]!.DeepClone();
|
||||
first.Remove("version");
|
||||
first.Remove("date");
|
||||
first["Version"] = version;
|
||||
first["Date"] = date;
|
||||
first["video"] = "https://www.youtube.com/watch?v=abc";
|
||||
File.WriteAllText(manifest, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var state = await owner.GetChangelogAsync("EZR");
|
||||
Assert.Empty(state.Entries[0].Changes); // liste à plat omise : les sections suffisent à l'éditeur
|
||||
var edited = state.Entries[0];
|
||||
edited.Title = "Titre corrigé";
|
||||
await owner.SaveChangelogEntryAsync("EZR", edited, state.ManifestSha256);
|
||||
|
||||
var saved = JsonNode.Parse(File.ReadAllText(manifest))!["changelog"]![0]!.AsObject();
|
||||
Assert.Equal("1.1", saved["Version"]!.GetValue<string>());
|
||||
Assert.NotNull(saved["Date"]);
|
||||
Assert.Null(saved["version"]);
|
||||
Assert.Equal("https://www.youtube.com/watch?v=abc", saved["video"]!.GetValue<string>());
|
||||
Assert.Equal("Titre corrigé", saved["title"]!.GetValue<string>());
|
||||
Assert.Equal("1.1", (await owner.GetChangelogAsync("EZR")).Entries[0].Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Risky_manifest_replacements_are_refused()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var manifest = PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var state = await owner.GetChangelogAsync("EZR");
|
||||
var directory = Path.GetDirectoryName(manifest)!;
|
||||
|
||||
// Manifest lisible par le groupe seulement (nginx = groupe www-data), dossier sans setgid : refusé.
|
||||
File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) & ~UnixFileMode.SetGroup);
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256));
|
||||
Assert.Equal(503, refused.Status);
|
||||
Assert.Contains("setgid", refused.Message);
|
||||
Assert.DoesNotContain(directory, refused.Message);
|
||||
|
||||
// Avec setgid : accepté.
|
||||
File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) | UnixFileMode.SetGroup);
|
||||
state = await owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256);
|
||||
|
||||
// Manifest remplacé par un lien symbolique : jamais remplacé (le lien serait rompu).
|
||||
var target = Path.Combine(directory, "manifest-1.1.json");
|
||||
File.Move(manifest, target);
|
||||
File.CreateSymbolicLink(manifest, target);
|
||||
var link = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256));
|
||||
Assert.Contains("lien symbolique", link.Message);
|
||||
Assert.NotNull(new FileInfo(manifest).LinkTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_manifest_republished_in_the_meantime_is_never_overwritten()
|
||||
{
|
||||
var manifest = PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var state = await owner.GetChangelogAsync("EZR");
|
||||
|
||||
// Nouvelle version envoyée par WinSCP pendant l'édition.
|
||||
var republished = File.ReadAllText(manifest).Replace("\"version\": \"1.1\"", "\"version\": \"1.2\"");
|
||||
File.WriteAllText(manifest, republished);
|
||||
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256));
|
||||
Assert.Equal(409, refused.Status);
|
||||
Assert.Equal(republished, File.ReadAllText(manifest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_edits_are_refused()
|
||||
{
|
||||
var manifest = PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(Settings(manifest));
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var state = await owner.GetChangelogAsync("EZR");
|
||||
var original = File.ReadAllText(manifest);
|
||||
|
||||
async Task Refused(ChangelogEntry entry, int status)
|
||||
{
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", entry, state.ManifestSha256));
|
||||
Assert.Equal(status, error.Status);
|
||||
}
|
||||
|
||||
await Refused(new ChangelogEntry { Version = "2.0", Title = "Version inexistante" }, 404); // seules les versions existantes
|
||||
await Refused(new ChangelogEntry { Version = "1.1" }, 400); // notes vides
|
||||
await Refused(new ChangelogEntry
|
||||
{
|
||||
Version = "1.1",
|
||||
Sections = Enumerable.Range(0, ChangelogLimits.MaxSectionsPerEntry + 1)
|
||||
.Select(i => new ChangelogSection { Title = $"S{i}", Items = new() { new() { Text = "x" } } }).ToList()
|
||||
}, 400);
|
||||
await Refused(new ChangelogEntry { Version = "1.1", Title = new string('x', ChangelogLimits.MaxTitleLength + 1) }, 400);
|
||||
Assert.Throws<ArgumentException>(() => { _ = owner.SaveChangelogEntryAsync("EZR", new ChangelogEntry { Version = "1.1/../x" }, state.ManifestSha256); });
|
||||
|
||||
// Version de l'adresse différente de celle des notes, requête non signée.
|
||||
var session = await api.RawLoginAsync(ownerKey);
|
||||
using (var mismatch = await api.SendSignedRawAsync(session, ownerKey, HttpMethod.Put, ApiRoutes.GameChangelogEntry("EZR", "1.0"),
|
||||
new ChangelogUpdateRequest { BaseSha256 = state.ManifestSha256, Entry = state.Entries[0] }))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, mismatch.StatusCode);
|
||||
using (var unsigned = await api.SendSignedRawAsync(session, ownerKey, HttpMethod.Put, ApiRoutes.GameChangelogEntry("EZR", "1.1"),
|
||||
new ChangelogUpdateRequest { BaseSha256 = state.ManifestSha256, Entry = state.Entries[0] }, includeSignature: false))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unsigned.StatusCode);
|
||||
Assert.Equal(ApiErrorCodes.SignatureRequired, (await TestApi.ReadErrorAsync(unsigned)).Code);
|
||||
}
|
||||
|
||||
Assert.Equal(original, File.ReadAllText(manifest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Games_without_a_configured_manifest_cannot_be_edited()
|
||||
{
|
||||
var other = PublishedManifest(gameId: "DEMO");
|
||||
await using var api = await TestApi.StartAsync(Settings(other)); // EZR → manifest d'un autre jeu
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var wrongGame = await Assert.ThrowsAsync<ApiException>(() => owner.GetChangelogAsync("EZR"));
|
||||
Assert.Equal(503, wrongGame.Status);
|
||||
Assert.Contains("DEMO", wrongGame.Message);
|
||||
|
||||
var missing = await Assert.ThrowsAsync<ApiException>(() => owner.GetChangelogAsync("AUTRE"));
|
||||
Assert.Equal(503, missing.Status);
|
||||
Assert.Equal(ApiErrorCodes.NotConfigured, missing.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Game_manifest_configuration_is_checked_at_startup()
|
||||
{
|
||||
var data = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(data);
|
||||
var manifest = PublishedManifest();
|
||||
LauncherApiOptions Options(string id, string file) => new()
|
||||
{
|
||||
PublicOrigin = "https://launcher.example",
|
||||
DataDirectory = data,
|
||||
Games = new() { [id] = new LauncherApiOptions.GameSettings { ManifestFile = file } }
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Options("../EZR", manifest).Validate("/"));
|
||||
Assert.Throws<InvalidOperationException>(() => Options("EZR", "exovivalzombie/manifest.json").Validate("/"));
|
||||
Assert.Throws<InvalidOperationException>(() => Options("EZR", Path.Combine(data, "absent", "manifest.json")).Validate("/"));
|
||||
Assert.Throws<InvalidOperationException>(() => Options("EZR", Path.Combine(data, "manifest.json")).Validate("/")); // données privées
|
||||
|
||||
var ok = Options("EZR", manifest);
|
||||
ok.Validate("/");
|
||||
Assert.Equal(manifest, ok.GameManifestFiles["ezr"]);
|
||||
var disabled = Options("EZR", "");
|
||||
disabled.Validate("/");
|
||||
Assert.Empty(disabled.GameManifestFiles);
|
||||
}
|
||||
|
||||
private static string ChangelogStoreHash(string path) => GameLauncher.Server.Storage.ChangelogStore.Sha256(File.ReadAllBytes(path));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -15,6 +16,7 @@ namespace ManifestBuilder;
|
||||
/// Exemple :
|
||||
/// ManifestBuilder --source "D:\Builds\MonJeu" --output "D:\Publish\MonJeu" --version 1.0.3
|
||||
/// --exe MonJeu.exe --game-id MonJeu --notes notes.txt
|
||||
/// --remote-manifest https://launcher.example/monjeu/manifest.json
|
||||
///
|
||||
/// Le dossier --output doit être conservé d'une version à l'autre : les fichiers déjà
|
||||
/// présents dans objects/ ne sont pas recopiés, il suffit d'envoyer les nouveautés.
|
||||
@@ -64,6 +66,35 @@ public static class Program
|
||||
}
|
||||
|
||||
var gameId = o.GameId ?? previous?.GameId ?? Path.GetFileName(source);
|
||||
|
||||
// Historique des notes : celui du manifest EN LIGNE si demandé (corrections faites depuis le menu
|
||||
// d'administration), sinon celui du manifest local.
|
||||
GameManifest? published = null;
|
||||
if (o.RemoteManifest is not null)
|
||||
{
|
||||
using var http = new HttpDownloader("ManifestBuilder");
|
||||
try
|
||||
{
|
||||
published = await ChangelogHistory.FetchPublishedAsync(http, o.RemoteManifest);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or InvalidDataException or IOException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest en ligne illisible ({o.RemoteManifest}) : {ex.Message}. Sans lui, les notes corrigées depuis le " +
|
||||
"menu d'administration seraient perdues : réessayez, ou retirez --remote-manifest pour reprendre l'historique local.", ex);
|
||||
}
|
||||
if (published is null)
|
||||
{
|
||||
Info("Aucun manifest en ligne (première publication ?) : historique des notes repris du manifest local.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(published.GameId) && !string.Equals(published.GameId, gameId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"Le manifest en ligne est celui du jeu « {published.GameId} », pas de « {gameId} » : vérifiez --remote-manifest.");
|
||||
Info($"Historique des notes repris du manifest en ligne : {published.Changelog?.Count ?? 0} version(s) (version en ligne : {published.Version}).");
|
||||
}
|
||||
}
|
||||
var exe = PathHelper.ToManifestPath(o.Executable ?? previous?.Executable ?? "");
|
||||
if (string.IsNullOrWhiteSpace(exe)) throw new ArgumentException("--exe est obligatoire pour la première publication.");
|
||||
if (!File.Exists(Path.Combine(source, exe.Replace('/', Path.DirectorySeparatorChar))))
|
||||
@@ -143,7 +174,6 @@ public static class Program
|
||||
// 4. Notes de version (l'historique précédent est conservé)
|
||||
// Format enrichi : titre, sous-titre, introduction, sections, commentaires, sous-points
|
||||
// (voir ChangelogParser). La liste à plat "changes" reste renseignée pour les anciens launchers.
|
||||
var changelog = previous?.Changelog?.Where(c => c.Version != o.Version).ToList() ?? new List<ChangelogEntry>();
|
||||
var entry = new ChangelogEntry { Version = o.Version, Date = DateTimeOffset.Now, Title = o.Title };
|
||||
if (o.NotesFile is not null)
|
||||
{
|
||||
@@ -153,8 +183,7 @@ public static class Program
|
||||
$"{entry.Sections.Sum(s => s.Items.Count(i => i.IsComment))} commentaire(s).");
|
||||
}
|
||||
var parsedItems = entry.Sections.Sum(s => s.Items.Count);
|
||||
if (!entry.IsEmpty) changelog.Insert(0, entry);
|
||||
changelog = ChangelogLimits.Normalize(changelog); // mêmes bornes que le launcher
|
||||
var changelog = ChangelogHistory.Build((published ?? previous)?.Changelog, entry); // mêmes bornes que le launcher
|
||||
if (changelog.FirstOrDefault(c => c.Version == o.Version) is { } kept && kept.Sections.Sum(s => s.Items.Count) < parsedItems)
|
||||
Info($"Attention : notes de version trop longues, tronquées à {ChangelogLimits.MaxItemsPerEntry} éléments.");
|
||||
|
||||
@@ -201,6 +230,8 @@ public static class Program
|
||||
Console.WriteLine($" Dossier : {output}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Envoyez sur le serveur (dans cet ordre) : objects/ (nouveaux fichiers), l'archive .zip, puis manifest.json.");
|
||||
if (o.RemoteManifest is not null)
|
||||
Console.WriteLine("Envoyez-les sans attendre : une note corrigée en ligne d'ici l'envoi serait écrasée (sinon, relancez la publication).");
|
||||
}
|
||||
|
||||
private static Regex GlobToRegex(string glob)
|
||||
@@ -226,6 +257,9 @@ public static class Program
|
||||
sections encadrées de ----- (ou "## Section"), "- modification",
|
||||
" * sous-point", paragraphes = commentaires, "NOUVEAU : …" = étiquette
|
||||
--title <texte> Titre des notes de version
|
||||
--remote-manifest <url>
|
||||
Manifest EN LIGNE du jeu (https) : l'historique des notes en est repris, pour
|
||||
garder les corrections faites depuis le menu d'administration
|
||||
--args <texte> Arguments de lancement du jeu
|
||||
--exclude <motifs> Fichiers à exclure, séparés par ';' (ex. "*.pdb;Logs/**")
|
||||
--fast Compression rapide (archive plus grosse)
|
||||
@@ -243,6 +277,7 @@ public static class Program
|
||||
public string? GameId { get; private set; }
|
||||
public string? NotesFile { get; private set; }
|
||||
public string? Title { get; private set; }
|
||||
public Uri? RemoteManifest { get; private set; }
|
||||
public string? LaunchArguments { get; private set; }
|
||||
public List<string> Excludes { get; } = new();
|
||||
public bool FastCompression { get; private set; }
|
||||
@@ -264,6 +299,13 @@ public static class Program
|
||||
case "--game-id": o.GameId = Next(); break;
|
||||
case "--notes": o.NotesFile = Next(); break;
|
||||
case "--title": o.Title = Next(); break;
|
||||
case "--remote-manifest":
|
||||
var url = Next();
|
||||
o.RemoteManifest = Uri.TryCreate(url, UriKind.Absolute, out var remote) &&
|
||||
(remote.Scheme == Uri.UriSchemeHttps || (remote.Scheme == Uri.UriSchemeHttp && remote.IsLoopback))
|
||||
? remote
|
||||
: throw new ArgumentException($"--remote-manifest : adresse https:// attendue ({url}).");
|
||||
break;
|
||||
case "--args": o.LaunchArguments = Next(); break;
|
||||
case "--exclude":
|
||||
o.Excludes.AddRange(Next().Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
+63
-77
@@ -1,102 +1,88 @@
|
||||
# Version 1.6
|
||||
# Version 1.8
|
||||
EXOVIVAL ZOMBIE REBORN - NOTES DE MISE A JOUR
|
||||
Niveau 1 « L'école » - Mise à jour du 25 octobre 2026
|
||||
Niveau 1 « L'école » - Mise à jour du 1er novembre 2026
|
||||
==================================================================
|
||||
|
||||
Merci aux bêta-testeurs ! Cette version supprime un blocage possible au gymnase, rend le crochetage plus clair et ajuste l'équilibre des lumières et de l'exploration.
|
||||
Merci aux bêta-testeurs ! Cette version remplit l'école de zombies qui rôdent, leur donne de l'oreille, rééquilibre les armes et ajoute de petits soins à fabriquer soi-même. Elle corrige aussi des zombies qui restaient bloqués sans attaquer.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
PROGRESSION : PROBLÈME BLOQUANT CORRIGÉ
|
||||
ZOMBIES QUI RÔDENT
|
||||
------------------------------------------------------------------
|
||||
- Coffre du gymnase : casser les trois épingles à cheveux ne bloque
|
||||
plus la partie. La dernière épingle ne casse jamais : vous pouvez
|
||||
réessayer autant que vous voulez pour récupérer le fusible n°2.
|
||||
- NOUVEAU : huit zombies rôdent dans l'école dès le début de la
|
||||
partie.
|
||||
* autour du car scolaire, sur le terrain de basket ;
|
||||
* dans la cour de service et sur le toit bas ;
|
||||
* au gymnase, dans le bureau d'EPS et à l'étage.
|
||||
- Ils sont plus lents et frappent moins fort que les zombies qui vous
|
||||
traquent pendant l'histoire : de quoi se défouler un peu. On peut
|
||||
les distancer en marchant.
|
||||
- NOUVEAU : trois apparences différentes, dont une nouvelle : le
|
||||
prisonnier.
|
||||
- NOUVEAU : alerte de groupe. Quand un zombie vous attaque, ceux du
|
||||
même endroit qui sont à proximité l'entendent et accourent.
|
||||
- Les deux zombies de l'histoire (celui qui se réveille au retour du
|
||||
courant et celui de la bibliothèque) restent les plus dangereux.
|
||||
|
||||
Astuce : au gymnase, attaquer un zombie de face attire souvent les autres.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CROCHETAGE
|
||||
LES ZOMBIES VOUS ENTENDENT
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : les commandes s'affichent pendant le crochetage.
|
||||
* souris : placer l'épingle ;
|
||||
* Espace (maintenir) : tourner la serrure ;
|
||||
* Q : retour.
|
||||
- NOUVEAU : le nombre d'épingles restantes s'affiche.
|
||||
- NOUVEAU : en ramassant les épingles à cheveux, une aide explique
|
||||
comment crocheter une serrure.
|
||||
- Le crochetage est plus tolérant : la serrure réagit plus tôt quand
|
||||
l'épingle approche du bon angle, et une épingle résiste plus
|
||||
longtemps avant de casser.
|
||||
|
||||
Astuce : plus la serrure tourne loin, plus l'épingle est proche du bon angle. Si elle bloque, relâchez et décalez l'épingle : forcer l'use jusqu'à la casser.
|
||||
- NOUVEAU : marcher ou courir fait du bruit. Un zombie vous entend,
|
||||
même dans son dos, se retourne et vous attaque.
|
||||
* courir s'entend de beaucoup plus loin que marcher ;
|
||||
* un mur ou une porte fermée étouffe le bruit ;
|
||||
* on n'entend rien d'un étage à l'autre.
|
||||
- NOUVEAU : accroupi (touche Ctrl), vous êtes silencieux : vous pouvez
|
||||
approcher un zombie par derrière sans qu'il vous remarque.
|
||||
- Dès que vous le blessez, le zombie se retourne et fonce sur vous.
|
||||
- Accroupi ou pas, un zombie qui vous voit en face vous attaque.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
OBJETS ET EXPLORATION
|
||||
ARMES : DÉGÂTS RÉÉQUILIBRÉS
|
||||
------------------------------------------------------------------
|
||||
- Fusible n°1 : il n'est plus posé à côté du registre de la salle des
|
||||
professeurs. Cherchez-le ailleurs dans l'école : le registre
|
||||
indique où il a été rangé.
|
||||
- NOUVEAU : un deuxième couteau de poche se trouve dans l'école.
|
||||
* si vous avez raté celui du car, vous pouvez quand même casser
|
||||
les caisses et la vitrine de la hache ;
|
||||
* on ne porte qu'un seul couteau : le second reste en place, avec
|
||||
un message.
|
||||
- La bougie de la classe de CM1 est éteinte tant qu'on ne l'a pas
|
||||
prise en main.
|
||||
- Chaque arme fait désormais toujours les mêmes dégâts (plus de tirage
|
||||
au hasard), et viser la tête double les dégâts, à toutes les armes.
|
||||
* hache : un seul coup à la tête suffit, trois coups au corps ;
|
||||
* couteau de poche : deux coups à la tête, sept au corps ;
|
||||
* pistolet : trois balles dans la tête, sept au corps.
|
||||
|
||||
Astuce : approchez accroupi dans le dos d'un zombie et frappez à la tête avec la hache.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
LUMIÈRES : DURÉE RÉDUITE
|
||||
PETITS SOINS À LA RESIDENT EVIL
|
||||
------------------------------------------------------------------
|
||||
- Lampe torche : une pile dure maintenant 4 minutes (au lieu de 12).
|
||||
- Lanterne : un bidon de pétrole dure maintenant 2 minutes 30 (au
|
||||
lieu de 3 minutes).
|
||||
|
||||
Gardez un œil sur vos piles !
|
||||
- NOUVEAU : herbes médicinales, qui soignent un peu.
|
||||
* les plantes vertes de l'école semblent perdre leurs feuilles :
|
||||
regardez à leur pied, et au pied des buissons dehors.
|
||||
- NOUVEAU : huiles médicinales, qui soignent davantage. Quelques
|
||||
flacons traînent dans l'école.
|
||||
- NOUVEAU : mélangez une herbe et une huile pour obtenir une potion de
|
||||
soin, bien plus efficace.
|
||||
* inventaire (Tab) › herbe › Combiner › huile ;
|
||||
* c'est le seul mélange possible.
|
||||
- La trousse de secours reste le meilleur remède.
|
||||
- Comme la trousse, un soin ne peut pas être utilisé quand vous avez
|
||||
toute votre vie : il n'est pas gaspillé.
|
||||
- NOUVEAU : une note de l'infirmière, quelque part dans l'école,
|
||||
explique tout cela.
|
||||
- Les herbes et les huiles affichent le petit cercle blanc des objets
|
||||
à prendre quand on s'en approche.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
INVENTAIRE
|
||||
CORRIGÉ DEPUIS LA VERSION 1.7
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : inventaire plein. La première fois qu'un objet ne peut pas
|
||||
être ramassé faute de place, une aide vous l'indique :
|
||||
* faites de la place (Tab : lâcher ou jeter un objet) ;
|
||||
* ou réarrangez vos objets ;
|
||||
* ou déposez-en dans une caisse de rangement de l'école.
|
||||
- CORRIGÉ : un zombie arrivé au contact du joueur pouvait rester
|
||||
immobile, les bras croisés, sans plus jamais frapper.
|
||||
* surtout dans les escaliers ;
|
||||
* ou quand le joueur était sur un banc, une marche ou des débris ;
|
||||
* ou quand plusieurs zombies l'entouraient.
|
||||
Le zombie se tourne maintenant toujours vers le joueur et attaque normalement.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CAISSES ET OBJETS TENUS
|
||||
------------------------------------------------------------------
|
||||
- Tourner une caisse (R maintenu + souris) est beaucoup plus réactif :
|
||||
la rotation suit maintenant le mouvement de la souris.
|
||||
- La molette rapproche ou éloigne l'objet tenu bien plus vite (avant,
|
||||
quelques millimètres seulement par cran).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SAUVEGARDES
|
||||
------------------------------------------------------------------
|
||||
- Charger une partie : les emplacements sont numérotés dans l'ordre
|
||||
chronologique. La plus ancienne sauvegarde porte le n°0 et la plus
|
||||
récente le numéro le plus élevé (avant, c'était l'inverse).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
TEXTES ET INTERFACE
|
||||
------------------------------------------------------------------
|
||||
- Les messages d'aide de plusieurs lignes ne débordent plus du cadre
|
||||
noir en haut de l'écran.
|
||||
- Le registre d'appel est mis à jour (nouvel emplacement du
|
||||
fusible n°1).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CONNU, EN COURS
|
||||
------------------------------------------------------------------
|
||||
- La visée du pistolet fait un simple zoom au lieu de viser par le
|
||||
viseur. Une amélioration est prévue.
|
||||
|
||||
|
||||
Vos sauvegardes restent compatibles. Une partie commencée avant cette version garde l'ancien emplacement du fusible n°1 (s'il n'a pas encore été ramassé) et la charge des piles déjà en place : pour profiter de tous les changements, commencez une nouvelle partie.
|
||||
Pour profiter de tous les changements (zombies rôdeurs, herbes, huiles et note de l'infirmière à leur place de départ), commencez une nouvelle partie.
|
||||
@@ -5,6 +5,8 @@ REM 1. Compilez votre jeu dans D:\Builds\MonJeu
|
||||
REM 2. Écrivez les nouveautés dans notes.txt (format : voir README, § Format de notes.txt)
|
||||
REM 3. Lancez ce script, puis envoyez le contenu de D:\Publish\MonJeu sur votre serveur
|
||||
REM (objects\ et le .zip d'abord, manifest.json en dernier).
|
||||
REM L'historique des notes est repris du manifest EN LIGNE (--remote-manifest) : les
|
||||
REM corrections faites depuis le menu d'administration ne sont pas perdues.
|
||||
REM ---------------------------------------------------------------------------
|
||||
|
||||
set VERSION=%1
|
||||
@@ -20,6 +22,7 @@ dotnet run --project "%~dp0ManifestBuilder\ManifestBuilder.csproj" -c Release --
|
||||
--exe "Exovival Zombie Reborn.exe" ^
|
||||
--game-id "EZR" ^
|
||||
--notes "%~dp0notes.txt" ^
|
||||
--remote-manifest "https://launcher.lanisys.fr/exovivalzombie/manifest.json" ^
|
||||
--exclude "*.pdb;*.log" ^
|
||||
--prune-objects
|
||||
pause
|
||||
Reference in new issue
Block a user